diff --git a/.gitattributes b/.gitattributes index 5906df7..5f7a1c2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,5 @@ *.md text eol=lf *.yml text eol=lf *.json text eol=lf +*.ts text eol=lf +*.tsx text eol=lf diff --git a/.github/workflows/node-runtime.yml b/.github/workflows/node-runtime.yml new file mode 100644 index 0000000..e418637 --- /dev/null +++ b/.github/workflows/node-runtime.yml @@ -0,0 +1,125 @@ +name: Node runtime migration gate + +on: + push: + branches: + - master + pull_request: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +jobs: + npm-artifact: + name: Pack the single npm candidate + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + filename: ${{ steps.pack.outputs.filename }} + steps: + - uses: actions/checkout@v6.0.3 + - uses: actions/setup-node@v7.0.0 + with: + node-version: 24.18.0 + - run: npm install --global @nubjs/nub@0.7.5 + - run: nub ci + - name: Pack once outside checkout + id: pack + run: | + node scripts/pack-node.ts > "$RUNNER_TEMP/cadder-npm.json" + node --input-type=module -e 'import fs from "node:fs"; import path from "node:path"; const report = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, "cadder-npm.json"), "utf8")); fs.appendFileSync(process.env.GITHUB_OUTPUT, "archive=" + report.archive + "\nfilename=" + path.basename(report.archive) + "\n");' + - uses: actions/upload-artifact@v7.0.1 + with: + name: cadder-npm-candidate + path: ${{ steps.pack.outputs.archive }} + if-no-files-found: error + - run: git diff --exit-code + runtime: + name: Node runtime (${{ matrix.os }}) + needs: npm-artifact + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-15, macos-15-intel] + steps: + - uses: actions/checkout@v6.0.3 + - uses: actions/setup-node@v7.0.0 + with: + node-version: 24.18.0 + - name: Bootstrap pinned repository manager + run: npm install --global @nubjs/nub@0.7.5 + - name: Install exact locked dependencies + run: nub ci + - name: Check TypeScript + run: nub run typecheck + - name: Check lints + run: nub run lint + - name: Check formatting + run: nub run format:check + - name: Test runtime and coverage + env: + CADDER_COVERAGE_DIR: ${{ runner.temp }}/cadder-node-coverage + run: nub run coverage + - name: Pack native runtime fixtures outside checkout + id: fixtures + shell: bash + run: echo "stage=$(node scripts/prepare-runtime-gate.ts)" >> "$GITHUB_OUTPUT" + - name: Native compiled-JS runtime smoke without TS loader + run: node "${{ steps.fixtures.outputs.stage }}/runtime-smoke.mjs" "${{ steps.fixtures.outputs.stage }}/runtime-child.mjs" + - name: Native same-owner elevated daemon and different-account denial + if: runner.os != 'Windows' + run: node "${{ steps.fixtures.outputs.stage }}/runtime-privilege-smoke.mjs" "${{ steps.fixtures.outputs.stage }}/runtime-child.mjs" "${{ steps.fixtures.outputs.stage }}/runtime-denied-client.mjs" + - name: Prepare interactive Windows gate without launching Sandbox + if: runner.os == 'Windows' + run: node scripts/prepare-windows-runtime-sandbox.ts + - name: Prepare the separate, checksum-pinned upstream Caddy fixture + id: upstream + shell: bash + run: | + path="$(node scripts/prepare-caddy-gate.ts)" + echo "path=$path" >> "$GITHUB_OUTPUT" + - name: Allow the separate Linux fixture to bind web ports + if: runner.os == 'Linux' + run: sudo setcap cap_net_bind_service=+ep "${{ steps.upstream.outputs.path }}" + - name: Fetch the exact npm candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v7.0.0 + with: + name: cadder-npm-candidate + path: ${{ runner.temp }}/cadder-npm-candidate + - name: Verify packed npm and the shared real-Caddy catalog on the Node minimum + run: nub run verify:node "${{ steps.upstream.outputs.path }}" "${{ runner.temp }}/cadder-npm-candidate/${{ needs.npm-artifact.outputs.filename }}" + - name: Select the independently pinned SEA builder + uses: actions/setup-node@v7.0.0 + with: + node-version: 26.12.0 + - name: Build native SEA artifacts without publishing + id: sea + shell: bash + run: | + node scripts/pack-sea.ts > "$RUNNER_TEMP/cadder-sea.json" + node --input-type=module -e 'import fs from "node:fs"; import path from "node:path"; const report = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, "cadder-sea.json"), "utf8")); fs.appendFileSync(process.env.GITHUB_OUTPUT, "archive=" + report.archive + "\nstage=" + report.stage + "\n");' + - name: Verify extracted SEA without Node/npm on product PATH + run: node scripts/verify-sea.ts "${{ steps.sea.outputs.archive }}" + - name: Verify SEA through the same real-Caddy catalog + run: node scripts/verify-product-journey.ts "${{ steps.sea.outputs.stage }}" "${{ steps.upstream.outputs.path }}" + - name: Retain exact native candidate artifacts + uses: actions/upload-artifact@v7.0.1 + with: + name: cadder-node-candidate-${{ matrix.os }} + path: ${{ steps.sea.outputs.archive }} + if-no-files-found: error + - name: Verify no tracked build output + run: git diff --exit-code + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: node-coverage-${{ matrix.os }} + path: ${{ runner.temp }}/cadder-node-coverage + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index c6d0892..9066ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Rust / Cargo target/ +# Node dependencies and transient test output +.astro/ + # Build and release outputs artifacts/ dist/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..554f2a3 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "printWidth": 100 +} diff --git a/AGENTS.md b/AGENTS.md index 90c5f80..5e0c9e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,25 @@ mise run check ## Engineering Notes +### Node migration + +`openspec/changes/reset-cadder-architecture/node-migration.md` defines the approved +2.0 target: one TypeScript/Node product with equal npm and Node SEA distributions. +The released Rust 1.0.5 workspace and its existing packaging remain the baseline +until both migration distribution gates pass. Do not restore superseded 0.8 +crates or mix Rust and Node IPC. + +Use `nub ci` and `nub run check` for the root TypeScript workspace. Sources and +runtime fixtures live in `src/`, `test/`, and `scripts/*.ts`; generated outputs +and coverage belong in system or runner temp outside the checkout. The Node +runtime gate supplements, rather than replaces, the existing Rust, docs, npm +and cargo-dist checks. Under the approved migration sequencing, Windows Sandbox +privilege acceptance runs on the completed product before G7 acceptance, Rust +removal or release. It does not block Caddy integration implementation; deferred +evidence must remain open rather than be recorded as passed. + +### Released Rust baseline + - Cadder is cross-platform by default. OS-specific code must sit behind a small abstraction and keep Windows, Linux, and macOS behavior explicit. - Runtime state is per user and rooted in `directories::ProjectDirs`, with `CADDER_RUNTIME_DIR` available for tests and custom deployments. - IPC is versioned newline-delimited JSON over a per-user local socket via `interprocess`. diff --git a/crates/cadder-client/src/cli/output.rs b/crates/cadder-client/src/cli/output.rs index 27a7948..c27da8c 100644 --- a/crates/cadder-client/src/cli/output.rs +++ b/crates/cadder-client/src/cli/output.rs @@ -432,16 +432,20 @@ mod tests { LogEntry, LogEntryKind, LogSeverity, LogStreamIdentity, LogStreamStatus, OwnerProcessIdentity, RegisteredDomain, RuntimeDiagnostic, RuntimeState, SourcePath, }; - use chrono::Utc; + use chrono::{TimeZone, Utc}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + fn fixture_time() -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap() + } + fn registration( id: &str, project: &str, state: ActivationState, domains: &[(&str, ActivationState, Option<&str>)], ) -> EntrypointRegistration { - let now = Utc::now(); + let now = fixture_time(); let nonce = format!("{id}-nonce"); EntrypointRegistration { registration_id: id.to_string(), @@ -476,7 +480,7 @@ mod tests { } fn populated_snapshot() -> GuiStateSnapshot { - let now = Utc::now(); + let now = fixture_time(); GuiStateSnapshot { captured_at_utc: now, registrations: vec![ @@ -548,7 +552,7 @@ mod tests { #[test] fn list_and_status_outputs_cover_empty_and_populated_snapshots() { let empty = GuiStateSnapshot { - captured_at_utc: Utc::now(), + captured_at_utc: fixture_time(), registrations: Vec::new(), runtime: RuntimeState::idle(), config: ConfigState::idle(), @@ -651,7 +655,7 @@ mod tests { #[test] fn diagnostics_and_logs_cover_empty_and_detailed_reports() { let empty = GuiStateSnapshot { - captured_at_utc: Utc::now(), + captured_at_utc: fixture_time(), registrations: Vec::new(), runtime: RuntimeState::idle(), config: ConfigState::idle(), @@ -671,7 +675,7 @@ mod tests { stream_status: LogStreamStatus::Active, entries: vec![LogEntry { sequence_number: 7, - timestamp_utc: Utc::now(), + timestamp_utc: fixture_time(), severity: LogSeverity::Warn, stream, attribution_kind: LogAttributionKind::Domain, diff --git a/crates/cadder-daemon/tests/fixtures/test_process.rs b/crates/cadder-daemon/tests/fixtures/test_process.rs index 3b1bb8a..97fae4b 100644 --- a/crates/cadder-daemon/tests/fixtures/test_process.rs +++ b/crates/cadder-daemon/tests/fixtures/test_process.rs @@ -1,7 +1,7 @@ use std::{ env, fs::{self, OpenOptions}, - io::{self, Write}, + io::{self, Read, Write}, path::{Path, PathBuf}, process::{Command, ExitCode, Stdio}, thread, @@ -68,6 +68,10 @@ fn fake_caddy(directory: &Path, mode: &str) -> io::Result { let arguments = env::args().skip(1).collect::>(); append_line(directory.join("fake-caddy.log"), &arguments.join(" "))?; + if mode.starts_with("shim-passthrough-") { + return shim_passthrough(directory, mode, &arguments); + } + match arguments.first().map(String::as_str) { Some("adapt") => adapt(mode), Some("reload") => reload(directory, mode), @@ -77,6 +81,26 @@ fn fake_caddy(directory: &Path, mode: &str) -> io::Result { } } +fn shim_passthrough(directory: &Path, mode: &str, arguments: &[String]) -> io::Result { + let mut stdin = Vec::new(); + io::stdin().read_to_end(&mut stdin)?; + let record = serde_json::json!({ + "args": arguments, + "stdin_bytes": stdin, + }); + fs::write( + directory.join("shim-passthrough.json"), + serde_json::to_vec(&record).map_err(io::Error::other)?, + )?; + println!("FAKE_CADDY_PASSTHROUGH_STDOUT"); + eprintln!("FAKE_CADDY_PASSTHROUGH_STDERR"); + Ok(if mode == "shim-passthrough-failure" { + 23 + } else { + 0 + }) +} + fn adapt(mode: &str) -> io::Result { match mode { "adapter-fail-adapt" | "project-adapt-fail" => { diff --git a/crates/cadder-shim/tests/shim_binary.rs b/crates/cadder-shim/tests/shim_binary.rs index 17d2b65..ccdfbe9 100644 --- a/crates/cadder-shim/tests/shim_binary.rs +++ b/crates/cadder-shim/tests/shim_binary.rs @@ -1,7 +1,8 @@ use std::{ - fs, + env, fs, + io::Write, path::PathBuf, - process::{Command, Output}, + process::{Command, Output, Stdio}, }; fn run_shim(args: &[&str]) -> Output { @@ -22,6 +23,116 @@ fn unique_runtime_dir(name: &str) -> PathBuf { )) } +fn native_fixture_path() -> PathBuf { + let shim = PathBuf::from(env!("CARGO_BIN_EXE_cadder-shim")); + let profile = shim.parent().expect("shim binary has a profile directory"); + let path = profile.join(if cfg!(windows) { + "cadder-test-process.exe" + } else { + "cadder-test-process" + }); + assert!( + path.is_file(), + "missing native test process at {}; build it with `cargo build -p cadder-daemon --bin cadder-test-process` before this focused test", + path.display() + ); + path +} + +fn run_passthrough(mode: &str, input: &[u8], args: &[&str]) -> (tempfile::TempDir, Output) { + let temp = tempfile::tempdir().unwrap(); + let fixture_name = if cfg!(windows) { + "caddy-test-process.exe" + } else { + "caddy-test-process" + }; + let fixture = temp.path().join(fixture_name); + fs::copy(native_fixture_path(), &fixture).unwrap(); + fs::write(temp.path().join("cadder-test.mode"), mode).unwrap(); + // Copying the released shim makes its portable trusted config path disposable and deterministic. + let portable_config = format!("[caddy]\nreal_command = \"{fixture_name}\"\n"); + fs::write(temp.path().join("cadder.toml"), portable_config).unwrap(); + let shim_name = if cfg!(windows) { + "cadder-shim.exe" + } else { + "cadder-shim" + }; + let shim = temp.path().join(shim_name); + fs::copy(env!("CARGO_BIN_EXE_cadder-shim"), &shim).unwrap(); + + let path = env::join_paths([temp.path()]).unwrap(); + let mut child = Command::new(&shim) + .args(args) + .env("PATH", path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input_pipe = child.stdin.take().expect("shim stdin is piped"); + input_pipe.write_all(input).unwrap(); + drop(input_pipe); + (temp, wait_for_output(child)) +} + +fn wait_for_output(mut child: std::process::Child) -> Output { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if child.try_wait().unwrap().is_some() { + return child.wait_with_output().unwrap(); + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let output = child.wait_with_output().unwrap(); + panic!( + "released shim passthrough exceeded 10s; stdout={:?}, stderr={:?}", + output.stdout, output.stderr + ); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +fn passthrough_record(temp: &tempfile::TempDir) -> serde_json::Value { + serde_json::from_slice(&fs::read(temp.path().join("shim-passthrough.json")).unwrap()).unwrap() +} + +#[test] +fn real_caddy_passthrough_preserves_arguments_stdin_streams_and_success_exit() { + let args = [ + "fmt", + "name with spaces", + "żółć", + "quote\"value", + "apostrophe's", + ]; + let input = b"stdin bytes \xE2\x9C\x93\n"; + let (temp, output) = run_passthrough("shim-passthrough-success", input, &args); + + assert_eq!(output.status.code(), Some(0)); + assert_eq!(output.stdout, b"FAKE_CADDY_PASSTHROUGH_STDOUT\n"); + assert_eq!(output.stderr, b"FAKE_CADDY_PASSTHROUGH_STDERR\n"); + let record = passthrough_record(&temp); + assert_eq!(record["args"], serde_json::json!(args)); + assert_eq!(record["stdin_bytes"], serde_json::json!(input)); +} + +#[test] +fn real_caddy_passthrough_propagates_nonzero_child_exit() { + let (temp, output) = run_passthrough( + "shim-passthrough-failure", + b"failure input", + &["fmt", "--literal"], + ); + + assert_eq!(output.status.code(), Some(23)); + assert_eq!(output.stdout, b"FAKE_CADDY_PASSTHROUGH_STDOUT\n"); + assert_eq!(output.stderr, b"FAKE_CADDY_PASSTHROUGH_STDERR\n"); + let record = passthrough_record(&temp); + assert_eq!(record["args"], serde_json::json!(["fmt", "--literal"])); + assert_eq!(record["stdin_bytes"], serde_json::json!(b"failure input")); +} + #[test] fn shim_info_flag_reports_release_identity_json() { let output = run_shim(&["--cadder-shim-info"]); diff --git a/docs/NODE-CANDIDATE.md b/docs/NODE-CANDIDATE.md new file mode 100644 index 0000000..8279c18 --- /dev/null +++ b/docs/NODE-CANDIDATE.md @@ -0,0 +1,50 @@ +# Cadder 2.0 release candidate + +This is an unpublished 2.0.0-rc.1 candidate. Use the supplied npm tarball or the +standalone archive for your platform. Both provide `cadder`, `cadderd`, and the +`caddy` shim. Upstream Caddy is installed separately. + +The npm package requires Node 24 LTS, version 24.18.0 or later in the 24.x series: + +```sh +npm install --global ./cadder-2.0.0-rc.1.tgz +``` + +Standalone archives need no installed Node or npm. Verify the extracted files +against `SHA256SUMS`, keep the three executables and accompanying files together, +and add their directory to PATH. `BUILD.json` identifies the source and platform. + +## Configure Caddy + +Set one trusted upstream source in a per-user `cadder.toml`: + +- Windows: `%APPDATA%\Cadder\Cadder\config\cadder.toml`. +- macOS: `~/Library/Application Support/dev.Cadder.Cadder/cadder.toml`. +- Linux: `$XDG_CONFIG_HOME/cadder/cadder.toml`, or `~/.config/cadder/cadder.toml` when unset. + +Use the supplied `cadder.toml.example` as a starting point. For example, if +`caddy-real` is the name of your separately installed native Caddy executable: + +```toml +[caddy] +real_command = "caddy-real" +``` + +Alternatively, set `real_path` to its absolute path. Set only one selector. + +## Use Cadder + +Run `caddy run` from each project containing a Caddyfile. In another terminal, +use `cadder status`, `cadder projects list`, or `cadder tui`. + +In the TUI, Up/Down select a project or domain, Enter/Space toggle it, `s` asks +to stop the daemon, `r` restarts it, and `q` quits the TUI. Enter starts an +offline daemon. Run `cadder --help` for the current command surface. + +## Switch versions + +Stop the old daemon using its own `cadder daemon stop` before changing PATH. +Install a matching set of all three commands, then re-run `caddy run` in each +project. The candidate keeps separate runtime storage and does not import or +delete the Rust database. Keep the previous release for rollback; stop the +candidate before switching back and registering the projects again. diff --git a/docs/site/README.md b/docs/site/README.md index 89777bf..d02dfec 100644 --- a/docs/site/README.md +++ b/docs/site/README.md @@ -14,14 +14,14 @@ mise run docs-build When working directly in this package, run commands from `docs/site`. ```sh -npm ci -npm run dev -npm run check -npm run build -npm run preview +nub ci +nub run dev +nub run check +nub run build +nub run preview ``` -`npm run build` writes generated output to `docs/site/dist`. Do not commit generated output, `.astro`, cache directories, or preview artifacts. +`nub run build` writes generated output to `docs/site/dist`. Do not commit generated output, `.astro`, cache directories, or preview artifacts. ## Content source @@ -35,6 +35,6 @@ The repository CI validates documentation from source on `master` through `mise mise run docs-check ``` -Release or publishing workflows may additionally run `mise run docs-build` and upload `docs/site/dist`, but they should not write generated site output back to the repository. +The documentation workflow runs `mise run docs-build` and uploads `docs/site/dist`. Production documentation is published below `https://maxie.dev/cadder/`. diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index b651010..4628453 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -65,6 +65,7 @@ export default defineConfig({ label: 'Reference', items: [ { label: 'Releases', slug: 'reference/releases' }, + { label: 'Try the 2.0 candidate', slug: 'reference/node-candidate' }, { label: 'Runtime configuration', slug: 'reference/runtime-configuration' }, { label: 'Real Caddy resolution', slug: 'reference/real-caddy-resolution' }, ], diff --git a/docs/site/nub.lock b/docs/site/nub.lock new file mode 100644 index 0000000..8391617 --- /dev/null +++ b/docs/site/nub.lock @@ -0,0 +1,4002 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@astrojs/check': + specifier: 0.9.10 + version: 0.9.10(typescript@6.0.3) + '@astrojs/starlight': + specifier: 0.42.1 + version: 0.42.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2)(typescript@6.0.3) + '@fontsource-variable/fira-code': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource-variable/space-grotesk': + specifier: 5.3.0 + version: 5.3.0 + '@fortawesome/free-solid-svg-icons': + specifier: 7.3.1 + version: 7.3.1 + astro: + specifier: 7.3.2 + version: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2) + astro-expressive-code: + specifier: 0.44.2 + version: 0.44.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2) + sharp: + specifier: 0.35.4 + version: 0.35.4(@types/node@25.9.2) + simple-icons: + specifier: 16.31.0 + version: 16.31.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + +packages: + + '@astrojs/check@0.9.10': + resolution: {integrity: sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + + '@astrojs/compiler-binding-android-arm64@0.4.1': + resolution: {integrity: sha512-Wtx2DZORuNTLASLLZ8hlrjFaJe1LUloatcCn9EPq7knBhRpjMGXeJANIJMeBZG4igvz7x+3nyAJM2iwCSW9xog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@astrojs/compiler-binding-darwin-arm64@0.4.1': + resolution: {integrity: sha512-7iwXcU+hB60Y1SvOfnWHNioT72gDsDQKaDipXh2Onwp/TvQMozhbvhy13wJR8Af940a0h6PbddCeJFpj791UDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.4.1': + resolution: {integrity: sha512-pifjICos49RXhSVKSDu5gDB0/8j0O7DmxSp1DaWW2PyLG5ekwSkcrfOZto5JqhdI7QYMZB3XOiXpsdOXpS4npA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.1': + resolution: {integrity: sha512-hsZMlNWc6LqWHh9LlSwTq1/XCY7pxA5rkkKIdZRV8mgbOz9OwLvjEoe5Jry13eIZjMUQO8XnQyzk8o/Pjw2YFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-musl@0.4.1': + resolution: {integrity: sha512-a3EW27f+Z9uf57vYmpLgUriA1y9wDhKz57PT8D8XlFZt2HZEWhUxKuxI1jvKVI9p8okYee11WXEm/FHY1G9yoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-gnu@0.4.1': + resolution: {integrity: sha512-XruPTKB/SS4DFzeAjQ1ZyM0ieHDjapHkOSnnzRApZ4FXY2YAI7UY3mETjfJiIS/acyZyrnPH5jObv1gATTdUJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-musl@0.4.1': + resolution: {integrity: sha512-E8j7lGanqNpudtkUbsFaOLJhmwFe4Ecqux7K9hkj4wULIPNZRM2bYZNcMSj8r1oWvaUiwZl8LaaNwv3+14jqhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-wasm32-wasi@0.4.1': + resolution: {integrity: sha512-vNLxIv41ZE+nmPFtw24cRW5ZXG+ZPcAGtgigtjCP8lvcxKI7u2GrXoMsCgEH5fMw8WCEJpwLvsVBg15rJKAkyA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.1': + resolution: {integrity: sha512-/xTDgRsFDoTh0/+KFXJ74DY4Jqn8CoSHwa8UP5RAblHfnRHkenwfSl4zYDET61O9vXKLk09UzHjjq09yP20/AQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.4.1': + resolution: {integrity: sha512-0x5J6iHZO0Fjo/CVzoIbXKzWnGESftDbEUQ/oXQ61HhUAFoR7+T1jfToZSNAS+gYyBXHSTpMWYC7lzarbhClcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.4.1': + resolution: {integrity: sha512-ZYVDW1P58OXyxqZbOMp+/2U9KA7y2esIx9r4pWdpZ/l6fCZhaw4ul+D7EgpRVxYLkyTWqzANbTTUFGPFP8zDHA==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.4.1': + resolution: {integrity: sha512-//NmAuRhy7eU9iP0ceI5Pymy55M+nUZ//kS6XeOyhBWdDib2KbJOyFaVJ52k7sDkJwTKVox7oPu3SpYiVnGm5A==} + engines: {node: '>=22.12.0'} + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/internal-helpers@0.11.0': + resolution: {integrity: sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==} + + '@astrojs/language-server@2.16.16': + resolution: {integrity: sha512-KuS17AOOqH/M5mHXPmKvtp8L+NNteARC7Xjf395+9R9dtJOBndqdStwU4V+OKzYZpgZ+hliV13knzx/oMtFl7Q==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-satteri@0.4.1': + resolution: {integrity: sha512-EniHbFNa6SQHDih7JnpPos3NWXO6hdt4raBcOosfGmlfc3gP9CI/sESA3VOf1PgJZ7SFfewlZW9Livn0JugEZg==} + + '@astrojs/mdx@8.0.1': + resolution: {integrity: sha512-VKodp/f3+XE6L0950K+ZOAolY47vdt0R78Ao+L2RdJ1hfAdYffL7IEjc/MsoyuS7CPVvCsd5/I3RP0ZyAIewdQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + '@astrojs/markdown-satteri': ^0.4.0 + astro: ^7.2.6 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/sitemap@3.7.3': {} + + '@astrojs/starlight@0.42.1': + resolution: {integrity: sha512-ABS/A0IfbJeMbuhCKVkrAcO3EWxhpeLveFuL28+7+BQje7ZHa9OXZbUoiLXdYUhH7XFaiAQjAg44RiF2uuZPJw==} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + astro: ^7.2.10 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/telemetry@3.3.3': + resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/yaml2ts@0.2.4': {} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bruits/satteri-darwin-arm64@0.10.5': + resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.10.5': + resolution: {integrity: sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + resolution: {integrity: sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-arm64-musl@0.10.5': + resolution: {integrity: sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@bruits/satteri-linux-x64-gnu@0.10.5': + resolution: {integrity: sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-x64-musl@0.10.5': + resolution: {integrity: sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@bruits/satteri-wasm32-wasi@0.10.5': + resolution: {integrity: sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + resolution: {integrity: sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.10.5': + resolution: {integrity: sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==} + cpu: [x64] + os: [win32] + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@ctrl/tinycolor@4.2.1': + resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} + engines: {node: '>=14'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@expressive-code/core@0.44.2': + resolution: {integrity: sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==} + + '@expressive-code/plugin-frames@0.44.2': + resolution: {integrity: sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==} + + '@expressive-code/plugin-shiki@0.44.2': + resolution: {integrity: sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==} + + '@expressive-code/plugin-text-markers@0.44.2': + resolution: {integrity: sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==} + + '@fontsource-variable/fira-code@5.3.0': {} + + '@fontsource-variable/space-grotesk@5.3.0': {} + + '@fortawesome/fontawesome-common-types@7.3.1': + engines: {node: '>=6'} + + '@fortawesome/free-solid-svg-icons@7.3.1': + engines: {node: '>=6'} + + '@img/colour@1.1.0': + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-project/types@0.149.0': + resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + + '@pagefind/darwin-arm64@1.5.2': + resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.2': + resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==} + cpu: [x64] + os: [darwin] + + '@pagefind/default-ui@1.5.2': {} + + '@pagefind/freebsd-x64@1.5.2': + resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.2': + resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.2': + resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.2': + resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.2': + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm-eabi@1.2.8': + resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.8': + resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.8': + resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.8': + resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.8': + resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.8': + resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.8': + resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.8': + resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.8': + resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.8': + resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@shikijs/core@4.4.3': + resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.4.3': + resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.4.3': + resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} + engines: {node: '>=20'} + + '@shikijs/langs@4.4.3': + resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.4.3': + resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} + engines: {node: '>=20'} + + '@shikijs/themes@4.4.3': + resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} + engines: {node: '>=20'} + + '@shikijs/types@4.4.3': + resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + + '@types/debug@4.1.13': {} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/js-yaml@4.0.9': {} + + '@types/mdast@4.0.4': {} + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@24.13.1': {} + + '@types/node@25.9.2': {} + + '@types/sax@1.2.7': {} + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@volar/kit@2.4.28': + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': {} + + '@volar/language-server@2.4.28': {} + + '@volar/language-service@2.4.28': {} + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': {} + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + am-i-vibing@0.4.0: + resolution: {integrity: sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==} + hasBin: true + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: {} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + astro-expressive-code@0.44.2: + resolution: {integrity: sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==} + peerDependencies: + astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0 + + astro@7.3.2: + resolution: {integrity: sha512-ysTcdpGP61XZpHoMRYC/CK19DQ94qkBvzXMtXEo0gEqPNkmOU/Tv++CtWmzaI4nF2Ck8RXFdiWvdlTWa2oOr9w==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: {} + + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.0: {} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@4.0.3: + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-selector-parser@3.3.0: + resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: {} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + engines: {node: '>=8'} + + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + + devlop@1.1.0: {} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expressive-code@0.44.2: + resolution: {integrity: sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-proc@0.1.0: + resolution: {integrity: sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==} + engines: {node: ^20.19.0 || >=22.12.0} + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-select@6.0.4: + resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + i18next@26.3.1: + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: {} + + is-decimal@2.0.1: {} + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: + engines: {node: '>=12'} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: + engines: {node: '>=6'} + + klona@2.0.6: + engines: {node: '>= 8'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: {} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@1.4.1: + resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + mdast-util-directive@3.1.0: {} + + mdast-util-from-markdown@2.0.3: {} + + mdast-util-phrasing@4.1.0: {} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: {} + + mdast-util-to-string@4.0.0: {} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromark-core-commonmark@2.0.3: {} + + micromark-extension-directive@4.0.0: {} + + micromark-factory-destination@2.0.1: {} + + micromark-factory-label@2.0.1: {} + + micromark-factory-space@2.0.1: {} + + micromark-factory-title@2.0.1: {} + + micromark-factory-whitespace@2.0.1: {} + + micromark-util-character@2.1.1: {} + + micromark-util-chunked@2.0.1: {} + + micromark-util-classify-character@2.0.1: {} + + micromark-util-combine-extensions@2.0.1: {} + + micromark-util-decode-numeric-character-reference@2.0.2: {} + + micromark-util-decode-string@2.0.1: {} + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: {} + + micromark-util-resolve-all@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: {} + + micromark-util-subtokenize@2.1.0: {} + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: {} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + p-limit@7.3.2: + resolution: {integrity: sha512-Ll0w3fU24vYpXoZmjjZIee6bJQDgG0oAyo1PdmFYI8UDwJJddaHAypxIH9avUu+t+lSsAwKVsb1jDCMIIChliw==} + engines: {node: '>=20'} + + p-queue@9.3.3: + resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==} + engines: {node: '>=20'} + + p-timeout@7.0.2: + resolution: {integrity: sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==} + engines: {node: '>=20'} + + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + pagefind@1.5.2: + hasBin: true + + parse-entities@4.0.2: {} + + path-browserify@1.0.1: {} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + readdirp@4.1.2: + engines: {node: '>= 14.18.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-expressive-code@0.44.2: + resolution: {integrity: sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==} + + remark-directive@4.0.0: {} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: {} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + rolldown@1.2.8: + resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + satteri@0.10.5: + resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shiki@4.4.3: + resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} + engines: {node: '>=20'} + + simple-icons@16.31.0: + engines: {node: '>=0.12.18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@9.0.1: + engines: {node: '>=20.19.5', npm: '>=10.8.2'} + hasBin: true + + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stream-replace-string@2.0.0: {} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + + stringify-entities@4.0.4: {} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinyclip@0.1.15: + resolution: {integrity: sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: {} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + + typescript@6.0.3: + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.18.2: {} + + undici-types@7.24.6: {} + + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + + unified@11.0.5: {} + + unifont@0.7.5: + resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: {} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: {} + + unist-util-visit-parents@6.0.2: {} + + unist-util-visit@5.1.0: {} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + url-extras@0.1.0: + resolution: {integrity: sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==} + engines: {node: '>=20'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-message@4.0.3: {} + + vfile@6.0.3: {} + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + volar-service-css@0.0.71: + resolution: {integrity: sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.71: + resolution: {integrity: sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.71: + resolution: {integrity: sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.71: + resolution: {integrity: sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.71: + resolution: {integrity: sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.71: + resolution: {integrity: sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.71: + resolution: {integrity: sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: {} + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver-types@3.18.3: + resolution: {integrity: sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==} + + vscode-languageserver@9.0.1: + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.1.0: {} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml-language-server@1.23.0: + resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.9.0: + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.1.0: + resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + + zwitch@2.0.4: {} + +snapshots: + + '@astrojs/check@0.9.10(typescript@6.0.3)': + dependencies: + '@astrojs/language-server': 2.16.16(typescript@6.0.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 6.0.3 + yargs: 18.1.0 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + + '@astrojs/compiler-binding-android-arm64@0.4.1': + optional: true + + '@astrojs/compiler-binding-darwin-arm64@0.4.1': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.4.1': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.1': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.4.1': + optional: true + + '@astrojs/compiler-binding@0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + optionalDependencies: + '@astrojs/compiler-binding-android-arm64': 0.4.1 + '@astrojs/compiler-binding-darwin-arm64': 0.4.1 + '@astrojs/compiler-binding-darwin-x64': 0.4.1 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.4.1 + '@astrojs/compiler-binding-linux-arm64-musl': 0.4.1 + '@astrojs/compiler-binding-linux-x64-gnu': 0.4.1 + '@astrojs/compiler-binding-linux-x64-musl': 0.4.1 + '@astrojs/compiler-binding-wasm32-wasi': 0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-binding-win32-arm64-msvc': 0.4.1 + '@astrojs/compiler-binding-win32-x64-msvc': 0.4.1 + + '@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + dependencies: + '@astrojs/compiler-binding': 0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/internal-helpers@0.11.0': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + js-yaml: 4.3.2 + picomatch: 4.0.7 + retext-smartypants: 6.2.0 + shiki: 4.4.3 + smol-toml: 1.8.0 + unified: 11.0.5 + + '@astrojs/language-server@2.16.16(typescript@6.0.3)': + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/yaml2ts': 0.2.4 + '@jridgewell/sourcemap-codec': 1.6.0 + '@volar/kit': 2.4.28(typescript@6.0.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28 + '@volar/language-service': 2.4.28 + muggle-string: 0.4.1 + tinyglobby: 0.2.17 + volar-service-css: 0.0.71(@volar/language-service@2.4.28) + volar-service-emmet: 0.0.71(@volar/language-service@2.4.28) + volar-service-html: 0.0.71(@volar/language-service@2.4.28) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28) + volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28) + volar-service-yaml: 0.0.71(@volar/language-service@2.4.28) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.1.0 + transitivePeerDependencies: + - prettier + + '@astrojs/markdown-satteri@0.4.1': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + satteri: 0.10.5 + + '@astrojs/mdx@8.0.1(@astrojs/markdown-satteri@0.4.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2)': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/markdown-satteri': 0.4.1 + astro: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2) + es-module-lexer: 2.3.2 + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/sitemap@3.7.3': + dependencies: + sitemap: 9.0.1 + stream-replace-string: 2.0.0 + zod: 4.6.5 + + '@astrojs/starlight@0.42.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2)(typescript@6.0.3)': + dependencies: + '@astrojs/markdown-satteri': 0.4.1 + '@astrojs/mdx': 8.0.1(@astrojs/markdown-satteri@0.4.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2) + '@astrojs/sitemap': 3.7.3 + '@pagefind/default-ui': 1.5.2 + '@types/hast': 3.0.5 + '@types/js-yaml': 4.0.9 + '@types/mdast': 4.0.4 + astro: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2) + astro-expressive-code: 0.44.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2) + bcp-47: 2.1.0 + hast-util-format: 1.1.0 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-string: 3.0.1 + hastscript: 9.0.1 + i18next: 26.3.1(typescript@6.0.3) + js-yaml: 4.3.2 + klona: 2.0.6 + magic-string: 1.4.1 + mdast-util-directive: 3.1.0 + mdast-util-to-markdown: 2.1.2 + mdast-util-to-string: 4.0.0 + pagefind: 1.5.2 + remark-directive: 4.0.0 + satteri: 0.10.5 + ultrahtml: 1.7.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - '@astrojs/markdown-remark' + - supports-color + + '@astrojs/telemetry@3.3.3': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + package-manager-detector: 1.8.0 + + '@astrojs/yaml2ts@0.2.4': + dependencies: + yaml: 2.9.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bruits/satteri-darwin-arm64@0.10.5': + optional: true + + '@bruits/satteri-darwin-x64@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-musl@0.10.5': + optional: true + + '@bruits/satteri-wasm32-wasi@0.10.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.10.5': + optional: true + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@ctrl/tinycolor@4.2.1': {} + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@expressive-code/core@0.44.2': + dependencies: + '@ctrl/tinycolor': 4.2.1 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hastscript: 9.0.1 + postcss: 8.5.28 + postcss-nested: 6.2.0(postcss@8.5.28) + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + + '@expressive-code/plugin-frames@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@expressive-code/plugin-shiki@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + shiki: 4.4.3 + + '@expressive-code/plugin-text-markers@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@fontsource-variable/fira-code@5.3.0': {} + + '@fontsource-variable/space-grotesk@5.3.0': {} + + '@fortawesome/fontawesome-common-types@7.3.1': {} + + '@fortawesome/free-solid-svg-icons@7.3.1': + dependencies: + '@fortawesome/fontawesome-common-types': 7.3.1 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@oslojs/encoding@1.1.0': {} + + '@oxc-project/types@0.149.0': {} + + '@pagefind/darwin-arm64@1.5.2': + optional: true + + '@pagefind/darwin-x64@1.5.2': + optional: true + + '@pagefind/default-ui@1.5.2': {} + + '@pagefind/freebsd-x64@1.5.2': + optional: true + + '@pagefind/linux-arm64@1.5.2': + optional: true + + '@pagefind/linux-x64@1.5.2': + optional: true + + '@pagefind/windows-arm64@1.5.2': + optional: true + + '@pagefind/windows-x64@1.5.2': + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.8': + optional: true + + '@rolldown/binding-android-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-x64@1.2.8': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.8': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.8': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.8': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.8': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.8': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@shikijs/core@4.4.3': + dependencies: + '@shikijs/primitive': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + + '@shikijs/primitive@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + + '@shikijs/types@4.4.3': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/js-yaml@4.0.9': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@24.13.1': + dependencies: + undici-types: 7.18.2 + + '@types/node@25.9.2': + dependencies: + undici-types: 7.24.6 + + '@types/sax@1.2.7': + dependencies: + '@types/node': 25.9.2 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.4.0': {} + + '@volar/kit@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + typesafe-path: 0.2.2 + typescript: 6.0.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-service@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.3 + vscode-uri: 3.1.0 + + '@vscode/l10n@0.0.18': {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + am-i-vibing@0.4.0: + dependencies: + process-ancestry: 0.1.0 + + ansi-regex@6.3.0: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + astro-expressive-code@0.44.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2)(astro@7.3.2): + dependencies: + astro: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2) + rehype-expressive-code: 0.44.2 + url-extras: 0.1.0 + + astro@7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.9.2): + dependencies: + '@astrojs/compiler-rs': 0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/markdown-satteri': 0.4.1 + '@astrojs/telemetry': 3.3.3 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.8.1 + '@oslojs/encoding': 1.1.0 + am-i-vibing: 0.4.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 2.0.1 + devalue: 5.9.4 + diff: 9.0.0 + dset: 3.1.4 + es-module-lexer: 2.3.2 + esbuild: 0.28.2 + find-proc: 0.1.0 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.3.2 + jsonc-parser: 3.3.1 + magic-string: 1.4.1 + magicast: 0.5.5 + mrmime: 2.0.1 + neotraverse: 1.0.1 + obug: 2.2.1 + p-limit: 7.3.2 + p-queue: 9.3.3 + package-manager-detector: 1.8.0 + piccolore: 0.1.3 + picomatch: 4.0.7 + semver: 7.8.5 + shiki: 4.4.3 + smol-toml: 1.8.0 + svgo: 4.1.0 + tinyclip: 0.1.15 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + ultrahtml: 1.7.0 + unifont: 0.7.5 + unstorage: 1.17.5 + vite: 8.3.0(@types/node@25.9.2)(esbuild@0.28.2) + vitefu: 1.1.3(vite@8.3.0(@types/node@25.9.2)(esbuild@0.28.2)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.6.5 + optionalDependencies: + sharp: 0.35.4(@types/node@25.9.2) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + bcp-47-match@2.0.3: {} + + bcp-47@2.1.0: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + + boolbase@1.0.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + ci-info@4.4.0: {} + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clsx@2.1.1: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@2.0.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-selector-parser@3.3.0: {} + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@7.0.0: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + defu@6.1.7: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devalue@5.9.4: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@9.0.0: {} + + direction@2.0.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@10.6.0: {} + + entities@4.5.0: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + eventemitter3@5.0.4: {} + + expressive-code@0.44.2: + dependencies: + '@expressive-code/core': 0.44.2 + '@expressive-code/plugin-frames': 0.44.2 + '@expressive-code/plugin-shiki': 0.44.2 + '@expressive-code/plugin-text-markers': 0.44.2 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.8: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.7): + dependencies: + picomatch: 4.0.7 + + find-proc@0.1.0: {} + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.2 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-select@6.0.4: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.3.0 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + http-cache-semantics@4.2.0: {} + + i18next@26.3.1(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + iron-webcrypto@1.2.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-docker@4.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: {} + + klona@2.0.6: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + 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 + + longest-streak@3.1.0: {} + + lru-cache@11.5.2: {} + + magic-string@1.4.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.19: {} + + neotraverse@1.0.1: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.5: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.2.1: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.12: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@7.3.2: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.3.3: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.2 + + p-timeout@7.0.2: {} + + package-manager-detector@1.8.0: {} + + pagefind@1.5.2: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.2 + '@pagefind/darwin-x64': 1.5.2 + '@pagefind/freebsd-x64': 1.5.2 + '@pagefind/linux-arm64': 1.5.2 + '@pagefind/linux-x64': 1.5.2 + '@pagefind/windows-arm64': 1.5.2 + '@pagefind/windows-x64': 1.5.2 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + path-browserify@1.0.1: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + postcss-nested@6.2.0(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + prismjs@1.30.0: {} + + process-ancestry@0.1.0: {} + + property-information@7.2.0: {} + + radix3@1.1.2: {} + + readdirp@4.1.2: {} + + readdirp@5.1.1: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-expressive-code@0.44.2: + dependencies: + expressive-code: 0.44.2 + + remark-directive@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.0 + micromark-extension-directive: 4.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + rolldown@1.2.8: + dependencies: + '@oxc-project/types': 0.149.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.8 + '@rolldown/binding-android-arm64': 1.2.8 + '@rolldown/binding-darwin-arm64': 1.2.8 + '@rolldown/binding-darwin-x64': 1.2.8 + '@rolldown/binding-freebsd-x64': 1.2.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 + '@rolldown/binding-linux-arm64-gnu': 1.2.8 + '@rolldown/binding-linux-arm64-musl': 1.2.8 + '@rolldown/binding-linux-ppc64-gnu': 1.2.8 + '@rolldown/binding-linux-s390x-gnu': 1.2.8 + '@rolldown/binding-linux-x64-gnu': 1.2.8 + '@rolldown/binding-linux-x64-musl': 1.2.8 + '@rolldown/binding-openharmony-arm64': 1.2.8 + '@rolldown/binding-win32-arm64-msvc': 1.2.8 + '@rolldown/binding-win32-x64-msvc': 1.2.8 + + satteri@0.10.5: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.10.5 + '@bruits/satteri-darwin-x64': 0.10.5 + '@bruits/satteri-linux-arm64-gnu': 0.10.5 + '@bruits/satteri-linux-arm64-musl': 0.10.5 + '@bruits/satteri-linux-x64-gnu': 0.10.5 + '@bruits/satteri-linux-x64-musl': 0.10.5 + '@bruits/satteri-wasm32-wasi': 0.10.5 + '@bruits/satteri-win32-arm64-msvc': 0.10.5 + '@bruits/satteri-win32-x64-msvc': 0.10.5 + + sax@1.6.1: {} + + semver@7.8.5: {} + + sharp@0.35.4(@types/node@25.9.2): + dependencies: + '@img/colour': 1.1.0 + '@types/node': 25.9.2 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + + shiki@4.4.3: + dependencies: + '@shikijs/core': 4.4.3 + '@shikijs/engine-javascript': 4.4.3 + '@shikijs/engine-oniguruma': 4.4.3 + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + simple-icons@16.31.0: {} + + sisteransi@1.0.5: {} + + sitemap@9.0.1: + dependencies: + '@types/node': 24.13.1 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.6.1 + + smol-toml@1.8.0: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + stream-replace-string@2.0.0: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + + tiny-inflate@1.0.3: {} + + tinyclip@0.1.15: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: + optional: true + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.8.5 + + typescript@6.0.3: {} + + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + + undici-types@7.18.2: {} + + undici-types@7.24.6: {} + + undici@8.10.2: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.5: + dependencies: + css-tree: 3.2.1 + ohash: 2.0.12 + undici: 8.10.2 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.2 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + url-extras@0.1.0: {} + + util-deprecate@1.0.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.3.0(@types/node@25.9.2)(esbuild@0.28.2): + dependencies: + '@types/node': 25.9.2 + esbuild: 0.28.2 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitefu@1.1.3(vite@8.3.0(@types/node@25.9.2)(esbuild@0.28.2)): + dependencies: + vite: 8.3.0(@types/node@25.9.2)(esbuild@0.28.2) + + volar-service-css@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + volar-service-emmet@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@volar/language-service': 2.4.28 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.1.0 + + volar-service-html@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + volar-service-prettier@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + vscode-uri: 3.1.0 + + volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + vscode-uri: 3.1.0 + + volar-service-typescript@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + path-browserify: 1.0.1 + semver: 7.8.5 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + + volar-service-yaml@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@volar/language-service': 2.4.28 + vscode-uri: 3.1.0 + yaml-language-server: 1.23.0 + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.3 + vscode-uri: 3.1.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.3 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver-types@3.18.3: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.1.0: {} + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yaml-language-server@1.23.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + prettier: 3.9.6 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.3 + vscode-uri: 3.1.0 + yaml: 2.8.3 + + yaml@2.8.3: {} + + yaml@2.9.0: {} + + yargs-parser@22.0.0: {} + + yargs@18.1.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.2.2 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@1.2.2: {} + + zod@4.6.5: {} + + zwitch@2.0.4: {} diff --git a/docs/site/package-lock.json b/docs/site/package-lock.json deleted file mode 100644 index 5b5dd49..0000000 --- a/docs/site/package-lock.json +++ /dev/null @@ -1,6773 +0,0 @@ -{ - "name": "cadder-docs", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "cadder-docs", - "devDependencies": { - "@astrojs/check": "0.9.10", - "@astrojs/starlight": "0.42.1", - "@fontsource-variable/fira-code": "5.3.0", - "@fontsource-variable/space-grotesk": "5.3.0", - "@fortawesome/free-solid-svg-icons": "7.3.1", - "astro": "7.3.2", - "sharp": "0.35.4", - "simple-icons": "16.31.0", - "typescript": "6.0.3" - } - }, - "node_modules/@astrojs/check": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.10.tgz", - "integrity": "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/language-server": "^2.16.7", - "chokidar": "^4.0.3", - "kleur": "^4.1.5", - "yargs": "^18.0.0" - }, - "bin": { - "astro-check": "bin/astro-check.js" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^6.0.0" - } - }, - "node_modules/@astrojs/compiler-binding": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.4.1.tgz", - "integrity": "sha512-ZYVDW1P58OXyxqZbOMp+/2U9KA7y2esIx9r4pWdpZ/l6fCZhaw4ul+D7EgpRVxYLkyTWqzANbTTUFGPFP8zDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@astrojs/compiler-binding-android-arm64": "0.4.1", - "@astrojs/compiler-binding-darwin-arm64": "0.4.1", - "@astrojs/compiler-binding-darwin-x64": "0.4.1", - "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.1", - "@astrojs/compiler-binding-linux-arm64-musl": "0.4.1", - "@astrojs/compiler-binding-linux-x64-gnu": "0.4.1", - "@astrojs/compiler-binding-linux-x64-musl": "0.4.1", - "@astrojs/compiler-binding-wasm32-wasi": "0.4.1", - "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.1", - "@astrojs/compiler-binding-win32-x64-msvc": "0.4.1" - } - }, - "node_modules/@astrojs/compiler-binding-android-arm64": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-android-arm64/-/compiler-binding-android-arm64-0.4.1.tgz", - "integrity": "sha512-Wtx2DZORuNTLASLLZ8hlrjFaJe1LUloatcCn9EPq7knBhRpjMGXeJANIJMeBZG4igvz7x+3nyAJM2iwCSW9xog==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-darwin-arm64": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.4.1.tgz", - "integrity": "sha512-7iwXcU+hB60Y1SvOfnWHNioT72gDsDQKaDipXh2Onwp/TvQMozhbvhy13wJR8Af940a0h6PbddCeJFpj791UDA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-darwin-x64": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.4.1.tgz", - "integrity": "sha512-pifjICos49RXhSVKSDu5gDB0/8j0O7DmxSp1DaWW2PyLG5ekwSkcrfOZto5JqhdI7QYMZB3XOiXpsdOXpS4npA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.4.1.tgz", - "integrity": "sha512-hsZMlNWc6LqWHh9LlSwTq1/XCY7pxA5rkkKIdZRV8mgbOz9OwLvjEoe5Jry13eIZjMUQO8XnQyzk8o/Pjw2YFA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.4.1.tgz", - "integrity": "sha512-a3EW27f+Z9uf57vYmpLgUriA1y9wDhKz57PT8D8XlFZt2HZEWhUxKuxI1jvKVI9p8okYee11WXEm/FHY1G9yoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.4.1.tgz", - "integrity": "sha512-XruPTKB/SS4DFzeAjQ1ZyM0ieHDjapHkOSnnzRApZ4FXY2YAI7UY3mETjfJiIS/acyZyrnPH5jObv1gATTdUJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-x64-musl": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.4.1.tgz", - "integrity": "sha512-E8j7lGanqNpudtkUbsFaOLJhmwFe4Ecqux7K9hkj4wULIPNZRM2bYZNcMSj8r1oWvaUiwZl8LaaNwv3+14jqhw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.4.1.tgz", - "integrity": "sha512-vNLxIv41ZE+nmPFtw24cRW5ZXG+ZPcAGtgigtjCP8lvcxKI7u2GrXoMsCgEH5fMw8WCEJpwLvsVBg15rJKAkyA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.2.4" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.4.1.tgz", - "integrity": "sha512-/xTDgRsFDoTh0/+KFXJ74DY4Jqn8CoSHwa8UP5RAblHfnRHkenwfSl4zYDET61O9vXKLk09UzHjjq09yP20/AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.4.1.tgz", - "integrity": "sha512-0x5J6iHZO0Fjo/CVzoIbXKzWnGESftDbEUQ/oXQ61HhUAFoR7+T1jfToZSNAS+gYyBXHSTpMWYC7lzarbhClcg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-rs": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.4.1.tgz", - "integrity": "sha512-//NmAuRhy7eU9iP0ceI5Pymy55M+nUZ//kS6XeOyhBWdDib2KbJOyFaVJ52k7sDkJwTKVox7oPu3SpYiVnGm5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/compiler-binding": "0.4.1" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@astrojs/internal-helpers": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.11.0.tgz", - "integrity": "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.4", - "@types/mdast": "^4.0.4", - "js-yaml": "^4.3.0", - "picomatch": "^4.0.4", - "retext-smartypants": "^6.2.0", - "shiki": "^4.0.2", - "smol-toml": "^1.6.0", - "unified": "^11.0.5" - } - }, - "node_modules/@astrojs/language-server": { - "version": "2.16.16", - "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.16.tgz", - "integrity": "sha512-KuS17AOOqH/M5mHXPmKvtp8L+NNteARC7Xjf395+9R9dtJOBndqdStwU4V+OKzYZpgZ+hliV13knzx/oMtFl7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/compiler": "^2.13.1", - "@astrojs/yaml2ts": "^0.2.4", - "@jridgewell/sourcemap-codec": "^1.5.5", - "@volar/kit": "~2.4.28", - "@volar/language-core": "~2.4.28", - "@volar/language-server": "~2.4.28", - "@volar/language-service": "~2.4.28", - "muggle-string": "^0.4.1", - "tinyglobby": "^0.2.16", - "volar-service-css": "0.0.71", - "volar-service-emmet": "0.0.71", - "volar-service-html": "0.0.71", - "volar-service-prettier": "0.0.71", - "volar-service-typescript": "0.0.71", - "volar-service-typescript-twoslash-queries": "0.0.71", - "volar-service-yaml": "0.0.71", - "vscode-html-languageservice": "^5.6.2", - "vscode-uri": "^3.1.0" - }, - "bin": { - "astro-ls": "bin/nodeServer.js" - }, - "peerDependencies": { - "prettier": "^3.0.0", - "prettier-plugin-astro": ">=0.11.0" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - }, - "prettier-plugin-astro": { - "optional": true - } - } - }, - "node_modules/@astrojs/language-server/node_modules/@astrojs/compiler": { - "version": "2.13.1", - "dev": true, - "license": "MIT" - }, - "node_modules/@astrojs/markdown-satteri": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.1.tgz", - "integrity": "sha512-EniHbFNa6SQHDih7JnpPos3NWXO6hdt4raBcOosfGmlfc3gP9CI/sESA3VOf1PgJZ7SFfewlZW9Livn0JugEZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/prism": "4.0.2", - "github-slugger": "^2.0.0", - "satteri": "^0.10.3" - } - }, - "node_modules/@astrojs/mdx": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-8.0.1.tgz", - "integrity": "sha512-VKodp/f3+XE6L0950K+ZOAolY47vdt0R78Ao+L2RdJ1hfAdYffL7IEjc/MsoyuS7CPVvCsd5/I3RP0ZyAIewdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/markdown-satteri": "0.4.1", - "es-module-lexer": "^2.0.0" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "@astrojs/markdown-remark": "^7.3.0", - "@astrojs/markdown-satteri": "^0.4.0", - "astro": "^7.2.6" - }, - "peerDependenciesMeta": { - "@astrojs/markdown-remark": { - "optional": true - } - } - }, - "node_modules/@astrojs/prism": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", - "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "prismjs": "^1.30.0" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@astrojs/sitemap": { - "version": "3.7.3", - "dev": true, - "license": "MIT", - "dependencies": { - "sitemap": "^9.0.0", - "stream-replace-string": "^2.0.0", - "zod": "^4.3.6" - } - }, - "node_modules/@astrojs/starlight": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.1.tgz", - "integrity": "sha512-ABS/A0IfbJeMbuhCKVkrAcO3EWxhpeLveFuL28+7+BQje7ZHa9OXZbUoiLXdYUhH7XFaiAQjAg44RiF2uuZPJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/markdown-satteri": "^0.4.0", - "@astrojs/mdx": "^8.0.0", - "@astrojs/sitemap": "^3.7.3", - "@pagefind/default-ui": "^1.3.0", - "@types/hast": "^3.0.5", - "@types/js-yaml": "^4.0.9", - "@types/mdast": "^4.0.4", - "astro-expressive-code": "^0.44.0", - "bcp-47": "^2.1.0", - "hast-util-format": "^1.1.0", - "hast-util-select": "^6.0.4", - "hast-util-to-html": "^9.0.5", - "hast-util-to-string": "^3.0.1", - "hastscript": "^9.0.1", - "i18next": "^26.0.7", - "js-yaml": "^4.1.1", - "klona": "^2.0.6", - "magic-string": "^1.2.3", - "mdast-util-directive": "^3.1.0", - "mdast-util-to-markdown": "^2.1.2", - "mdast-util-to-string": "^4.0.0", - "pagefind": "^1.5.2", - "remark-directive": "^4.0.0", - "satteri": "^0.10.3", - "ultrahtml": "^1.6.0", - "unified": "^11.0.5", - "unist-util-visit": "^5.1.0", - "vfile": "^6.0.3" - }, - "peerDependencies": { - "@astrojs/markdown-remark": "^7.3.0", - "astro": "^7.2.10" - }, - "peerDependenciesMeta": { - "@astrojs/markdown-remark": { - "optional": true - } - } - }, - "node_modules/@astrojs/telemetry": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", - "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ci-info": "^4.4.0", - "dset": "^3.1.4", - "is-docker": "^4.0.0", - "package-manager-detector": "^1.6.0" - }, - "engines": { - "node": "18.20.8 || ^20.3.0 || >=22.0.0" - } - }, - "node_modules/@astrojs/yaml2ts": { - "version": "0.2.4", - "dev": true, - "license": "MIT", - "dependencies": { - "yaml": "^2.8.3" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bruits/satteri-darwin-arm64": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", - "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@bruits/satteri-darwin-x64": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", - "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@bruits/satteri-linux-arm64-gnu": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", - "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-linux-arm64-musl": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", - "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-linux-x64-gnu": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", - "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-linux-x64-musl": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", - "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-wasm32-wasi": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", - "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.2.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@bruits/satteri-win32-arm64-msvc": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", - "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@bruits/satteri-win32-x64-msvc": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", - "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@capsizecss/unpack": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", - "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fontkitten": "^1.0.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@clack/core": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", - "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-wrap-ansi": "^0.2.0", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 20.12.0" - } - }, - "node_modules/@clack/prompts": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", - "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@clack/core": "1.5.1", - "fast-string-width": "^3.0.2", - "fast-wrap-ansi": "^0.2.0", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 20.12.0" - } - }, - "node_modules/@ctrl/tinycolor": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.1.tgz", - "integrity": "sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@emmetio/abbreviation": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", - "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emmetio/scanner": "^1.0.4" - } - }, - "node_modules/@emmetio/css-abbreviation": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", - "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emmetio/scanner": "^1.0.4" - } - }, - "node_modules/@emmetio/css-parser": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz", - "integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emmetio/stream-reader": "^2.2.0", - "@emmetio/stream-reader-utils": "^0.1.0" - } - }, - "node_modules/@emmetio/html-matcher": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz", - "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@emmetio/scanner": "^1.0.0" - } - }, - "node_modules/@emmetio/scanner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", - "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@emmetio/stream-reader": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz", - "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@emmetio/stream-reader-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz", - "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@emnapi/core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", - "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@expressive-code/core": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.2.tgz", - "integrity": "sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ctrl/tinycolor": "^4.0.4", - "hast-util-select": "^6.0.2", - "hast-util-to-html": "^9.0.1", - "hast-util-to-text": "^4.0.1", - "hastscript": "^9.0.0", - "postcss": "^8.4.38", - "postcss-nested": "^6.0.1", - "unist-util-visit": "^5.0.0", - "unist-util-visit-parents": "^6.0.1" - } - }, - "node_modules/@expressive-code/plugin-frames": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.2.tgz", - "integrity": "sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2" - } - }, - "node_modules/@expressive-code/plugin-shiki": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.2.tgz", - "integrity": "sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2", - "shiki": "^4.0.2" - } - }, - "node_modules/@expressive-code/plugin-text-markers": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.2.tgz", - "integrity": "sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2" - } - }, - "node_modules/@fontsource-variable/fira-code": { - "version": "5.3.0", - "dev": true, - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource-variable/space-grotesk": { - "version": "5.3.0", - "dev": true, - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fortawesome/fontawesome-common-types": { - "version": "7.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "7.3.1", - "dev": true, - "license": "(CC-BY-4.0 AND MIT)", - "dependencies": { - "@fortawesome/fontawesome-common-types": "7.3.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", - "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", - "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 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" - } - }, - "node_modules/@oslojs/encoding": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", - "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@oxc-project/types": { - "version": "0.149.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", - "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" - } - }, - "node_modules/@pagefind/darwin-arm64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", - "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@pagefind/darwin-x64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", - "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@pagefind/default-ui": { - "version": "1.5.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@pagefind/freebsd-x64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", - "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@pagefind/linux-arm64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", - "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@pagefind/linux-x64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", - "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@pagefind/windows-arm64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", - "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@pagefind/windows-x64": { - "version": "1.5.2", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", - "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", - "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", - "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", - "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", - "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", - "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", - "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", - "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", - "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", - "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", - "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", - "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", - "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", - "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", - "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@shikijs/core": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", - "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/primitive": "4.4.3", - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5", - "hast-util-to-html": "^9.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", - "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.6" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", - "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/langs": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", - "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/primitive": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", - "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/themes": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", - "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/types": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", - "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", - "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/debug": { - "version": "4.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", - "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/nlcst": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", - "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/node": { - "version": "25.9.2", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/@types/sax": { - "version": "1.2.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", - "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@volar/kit": { - "version": "2.4.28", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-service": "2.4.28", - "@volar/typescript": "2.4.28", - "typesafe-path": "^0.2.2", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "typescript": "*" - } - }, - "node_modules/@volar/language-core": { - "version": "2.4.28", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.28" - } - }, - "node_modules/@volar/language-server": { - "version": "2.4.28", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "@volar/language-service": "2.4.28", - "@volar/typescript": "2.4.28", - "path-browserify": "^1.0.1", - "request-light": "^0.7.0", - "vscode-languageserver": "^9.0.1", - "vscode-languageserver-protocol": "^3.17.5", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@volar/language-service": { - "version": "2.4.28", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "vscode-languageserver-protocol": "^3.17.5", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@volar/source-map": { - "version": "2.4.28", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.28", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@vscode/emmet-helper": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz", - "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "emmet": "^2.4.3", - "jsonc-parser": "^2.3.0", - "vscode-languageserver-textdocument": "^1.0.1", - "vscode-languageserver-types": "^3.15.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@vscode/emmet-helper/node_modules/jsonc-parser": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", - "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vscode/l10n": { - "version": "0.0.18", - "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", - "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-draft-04": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", - "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^8.5.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-i18n": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz", - "integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^8.0.0-beta.0" - } - }, - "node_modules/am-i-vibing": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", - "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "process-ancestry": "^0.1.0" - }, - "bin": { - "am-i-vibing": "dist/cli.mjs" - } - }, - "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/astro": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.3.2.tgz", - "integrity": "sha512-ysTcdpGP61XZpHoMRYC/CK19DQ94qkBvzXMtXEo0gEqPNkmOU/Tv++CtWmzaI4nF2Ck8RXFdiWvdlTWa2oOr9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/compiler-rs": "^0.4.0", - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/markdown-satteri": "0.4.1", - "@astrojs/telemetry": "3.3.3", - "@capsizecss/unpack": "^4.0.0", - "@clack/prompts": "^1.1.0", - "@oslojs/encoding": "^1.1.0", - "am-i-vibing": "^0.4.0", - "aria-query": "^5.3.2", - "axobject-query": "^4.1.0", - "ci-info": "^4.4.0", - "clsx": "^2.1.1", - "common-ancestor-path": "^2.0.0", - "cookie": "^2.0.1", - "devalue": "^5.8.1", - "diff": "^9.0.0", - "dset": "^3.1.4", - "es-module-lexer": "^2.0.0", - "esbuild": "^0.28.0", - "find-proc": "0.1.0", - "flattie": "^1.1.1", - "fontace": "~0.4.1", - "get-tsconfig": "5.0.0-beta.4", - "github-slugger": "^2.0.0", - "html-escaper": "3.0.3", - "http-cache-semantics": "^4.2.0", - "js-yaml": "^4.3.0", - "jsonc-parser": "^3.3.1", - "magic-string": "^1.0.0", - "magicast": "^0.5.2", - "mrmime": "^2.0.1", - "neotraverse": "^1.0.1", - "obug": "^2.1.1", - "p-limit": "^7.3.0", - "p-queue": "^9.1.0", - "package-manager-detector": "^1.6.0", - "piccolore": "^0.1.3", - "picomatch": "^4.0.4", - "semver": "^7.7.4", - "shiki": "^4.0.2", - "smol-toml": "^1.6.0", - "svgo": "^4.0.1", - "tinyclip": "^0.1.12", - "tinyexec": "^1.0.4", - "tinyglobby": "^0.2.15", - "ultrahtml": "^1.6.0", - "unifont": "~0.7.5", - "unstorage": "^1.17.5", - "vite": "^8.0.13", - "vitefu": "^1.1.2", - "xxhash-wasm": "^1.1.0", - "yargs-parser": "^22.0.0", - "zod": "^4.5.4" - }, - "bin": { - "astro": "bin/astro.mjs" - }, - "engines": { - "node": ">=22.12.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/astrodotbuild" - }, - "optionalDependencies": { - "sharp": "^0.35.4" - }, - "peerDependencies": { - "@astrojs/markdown-remark": "^7.3.0" - }, - "peerDependenciesMeta": { - "@astrojs/markdown-remark": { - "optional": true - } - } - }, - "node_modules/astro-expressive-code": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.44.2.tgz", - "integrity": "sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "rehype-expressive-code": "^0.44.2", - "url-extras": "^0.1.0" - }, - "peerDependencies": { - "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/bcp-47": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/bcp-47-match": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", - "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/ccount": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/common-ancestor-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", - "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">= 18" - } - }, - "node_modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cookie-es": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", - "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", - "dev": true, - "license": "MIT" - }, - "node_modules/crossws": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", - "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "uncrypto": "^0.1.3" - } - }, - "node_modules/css-select": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", - "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^7.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "nth-check": "^2.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-selector-parser": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", - "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "license": "MIT" - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css-what": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", - "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "~2.2.0" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.9.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", - "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", - "dev": true, - "license": "MIT" - }, - "node_modules/devlop": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/diff": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", - "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/direction": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", - "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", - "dev": true, - "license": "MIT", - "bin": { - "direction": "cli.js" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dset": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", - "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/emmet": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", - "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==", - "dev": true, - "license": "MIT", - "workspaces": [ - "./packages/scanner", - "./packages/abbreviation", - "./packages/css-abbreviation", - "./" - ], - "dependencies": { - "@emmetio/abbreviation": "^2.3.3", - "@emmetio/css-abbreviation": "^2.1.8" - } - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/expressive-code": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.2.tgz", - "integrity": "sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2", - "@expressive-code/plugin-frames": "^0.44.2", - "@expressive-code/plugin-shiki": "^0.44.2", - "@expressive-code/plugin-text-markers": "^0.44.2" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/find-proc": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.1.0.tgz", - "integrity": "sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/flattie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", - "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/fontace": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", - "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fontkitten": "^1.0.2" - } - }, - "node_modules/fontkitten": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", - "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tiny-inflate": "^1.0.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-tsconfig": { - "version": "5.0.0-beta.4", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", - "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "engines": { - "node": ">=20.20.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/github-slugger": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", - "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", - "dev": true, - "license": "ISC" - }, - "node_modules/h3": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", - "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cookie-es": "^1.2.3", - "crossws": "^0.3.5", - "defu": "^6.1.6", - "destr": "^2.0.5", - "iron-webcrypto": "^1.2.1", - "node-mock-http": "^1.0.4", - "radix3": "^1.1.2", - "ufo": "^1.6.3", - "uncrypto": "^0.1.3" - } - }, - "node_modules/hast-util-embedded": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", - "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-is-element": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-format": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", - "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-embedded": "^3.0.0", - "hast-util-minify-whitespace": "^1.0.0", - "hast-util-phrasing": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "html-whitespace-sensitive-tag-names": "^3.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-has-property": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", - "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-is-body-ok-link": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", - "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-is-element": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", - "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-minify-whitespace": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", - "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-embedded": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-phrasing": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", - "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-embedded": "^3.0.0", - "hast-util-has-property": "^3.0.0", - "hast-util-is-body-ok-link": "^3.0.0", - "hast-util-is-element": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-select": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", - "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "bcp-47-match": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "css-selector-parser": "^3.0.0", - "devlop": "^1.0.0", - "direction": "^2.0.0", - "hast-util-has-property": "^3.0.0", - "hast-util-to-string": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "nth-check": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-string": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", - "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-text": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", - "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unist-util-find-after": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/html-escaper": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", - "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/html-whitespace-sensitive-tag-names": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", - "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/i18next": { - "version": "26.3.1", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://www.locize.com/i18next" - }, - { - "type": "individual", - "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" - }, - { - "type": "individual", - "url": "https://www.locize.com" - } - ], - "license": "MIT", - "peerDependencies": { - "typescript": "^5 || ^6" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/iron-webcrypto": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", - "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/brc-dd" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-docker": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", - "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "dev": true, - "license": "MIT" - }, - "node_modules/kleur": { - "version": "4.1.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/klona": { - "version": "2.0.6", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "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/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/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/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/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/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/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/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/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/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/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/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/longest-streak": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", - "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" - } - }, - "node_modules/magicast": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", - "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "source-map-js": "^1.2.1" - } - }, - "node_modules/mdast-util-directive": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/micromark": { - "version": "4.0.2", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-directive": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "parse-entities": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/muggle-string": { - "version": "0.4.1", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/neotraverse": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", - "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/nlcst-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", - "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-mock-http": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", - "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/ofetch": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", - "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "destr": "^2.0.5", - "node-fetch-native": "^1.6.7", - "ufo": "^1.6.1" - } - }, - "node_modules/ohash": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", - "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "dev": true, - "license": "MIT" - }, - "node_modules/oniguruma-parser": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", - "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", - "dev": true, - "license": "MIT" - }, - "node_modules/oniguruma-to-es": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", - "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "oniguruma-parser": "^0.12.2", - "regex": "^6.1.0", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/p-limit": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.2.tgz", - "integrity": "sha512-Ll0w3fU24vYpXoZmjjZIee6bJQDgG0oAyo1PdmFYI8UDwJJddaHAypxIH9avUu+t+lSsAwKVsb1jDCMIIChliw==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.2.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", - "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.4", - "p-timeout": "^7.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-timeout": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", - "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-manager-detector": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", - "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/pagefind": { - "version": "1.5.2", - "dev": true, - "license": "MIT", - "bin": { - "pagefind": "lib/runner/bin.cjs" - }, - "optionalDependencies": { - "@pagefind/darwin-arm64": "1.5.2", - "@pagefind/darwin-x64": "1.5.2", - "@pagefind/freebsd-x64": "1.5.2", - "@pagefind/linux-arm64": "1.5.2", - "@pagefind/linux-x64": "1.5.2", - "@pagefind/windows-arm64": "1.5.2", - "@pagefind/windows-x64": "1.5.2" - } - }, - "node_modules/parse-entities": { - "version": "4.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "dev": true, - "license": "MIT" - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/piccolore": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", - "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", - "dev": true, - "license": "ISC" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/process-ancestry": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", - "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/radix3": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", - "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true, - "license": "MIT" - }, - "node_modules/rehype-expressive-code": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.2.tgz", - "integrity": "sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expressive-code": "^0.44.2" - } - }, - "node_modules/remark-directive": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-directive": "^3.0.0", - "micromark-extension-directive": "^4.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/request-light": { - "version": "0.7.0", - "dev": true, - "license": "MIT" - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/retext-smartypants": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", - "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "nlcst-to-string": "^4.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rolldown": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", - "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.149.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.8", - "@rolldown/binding-android-arm64": "1.2.8", - "@rolldown/binding-darwin-arm64": "1.2.8", - "@rolldown/binding-darwin-x64": "1.2.8", - "@rolldown/binding-freebsd-x64": "1.2.8", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", - "@rolldown/binding-linux-arm64-gnu": "1.2.8", - "@rolldown/binding-linux-arm64-musl": "1.2.8", - "@rolldown/binding-linux-ppc64-gnu": "1.2.8", - "@rolldown/binding-linux-s390x-gnu": "1.2.8", - "@rolldown/binding-linux-x64-gnu": "1.2.8", - "@rolldown/binding-linux-x64-musl": "1.2.8", - "@rolldown/binding-openharmony-arm64": "1.2.8", - "@rolldown/binding-win32-arm64-msvc": "1.2.8", - "@rolldown/binding-win32-x64-msvc": "1.2.8" - } - }, - "node_modules/satteri": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", - "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.5", - "@types/hast": "^3.0.5", - "@types/mdast": "^4.0.4", - "@types/unist": "^3.0.3" - }, - "optionalDependencies": { - "@bruits/satteri-darwin-arm64": "0.10.5", - "@bruits/satteri-darwin-x64": "0.10.5", - "@bruits/satteri-linux-arm64-gnu": "0.10.5", - "@bruits/satteri-linux-arm64-musl": "0.10.5", - "@bruits/satteri-linux-x64-gnu": "0.10.5", - "@bruits/satteri-linux-x64-musl": "0.10.5", - "@bruits/satteri-wasm32-wasi": "0.10.5", - "@bruits/satteri-win32-arm64-msvc": "0.10.5", - "@bruits/satteri-win32-x64-msvc": "0.10.5" - } - }, - "node_modules/sax": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", - "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/shiki": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", - "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.4.3", - "@shikijs/engine-javascript": "4.4.3", - "@shikijs/engine-oniguruma": "4.4.3", - "@shikijs/langs": "4.4.3", - "@shikijs/themes": "4.4.3", - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/simple-icons": { - "version": "16.31.0", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/simple-icons" - }, - { - "type": "github", - "url": "https://github.com/sponsors/simple-icons" - } - ], - "license": "CC0-1.0", - "engines": { - "node": ">=0.12.18" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sitemap": { - "version": "9.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "^24.9.2", - "@types/sax": "^1.2.1", - "arg": "^5.0.0", - "sax": "^1.4.1" - }, - "bin": { - "sitemap": "dist/esm/cli.js" - }, - "engines": { - "node": ">=20.19.5", - "npm": ">=10.8.2" - } - }, - "node_modules/sitemap/node_modules/@types/node": { - "version": "24.13.1", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/sitemap/node_modules/@types/node/node_modules/undici-types": { - "version": "7.18.2", - "dev": true, - "license": "MIT" - }, - "node_modules/smol-toml": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", - "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stream-replace-string": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/svgo": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", - "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^11.1.0", - "css-select": "^6.0.0", - "css-tree": "^3.0.1", - "css-what": "^7.0.0", - "csso": "^5.0.5", - "picocolors": "^1.1.1", - "sax": "1.6.1" - }, - "bin": { - "svgo": "bin/svgo.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" - } - }, - "node_modules/tiny-inflate": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", - "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyclip": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", - "integrity": "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >= 17.3.0" - } - }, - "node_modules/tinyexec": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", - "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typesafe-path": { - "version": "0.2.2", - "dev": true, - "license": "MIT" - }, - "node_modules/typescript": { - "version": "6.0.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-auto-import-cache": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz", - "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.8" - } - }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ultrahtml": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", - "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", - "dev": true, - "license": "MIT" - }, - "node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", - "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/undici-types": { - "version": "7.24.6", - "dev": true, - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unifont": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", - "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.1.0", - "ohash": "^2.0.11", - "undici": "^8.0.0" - } - }, - "node_modules/unist-util-find-after": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", - "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unstorage": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", - "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "^3.1.3", - "chokidar": "^5.0.0", - "destr": "^2.0.5", - "h3": "^1.15.10", - "lru-cache": "^11.2.7", - "node-fetch-native": "^1.6.7", - "ofetch": "^1.5.1", - "ufo": "^1.6.3" - }, - "peerDependencies": { - "@azure/app-configuration": "^1.8.0", - "@azure/cosmos": "^4.2.0", - "@azure/data-tables": "^13.3.0", - "@azure/identity": "^4.6.0", - "@azure/keyvault-secrets": "^4.9.0", - "@azure/storage-blob": "^12.26.0", - "@capacitor/preferences": "^6 || ^7 || ^8", - "@deno/kv": ">=0.9.0", - "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", - "@planetscale/database": "^1.19.0", - "@upstash/redis": "^1.34.3", - "@vercel/blob": ">=0.27.1", - "@vercel/functions": "^2.2.12 || ^3.0.0", - "@vercel/kv": "^1 || ^2 || ^3", - "aws4fetch": "^1.0.20", - "db0": ">=0.2.1", - "idb-keyval": "^6.2.1", - "ioredis": "^5.4.2", - "uploadthing": "^7.4.4" - }, - "peerDependenciesMeta": { - "@azure/app-configuration": { - "optional": true - }, - "@azure/cosmos": { - "optional": true - }, - "@azure/data-tables": { - "optional": true - }, - "@azure/identity": { - "optional": true - }, - "@azure/keyvault-secrets": { - "optional": true - }, - "@azure/storage-blob": { - "optional": true - }, - "@capacitor/preferences": { - "optional": true - }, - "@deno/kv": { - "optional": true - }, - "@netlify/blobs": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/blob": { - "optional": true - }, - "@vercel/functions": { - "optional": true - }, - "@vercel/kv": { - "optional": true - }, - "aws4fetch": { - "optional": true - }, - "db0": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "ioredis": { - "optional": true - }, - "uploadthing": { - "optional": true - } - } - }, - "node_modules/unstorage/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/unstorage/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/url-extras": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/url-extras/-/url-extras-0.1.0.tgz", - "integrity": "sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vfile": { - "version": "6.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", - "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.6", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/volar-service-css": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.71.tgz", - "integrity": "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-css-languageservice": "^6.3.0", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/volar-service-emmet": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.71.tgz", - "integrity": "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emmetio/css-parser": "^0.4.1", - "@emmetio/html-matcher": "^1.3.0", - "@vscode/emmet-helper": "^2.9.3", - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/volar-service-html": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.71.tgz", - "integrity": "sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-html-languageservice": "^5.3.0", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/volar-service-prettier": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.71.tgz", - "integrity": "sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0", - "prettier": "^2.2 || ^3.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - }, - "prettier": { - "optional": true - } - } - }, - "node_modules/volar-service-typescript": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.71.tgz", - "integrity": "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-browserify": "^1.0.1", - "semver": "^7.6.2", - "typescript-auto-import-cache": "^0.3.5", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-nls": "^5.2.0", - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/volar-service-typescript-twoslash-queries": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.71.tgz", - "integrity": "sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-uri": "^3.0.8" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/volar-service-yaml": { - "version": "0.0.71", - "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.71.tgz", - "integrity": "sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-uri": "^3.0.8", - "yaml-language-server": "~1.23.0" - }, - "peerDependencies": { - "@volar/language-service": "~2.4.0" - }, - "peerDependenciesMeta": { - "@volar/language-service": { - "optional": true - } - } - }, - "node_modules/vscode-css-languageservice": { - "version": "6.3.10", - "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", - "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vscode/l10n": "^0.0.18", - "vscode-languageserver-textdocument": "^1.0.12", - "vscode-languageserver-types": "3.17.5", - "vscode-uri": "^3.1.0" - } - }, - "node_modules/vscode-css-languageservice/node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-html-languageservice": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", - "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vscode/l10n": "^0.0.18", - "vscode-languageserver-textdocument": "^1.0.12", - "vscode-languageserver-types": "^3.17.5", - "vscode-uri": "^3.1.0" - } - }, - "node_modules/vscode-json-languageservice": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz", - "integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jsonc-parser": "^3.0.0", - "vscode-languageserver-textdocument": "^1.0.1", - "vscode-languageserver-types": "^3.16.0", - "vscode-nls": "^5.0.0", - "vscode-uri": "^3.0.2" - }, - "engines": { - "npm": ">=7.0.0" - } - }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver": { - "version": "9.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-languageserver-protocol": "3.17.5" - }, - "bin": { - "installServerIntoExtension": "bin/installServerIntoExtension" - } - }, - "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" - } - }, - "node_modules/vscode-languageserver-protocol/node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", - "integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-nls": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", - "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xxhash-wasm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", - "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", - "dev": true, - "license": "MIT" - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yaml": { - "version": "2.9.0", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yaml-language-server": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.23.0.tgz", - "integrity": "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vscode/l10n": "^0.0.18", - "ajv": "^8.17.1", - "ajv-draft-04": "^1.0.0", - "ajv-i18n": "^4.2.0", - "prettier": "^3.8.1", - "request-light": "^0.5.7", - "vscode-json-languageservice": "4.1.8", - "vscode-languageserver": "^9.0.0", - "vscode-languageserver-textdocument": "^1.0.1", - "vscode-languageserver-types": "^3.16.0", - "vscode-uri": "^3.0.2", - "yaml": "2.8.3" - }, - "bin": { - "yaml-language-server": "bin/yaml-language-server" - } - }, - "node_modules/yaml-language-server/node_modules/request-light": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz", - "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==", - "dev": true, - "license": "MIT" - }, - "node_modules/yaml-language-server/node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^8.2.1", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/docs/site/package.json b/docs/site/package.json index 8c358a0..932f8f7 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -16,9 +16,17 @@ "@fontsource-variable/space-grotesk": "5.3.0", "@fortawesome/free-solid-svg-icons": "7.3.1", "astro": "7.3.2", + "astro-expressive-code": "0.44.2", "sharp": "0.35.4", "simple-icons": "16.31.0", "typescript": "6.0.3" }, - "packageManager": "npm@11.16.0" + "packageManager": "nub@0.7.5", + "devEngines": { + "packageManager": { + "name": "nub", + "version": "^0.7.5", + "onFail": "ignore" + } + } } diff --git a/docs/site/src/content/docs/reference/node-candidate.mdx b/docs/site/src/content/docs/reference/node-candidate.mdx new file mode 100644 index 0000000..903d138 --- /dev/null +++ b/docs/site/src/content/docs/reference/node-candidate.mdx @@ -0,0 +1,58 @@ +--- +title: Try the 2.0 candidate +description: Install a supplied Cadder 2.0 release candidate through npm or a standalone archive. +--- + +Cadder 2.0 is an unpublished migration candidate. The current public release remains 1.0.5. Use this page only with a supplied 2.0.0-rc.1 package or archive; availability in npm and GitHub Releases has not been announced. + +Both distributions provide the same three commands: `cadder`, `cadderd`, and the `caddy` shim. Upstream Caddy is a separate prerequisite in both cases. + +## Install the npm package + +Use Node 24 LTS, version 24.18.0 or later within the 24.x series. Install the supplied tarball with npm: + +```sh +npm install --global ./cadder-2.0.0-rc.1.tgz +cadder --version +``` + +The package contains compiled JavaScript. Installation does not download Caddy or a platform binary, compile source, or require a TypeScript loader. + +## Install the standalone archive + +Choose the supplied archive for Windows x64, Linux x64, Intel macOS, or Apple Silicon macOS. Extract it into a user-owned directory, verify its files against `SHA256SUMS`, and add that directory to PATH. Keep all three executables and accompanying files together. + +Standalone executables include their Node runtime and do not require Node or npm on PATH. `BUILD.json` identifies the candidate source and platform; `NODE-LICENSE` and `THIRD-PARTY-NOTICES` contain the bundled software notices. + +## Run a project + +Create a per-user `cadder.toml` from the supplied `cadder.toml.example`: + +- Windows: `%APPDATA%\Cadder\Cadder\config\cadder.toml`. +- macOS: `~/Library/Application Support/dev.Cadder.Cadder/cadder.toml`. +- Linux: `$XDG_CONFIG_HOME/cadder/cadder.toml`, or `~/.config/cadder/cadder.toml` when unset. + +Set `real_path` to the absolute path of your separately installed Caddy, or `real_command` to its program name on PATH. Set one selector. Then run the existing Caddyfile from its project directory: + +```sh +caddy run +``` + +In another terminal: + +```sh +cadder status +cadder projects list +cadder domains list +cadder tui +``` + +In the TUI, Up and Down select a project or domain; Enter or Space toggles it. Press `s` and confirm with `y` to stop the daemon, `r` to restart it, or `q` to quit the TUI. When offline, Enter starts the daemon. Bare `cadder` displays help. + +## Switch from 1.0.5 + +1. Use the old installation's `cadder daemon stop` before changing PATH. +2. Install all three commands from the same 2.0 candidate. Avoid mixing npm and standalone commands in one installation directory. +3. Run `caddy run` again in each project. Live registrations are not restored across the transition. + +The candidate uses separate runtime storage and does not import or delete the Rust database. Keep the previous release available for rollback. Before switching back, stop the candidate with `cadder daemon stop`, restore the previous commands on PATH, and register the projects again. diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..07b1e7f --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,4 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended); diff --git a/mise.lock b/mise.lock index 207c0f8..50a7e49 100644 --- a/mise.lock +++ b/mise.lock @@ -126,6 +126,10 @@ url = "https://nodejs.org/dist/v24.18.0/node-v24.18.0-win-x64.zip" version = "1.5.0" backend = "npm:@fission-ai/openspec" +[[tools."npm:@nubjs/nub"]] +version = "0.7.5" +backend = "npm:@nubjs/nub" + [[tools.rust]] version = "1.95.0" backend = "core:rust" diff --git a/mise.toml b/mise.toml index a6fb04c..a2e19df 100644 --- a/mise.toml +++ b/mise.toml @@ -6,6 +6,7 @@ node = "24.18.0" "npm:@fission-ai/openspec" = "1.5.0" "cargo:cargo-llvm-cov" = "0.8.7" "aqua:axodotdev/cargo-dist" = "0.33.0" +"npm:@nubjs/nub" = "0.7.5" [tasks.fmt] description = "Check Rust formatting" @@ -19,6 +20,10 @@ run = "cargo clippy --workspace --all-targets -- -D warnings" description = "Run workspace tests" run = "cargo test --workspace" +[tasks.node-check] +description = "Validate the locked Node migration workspace and coverage" +run = ["nub ci", "nub run check"] + [tasks.openspec-check] description = "Validate every OpenSpec artifact strictly" run = [ @@ -30,16 +35,16 @@ run = [ description = "Install and validate the documentation site" dir = "docs/site" run = [ - "npm ci", - "npm run check", + "nub ci", + "nub run check", ] [tasks.docs-build] description = "Build the documentation site" dir = "docs/site" run = [ - "npm ci", - "npm run build", + "nub ci", + "nub run build", ] [tasks.npm-check] @@ -60,6 +65,7 @@ run = [ "cargo fmt --all -- --check", "cargo clippy --workspace --all-targets -- -D warnings", "cargo test --workspace", + { task = "node-check" }, "openspec doctor", "openspec validate --all --strict --no-interactive", { task = "docs-check" }, diff --git a/nub.lock b/nub.lock new file mode 100644 index 0000000..9a437ee --- /dev/null +++ b/nub.lock @@ -0,0 +1,2389 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@peculiar/x509': + specifier: 2.1.0 + version: 2.1.0 + commander: + specifier: 15.0.0 + version: 15.0.0 + ink: + specifier: 8.0.0 + version: 8.0.0(@types/react@19.3.0)(react@19.3.0) + react: + specifier: 19.3.0 + version: 19.3.0 + reflect-metadata: + specifier: 0.2.2 + version: 0.2.2 + smol-toml: + specifier: 1.9.0 + version: 1.9.0 + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@eslint/js': + specifier: 10.0.1 + version: 10.0.1(eslint@10.0.3) + '@types/node': + specifier: 24.19.1 + version: 24.19.1 + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@vitest/coverage-v8': + specifier: 5.0.3 + version: 5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0))(vitest@5.0.3) + esbuild: + specifier: 0.28.2 + version: 0.28.2 + eslint: + specifier: 10.0.3 + version: 10.0.3 + ink-testing-library: + specifier: 4.0.0 + version: 4.0.0(@types/react@19.3.0) + prettier: + specifier: 3.8.3 + version: 3.8.3 + tsx: + specifier: 4.23.0 + version: 4.23.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: 8.57.0 + version: 8.57.0(eslint@10.0.3)(typescript@6.0.3) + vitest: + specifier: 5.0.3 + version: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0)) + +packages: + + '@alcalzone/ansi-tokenize@0.3.1': + resolution: {integrity: sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==} + engines: {node: '>=18'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + + '@peculiar/asn1-asym-key@2.10.0': + resolution: {integrity: sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg==} + engines: {node: '>=14'} + + '@peculiar/asn1-cms@2.10.0': + resolution: {integrity: sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==} + engines: {node: '>=14'} + + '@peculiar/asn1-csr@2.10.0': + resolution: {integrity: sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==} + engines: {node: '>=14'} + + '@peculiar/asn1-ecc@2.10.0': + resolution: {integrity: sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==} + engines: {node: '>=14'} + + '@peculiar/asn1-pfx@2.10.0': + resolution: {integrity: sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs8@2.10.0': + resolution: {integrity: sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs9@2.10.0': + resolution: {integrity: sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==} + engines: {node: '>=14'} + + '@peculiar/asn1-rsa@2.10.0': + resolution: {integrity: sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==} + engines: {node: '>=14'} + + '@peculiar/asn1-schema@2.10.0': + resolution: {integrity: sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509-attr@2.10.0': + resolution: {integrity: sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509-post-quantum@2.10.0': + resolution: {integrity: sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509@2.10.0': + resolution: {integrity: sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==} + engines: {node: '>=14'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@2.1.0': + resolution: {integrity: sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==} + engines: {node: '>=20.0.0'} + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@typescript-eslint/eslint-plugin@8.57.0': + resolution: {integrity: sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.57.0': + resolution: {integrity: sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.57.0': + resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.57.0': + resolution: {integrity: sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.0': + resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.57.0': + resolution: {integrity: sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.57.0': + resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.0': + resolution: {integrity: sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.57.0': + resolution: {integrity: sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.57.0': + resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/coverage-v8@5.0.3': + resolution: {integrity: sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==} + peerDependencies: + '@vitest/browser': 5.0.3 + vitest: 5.0.3 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.2': + resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.2': + resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + engines: {node: '>=22'} + + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansi-styles@7.0.0: + resolution: {integrity: sha512-kKvt3m4uwzqL0wlkPd09CmljPJGOZZ4D0fP65sqFSvPkMRKhNi+74MgIJ5QxE6SxqB4t4KyUFGg8+n5zjo6hew==} + engines: {node: '>=22'} + + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chalk@6.0.1: + resolution: {integrity: sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==} + engines: {node: '>=22'} + + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} + + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.0.3: + resolution: {integrity: sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + + ink@8.0.0: + resolution: {integrity: sha512-M2aFwZOqmkFxCwedy6xrP4E+DGoK57iahAFnfTqNyOyJ54O7DZzu027z/NI8EGy44Sw9O9BcbTF4QEUS2GHAtg==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.3.0' + react: '>=19.3.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + + quick-lru@7.3.0: + resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} + engines: {node: '>=18'} + + react-reconciler@0.34.0: + resolution: {integrity: sha512-eVibvB1fc9j5Qf1KRkiM3GzRPBdjg2P6G6QV2f3P6Eb3OGMxRBhdg2rrUwjbV5JrQaJj8nOa8uajmmJA5tJWjQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.3.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + slice-ansi@9.0.1: + resolution: {integrity: sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==} + engines: {node: '>=22'} + + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.0: + resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + engines: {node: '>=18.0.0'} + hasBin: true + + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + + typescript-eslint@8.57.0: + resolution: {integrity: sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.3.2: + resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: {} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@10.0.2: + resolution: {integrity: sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==} + engines: {node: '>=20'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@alcalzone/ansi-tokenize@0.3.1': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@10.0.3)': + dependencies: + eslint: 10.0.3 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.0.3)': + dependencies: + eslint: 10.0.3 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.152.0': {} + + '@peculiar/asn1-asym-key@2.10.0': + dependencies: + '@peculiar/asn1-pkcs8': 2.10.0 + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + '@peculiar/asn1-x509-attr': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.10.0': + dependencies: + '@peculiar/asn1-cms': 2.10.0 + '@peculiar/asn1-pkcs8': 2.10.0 + '@peculiar/asn1-rsa': 2.10.0 + '@peculiar/asn1-schema': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.10.0': + dependencies: + '@peculiar/asn1-cms': 2.10.0 + '@peculiar/asn1-pfx': 2.10.0 + '@peculiar/asn1-pkcs8': 2.10.0 + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + '@peculiar/asn1-x509-attr': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.10.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-post-quantum@2.10.0': + dependencies: + '@peculiar/asn1-asym-key': 2.10.0 + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.10.0': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@2.1.0': + dependencies: + '@peculiar/asn1-cms': 2.10.0 + '@peculiar/asn1-csr': 2.10.0 + '@peculiar/asn1-ecc': 2.10.0 + '@peculiar/asn1-pkcs9': 2.10.0 + '@peculiar/asn1-rsa': 2.10.0 + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/asn1-x509': 2.10.0 + '@peculiar/asn1-x509-post-quantum': 2.10.0 + pvtsutils: 1.3.6 + tslib: 2.8.1 + tsyringe: 4.10.0 + + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.0.3)(typescript@6.0.3))(eslint@10.0.3)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/type-utils': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.57.0 + eslint: 10.0.3 + ignore: 7.0.12 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.0(eslint@10.0.3)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3 + eslint: 10.0.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3) + '@typescript-eslint/types': 8.57.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + + '@typescript-eslint/tsconfig-utils@8.57.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.57.0(eslint@10.0.3)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.0.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.0': {} + + '@typescript-eslint/typescript-estree@8.57.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3) + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.0(eslint@10.0.3)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.0.3) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + eslint: 10.0.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/coverage-v8@5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0))(vitest@5.0.3)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-report': 1.0.2 + ast-v8-to-istanbul: 1.0.7 + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0)) + + '@vitest/istanbul-lib-coverage@1.0.2': {} + + '@vitest/istanbul-lib-report@1.0.2': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.2 + + '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.2 + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0) + + '@vitest/spy@5.0.3': {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@6.4.0: {} + + ansi-styles@6.2.3: {} + + ansi-styles@7.0.0: {} + + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + auto-bind@5.0.1: {} + + balanced-match@4.0.4: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + chai@6.3.0: {} + + chalk@6.0.1: {} + + cli-boxes@4.0.1: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-truncate@6.1.1: + dependencies: + slice-ansi: 9.0.1 + string-width: 8.3.0 + + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + + commander@15.0.0: {} + + convert-to-spaces@2.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + environment@1.1.0: {} + + es-module-lexer@2.3.2: {} + + es-toolkit@1.52.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.0.3: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.0.3) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + dependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + get-east-asian-width@1.7.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + ignore@5.3.2: {} + + ignore@7.0.12: {} + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} + + ink-testing-library@4.0.0(@types/react@19.3.0): + dependencies: + '@types/react': 19.3.0 + + ink@8.0.0(@types/react@19.3.0)(react@19.3.0): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.1 + '@types/react': 19.3.0 + ansi-escapes: 7.3.0 + ansi-styles: 7.0.0 + auto-bind: 5.0.1 + chalk: 6.0.1 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.52.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + quick-lru: 7.3.0 + react: 19.3.0 + react-reconciler: 0.34.0(react@19.3.0) + signal-exit: 3.0.7 + stack-utils: 2.0.6 + string-width: 8.3.0 + terminal-size: 4.0.1 + type-fest: 5.10.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.2 + yoga-layout: 3.2.1 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.7.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ci@2.0.0: {} + + isexe@2.0.0: {} + + js-tokens@10.0.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + 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 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 + + mimic-fn@2.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + natural-compare@1.4.0: {} + + obug@2.2.1: {} + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + patch-console@2.0.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + punycode@2.3.1: {} + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + + quick-lru@7.3.0: {} + + react-reconciler@0.34.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react@19.3.0: {} + + reflect-metadata@0.2.2: {} + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + + scheduler@0.28.0: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + slice-ansi@9.0.1: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + smol-toml@1.9.0: {} + + source-map-js@1.2.2: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + std-env@4.3.0: {} + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + tagged-tag@1.0.0: {} + + terminal-size@4.0.1: {} + + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.2.0: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsx@4.23.0: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + + typescript-eslint@8.57.0(eslint@10.0.3)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.0.3)(typescript@6.0.3))(eslint@10.0.3)(typescript@6.0.3) + '@typescript-eslint/parser': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.0.3)(typescript@6.0.3) + eslint: 10.0.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0): + dependencies: + '@types/node': 24.19.1 + esbuild: 0.28.2 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.12 + tinyglobby: 0.2.17 + tsx: 4.23.0 + optionalDependencies: + fsevents: 2.3.3 + + vitest@5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0)): + dependencies: + '@types/chai': 5.2.3 + '@types/node': 24.19.1 + '@vitest/coverage-v8': 5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0))(vitest@5.0.3) + '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.0) + why-is-node-running: 3.2.1 + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.1: {} + + widest-line@6.0.0: + dependencies: + string-width: 8.3.0 + + word-wrap@1.2.5: {} + + wrap-ansi@10.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.3.0 + + yocto-queue@0.1.0: {} + + yoga-layout@3.2.1: {} + + zod@4.6.5: {} diff --git a/openspec/README.md b/openspec/README.md index 74888b3..bcf920c 100644 --- a/openspec/README.md +++ b/openspec/README.md @@ -1,16 +1,28 @@ # Cadder OpenSpec -> Last reviewed: 2026-07-12 +> Last reviewed: 2026-10-05 -OpenSpec records the accepted Cadder 1.0 contract and the evidence that the -implementation satisfies it. Product requirements live here; public docs +OpenSpec records the accepted Cadder target contract and its implementation +evidence. Product requirements live here; public docs explain how people use the finished product. +The active `reset-cadder-architecture` now defines the approved 2.0 Node migration, +not another Rust reset. Its functional scope preserves the released Rust 1.0.5 +CLI/TUI, not superseded history/IIS/autostart or machine-output workflows. Its +`acceptance.md` maps the approved scope to stable +requirement IDs, ordered gates and evidence; `tasks.md` counts only Node work. +Main specs now include the reviewed Node migration deltas. The released Rust +1.0.5 product remains the operational baseline until the migration gates pass. +Contract synchronization is not product readiness or release authorization. +The approved continuation schedules Windows Sandbox against the completed product; +open G1/G2 acceptance does not block groups 3-6 implementation against delivered +boundaries. Final G7 acceptance, Rust removal and release still require that proof. + ## Contract model -`openspec/specs/` describes the accepted target behavior for Cadder 1.0. A main -spec states what the released product does, even while its implementation is in -progress. The active changes under `openspec/changes/` show which parts still +`openspec/specs/` describes accepted target behavior, including the Node migration. +A main spec states the product contract; it does not claim that its implementation +has shipped. The active changes under `openspec/changes/` show which parts still need implementation or verification. This target-contract model is deliberate. It differs from the default OpenSpec @@ -43,6 +55,10 @@ refer to these identifiers. Renaming prose does not change an identifier. Keep only one implementation change active at a time unless two changes touch independent subsystems and their dependency order is explicit. +The completed `close-node-rpc-catalog` protocol/port slice is retained pending its +known archive/validator blocker. `migrate-caddy-preparation` consumes those leaf +contracts in a separate Caddy source boundary; it does not reopen protocol work. +No overlapping source writers or duplicate requirements are authorized. ## Validation @@ -52,12 +68,12 @@ Run these commands from the repository root: openspec doctor openspec schema validate implementation openspec validate --specs --strict -cargo xtask openspec-check +mise run openspec-check ``` -The repository-specific check verifies requirement identifiers, references, -verification coverage, and documentation boundaries that OpenSpec does not -validate itself. +Also review stable requirement IDs, task-to-acceptance references, verification +coverage and documentation boundaries explicitly; CLI schema validation does not +prove that a plan covers the requested product or that implementation exists. After every implementation change is archived, the final release gate also runs `openspec validate --all --strict` against the remaining contract changes diff --git a/openspec/changes/close-node-rpc-catalog/.openspec.yaml b/openspec/changes/close-node-rpc-catalog/.openspec.yaml new file mode 100644 index 0000000..79e0856 --- /dev/null +++ b/openspec/changes/close-node-rpc-catalog/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-06 diff --git a/openspec/changes/close-node-rpc-catalog/design.md b/openspec/changes/close-node-rpc-catalog/design.md new file mode 100644 index 0000000..68a5d7f --- /dev/null +++ b/openspec/changes/close-node-rpc-catalog/design.md @@ -0,0 +1,126 @@ +## Context + +The retained behavioral baseline is Rust 1.0.5. The main Node target specs have +been synchronized without archiving the migration roadmap. Current rpc.ts accepts +arbitrary methods/unknown values; response validation allows both or neither +outcome. Existing client/server and compiled fixtures already use mutual HMAC. + +## Requirements + +IPC-001/002 keep authentication and framing authoritative. IPC-003 owns strict +operation, payload, result and error validation at dispatch and client boundaries. + +## Goals and non-goals + +### Goals + +Close the eight released operations and retain their DTO semantics. Provide +method-indexed TypeScript types and Zod schemas. Exercise malformed authenticated +peers and complete fixture shutdown through the catalog. + +### Non-goals + +No product state handlers, Caddy adapter, durable storage, presentation methods, +Rust interoperability or new capability negotiation. + +## Ownership and trust boundaries + +Protocol schemas import no daemon, storage or process implementations. The server +validates before invoking a supplied handler and validates its result before +sending. The client validates before sending and validates the received outcome. +Fixtures own fake state and recovery diagnostics, never production modules. + +The adjacent task 1.5 contract slice adds exactly four leaf interfaces in +`src/contracts/ports.ts`: + +- `CaddyPort` owns typed `validate` and `apply` configuration calls only; its + candidate carries the complete adapted JSON body (`format: 'json'`, `body`) + and effective hash. Existing CADDY-005 validation bounds that body to 32 MiB + before the port is called. The port does not own executable resolution, + process lifecycle or admin transport. +- `PlatformPort` owns current-owner lookup and fail-closed protected-path checks. + `RuntimeOwner` is a type-only contract and is re-exported by the existing + security adapter without making the contract depend on that implementation. +- `StoragePort` owns loading and persisting `DesiredState`, which contains only + stable project/domain intent, including project and domain `enabled` flags, and + no session nonce, process identity, lease or active-route fields. +- `ClientServicePort` owns typed state, bounded log, project/domain activation and + shutdown calls. Its payloads/results are aliases of the closed eight-operation + RPC catalog, not a generic transport proxy. + +All port methods return the small discriminated `PortResult` using the existing +`ProtocolError` category type. The leaf imports only protocol contracts and never +imports daemon, storage, process or platform implementation modules. + +## Data flow and public contracts + +The four port declarations are contract-only and do not add a method to the wire +catalog or change dispatch behavior. + +An authenticated request identifies one of register-entrypoint-request, +unregister-entrypoint-request, heartbeat-entrypoint-request, query-state-request, +set-entrypoint-enabled-request, set-domain-enabled-request, query-logs-request or +shutdown-daemon-request. All DTO objects reject unknown fields. Responses contain +one outcome and correlate their envelope and nested result/error request IDs. +Business rejection remains a result with accepted:false. + +## Decisions + +- Retain the Node envelope names method/params to minimize transport changes; + the eight method values map directly to released behavior. This is not Rust + wire compatibility. Numeric protocol 3 and security policy 2 remain exact. +- Keep UUID request IDs for Node-generated requests. Malformed requests lacking + a valid UUID use a fixed valid UUID for uncorrelated rejection, distinct from + the original request. Clients never accept that fallback as their own response. +- Mirror optional/null DTO values and enum spellings; do not invent a string + encoding for integers. Reject unsafe JavaScript integer representations. +- Registration identity/nonce ownership is business policy: invalid ownership + remains accepted:false, not a schema/transport error. Schemas validate shape; + later handlers enforce ownership semantics. +- RPC omitted/null log limit remains a handler default of 100 with clamping to + 1–200; CLI default 50 is separate. Non-negative safe integer input is allowed. +- Frame size remains the 1 MiB aggregate bound. Logs results cannot exceed 200 + entries. No speculative smaller per-field limits are added. +- Unexpected handler errors use a bounded generic internal diagnostic, not the + original exception text. Existing runtime/security failures remain distinct. +- Recovery state travels over fixture stdout, not a product snapshot field. + +## Failure and recovery + +Unknown methods or malformed payloads receive one bounded typed error without +handler execution. A valid request's result/error is correlated; invalid handler +output becomes an internal protocol error. Connections close after one response. +Authentication/sequence/version violations continue to fail before RPC. + +## Migration and rollback + +Only Node fixture callers change. No Rust endpoint/data negotiation or migration +occurs. Existing runtime data and baseline Rust sources remain untouched. + +## Test strategy + +Pure schema round trips cover all eight payload/results, nullability, enums and +unknown fields. Authenticated fake peers cover malformed requests, unknown +methods, result/error exclusivity, nested/envelope IDs and invalid handler output. +Compiled child/native smoke uses query-state and shutdown catalog methods. Run +focused tests then nub run check and the Windows compiled-JS smoke. Other native +platforms and real Sandbox remain open evidence gates. + +## Risks and trade-offs + +- Rust u64/usize exceed JS safe integers: refuse lossy numbers, record this bound. +- Fixture migration can masquerade as product readiness: keep fake handlers only + under test/ and keep G1/G2 open. +- Response/result nested IDs require one authoritative population point: server + dispatch fills them from the validated request; client checks them. +- OpenSpec 1.5's strict change validator requires a delta even for the configured + spec-free implementation schema. The current checkout has no cargo xtask alias + or manifest for the documented custom check. Strict main-spec validation and + artifact/schema inspection remain available. Full mise openspec-check is blocked + by that tool/schema mismatch; do not add duplicate requirements or change + repository validation policy in this slice. + +## Open questions + +None. These are implementation decisions within accepted IPC-001..003, not new +product requirements. diff --git a/openspec/changes/close-node-rpc-catalog/proposal.md b/openspec/changes/close-node-rpc-catalog/proposal.md new file mode 100644 index 0000000..1fb58d3 --- /dev/null +++ b/openspec/changes/close-node-rpc-catalog/proposal.md @@ -0,0 +1,44 @@ +## Why + +Preserve the released project/shim/daemon/operator journey without porting Caddy +before G2. The Node foundation currently accepts arbitrary fixture RPC methods +and unconstrained results. The next capability is authenticated local IPC with +exactly eight validated operations and one correlated result or error, plus the +small contract-only ports that keep the next product boundaries explicit. + +## Requirement IDs + +- `IPC-001`: Keep authenticated, bounded local transport. +- `IPC-002`: Reject incompatible peers before dispatch. +- `IPC-003`: Close the eight-operation request/result/error catalog. +- `TOP-001`: Keep one daemon-owned installation boundary across the ports. +- `TOP-002`: Keep CLI/TUI access behind one typed client-service boundary. + +## Scope + +Implement reset-cadder-architecture task 2.4 in the protocol module, client +connection and existing dispatcher. Migrate runtime test fixtures to the product +catalog without introducing production handlers or test-only product fields. +Add only four mockable leaf ports—Caddy, platform, storage and client service—using +existing protocol DTO/result/error types; these are contracts, not handlers. + +## Non-goals + +No Caddy porting or lifecycle implementation, CLI/TUI implementation, new RPC +operation, storage worker, installation resolver, privilege acceptance or release +change. The four port declarations do not implement any of those boundaries. The existing +publish-cadder-1-0 implementation change owns separate Rust release work and is +not modified or a dependency of this Node protocol slice. + +## Success criteria + +Every operation has closed payload/result schemas. Unknown methods and fields +receive bounded typed rejection without invoking a handler. Clients reject +uncorrelated, malformed and non-exclusive responses. Runtime fixtures exercise +the same transport without arbitrary status/shutdown methods. + +## Impact + +Only Node protocol/connection/dispatch source, the four-port contract leaf and +focused tests/fixtures change. Dependencies, released Rust code and packaging +remain unchanged. This slice does not close G1, G2 or Windows Sandbox acceptance. diff --git a/openspec/changes/close-node-rpc-catalog/tasks.md b/openspec/changes/close-node-rpc-catalog/tasks.md new file mode 100644 index 0000000..9cba649 --- /dev/null +++ b/openspec/changes/close-node-rpc-catalog/tasks.md @@ -0,0 +1,15 @@ +## 1. Implementation + +- [x] 1.1 `[IPC-003]` Define the closed eight-operation Zod DTO/error catalog and method-indexed types (verification: all eight round trips and strict unknown-field/schema negative tests pass; migration verification.md, Closed RPC verification). +- [x] 1.2 `[IPC-001, IPC-002, IPC-003]` Validate authenticated requests before dispatch and correlate one typed result/error; type and validate client calls (verification: authenticated malformed/unknown-method, malformed-response and oversized-response tests pass without invalid handler invocation; independent MiMo reviews pass). +- [x] 1.3 `[IPC-003]` Migrate runtime tests and compiled fixtures to query-state/shutdown catalog requests without product mock fields (verification: runtime-system tests and compiled-JS Windows smoke pass). +- [x] 1.4 `[TOP-001, TOP-002, IPC-003]` Define exactly four mockable leaf ports (Caddy, platform, storage and client service) with typed method/result/error contracts, stable desired-state storage shape and protocol-only imports (verification: focused contract fakes and static import-direction checks pass). + +## 2. Documentation and migration + +- [x] 2.1 `[IPC-003]` Reconcile catalog fixtures and record only executed evidence in the migration roadmap (verification: compatibility tests pass and missing native/Sandbox gates remain open; Closed RPC verification). +- [x] 2.2 `[IPC-002]` Verify no Rust IPC negotiation, old-data migration, dependency or release change was introduced (verification: parent and independent MiMo scoped diff/import reviews). + +## 3. Final verification + +- [x] 3.1 `[IPC-001, IPC-002, IPC-003]` Run nub run check, focused compiled smoke and strict main-spec checks; independently review the integrated slice (verification: parent checks and final independent MiMo Pro integrated review pass; verification.md records the existing OpenSpec 1.5 spec-free implementation validation limitation and teardown transport cancellation without adding duplicate requirement deltas). diff --git a/openspec/changes/close-node-rpc-catalog/verification.md b/openspec/changes/close-node-rpc-catalog/verification.md new file mode 100644 index 0000000..bff975c --- /dev/null +++ b/openspec/changes/close-node-rpc-catalog/verification.md @@ -0,0 +1,69 @@ +## Summary + +The focused closed-RPC and four-port contract slice is implemented and verified. +Final independent MiMo Pro review passes integrated schemas, client/server types, +ports and evidence boundaries. All tasks in this slice are complete. + +This is not archive-ready under the installed all-item validator: OpenSpec 1.5 +requires a delta for the configured spec-free implementation schema. The +repository's documented `cargo xtask openspec-check` command has no available +alias/manifest in this checkout. Neither validation policy nor normative +requirements were changed to hide these limitations. + +The preserved journey is the released 1.0.5 operation/model contract. The next +capability delivered is typed, validated authenticated Node fixture RPC through +exactly eight operations, with four reusable leaf ports. No product handlers, +Caddy adapter, storage worker, CLI/TUI or release parity is claimed. + +## Evidence + +| Requirement ID | Task | Evidence | Result | +| --- | --- | --- | --- | +| `IPC-003` | `1.1` | `test/rpc.test.ts`, compatibility payload fixtures: strict eight-operation request/result/error schemas, unknown-field and correlation checks | Pass | +| `IPC-001`, `IPC-002`, `IPC-003` | `1.2` | Authenticated IPC/client tests: schema validation before dispatch, one correlated dispatched outcome, bounded oversized-response error; independent MiMo reviews | Pass | +| `IPC-003` | `1.3` | Runtime fixtures use catalog query-state/shutdown operations; parent compiled plain-JS win32/x64 smoke | Pass | +| `TOP-001`, `TOP-002`, `IPC-003` | `1.4` | `test/ports.test.ts`: exact signatures/keys/results/errors, project/domain intent, full adapted JSON carrier and protocol-only imports; MiMo Flash review | Pass | +| `IPC-003` | `2.1` | Migration verification ledger distinguishes fixture tests, released-binary evidence and missing product/native gates | Pass | +| `IPC-002` | `2.2` | Scoped source/import review: no Rust IPC negotiation, old-data migration, dependency or release change | Pass | +| `IPC-001`, `IPC-002`, `IPC-003` | `3.1` | Parent `nub run check`: 227 tests, 98.92% line coverage; compiled Windows smoke; strict main specs: 15 pass; final independent MiMo Pro integrated review | Pass | + +The parent also ran 300 Rust workspace tests, formatting and all-target Clippy +with `-D warnings`. The final redirected shim-binary suite passes eight tests, +including real inherited stdin/stdout/stderr, exact argument arrays and exit +codes 0 and 23. These are baseline fixtures, not Node product or TTY acceptance. +The parent also passed the compiled runtime and two-installation fixture smokes +on linux/x64 in an already-cached Node 24.18.0 container. No image download, +package installation or privilege acceptance was performed; runtime files and +containers were disposable. + +Evidence is tied to the uncommitted source set over base HEAD +`93942af84f555eab2bd68e0a4a9db487ff82d040`. Its 198-file source/test/main-spec/ +manifest fingerprint is +`fe62f295286fd9af23425ad3d16bde28da884f219329faf51c3bc67afbcc4141`. +See the migration verification's Integrated nine-task checkpoint for the receipt +format, exclusions and individual approved-task outcomes. Planning/evidence +changes do not alter that fingerprint; it is not a new committed revision. + +## Gate + +- [x] Every task in `tasks.md` is complete. +- [x] Every in-scope requirement ID has passing focused evidence. +- [x] Focused tests pass. +- [x] Runtime and strict main-spec checks required by the design pass. +- [x] Documentation describes only verified behavior. +- [ ] `cargo xtask openspec-check` passes: command unavailable in this checkout. +- [ ] Strict all-item validation passes: 17 pass, one known missing-delta failure. + +## Residual risk + +- Teardown can revoke a received-but-undispatched authenticated request by + closing its transport. The caller then observes connection failure, not a + correlated `shuttingDown` envelope. The exactly-one outcome claim applies to + dispatch; cleanup does not guarantee delivery on a closing connection. +- Complete current-source Unix security/fault matrices, native macOS, actual + TTY, complete compatibility captures, application-storage behavior and npm/SEA + product evidence remain missing. +- Windows Sandbox privilege acceptance was deferred by the user, not passed. +- Runtime filesystem checks do not eliminate check-to-use or same-owner races. +- G1/G2 and migration tasks 1.3, 2.5, 2.8 and 2.9 remain open. No Caddy port, + staging, commit, archive, tag, push or publication was performed. diff --git a/openspec/changes/migrate-caddy-admin-security/.openspec.yaml b/openspec/changes/migrate-caddy-admin-security/.openspec.yaml new file mode 100644 index 0000000..79e0856 --- /dev/null +++ b/openspec/changes/migrate-caddy-admin-security/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-06 diff --git a/openspec/changes/migrate-caddy-admin-security/design.md b/openspec/changes/migrate-caddy-admin-security/design.md new file mode 100644 index 0000000..b589966 --- /dev/null +++ b/openspec/changes/migrate-caddy-admin-security/design.md @@ -0,0 +1,115 @@ +## Native Caddy boundary + +Pinned Caddy 2.11.4 admin identity accepts identifiers and issuer modules, not +static server certificate/key files. Its standard internal issuer can use an +imported root/intermediate CA. Remote administration requires client certificates; +access_control.public_keys contains base64 DER certificates, not raw SPKI. +Configure the authorized client leaf, not the issuing CA, to avoid authorizing +other clients issued by that CA. + +Admin disabled applies to the local plaintext endpoint in the pinned source; +remote administration has a separate startup path. Set disabled:true, explicit +loopback remote.listen, private identity identifiers and the internal issuer. +Never rely on remote's default :2021 or the default ACME identity issuers. These +source findings still require native startup and denial evidence. + +Identity uses Caddy DefaultStorage rather than arbitrary configured storage. +Explicit child environment must direct data/config/autosave paths into protected +runtime storage on each supported OS. Set install_trust:false for every CA that +can be provisioned, including local. No host trust-store or host Caddy operation +is permitted during development or acceptance. + +## Material ownership + +Use installed @peculiar/x509 2.1.0 with Node WebCrypto, not handwritten ASN.1 or +an OpenSSL/native dependency. Generate separate ECDSA P-256 root, intermediate +and client keys. Root/intermediate have CA/key-signing constraints; the client +is a non-CA digital-signature identity with clientAuth EKU. Caddy obtains and +renews its server leaf from the intermediate. Do not mint unused production +server credentials merely to satisfy a checklist. + +One small Caddy leaf module creates or loads a fixed complete PEM bundle in an +explicit directory for an explicit RuntimeOwner. The caller must already own +the runtime lifetime lock; this module adds no lock/lease/registry framework. +Reuse prepareRuntime, createProtectedFile and assertProtected. Never repair +permissions or replace existing credentials implicitly. + +Read existing files with bounded sizes; validate the complete bundle, validity +intervals, CA constraints, signing/client usages, issuer/signature chain and key +pair correspondence before returning usable material. Refuse partial, malformed, +expired, wrong-owner/ACL or linked material. Creation failure cleans only files +exclusively created by this call; it cannot remove pre-existing files. Required +credential bytes may be returned in memory to the internal TLS adapter, but +private keys and arbitrary parser errors must not enter diagnostics or logs. + +Restart loading reuses the same complete identity. Expired or incomplete material +is a typed failure, not permission to rotate a CA or overwrite client credentials. +Server leaf renewal is Caddy-owned; CA/client rotation is not an automatic new +product workflow in this slice. + +## Generated runtime files + +The user approved accepting safe inherited permissions rather than rewriting +native files. Keep assertProtected strict for Cadder-created runtime directories, +CA/client PEM files, IPC material and primary databases. On Windows these paths +still require the expected owner and an explicitly protected owner-only DACL. + +Caddy-generated storage files and SQLite journals may inherit owner-only access +from a verified protected runtime directory. Validate only the requested path +and its directory chain within that root: exact expected owner, owner-only access, +correct file/directory type and no links or junctions. On Windows every effective +access rule must grant only the runtime user, with usable owner access; the DACL +protection bit is not required on these descendants. Unix ownership and 0700/0600 +requirements remain unchanged. The root itself must pass strict validation. + +A different owner is unsafe even when the DACL lists only the runtime user: +Windows owners can change the DACL independently of its access entries. Existing +unsafe descendants are refused, not adopted, repaired or silently regenerated. +This is a read-only path check, not a tree monitor, ACL sealing pass or new storage +framework. Check a known SQLite journal before SQLite can consume it and after +creation while runtime exclusion remains held. Caddy startup integration uses +this boundary; full owned-process lifecycle remains roadmap 3.8. + +## Assembly and HTTPS increment + +Consume the existing guarded route plan rather than project admin/PKI fields. +Assemble a complete secure CaddyConfig before validation; no admin-policy merge +with arbitrary project settings. Preserve existing loopback route/host guards. +The HTTPS client uses explicit trusted CA, authorized client key/certificate and +normal Node server-name verification. No rejectUnauthorized:false, custom identity +bypass, HTTP retry, ambient trust substitution, redirects or arbitrary admin URLs. + +Independent active-state readback must compare the observed effective config, +not a submitted candidate or cached receipt. Define and test deterministic JSON +normalization at this backend boundary before exposing comparable hashes to +ConfigurationTransactions; unknown/unavailable observation is failure, never +idle/null. Idle lifecycle integration remains 3.8, not an invented success stub. +Apply transport failures without an explicit no-transition guarantee stay +ambiguous. Existing transaction fencing owns recovery ordering. + +## Verification and boundaries + +Deliver material generation/loading and fault tests before dependent admin +assembly/client integration. Then test TLS denial with disposable fixtures and +real pinned Caddy in isolated private staging, using existing owned command cleanup. +Native proof must preserve all host state and include generated server-key +ownership, not just Node-created PEM files. Do not contact unrelated localhost:2019 +or infer plaintext absence from an arbitrary host port probe. + +The initial native feasibility attempt stopped before script creation because +Arcantry required external-file confirmation unavailable in a headless child. +No Caddy process, certificate or listener was created. That denied external write +must not be retried through another mechanism. The user subsequently authorized +isolated private repository-local staging for temporary probes and permits Windows +Sandbox testing now. Shared source, validation and packaging must not depend on +that private staging. A new tool or permission denial still requires a stop and +supported approval, not an execution-mode workaround. Native evidence remains +open until actual results support it. + +Parent integrates independent MiMo reviews and runs proportional focused/full +Node checks. Existing implementation-schema missing-delta validation failures +remain explicit; no duplicate requirements or validator changes are added. +No production mocks, source-side generated JS, staging/commit/release or host +privilege/account/trust changes are authorized by this slice. Disposable Sandbox +execution is now user-authorized; partial-product results do not satisfy the final +completed-product privilege or G7 acceptance gate. diff --git a/openspec/changes/migrate-caddy-admin-security/proposal.md b/openspec/changes/migrate-caddy-admin-security/proposal.md new file mode 100644 index 0000000..2e18711 --- /dev/null +++ b/openspec/changes/migrate-caddy-admin-security/proposal.md @@ -0,0 +1,59 @@ +## Why + +Preserve the project Caddyfile -> managed shim -> daemon -> operator journey. +The next capability gives the existing transaction queue an authenticated Caddy +administration channel without restoring plaintext localhost administration. +Caddy remains the required runtime engine; Cadder remains TypeScript-only. + +## Requirement IDs + +- CADDY-006: Protected loopback mutual TLS, validated server identity, authorized + client material, no system trust installation and no project policy override. + +CADDY-003 supplies independent active-state verification and ambiguity fencing; +CADDY-002/004 supply owned process and local route boundaries. This slice does +not close their whole-product acceptance. + +## Scope + +Implement roadmap 3.6–3.7 incrementally: protected CA/client material first, +then secure configuration assembly and bounded authenticated administration. +Reuse existing runtime-security, CaddyConfig, preparation, composition and owned +command boundaries. No dependency additions or shared RPC/port changes. + +The approved engineering correction assigns root/intermediate CA and authorized +client generation to Node WebCrypto/@peculiar/x509. Caddy's standard internal +issuer obtains and renews the admin server certificate. Its default identity +storage must be redirected into the protected runtime boundary, not injected +with a separately generated leaf. + +## Non-goals + +No TLS proxy, custom issuer/plugin, CertMagic storage-layout injection, plaintext +fallback, OS trust installation, native runtime addon, CA installer or dependency +change. No daemon handlers, application SQLite worker, CLI/TUI, distribution, +full owned-server lifecycle (3.8), final completed-product privilege acceptance +or publication. The user permits disposable Sandbox testing of this increment; +it does not close G7. Test-only fake TLS servers are not production adapters. + +## Success criteria + +- Complete valid CA/client material is created or validated inside an explicitly + supplied owner-protected directory. Unsafe, partial, malformed, mismatched or + expired existing material is refused, not repaired or silently regenerated. +- Secure assembly owns admin/PKI policy and isolates Caddy identity/cache storage; + project input cannot widen trust, listeners or plaintext administration. +- Node HTTPS validates the CA chain and server identity and presents only the + authorized client certificate. Requests and responses are bounded; failures + never trigger HTTP fallback or cached-receipt observation. +- Native Caddy acceptance proves authorized access, missing/foreign-client + refusal, wrong server trust/identity refusal and protected storage. Source + inspection and fixture success alone do not close task 3.7. +- Focused/full Node checks cover changed boundaries above 85% own lines, with + independent MiMo review and explicit native/platform gaps. + +## Impact + +Additive Caddy leaf sources and dedicated tests; this focused planning/evidence +and roadmap progress only when supported by executed checks. Main requirements +are unchanged. Rust, earlier dirty work and final acceptance gates remain intact. diff --git a/openspec/changes/migrate-caddy-admin-security/tasks.md b/openspec/changes/migrate-caddy-admin-security/tasks.md new file mode 100644 index 0000000..8b39fbd --- /dev/null +++ b/openspec/changes/migrate-caddy-admin-security/tasks.md @@ -0,0 +1,14 @@ +## 1. Protected material (roadmap 3.6) + +- [x] 1.1 Generate/load a fixed protected root/intermediate CA and authorized client bundle with installed X.509/WebCrypto; validate complete material and return bounded typed failures without secret leakage. Parent material-increment evidence: 70 focused tests, actual Windows creation/reuse/ACL checks, 100% module coverage and the 550-test/97.27% full Node gate; see verification.md. +- [x] 1.2 Test valid generation/reuse, certificate/key constraints, expiry, malformed/partial/mismatched material, unsafe ownership/ACL/links and creation-failure cleanup. Run focused checks and independent review. MiMo Pro accepted the parent corrections; native Linux/macOS and real Caddy server-material evidence remain open in verification.md. + +## 2. Secure native administration (roadmap 3.7) + +- [x] 2.1 Assemble native loopback admin/internal-issuer/PKI policy without plaintext fallback or project override; isolate Caddy default identity/cache/config storage inside protected runtime paths. MiMo Pro review integrated; 92 policy tests passed. Native behavior remains task 2.3. +- [x] 2.2 Implement bounded Node HTTPS administration with authorized client and verified server identity; define independent active-config normalization/readback and uncertain apply failures for the existing queue. MiMo Flash review integrated; 61 client tests and the 703-test parent Node gate passed. No owned-process idle/lifecycle or native Caddy acceptance is claimed. +- [x] 2.3 Prove native pinned Caddy startup, authorized access/readback and no/foreign client, wrong-root and wrong-identity denial with owned teardown and owner-only generated server material. Keep Cadder-created roots/PEMs/databases strict; validate Caddy/SQLite descendants and their in-root directory chain without requiring an explicit Windows DACL protection bit or repairing permissions. Reject wrong owners, foreign access, links and root escapes; retain unsupported-platform and full-lifecycle gaps. Completed for ordinary-token Windows with pinned Caddy 2.11.4: full-policy validation, admin-only execution, native issuance, authorization/readback and owned cleanup. Unknown-SNI handshake denial and a separate stock offline peer-leaf name check are distinct evidence, not a mismatched-leaf network receipt. MiMo Pro accepted the inherited-storage boundary; see verification.md for remaining platform/lifecycle/privilege gates. + +## 3. Integration evidence + +- [x] 3.1 Integrate MiMo findings, simplification/fresh-eyes review and focused/full Node checks; validate planning and record only executed evidence. Keep roadmap/native/archive/final Sandbox gates open where proof is missing. MiMo Pro found no blockers in the final increment; parent nub run check passed with 727 tests, 2 existing Unix-native skips and 97.46% own-source line coverage. Planning validation was executed: the existing implementation-only missing-delta failure remains an explicit archive blocker, not a passed gate. Roadmap 3.6/3.7 and final completed-product acceptance remain open. diff --git a/openspec/changes/migrate-caddy-admin-security/verification.md b/openspec/changes/migrate-caddy-admin-security/verification.md new file mode 100644 index 0000000..3a23043 --- /dev/null +++ b/openspec/changes/migrate-caddy-admin-security/verification.md @@ -0,0 +1,278 @@ +# Verification + +## Approved issuance split + +The user approved correcting roadmap 3.6 and the reset design: Node +WebCrypto/X.509 creates protected root/intermediate CA and client material; +Caddy's standard internal issuer obtains and renews the admin server leaf. +CADDY-006 and final acceptance requirements are unchanged. + +Pinned Caddy 2.11.4 source inspection and independent MiMo Flash opinion support +this standard configuration path. Current official documentation was indexed +through grounded-docs without labelling it an exact-version snapshot. + +## Native feasibility status + +The initial delegated attempt stopped before execution. Its external probe +script write required Arcantry confirmation unavailable in the headless child. +No bypass was attempted. No Caddy process, certificates or listener were created; +only public source references remained in disposable temporary storage. + +Parent independently checked the native executable version (2.11.4), SHA-256 +and relevant module presence. This prerequisite inspection is not startup, +permission, TLS denial, reload or teardown proof. Roadmap 3.6–3.7 remain open. + +## Protected material increment + +Delivered src/caddy/certificates.ts and test/caddy-certificates.test.ts. +loadOrCreateAdminMaterial(directory, owner) creates or reuses six fixed protected +root/intermediate/client PEM files, validates complete profiles, signatures, +validity and key matches, and returns internal TLS credentials plus client leaf +DER for Caddy access control. The caller retains runtime-lock ownership. + +Parent checks after review fixes: + +- Focused suite: 70 tests passed, including actual Windows owned-temp creation, + reuse and owner-only ACL checks for all six files. Native Linux/macOS execution + and generated Caddy server-material protection remain unproved. +- nub run check: typecheck, ESLint, Prettier and coverage passed; 29 test files, + 550 passed and 2 existing Unix-native tests skipped. +- Own-source lines: 1,283/1,319 (97.27%). Certificates module: 128/128 lines, + 92/92 branches and 8/8 functions (100%). Coverage output stayed in runner temp. +- Active LSP probe had no TypeScript errors; six auxiliary style findings and + push-only clean-check uncertainty remain. This is not a universally clean + diagnostics claim. +- Strict main specs: 15 passed; reset roadmap and implementation schema valid; + git diff --check passed and nothing was staged. +- mise run openspec-check: 17 passed / 5 failed. Existing implementation-schema + missing-delta failures now include this focused slice; no duplicate requirements + or validation-tool changes were added to manufacture archive acceptance. + +MiMo Pro's initial material review found no P0/P1 and three P2 notes. Parent +required critical client EKU to match the generated fixed profile, added explicit +PEM-header and available-O_NOFOLLOW regression assertions, and clarified that +I/O/cleanup failure can leave partial files. The shared helper's AggregateError +has no cleanup-phase discriminator: the code does not guess a precise cleanup +classification or claim ownership of its residual path. MiMo Pro's targeted +follow-up accepted all three dispositions with no new defect in their blast +radius. Task 1.2 is complete within its tested increment. Roadmap 3.6/3.7 and +complete-slice acceptance remain open; these checks prove the CA/client increment, +not Caddy mTLS or server renewal. + +## Secure policy increment + +Delivered src/caddy/admin-policy.ts and test/caddy-admin-policy.test.ts. +prepareSecureAdminPolicy assembles the guarded route plan with explicit native +loopback mTLS administration, imported admin CA paths and internal web issuance. +It prepares protected runtime directories and confines child data/config/home/temp +environment paths. Wildcard subjects retain the existing composition contract. +normalizeCaddyConfig deterministically orders object keys while preserving arrays +and opaque JSON data, with complete-body, depth and traversal bounds. + +- Worker focused checks: 91 policy tests; 224 combined policy/material/composition + tests, typecheck, scoped lint and formatting passed. +- Parent nub run check after integration: typecheck, ESLint, Prettier and coverage + passed; 30 files, 641 passed / 2 existing Unix-native skips. +- Own-source lines: 1,421/1,457 (97.52%). Admin-policy module: 100% lines/functions + and 99.23% branches. Outputs remained in runner temporary storage. +- Actual owned-temp directory/material preparation and reuse passed on Windows; + Linux/macOS policy checks are modeled, not native acceptance. + +MiMo Pro accepted the policy with one test-gap note. Parent added a regression +for non-empty TLS subjects with fully pruned project routes, without requiring +subjects to equal the outer-guard union. The optional permission-error kind note +is deliberately unchanged: failure remains bounded and fail-closed, with no new +classification requirement accepted. Task 2.1 is complete as an assembly increment. +Real Caddy startup/readback/denial, generated descendant-file protection and server +renewal remain open. No Caddy process was launched by these tests; the parent +roadmap is not marked complete merely because assembly tests pass. + +## HTTPS client increment + +Delivered src/caddy/admin-client.ts and test/caddy-admin-client.test.ts. +CaddyAdminClient targets only the prepared loopback endpoint with explicit CA, +authorized client credentials and normal localhost certificate verification. +It refuses noncanonical candidates or changes to the pinned admin/PKI envelope, +bounds headers and complete UTF-8 response bodies, and aborts owned requests at an +absolute deadline. Completed HTTP 400 load rejection is distinct from uncertain +transport or other-status failures. GET /config/ independently normalizes observed +configuration; unavailable readback never becomes idle or a cached receipt. +Owned-process idle/lifecycle integration remains roadmap 3.8. + +- Worker: 60 focused client tests and 216 neighboring regressions passed; + typecheck, scoped lint and formatting passed. +- Parent nub run check: typecheck, ESLint, Prettier and coverage passed; 31 files, + 701 tests passed / 2 existing Unix-native skips. Own lines: 1,511/1,551 (97.42%). + Client coverage: 95.74% lines, 100% functions and 94.59% branches. +- Actual disposable Node HTTPS fixtures exercised CA/client/server-name/expiry + denial, complete independent readback, bounds, deadlines, disconnects and queue + rollback/fencing. They are not evidence of native Caddy authorization or renewal. +- git diff --check passed; nothing staged. Active LSP probes reported auxiliary + heuristic findings, not TypeScript compiler errors, and silent-on-clean coverage + remained inconclusive. JSON.parse findings in both leaf helpers have catch + boundaries at their callers; no blanket clean-diagnostics claim is made. +- The worker's direct external report write was refused by Arcantry. It stopped + that attempt and returned its final report for workflow-managed persistence; + no alternate write mechanism or permission override was used. + +MiMo Flash accepted the client with one explicitness note: idle/null refusal +previously depended on catching property access failure. Parent added an explicit +pre-network null guard and a dedicated no-request regression, preserving the +existing rejection outcome. No lifecycle stub or queue contract change was added. + +Final checks after both local review corrections: + +- Focused policy/client suites: 153 passed (92 policy, 61 client). +- nub run check: typecheck, ESLint, Prettier and coverage passed; 31 files, + 703 tests passed / 2 existing Unix-native skips; 1,511/1,551 own lines (97.42%). + Client branches: 94.73%; other reported module percentages remain unchanged. +- Fresh-eyes review checked the null guard's local blast radius and the composed + pruned-route fixture. Simplification review retained the single transport and + reused normalization boundary rather than introducing another validator/queue. +- The two auxiliary unchecked-JSON.parse findings were marked false positives: + private throwing validators are called inside their owning catch boundaries. + No inline suppression or broader diagnostics-cleanliness claim was added. +- git diff --check passed; nothing staged. +- Strict main specifications: 15 passed; reset-cadder-architecture valid; + OpenSpec doctor passed. mise run openspec-check still reported 17 passed / + 5 failed. Focused validation confirmed the unchanged missing-delta requirement + for this implementation-only change; no duplicate requirements were added. + +Tasks 2.1/2.2 are complete within their assembly/client scope. Native task 2.3, +complete-slice acceptance, roadmap 3.6/3.7, generated server-key protection and +final-product Sandbox remain open. No real Caddy process was launched by this +increment. The user subsequently authorized temporary probes in isolated private +repository-local staging and allows Sandbox testing now. The private directory is +excluded locally; the remote default branch does not track it, and existing test, +typecheck and formatting input globs do not consume it. No shared tool or product +may acquire a dependency on that staging. At that increment native execution and Sandbox results were still pending; +partial-product tests do not close final privilege/G7 acceptance. + +## Ordinary-owner correction and native administration evidence + +The user authorized isolated private probes and promotion of this sanitized +summary. No private paths, account identifiers, credential bytes or private +staging dependencies are included in shared sources. + +The Windows protection helper previously requested owner reassignment even when +an exclusively new path already had the expected owner. An ordinary-token +reproducer distinguished that write from DACL-only protection. protectCreated +now reads the actual owner and requests reassignment only when it differs; +assertProtected remains strict. Five native ordinary-token regressions cover +new directories/files under Modify-only inheritance, unsafe existing paths, +owner-read failure cleanup and refusal to assign a new file to another account. +MiMo Pro accepted the fix and its completed differing-owner coverage. + +Parent nub run check passed after that correction: 32 test files, 708 passed, +2 existing Unix-native skips, 1,511/1,551 own lines (97.42%), plus typecheck, +ESLint and Prettier. This predates the newly approved descendant-validation +increment and is not its final gate. + +Pinned stock Caddy 2.11.4 ran once on an ordinary Windows token using existing +product material/policy/client/image/owned-command boundaries. Unmodified full +factory configuration passed native validation without web listener binding. +The running admin-only candidate retained the exact admin/PKI policy, with +plaintext disabled, explicit loopback administration and no trust installation. +Authorized GET, complete POST/load and independent actual-config hash readback +passed. Missing clients, a distinct client issued by the same intermediate and +an incorrect server root were refused. Online unknown-SNI denial was EPROTO; +a separate stock offline name check against the normally verified peer leaf +returned ERR_TLS_CERT_ALTNAME_INVALID. This is not a mismatched-leaf network +receipt. Caddy issued its server leaf from the imported product intermediate. +Authorized stop returned 200; the owned child exited 0, streams settled and the +exclusively acquired private runtime was removed. MiMo Pro verified these +phase claims against the isolated evidence. + +The prior blanket strict audit failed honestly: 39 paths had the expected owner +and no foreign DACL grants or links, but 17 inherited owner-only DACLs lacked an +explicit protection bit. Fifteen belonged to native Caddy storage; two were a +probe-created intermediate directory and a SQLite journal, not Caddy files. +Product root and six PEM controls passed strict checks. An earlier elevated +Sandbox run also contained differently owned generated files; those remain +unsafe and are not reclassified by the ordinary-user result. + +The user approved the simpler acceptance boundary in design.md: strict Cadder +roots/credentials/databases; read-only owner/access/link validation for native +Caddy storage and SQLite journals beneath those protected roots, allowing safe +Windows inheritance without repairing ACLs. Implementation and a new native +proof were pending at that decision; their subsequent results follow below. +Earlier failed evidence remains retained rather than rewritten as a pass. + +## Inherited native storage increment + +Delivered assertRuntimeDescendant in src/platform/runtime-security.ts and known +DELETE-journal checks in src/daemon/runtime-lock.ts, with independent automated +coverage in test/runtime-descendants.test.ts and test/runtime-lock-faults.test.ts. +The validator inspects only the requested descendant and its in-root directory +chain beneath a strictly verified root. It refuses lexical escapes, links, +wrong kinds/owners and unsafe access without changing permissions. Cadder-created +roots, PEMs and primary databases retain strict validation. Unix UID and mode +requirements are unchanged. + +The journal is checked before SQLite opens the database and again while runtime +exclusion is held, before metadata publication. Only absence of that exact +journal leaf is tolerated; missing roots/ancestors and inspection failures remain +fatal. Unsafe existing journals are neither consumed nor removed. There is no +new scan, monitor, repair pass or storage framework. + +A fixture-only ACL mistake initially prevented cleanup of a task-created test +directory. After explicit approval, one DACL-only restoration on that exact +owned fixture permitted cleanup; the fixture inheritance was corrected. No +production, parent-directory or host-policy repair was performed. + +### Current native evidence + +A new ordinary-token Windows run used pinned stock Caddy 2.11.4 and the existing +product material, policy, HTTPS client and owned-command boundaries. The full +unmodified policy passed native validation; actual execution remained admin-only, +not a full web-runtime acceptance test. Authorized GET, complete POST/load and +independent normalized-config readback passed. Missing clients, a different +client from the same intermediate, a wrong server root and unknown SNI were +refused. Unknown-SNI EPROTO and the separate stock offline wrong-name check of +the normally verified peer leaf retain their distinct scope. + +Caddy issued the localhost server leaf from the product intermediate. A finite +probe audit found 39 paths with the expected owner, no foreign access grants and +no links: 18 strict product paths, 5 strict probe-scaffold paths and 16 safely +inherited native paths (15 Caddy paths and one SQLite journal). Product validation +failed on none. The generated server key passed descendant validation while +legacy assertProtected still refused its inherited DACL, confirming that strict +validation was not relaxed. No ACLs were repaired. + +Authorized stop returned 200; the owned child exited 0, its Job and streams +settled, and the exclusively acquired runtime was removed after ownership +revalidation. No trust installation, host elevation or Sandbox run was needed. +The earlier elevated generated-owner mismatch remains unsafe and unresolved. + +### Review and final checks + +MiMo Pro verified source, tests and the named native evidence independently and +returned OK with notes, with no blockers. Simplification/fresh-eyes review kept +one read-only validator and one known-journal wrapper. Two minor observations +remain explicit: the real-journal test pins the current SQLite creation timing; +the pre-existing strict check's treatment of InheritOnly FullControl was outside +this approved delta. Product-created strict ACLs use propagation-None rules. +Neither observation was converted into an unsolicited hardening task. + +Parent nub run check passed after the final source changes: + +- TypeScript typecheck, ESLint and Prettier passed. +- 33 test files passed; 727 tests passed and 2 existing Unix-native tests skipped. +- Own-source lines: 1,535/1,575 (97.46%); branches: 1,104/1,207 (91.46%). +- Runtime security and runtime lock: 100% line/function coverage. Coverage + artifacts remained in runner temporary storage. +- git diff --check passed; nothing was staged. Active diagnostics retained + auxiliary heuristic findings and push-only silent-check uncertainty; the + passing TypeScript compiler is not a universal LSP-cleanliness claim. +- OpenSpec doctor and all 15 main specifications passed; the maintained planning + check reported 17 passed / 5 failed. Focused strict validation again refused + this implementation-only change because it has no delta. That archive blocker + remains open; no duplicate requirements or validator changes were introduced. + +Tasks 2.3/3.1 are complete within this approved ordinary-Windows/admin-only +increment, including recording the blocked planning gate. This does not close +roadmap 3.6/3.7, complete migration or archive acceptance. Full web execution, +owned lifecycle 3.8, renewal/restart persistence, native Linux/macOS, elevated +owner handling, final completed-product privilege/Sandbox/G7 and distribution +acceptance remain open. Shared sources depend only on product code and ordinary +test fixtures, not on private probe scripts or results. diff --git a/openspec/changes/migrate-caddy-composition/.openspec.yaml b/openspec/changes/migrate-caddy-composition/.openspec.yaml new file mode 100644 index 0000000..79e0856 --- /dev/null +++ b/openspec/changes/migrate-caddy-composition/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-06 diff --git a/openspec/changes/migrate-caddy-composition/design.md b/openspec/changes/migrate-caddy-composition/design.md new file mode 100644 index 0000000..24ed525 --- /dev/null +++ b/openspec/changes/migrate-caddy-composition/design.md @@ -0,0 +1,76 @@ +## Evidence and dependency order + +The retained Rust behavior is in crates/cadder-daemon/src/caddy/configuration.rs: +active_domains, detect_conflicts, extract_http_routes, filter_route_hosts, +guard_registration_routes, compose_config and namespace_config_ids. Its tests +cover disabled domains, conflicts, loopback listeners and nested hostless siblings. +crates/cadder-ipc/src/state.rs defines enabled activation states and canonical +trim/trailing-dot/IDNA/ASCII-case behavior. Existing Node DTOs and preparation +carriers are the input contracts; this slice does not reopen those contracts. + +## Boundary and data flow + +Complete adapted JSON + registration activation/domain intent -> extracted HTTP +route trees -> canonical ownership check -> domain-filtered, outer-guarded trees +-> typed route plan containing owned HTTP/HTTPS servers and TLS subjects. + +Use a small pure API in new src/caddy modules. Parse only complete bounded +adapted carriers using existing preparation integrity checks. Preserve arbitrary +route/handler JSON; do not parse Caddyfile syntax or reinterpret handler settings. +Only route host matchers participate in domain extraction/filtering. Unrelated +handler properties named host are not registration ownership declarations. + +Registration activation and domain activation are both respected. Canonicalize +names at the business boundary rather than trusting caller case/spelling. +Node domainToASCII normally parses URL hosts, unlike Rust's literal IDNA input. +Use a fixed nonnumeric suffix during whole-domain mapping to prevent numeric IP +reinterpretation; keep the original trimmed domain with ASCII-only lowercasing +on failure or URL-delimiter input. Strip ASCII trailing dots before mapping. +Fixtures cover the observed differences, not exhaustive Node/Rust IDNA parity. +Deduplicate domains within one registration; competing registration IDs produce +bounded diagnostics with source paths and no successful plan. Reject ambiguous +duplicate registration input rather than making iteration order authoritative. +Keep route order within a project and a documented deterministic project order. + +## Route guards and failure behavior + +Preserve retained branches and hostless siblings behind an outer host matcher +containing only that registration's active canonical hosts. An emptied host +matcher must not become a catch-all. If pruning removes every subroute handler +from a retained terminal parent, preserve its matchers, empty handle array and +terminal position; deleting that parent would let a later route handle the request. +Nonterminal empty parents may be dropped. Proxy-shaped data inside an opaque +handler payload does not contribute upstream metadata. +A disabled-only project or a registration with no enabled hosts contributes no +routes. Never fabricate placeholder routes +when prepared input is missing or invalid. Return failure without mutating inputs +or a previously produced plan; committing/rejecting runtime changes belongs to +roadmap 3.4-3.5. + +Retain exact dual-stack loopback ports 80/443 and HTTP-copy ID namespacing from +the Rust behavior. Ignore project server listeners and root admin/TLS policy; +only extracted routes enter the plan. The plan is explicitly not CaddyConfig: +it supplies route servers and TLS subjects for later daemon-owned secure config +assembly. Do not emit localhost:2019, an admin fallback or a TLS-policy stub. + +## Verification + +Pure JSON fixtures exercise multiple projects, all activation states, IDN/case/ +trailing dots, duplicate ownership, malformed/hash-mismatched prepared input, +nested mixed enabled/disabled branches, hostless siblings, arbitrary handler +payloads, stable ordering, ID namespacing and input immutability. No installed +Caddy, network listener, process, filesystem permissions or Sandbox is needed. + +MiMo Flash independently checks retained behavior and boundary assumptions; +MiMo Pro reviews the actual implementation and test evidence. The parent resolves +concrete findings, performs simplification/fresh-eyes review, runs the full Node +gate and checks main specs/roadmap/schema plus the diff. Known global archive and +native/release blockers remain explicit, not claimed as passed. + +## Alternatives and migration + +Do not copy the Rust plaintext admin config or mock/placeholder branches into +the new production path. Do not build a generic JSON rule engine, new parser or +queue to deliver this slice. Additive pure modules leave the Rust baseline and +existing Node runtime/preparation unchanged and independently reversible. Later +queue and protected Admin API work consumes the plan before any real apply. diff --git a/openspec/changes/migrate-caddy-composition/proposal.md b/openspec/changes/migrate-caddy-composition/proposal.md new file mode 100644 index 0000000..ccd3e36 --- /dev/null +++ b/openspec/changes/migrate-caddy-composition/proposal.md @@ -0,0 +1,58 @@ +## Why + +Preserve the released project Caddyfile -> managed shim -> daemon -> operator +journey. The next capability combines already adapted project routes into a +local, host-guarded route plan and rejects competing active domain owners. +Caddy remains the mandatory separately installed server; this migrates Cadder's +integration, not Caddy itself. + +## Requirement IDs + +- `CADDY-002`: Compose active non-conflicting project routes (composition only). +- `CADDY-004`: Preserve exact active-host outer guards and IPv4/IPv6 loopback listeners. +- `REG-002`: Canonical domain ownership across active entrypoints. +- `REG-004`: Preserve existing adapted Caddyfile trees and activation behavior. + +## Scope + +Implement roadmap 3.3 as pure Node Caddy composition/domain functions and focused +tests. Consume the existing registration DTO and complete adapted JSON carriers. +Produce a typed route plan with the retained HTTP/HTTPS server shapes and TLS +subjects, or diagnostics without a usable plan. No process or active-state changes. + +The completed protocol and preparation slices remain unarchived because of the +known custom implementation-schema validation blocker. This slice consumes their +leaf contracts without modifying them. Dependency order is explicit; only one +writer uses the checkout, and parent-owned planning/evidence is separate from +worker-owned new composition source/tests. + +## Non-goals + +No placeholder production routes, Caddyfile parser, adapter subprocess changes, +mutation queue, apply/rollback, storage, registration sessions, Admin API, +certificate generation, server startup, CLI/TUI, dependencies, Rust changes, +packaging, Sandbox or host security changes. A route plan is not an executable +full Caddy configuration and must not be passed to the validation/apply port as +one. Protected administration remains roadmap 3.6-3.7. + +## Success criteria + +- Canonical case, trailing dots and IDN spelling identify the same domain owner. +- Only registered/activating/active registrations and domains contribute routes. +- Conflicts reject the complete plan; inactive entries cannot cause a conflict. +- Each complete retained registration route tree remains behind its own active + canonical hosts, including hostless siblings; disabled-only branches cannot + become hostless catch-alls. +- Project listeners/admin/TLS settings cannot widen the composed route boundary; + owned listeners are exactly 127.0.0.1/[::1] on ports 80 and 443. +- Input JSON/registrations remain unchanged; missing or invalid prepared input + does not silently become a placeholder route. +- Focused and full relevant Node checks pass with >=85% own-code line coverage; + independent MiMo review findings are reconciled. + +## Impact + +New Node Caddy composition/domain modules and dedicated tests; focused OpenSpec +plan/evidence and the existing roadmap 3.3 completion row only. No new product +requirements, RPC methods or public user commands. G1/G2/G3 and final native, +real-Caddy, distribution and Sandbox acceptance remain open. diff --git a/openspec/changes/migrate-caddy-composition/tasks.md b/openspec/changes/migrate-caddy-composition/tasks.md new file mode 100644 index 0000000..e517c58 --- /dev/null +++ b/openspec/changes/migrate-caddy-composition/tasks.md @@ -0,0 +1,14 @@ +## 1. Canonical domains and ownership + +- [x] 1.1 `[REG-002, REG-004]` Implement canonical domain extraction and enabled-state ownership checks using existing registration/carrier contracts (verification: case/IDN/trailing-dot, inactive entries, duplicates, deterministic diagnostics and integrity tests). + +## 2. Guarded multi-project composition + +- [x] 2.1 `[CADDY-002, CADDY-004, REG-004]` Compose retained trees into a pure typed route plan with exact loopback listeners and registration host guards (verification: multiple projects, disabled-only branches, hostless siblings, preserved handler JSON, HTTP IDs, ordering and immutable inputs). + +## 3. Evidence and closeout + +- [x] 3.1 `[CADDY-002, CADDY-004, REG-002, REG-004]` Reconcile MiMo review, focused/full Node checks and requirement-to-evidence coverage (verification: >=85% own-code lines, strict main/roadmap checks, schema/diff inspection and explicit remaining acceptance blockers). + +Checked tasks close roadmap 3.3 only. Queue/apply, protected administration, +server lifecycle, full requirement acceptance and G1/G2/G3 remain open. diff --git a/openspec/changes/migrate-caddy-composition/verification.md b/openspec/changes/migrate-caddy-composition/verification.md new file mode 100644 index 0000000..581c677 --- /dev/null +++ b/openspec/changes/migrate-caddy-composition/verification.md @@ -0,0 +1,105 @@ +## Summary + +Roadmap 3.3 and the focused implementation checks pass. Existing adapted project +JSON and registration/domain activation now produce a deterministic guarded route +plan, or bounded diagnostics with no usable plan. The new modules are +src/caddy/domains.ts and src/caddy/composition.ts; tests are in +test/caddy-composition.test.ts. The plan is not a complete runnable CaddyConfig. +No validation/apply wiring, Admin API, server or runtime-state mutation is added. + +Evidence applies to the uncommitted checkout based on HEAD +93942af84f555eab2bd68e0a4a9db487ff82d040. Earlier dirty work is preserved. +No staging, commit, publication, dependency, Rust production, Sandbox or host +privilege/account changes were performed by this slice. + +This is not archive-ready. The known OpenSpec 1.5 delta validator rejects the +three spec-free implementation changes. G1/G2/G3, real-Caddy/native/distribution +proof and final-product Sandbox remain open; checked tasks do not close whole +requirements or product acceptance. + +## Evidence + +| Requirement ID | Task | Evidence | Result | +| --- | --- | --- | --- | +| REG-002, REG-004 | 1.1 | `nub run test -- test/caddy-composition.test.ts`: canonical case/IDN/trailing dots, numeric literal and URL-delimiter differences, all registration/domain activation states, duplicate registration IDs, canonical self-deduplication, sorted competing-owner diagnostics and bounded paths. | Pass for composition boundary; exhaustive IDNA parity not claimed | +| REG-004 | 1.1 | Same suite: complete/hash/UTF-8/32 MiB carrier checks, malformed route shapes, sorted server extraction, first real route-position upstream and opaque payload rejection as metadata. | Pass; no Caddyfile parser or adapter execution added | +| CADDY-002, CADDY-004, REG-004 | 2.1 | Same suite: multi-project ordering, registration host guards, mixed/disabled-only matcher pruning, nested and separate hostless siblings, exact IPv4/IPv6 loopback listeners on 80/443, HTTP-only ID copies, immutable inputs and root admin/listener/TLS injection ignored. | Pass for pure route plan; server/apply acceptance pending | +| CADDY-002, CADDY-004, REG-004 | 2.1 | Terminal-parent regression retains chain position and matchers with an empty handler list after disabled subroutes prune; opaque proxy-shaped payload regression preserves JSON and yields no fake upstream. | Pass after parent repair; actual Caddy chain execution remains 3.9 | +| CADDY-002, CADDY-004, REG-002, REG-004 | 3.1 | Independent MiMo Flash opinion, MiMo Pro source review and targeted repair re-review; parent full Node/schema/main/roadmap checks, simplification/fresh-eyes pass and diff inspection below. | Slice checks pass; global/archive gates blocked | + +Final parent `nub run check` passes TypeScript, ESLint, Prettier and V8 coverage: +**415 tests pass, two existing Unix-native tests skip, 27 test files pass**. +Own TS/TSX lines are **1063/1098 (96.81%)**. Coverage-summary inspection includes +composition **62/62** and domains **105/106**, together **167/168 (99.40%)**; +composition's fully covered file is omitted from the compact text table, not the +coverage denominator. The uncovered domain line is the comparator's equal return. + +Final focused suite: `nub run test -- test/caddy-composition.test.ts`, **63 pass**. +An earlier focused run with `-t 'terminal chain|opaque non-route'` deliberately +reproduced the terminal regression: one failed, one passed. The repaired focused +and final full checks above pass; that initial failure is not passing evidence. + +## Review and repairs + +MiMo Flash identified plan-level risks around emptied host matchers, opaque host +properties, canonical equality and route-plan/full-config separation. The final +source and fixtures preserve those boundaries. A matcher emptied by filtering +is removed as an impossible OR alternative, never converted to a catch-all; +hostless siblings remain inside the registration's outer active-host guard. +Only actual route matcher and subroute positions contribute hosts/upstreams. + +MiMo Pro initially returned OK with notes, no P0/P1, and two P2 findings: + +- Terminal chain position: deleting a parent after all subroute handlers prune + could expose the following route. The parent reproduced this, then retained + terminal parents with their matchers, terminal flag and empty handle array. + Nonterminal empty parents and disabled-only host matcher routes still prune. + Regression fixtures cover active-host and hostless terminal parents. +- Opaque upstream metadata: narrower traversal than Rust's broad JSON walk is + intentional. A new fixture pins that a proxy-shaped plugin payload does not + contribute a domain/upstream and remains unchanged in the composed routes. + +The same MiMo Pro reviewer inspected those repairs and returned **OK**, both P2 +items resolved and no new defect in their blast radius. The follow-up's earlier +pending-full-gate caveat is superseded by the final passing parent gate above. +Parent simplification replaced a nested comparator ternary with explicit returns; +fresh-eyes inspection found no further substantive issue. + +The original Flash run failed with an upstream inference-generation error; +the original Pro run timed out without a verdict. Exact-run native resumes +produced the completed opinion, review and targeted follow-up. Failed receipts +remain failures, not successful reviews. A child direct report write was boundary +blocked; native runtime capture persisted the final response without a bypass or +alternate CLI. No orchestration failure is counted as product evidence. + +## Gate + +- [x] Every focused task and its stated slice checks is complete. +- [x] Every in-scope requirement aspect has evidence above. +- [x] Focused and full Node checks pass above 85% own-code lines. +- [x] `openspec schema validate implementation` passes. +- [x] `openspec validate --specs --strict --no-interactive`: 15 pass. +- [x] `openspec validate reset-cadder-architecture --strict --no-interactive` passes. +- [x] `git diff --check` passes; staged content is empty; no generated JS/DLL/EXE appears in the affected source/test tree. +- [ ] Global `mise run openspec-check` passes: currently 17 items pass and three spec-free implementation changes fail the missing-delta validator. +- [ ] Strict focused change validation passes: currently fails with `Change must have at least one delta. No deltas found.` +- [ ] Archive is authorized and validation-compatible. + +No fake deltas, checker replacement or validation-policy workaround was added. +Active LSP probes did not uniformly certify clean results; auxiliary rules retain +non-null assertion/filter-map warnings and runtime-type-check hints. Maintained +TypeScript and lint checks pass; this is not an LSP-clean claim. OpenSpec artifact +instructions also emitted the known custom-schema `specs` rule warning while +returning verification instructions; it was not treated as product failure or +silently repaired. The combined Rust/docs/npm/cargo-dist gate was not rerun for +this Node-only slice; the earlier preparation evidence remains separately dated. + +## Residual risk + +Pure JSON assertions do not prove execution by real Caddy, empty-handler terminal +semantics or every adapter output shape. Native Caddy, protected mTLS assembly, +queue/apply/rollback, lifecycle, distribution and final-product Sandbox remain +later roadmap work. Canonicalization fixtures cover observed Node/Rust IDNA +edges, not exhaustive Unicode/version parity. Pending/committed-state isolation +and preservation of the currently committed owner during a runtime mutation +belong to 3.4-3.5; this slice only rejects the complete conflicting route plan. diff --git a/openspec/changes/migrate-caddy-lifecycle/.openspec.yaml b/openspec/changes/migrate-caddy-lifecycle/.openspec.yaml new file mode 100644 index 0000000..46f2642 --- /dev/null +++ b/openspec/changes/migrate-caddy-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-08 diff --git a/openspec/changes/migrate-caddy-lifecycle/design.md b/openspec/changes/migrate-caddy-lifecycle/design.md new file mode 100644 index 0000000..00c914c --- /dev/null +++ b/openspec/changes/migrate-caddy-lifecycle/design.md @@ -0,0 +1,91 @@ +## Existing boundaries + +ConfigurationTransactions owns mutation ordering and publication. Its required +ConfigurationRuntime.apply accepts CaddyConfig or null; readActive returns an +independently observed hash, positively proved idle, or a failure. +CaddyAdminClient supplies bounded verified HTTPS /load and /config/ but not +process ownership. PinnedCaddyImage verifies the executable around native +creation. runOwnedCommand owns a Windows Job or Unix process group and waits for +owned cleanup. Reuse these boundaries. + +## Lifecycle increment + +One lifecycle object holds one owned execution and one prepared secure admin +identity/environment snapshot. It starts Caddy with a complete normalized secure +JSON candidate, never --resume, project environment or plaintext fallback. +Runtime exclusion is held by the caller throughout this object's lifetime. +Guard malformed/security-changing candidates before side effects. Freeze/copy +caller inputs. Initial and reload observation use the existing HTTPS readback. + +Only add the minimal long-lived mode to existing owned execution needed to avoid +a configuration-command deadline or unbounded lifetime output accumulation. +Keep adapt/validate command defaults and output limits unchanged. Preserve pin +checks and existing group/Job finalizers. No second native launcher, generic +process registry, monitoring framework or process enumeration is introduced. +Windows uses private child pipes so its verified-resume handshake cannot race +native output. Wrapper success follows Job and stream settlement; a bounded +four-byte footer carries the native exit code separately from helper failures. +A fixed-buffer stdout payload counter preserves immediate short-command limits; +long-lived execution drains without accumulating lifetime output. + +Before launching, start performs a bounded read-only conflict check and refuses +an already-responsive authenticated endpoint. Persisted TLS identity proves the +peer identity, not ownership of a newly resumed process. This check grants no +permission to adopt or stop the existing process. Active readback begins only +after verified native creation/resume and continues to watch owned settlement. +Failure cancels and settles that execution before another start or staging +cleanup. A process that exits unexpectedly is not automatically adopted or +restarted. A later explicit apply/reconciliation may start the pinned image again. +Unknown or incomplete teardown remains failure/ambiguity, never proof of idle. + +The existing transaction queue's explicit reconciliation may restore the last +committed target after an unavailable initial observation, not just a mismatch. +The runtime still refuses unsafe or unproved teardown; restoration must succeed +and independent final readback must match before the queue clears its fence. +This narrow integration correction neither publishes an abandoned candidate nor +persists new intent or introduces automatic recovery. + +Reload delegates to the existing HTTPS client. Transaction verification, +rollback and persistence remain in ConfigurationTransactions. Null performs a +real graceful /stop, waits for owned settlement, then forces through the existing +owned control boundary if needed. close stops admissions, cancels startup or +in-flight admin work, and drains teardown; it does not release the daemon lock +or close the caller-owned resolver/storage. No global Ctrl+C handlers belong to +the leaf adapter; callers invoke close from their shutdown/signal path. + +## Security and staging + +Use the secure policy's isolated environment for validation and execution. +Stage complete JSON only in protected runtime-owned storage and retain it until +owned execution settles. Strict product-created path checks remain unchanged. +Known generated Caddy storage paths use the accepted read-only descendant check; +no ACL rewrite, general tree scan or secret-bearing diagnostic is added. + +Windows native creation must also preserve the actual user's ownership. An +elevated primary token may otherwise create SQLite journals and Caddy keys with +the Administrators group as owner, even beneath a user-only inherited DACL. +After validating the runtime directory, lock file and existing known journal, +`acquireRuntimeLock` awaits `normalizeRuntimeCreationOwner` before opening SQLite. +The bounded existing PowerShell bridge targets the Node process primary token, +verifies its TokenUser against the runtime owner, and queries TokenOwner. Only a +mismatch requests TOKEN_ADJUST_DEFAULT and sets TokenOwner to that existing user +SID. A separately reopened query-only token must prove the same user and owner +before admission continues. Unix remains a no-op; `runtimeOwner` stays +observational. This changes default ownership for future native-created objects, +not token privileges, groups, elevation, identity or existing file ACLs. Unsafe +existing journals still fail before normalization. No native addon or additional +launcher is introduced. Runtime exclusion remains the caller's prerequisite for +Caddy lifecycle execution. + +## Verification + +Use fake Caddy/process/admin seams in unit tests and disposable Node-native +children for lifecycle ownership evidence; tests require neither real Caddy nor +private paths. Prove idle/start/reload, unexpected exit, startup timeout/failure, +closed admission, cancellation, forced stop and unrelated process survival. +Integrate with the real transaction queue for idle rollback and reconciliation. +Native real-Caddy evidence is optional for this first adapter increment and must +be distinguished from fake fixtures and the previously completed admin-only +Windows proof. Whole roadmap 3.8/G3 acceptance remains open until its full native +scenarios pass. Known implementation-schema archive/validator issues are not +fixed by copying requirements or changing tooling in this slice. diff --git a/openspec/changes/migrate-caddy-lifecycle/proposal.md b/openspec/changes/migrate-caddy-lifecycle/proposal.md new file mode 100644 index 0000000..376f701 --- /dev/null +++ b/openspec/changes/migrate-caddy-lifecycle/proposal.md @@ -0,0 +1,50 @@ +## Why + +Preserve the project Caddyfile -> managed shim -> daemon -> operator journey. +The next capability runs and stops the daemon's own Caddy with independently +observed configuration, so the delivered transaction queue can leave and restore +idle. Existing resolution, validation, route composition and secure administration +are implemented; none currently owns a long-lived Caddy lifecycle. + +## Requirement IDs + +- `CADDY-001`: retain the pinned trusted executable. +- `CADDY-002`: one owned child serves composed routes. +- `CADDY-003`: independent readback and real idle transitions for the queue. +- `CADDY-006`: retain verified loopback mTLS for lifecycle administration. +- `RUN-003`: bounded graceful/forced teardown of owned work only. +- `RUN-004`: retain startup identity/configuration until restart. + +## Scope + +Implement a small Caddy lifecycle adapter over the existing pinned-image, +owned-command and HTTPS boundaries. Cover initial start, reload, independent +inspection, unexpected exit, idle rollback, bounded stop and admission close. +Extend existing owned execution only where long-lived execution needs it; +reuse its Windows Job/Unix group instead of another process framework. + +## Non-goals + +No registration handlers, SQLite application worker, shim, CLI/TUI, packaging, +auto-restart loop, process adoption/enumeration, trust installation, ACL repair, +new dependencies or runtime framework. No Rust removal or release action. +Native platform and full product acceptance remain separate gates. + +## Success criteria + +- Start and reload use the pinned image and immutable secure policy. +- Active hashes come from mTLS readback, never submitted bytes or receipts. +- Idle is reported only when absence/owned settlement is positively established; + unexpected exit or failed observation cannot masquerade as a committed state. +- Graceful stop has a deadline and then forces only the owned Job/group. +- Startup failure, cancellation and close settle acquired work before releasing + staging; unrelated processes survive fixture success and failure scenarios. +- Focused tests, Node checks and independent review provide executed evidence; + unexecuted native/product gates stay open. + +## Impact + +Caddy lifecycle/admin modules, the existing owned-command seam and direct tests. +The internal ConfigurationRuntime is implemented without changing public RPC or +CaddyPort. Conceptually this removes the missing idle/lifecycle seam; it does not +replace the existing resolver, queue or native process ownership implementation. diff --git a/openspec/changes/migrate-caddy-lifecycle/tasks.md b/openspec/changes/migrate-caddy-lifecycle/tasks.md new file mode 100644 index 0000000..28ea76f --- /dev/null +++ b/openspec/changes/migrate-caddy-lifecycle/tasks.md @@ -0,0 +1,15 @@ +## 1. Owned lifecycle adapter + +- [x] 1.1 Reuse the existing pinned-image/owned-command boundary for bounded-start, long-lived Caddy without lifetime stream accumulation; preserve command-mode defaults and native cleanup semantics (CADDY-001/002, RUN-003/004). Native Windows Node fixtures cover startup notification, binary streams/footer, full native exit codes, exact/zero/overflow bounds and Job cleanup; see verification.md. +- [x] 1.2 Implement secure start/reload/readback, real idle stop, unexpected-exit diagnostics and close/admission settlement as ConfigurationRuntime, without duplicating the transaction queue (CADDY-003/006, RUN-003). The bounded conflict check refuses an existing authenticated endpoint; explicit queue reconciliation restores committed state only with independent final proof. Full product/native acceptance remains open. + +## 2. Verification + +- [x] 2.1 Test lifecycle success/failures, start cancellation, bounded forced teardown, immutable policy, uncertain observations and unrelated-process survival with fake adapters/native fixtures (CADDY-002/003/006, RUN-003/004). Include transaction idle rollback/reconciliation regressions. Parent post-review focused suites passed 96 tests; native ownership and HTTPS fixtures remain separate from actual Caddy lifecycle acceptance. +- [x] 2.2 Integrate independent review, simplification/fresh-eyes checks and the full Node gate; record exact executed evidence and retain native/product/archive gaps (CADDY-001/002/003/006, RUN-003/004). F1/F2 corrected and accepted by the targeted independent follow-up; the initial parent gate passed 780 tests with 2 skips and 96.14% own-source line coverage. Planning check remains blocked by implementation-only missing deltas; see verification.md. + +## 3. Windows native creation-owner correction + +- [x] 3.1 Preserve the actual user as default owner of future native-created files before SQLite admission; verify the Node primary token, request adjustment only on mismatch, require independent readback, and refuse unsafe existing journals first (RUN-005, CADDY-006). Keep Unix and runtimeOwner observation unchanged; no privilege or existing ACL changes. +- [x] 3.2 Exercise the production correction with real SQLite and Caddy 2.11.4 in an isolated Windows Sandbox: generated journal/key protection, HTTP/HTTPS multi-project routing, reload, malformed rejection with last-known-good retention, idle stop and material-reused restart (RUN-005, CADDY-002/003/006). Guest identity was elevated; this is leaf integration, not completed-product privilege or distribution acceptance. +- [x] 3.3 Add mocked fail-closed/admission-order regressions, independent static review and the final Node gate: 812 passed, 2 existing Unix-native skips, 96.16% own-source lines (RUN-005, CADDY-006). Native C# success paths are exercised separately by the Sandbox scenario, not by TypeScript coverage. diff --git a/openspec/changes/migrate-caddy-lifecycle/verification.md b/openspec/changes/migrate-caddy-lifecycle/verification.md new file mode 100644 index 0000000..a3e5f7a --- /dev/null +++ b/openspec/changes/migrate-caddy-lifecycle/verification.md @@ -0,0 +1,180 @@ +# Verification + +## Scope and source + +The initial leaf owned-Caddy lifecycle increment was verified on the migration +branch at base commit `93942af84f555eab2bd68e0a4a9db487ff82d040` with then-uncommitted +source changes. It was subsequently committed as `31b88e1` and pushed. The Windows +creation-owner follow-up below was verified against that commit plus the exact +hashed production delta. Neither result accepts a tagged release candidate, +whole roadmap 3.8, G3, final product or either distribution. Existing Rust remains. + +Delivered `src/caddy/runtime.ts`, extensions to the existing administration and +owned-command boundaries, and direct regression tests. A small correction to +`ConfigurationTransactions.reconcile` connects explicit recovery to this backend; +there is no new queue, launcher, process registry, automatic restart or RPC. + +## Requirement and task evidence + +| Requirement / task | Executed evidence | Boundary | +| --- | --- | --- | +| CADDY-001, RUN-004 / 1.1 | Existing resolver/image regression suite; immutable environment/candidate tests; verified native-start notification | PinnedCaddyImage is reused unchanged; no distribution recursion acceptance inferred | +| CADDY-002, RUN-003 / 1.1, 1.2, 2.1 | Native Windows Node argv/binary stream/exit/footer, immediate limits, cancellation/orphan cleanup and unrelated-child survival; lifecycle graceful/forced stop and close tests | Native process ownership and fake lifecycle tests are separate, not real-Caddy acceptance | +| CADDY-003 / 1.2, 2.1 | Independent observed hashes; ambiguity/rejection; actual idle rollback through ConfigurationTransactions; explicit crash recovery and failed-cleanup fencing | Application storage remains a fake atomic port; no handler or registration recovery claim | +| CADDY-006 / 1.2, 2.1 | Existing actual Node HTTPS TLS tests plus new bounded stop/cancellation; security-envelope refusal and startup conflict regressions | No plaintext fallback; prior native administration proof is not full lifecycle proof | +| All in-scope IDs / 2.2 | Independent review and accepted F1/F2 corrections; parent focused/full gates; fresh-eyes/simplification and planning checks | Failed planning and unexecuted native/product gates remain explicit | + +## Checks + +Worker final focused command: + +```sh +nub run test -- test/caddy-runtime.test.ts test/caddy-owned-process.test.ts test/caddy-admin-client.test.ts test/caddy-preparation.test.ts test/caddy-resolution.test.ts test/configuration-transactions.test.ts +``` + +It passed 271 tests with 2 existing Unix-native skips. This result predates the +parent's F1/F2 corrections and is not the final increment gate. + +Parent post-review correction checks: + +```sh +nub run test -- test/caddy-runtime.test.ts test/configuration-transactions.test.ts +nub run typecheck +nub run check +``` + +- Focused lifecycle/queue: 96 passed in two files; typecheck passed. +- Final Node gate: TypeScript, ESLint, Prettier and V8 coverage passed; 34 files, + 780 tests passed and 2 existing Unix-native tests skipped. +- Own lines: 1,721/1,790 (96.14%); statements 94.73%, branches 90.48%, functions + 96.78%. Lifecycle module: 92.48% lines and 86.72% branches. The Windows-host + owned-command module measured 80.57% lines; its unexecuted Unix/native paths + are not excluded or claimed covered. The workspace line gate is 85%. +- Active LSP probes reported no compiler errors but retained auxiliary style + heuristics and silent-on-clean uncertainty. The passing compiler is not a + universal clean-diagnostics claim. +- Coverage and validation logs remained outside checkout; no generated Node + output was added to source/test trees. Existing maintained JS tooling remains. +- `git diff --check` passed; the staged file list was empty. +- OpenSpec doctor, implementation schema and all 15 strict main specs passed; + reset roadmap validation passed. `mise run openspec-check` reported 17 passed + and 6 failed: implementation-only changes, including this slice, are rejected + for missing deltas by the known strict validator behavior. Archive readiness + remains blocked; no fake requirements or tooling changes were added. + +## Independent review and corrections + +The initial review found two concrete integration defects. F1: native resume +alone allowed an existing endpoint with the reused TLS identity to masquerade as +the new owned execution. Start now performs a bounded read-only conflict check +before validation/staging/launch and refuses that endpoint without adopting or +stopping it. Readiness still waits for verified native start. F2: the transaction +queue refused restoration whenever its initial observation was unavailable, +preventing explicit recovery after a clean owned exit. Reconciliation now attempts +last-known-good restoration in that case and still requires successful apply plus +independent final proof. Uncertain native cleanup remains fenced and cannot start +a replacement. + +The same reviewer inspected only these corrections and their blast radius, +accepted both findings as resolved, and identified no new material P0/P1/P2 issue. +Verdict: OK with notes. Native acceptance is separate: the preflight conflict +check is not an atomic endpoint-to-process identity proof. + +Fresh-eyes review checked unchanged immutable snapshots, cancellation/close +ordering, protected staging retention and strict uncertain-cleanup behavior. +Simplification retained the existing launcher/queue and fixed-size stream buffers, +without adding process enumeration, a generic lifecycle framework or monitoring. + +## Failures and limitations + +The first worker exceeded its 30-minute harness deadline. Parent inspected and +preserved the partial sources, then revived that exact retained child through +the same subagent protocol. The timeout is not successful verification. A report +write required unavailable user confirmation; the worker stopped the write and +returned its final report for normal runtime persistence. No alternate-path or +permission workaround was used. + +A pre-fix four-byte footer delayed immediate stdout overflow; a bounded payload +counter in the same native pump corrected it without changing normal stream +limits or native exit codes. One subsequent worker run transiently accepted the +32-MiB-plus-one adaptation stderr fixture. Its targeted rerun, complete focused +rerun and both parent full gates passed without suppression or fixture changes. +The transient cause remains unproved and is retained as a diagnostic limitation, +not declared fixed or attributed to the environment. + +## Windows native creation-owner follow-up + +A real Windows Sandbox run first failed before Caddy launch: SQLite's native +DELETE journal had the Administrators group as owner beneath a sole-user DACL. +The strict read-only descendant check correctly rejected it. A minimized native +reproduction confirmed the difference between explicitly protected paths and the +journal. No existing owner or ACL was repaired or weakened. + +A guest-only default-owner prototype then proved the mechanism. After correcting +an administration-key path error in that temporary scenario, real SQLite and the +full Caddy lifecycle probe passed. Two intervening Sandbox frontend exits without +guest reports were not accepted as successful execution. A guest logon marker +made subsequent startup and scenario execution observable; the earlier missing +reports have no proved cause. + +The production correction now targets the Node primary token through the +existing bounded PowerShell bridge. It requests query access first, conditional +TOKEN_ADJUST_DEFAULT only for a mismatched TokenOwner, and separate query-only +readback. The lock's existing-journal preflight still precedes normalization; +SQLite admission follows verified normalization. Unix and runtimeOwner remain +unchanged. No groups, privileges, elevation, identity or existing ACLs change. + +The parent ran the production source delta in a fresh isolated Windows Sandbox +using Node 24.18.0 and separately installed Caddy 2.11.4. Input and source hashes +were verified; no prototype mutation helper was included. The guest's actual +token was elevated. Independent read-only token observation proved the default +owner changed from Administrators to the same TokenUser, with unchanged identity +and elevation. A repeat production helper call retained that owner. + +Executed assertions passed: + +- Real runtime-lock acquisition and user-owned native SQLite journal. +- Real pinned Caddy adaptation, guarded composition and secure initial startup, + followed by independent matching configuration readback. +- Read-only protection checks for native-generated web CA and administration keys. +- HTTP and verified HTTPS for two projects after reload; unknown and disabled + hosts did not receive a project response. +- Real malformed candidate rejection and independently observed last-known-good + retention. +- Idle stop and free listeners; restart with reused administration + material and web CA, followed by independent readback and HTTPS routing. +- Owned execution settlement, lock release, runtime removal and final free + listeners. The guest report and bootstrap both passed; scenario exit was 0. + +Runtime data and credentials stayed in guest temporary storage. Only bounded +reports were exported to task-owned system temporary storage. Input was mapped +read-only; report output was the only writable mapping. Networking, clipboard, +GPU, camera, microphone and printer sharing were disabled. No real Caddy ran on +the host, no unrelated process was stopped, and no system trust was installed. + +The four-file production/test correction received independent static review with +no material finding. Mocked helper and lock-failure tests passed 63/63, covering +malformed/mismatched proof, helper failure, Unix no-op, preflight ordering and the +pending-normalization admission barrier. Script-string and mocked tests do not +execute native C# branches; the real Sandbox run supplies separate native proof. + +The final `nub run check` passed TypeScript, ESLint, Prettier and V8 coverage: +35 files, 812 tests passed, 2 existing Unix-native skips. Own-source line coverage +was 1,728/1,797 (96.16%); statements 94.75%, branches 90.60%, functions 96.79%. +Coverage measures TypeScript, not C# embedded in the Windows bridge. Active LSP +probes found no diagnostics but could not confirm clean files on a silent-on-clean +server; the passing compiler is the type-check evidence. Reports stayed outside +the checkout. + +Still open: + +- Real-Caddy lifecycle/HTTP/HTTPS integration on Linux/macOS and native startup + conflict scenarios. The delivered Windows leaf scenario does not exercise + ordinary-client/elevated-daemon IPC or different-account acceptance. +- Full daemon/handler/shim Ctrl+C, shutdown, registration and recovery journeys. +- Linux/macOS execution of the changed native group-settlement path. +- Completed-product Windows privilege/Sandbox, G1/G2/G3/G7 and npm/SEA parity. +- Existing implementation-only archive/validator blocker. + +Roadmap 3.8 and 3.9 remain unchecked. This increment supplies the backend needed +for those gates; no Rust removal, tagging or publication was authorized. diff --git a/openspec/changes/migrate-caddy-preparation/.openspec.yaml b/openspec/changes/migrate-caddy-preparation/.openspec.yaml new file mode 100644 index 0000000..79e0856 --- /dev/null +++ b/openspec/changes/migrate-caddy-preparation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-06 diff --git a/openspec/changes/migrate-caddy-preparation/design.md b/openspec/changes/migrate-caddy-preparation/design.md new file mode 100644 index 0000000..8747169 --- /dev/null +++ b/openspec/changes/migrate-caddy-preparation/design.md @@ -0,0 +1,156 @@ +## Context + +The operational baseline is released Rust 1.0.5. Current Node code already has +strict RPC DTOs, authenticated runtime, installation identity and four leaf ports. +No Node Caddy implementation existed at intake; this slice now delivers preparation only. Source authority is retained config.rs, +caddy/{resolver,configuration,tests}.rs, caddy_path_trust.rs, caddy_image.rs and +process_tree.rs; test/compatibility freezes their retained behavior. + +## Requirements + +REG-004 governs parsing and precedence. CADDY-001/SHIM-003 govern selection, +identity and pinning. CADDY-005 governs short-lived output limits and cleanup. +RUN-003 governs daemon shutdown/server lifecycle and remains outside slice closure. +The complete JSON carrier and validation result already exist in +src/contracts/ports.ts. No normative requirement is duplicated here. + +## Goals and non-goals + +Deliver configuration -> trusted executable -> bounded adapt -> JSON candidate -> +bounded validate. Keep the installed upstream Caddy separate and mandatory. +Do not compose routes, apply configuration, contact Admin API or start a server. + +## Ownership and trust boundaries + +Configuration parsing is pure; executable selection is daemon-owned. Preserve +explicit absolute daemon override, portable cadder.toml, per-user configuration, +system configuration, then safe PATH. Trusted files select a single command name +or absolute path; no arguments, relative command paths or project/shim/environment +selectors. Existing empty higher-priority files can fall through, malformed or +unsafe selected sources cannot. Use physical installation identity for npm and +SEA, not cwd, argv wrappers or the shared Node executable as a portable anchor. + +Platform defaults preserve the released OS configuration locations. Windows +known folders use the existing small PowerShell/.NET adapter rather than trusting +PROGRAMDATA/APPDATA overrides for system policy. Filesystem checks use native +regular-file/executable checks and dev/inode or equivalent identity; Unix aliases +are followed for identity, Windows final reparse/script candidates are denied. +Reject local/global npm script wrappers; native Windows Scoop descriptors may +resolve only to a checked native target. Compare all known distribution entries +and aliases before executing a candidate. + +Cache selected path and immutable image evidence for the resolver lifetime. +Reverify canonical path, identity and content digest before and immediately after +spawn; retain an open image anchor and release it on explicit close. Compatibility +probes preserve baseline versions >=2.11.4 and <3.0.0 and the 19 required module IDs. +Evidence failures never silently select another image. Shared probes belong to +the resolver lifetime: caller cancellation stops that wait, not other callers' +probes, and closing the resolver cancels its pending probe. This is check-and-detect behavior, not an +atomic pathname-exec guarantee against arbitrary same-owner concurrent swaps. + +## Data flow and public contracts + +1. Parse strict TOML with installed smol-toml and Zod; retain snake_case selectors. +2. Resolve and pin one trusted native image, then run bounded version/modules probes. +3. Adapt using an argument array with canonical config path when supplied, raw + path otherwise, and retained adapter metadata or caddyfile default. +4. Reject failed/partial/malformed output; carry complete adapted JSON and SHA-256. +5. Validate a JSON candidate through real Caddy's command, exposing the existing + CaddyPort validation signature without adding an unimplemented apply method. + +Errors stay typed and bounded. No child stderr or project text becomes an +unbounded authenticated error payload. No RPC catalog or daemon-ready state changes. + +## Decisions + +Use Node spawn with shell disabled and Node crypto/filesystem primitives. One +narrow owned-command helper is shared by compatibility probes and adaptation; +this is application process ownership, not a custom build/test framework. + +A native Windows reproducer confirmed that taskkill cannot clean a descendant +retaining stdout/stderr after its leader exits. The supervisor therefore approves +one narrow PowerShell/.NET Windows Job Object adapter, matching the retained Rust +per-command job semantics. This is necessary product OS integration, not optional +hardening: attach the selected child before it can execute, keep the job handle +non-inheritable, disallow descendant breakaway and kill the owned job on completion, +failure or wrapper exit. Preserve direct native argv and binary streams, existing +image checks, limits and bounded teardown. No native Node addon, distributed +launcher binary, general process broker, external tool installation, dependency +change or host privilege/account configuration is authorized. Failure to prove +the narrow adapter must stop this slice rather than trigger another layer. + +Implementation is serial: config/resolver plus their required bounded probe +helper, then adapter/validation using that verified handoff. One writer owns +each stage; fresh MiMo reviews cannot write source. Production has no test +bypass, fake backend, ambient fixture switch or arbitrary runner injection. +Synthetic fake-Caddy behavior remains in test/fixtures and owned temporary files. + +## Failure and recovery + +Adapt/validate defaults are 30 seconds and at most 32 MiB separately on stdout +and stderr; metadata probes retain the smaller 1 MiB bound. Reject output as soon +as a bound is exceeded rather than accepting its prefix. Always observe stream, +spawn and exit errors, settle pending reads, and close stdin explicitly. + +Cleanup targets only the owned child/process group/tree, with a bounded teardown +and primary-error preservation. Unix can use an owned detached process group; +Windows must use a narrow owned-tree mechanism and prove its actual guarantees. +An orphan/pipe-inheritance gap cannot be recorded as full CADDY-005 completion: +report a concrete blocker before adding broad process enumeration, native addons +or another orchestration layer. Unrelated processes must survive. + +After native success, the Windows helper waits up to four seconds for the Job's +active process count to reach zero. After leader exit, unsettled pipes cause a +bounded five-second stream error, not an operation timeout. Unix descendants +that deliberately escape the group cannot be reclaimed by this narrow mechanism; +that native acceptance limitation remains open, without process enumeration. + +Validation staging belongs under exclusively created system-temp storage and is +removed after all child finalizers, using three built-in removal retries with +100 ms linear backoff for transient filesystem locks. No generated JS, bundles or captures enter +checkout. Candidate rejection does not publish state or modify existing config. + +## Migration and rollback + +No product cutover, old-data access or persisted schema change. Retain Rust and +its packaging unchanged. Reverting this slice removes only the new Node modules +and tests, not runtime or compatibility work. Sandbox is mandatory final-product +acceptance under 2.8/7.4; no intermediate test is falsely marked passed. + +## Test strategy + +Use frozen TOML inputs and retained Rust precedence/adapter cases as authority. +Test explicit/portable/user/system/PATH sources, invalid-priority denial, command +injection, relative PATH entries, installation entries, aliases/hardlinks, +wrappers, pin mutations and missing/incompatible probes. + +Execute disposable fake Caddy processes for real argv/cwd, adaptation/validation, +invalid UTF-8/JSON, exits, both stream limits, spawn errors, timeout, abort, +parent/grandchild cleanup and unrelated-child survival. Fake executables must +pass the same production path policy; no test-only production trust switch. +Use current build dependencies only for outside-checkout fixture emission. +Native Windows proof and modeled Unix cases are labeled separately; later real +Caddy/npm/SEA acceptance is not supplied by these tests. + +Run focused Vitest tests after each stage, nub run check after integration, +strict main/roadmap OpenSpec checks and git diff --check. Inspect LSP results +without treating an inconclusive push-only probe as clean. Retain Rust tests as +baseline checks when test-only fixtures are affected. + +## Risks and trade-offs + +- Path-based spawning cannot claim atomic image identity -> pre/post verification, + owned cleanup and explicit race residual, with mutation regressions. +- Windows inherited descendants/pipes require concrete teardown proof -> do not + substitute a live-parent taskkill case for orphan cleanup evidence. +- npm/SEA artifacts are not built yet -> realistic layout fixtures, no distribution + acceptance claim until G6. +- Existing OpenSpec custom implementation validation mismatch -> schema/status + and main-spec validation plus honest residual; no fake deltas or validator layer. + +## Open questions + +No product behavior or sequencing question is open. If a required native cleanup +or identity guarantee cannot be delivered with the approved small Node/platform +boundary, stop with the reproducer and request a parent decision before expanding +implementation. Such a blocker leaves the affected task incomplete. diff --git a/openspec/changes/migrate-caddy-preparation/proposal.md b/openspec/changes/migrate-caddy-preparation/proposal.md new file mode 100644 index 0000000..f13e2e0 --- /dev/null +++ b/openspec/changes/migrate-caddy-preparation/proposal.md @@ -0,0 +1,65 @@ +## Why + +Preserve the released project Caddyfile -> managed shim -> daemon -> operator +journey. The next executable capability takes an existing project configuration, +selects trusted real Caddy, adapts it to JSON and validates a candidate without +starting a server or changing active routes. Caddy remains separately installed +and mandatory; this migrates Cadder's integration, not Caddy itself. + +The user approved deferring Windows Sandbox to completed-product acceptance. +Open G1/G2 evidence remains mandatory before G7, Rust removal or release. + +## Requirement IDs + +- `REG-004`: Preserve existing cadder.toml/Caddyfile formats and precedence. +- `CADDY-001`: Trusted real-Caddy selection, lifetime pinning and recursion denial. +- `CADDY-005`: Bounded adaptation output and owned adapter cleanup. +- `SHIM-003`: Reject npm/SEA wrappers and equivalent alias/file identities. + +RUN-003 daemon shutdown/server lifecycle is a later dependency (roadmap 3.8), +not an in-scope completion claim. Short-lived cleanup belongs to CADDY-005. + +## Scope + +Implement roadmap 3.1-3.2 in new Node Caddy modules and narrow platform helpers: +strict smol-toml parsing; explicit/portable/user/system/safe-PATH precedence; +executable identity/digest pinning and compatibility probes; direct bounded +adapt/validate subprocesses; typed results compatible with the validation part +of the existing Caddy port. Test real disposable fake executables, not installed +real Caddy or a production mock backend. + +The delivered close-node-rpc-catalog slice owns protocol/port contracts and has +complete implementation artifacts but remains unarchived due the known custom +schema validation limitation. This Caddy slice consumes those contracts without +modifying protocol behavior; its dependency and separate source ownership are +explicit. Only one writer uses the checkout at a time. + +## Non-goals + +No route composition, registration handler, mutation queue, apply/rollback, +Admin API contact, certificate generation, persistent state worker, real Caddy +server lifecycle, CLI/TUI, release packaging, Sandbox/UAC/account changes or +Rust production changes. Do not add an apply stub or a mock production backend. +Existing roadmap tasks 3.3-3.9 and all final acceptance gaps stay open. + +## Success criteria + +- Existing TOML selectors parse with unknown fields and conflicting selectors + rejected; an invalid higher-priority source never silently falls back. +- Only trusted source locations or safe absolute PATH entries select a native + executable; project/cwd/environment selectors cannot redirect it. +- npm wrappers, Cadder SEA entries, symlink/hardlink aliases and pinned-image + mutations are refused without recursive execution or silent reselection. +- The retained Caddy compatibility policy and 32 MiB-per-stream/30-second command + defaults apply; malformed JSON, nonzero exit, spawn failure, timeout, output + overflow and cancellation never yield usable partial configuration. +- Owned-child cleanup is bounded and never terminates unrelated processes. +- Focused tests and the relevant full Node gate pass with >=85% own-code line + coverage; unsupported native proofs remain identified, not inferred. + +## Impact + +New src/caddy modules, narrow platform integration and test fixtures; roadmap +sequencing and evidence documentation. Reuse current smol-toml/Zod and Node APIs. +No dependency change without a demonstrated need and parent decision. No version, +CI, publication-policy, host security or existing Rust release-path changes. diff --git a/openspec/changes/migrate-caddy-preparation/tasks.md b/openspec/changes/migrate-caddy-preparation/tasks.md new file mode 100644 index 0000000..d1e5010 --- /dev/null +++ b/openspec/changes/migrate-caddy-preparation/tasks.md @@ -0,0 +1,20 @@ +## 1. Configuration and trusted resolution + +- [x] 1.1 `[REG-004, CADDY-001]` Implement strict existing TOML selectors and released configuration locations/precedence (verification: focused config tests include unknown fields, conflicting selectors and malformed higher-priority denial). +- [x] 1.2 `[CADDY-001, SHIM-003]` Resolve trusted native Caddy, deny recursive npm/SEA/alias identities and pin immutable image/compatibility evidence (verification: source/layout, wrapper, hardlink, changed-image and version/module tests). +- [x] 1.3 `[CADDY-005]` Bound owned probe/process streams, deadlines and teardown without unrelated process termination (verification: real fake-child stream/timeout/abort/descendant tests; native gaps remain explicit). + +## 2. Adaptation and validation + +- [x] 2.1 `[REG-004, CADDY-005]` Adapt existing config paths and adapter metadata through the pinned executable into a complete typed JSON candidate (verification: real argv, canonical/raw paths, retained defaults, malformed output and nonzero-exit cases). +- [x] 2.2 `[CADDY-001, CADDY-005]` Validate a bounded JSON candidate with the existing validation-port signature and remove owned staging on every outcome (verification: success, rejection, timeout/overflow/spawn/pin failures and no leftover temp files). + +## 3. Evidence and closeout + +- [x] 3.1 `[REG-004, CADDY-001, CADDY-005, SHIM-003]` Integrate independent MiMo findings, full relevant validation and requirement-to-evidence reconciliation (verification: nub run check >=85% own lines; main/roadmap strict checks; diff inspection; record native/residual blockers without closing G1/G2/G3). + +Implementation and the stated slice checks are complete; evidence is in +verification.md. RUN-003 server shutdown remains roadmap 3.8, not a delivered +requirement here. Native Unix/real-Caddy/channel proofs and the OpenSpec 1.5 +custom-schema archive blocker remain explicit. Checked tasks do not close +CADDY-005 as a whole, G3, release acceptance or the archive gate. diff --git a/openspec/changes/migrate-caddy-preparation/verification.md b/openspec/changes/migrate-caddy-preparation/verification.md new file mode 100644 index 0000000..f042be2 --- /dev/null +++ b/openspec/changes/migrate-caddy-preparation/verification.md @@ -0,0 +1,142 @@ +## Summary + +Roadmap 3.1-3.2 implementation and the focused task checks pass. The delivered +capability is trusted configuration selection -> pinned real-Caddy image -> +adapted JSON/hash -> validation. It does not apply configuration, start a server +or expose a new operator command. Released Rust 1.0.5 remains operational. + +Base HEAD is `93942af84f555eab2bd68e0a4a9db487ff82d040`; this work and earlier +migration changes remain uncommitted. Existing dirty work was preserved. +The source additions are `src/caddy/{configuration,executable,image,resolver, +adapter,preparation,validation}.ts`, `src/platform/owned-command.ts`, four +`test/caddy-*.test.ts` files and three Caddy fixture files. + +This is not archive-ready: the OpenSpec 1.5 strict delta validator rejects both +spec-free implementation changes. G1/G2/G3, native Unix/real-Caddy/channel proof +and final-product Windows Sandbox remain open. Checked tasks record the stated +slice checks, not complete requirement or product acceptance. + +## Evidence + +| Requirement ID | Task | Evidence | Result | +| --- | --- | --- | --- | +| REG-004, CADDY-001 | 1.1 | `nub run test -- test/caddy-configuration.test.ts`; included in final full check. Strict TOML, unknown/conflicting fields, UTF-8, released OS locations and override/portable/user/system/PATH priority with malformed-priority denial. | Pass for slice; Unix locations modeled | +| CADDY-001, SHIM-003 | 1.2 | `nub run test -- test/caddy-resolution.test.ts`; included in final full check. Real native fixture executables, npm/SEA inventories, wrappers, hardlinks/reparse points, safe/quoted PATH, fully qualified Windows paths, sticky evidence failure and image mutation/replacement. | Pass; built distribution acceptance pending | +| CADDY-005 | 1.3 | `nub run test -- test/caddy-owned-process.test.ts`: 12 pass, one Unix-only skip. Native argv/binary streams, exact bounds, overflow, spawn errors, timeout/abort, exited-parent inherited pipes, Job membership and unrelated survival. | Native Windows pass; Unix proof pending | +| REG-004, CADDY-005 | 2.1 | `nub run test -- test/caddy-preparation.test.ts`; 33 pass at stage handoff and included in final full check. Canonical/raw paths, source cwd, adapter default/metadata, complete JSON/hash, malformed/invalid UTF-8/nonzero output, both exact stream bounds and overflow. | Pass with disposable native fake Caddy | +| CADDY-001, CADDY-005 | 2.2 | Same preparation suite: typed validation-only signature, candidate integrity, real exclusive temp input, abort/timeout/descendant/spawn/pin failures and staging removal. Native external-file-lock rejection returns failure, never validated success. | Pass; persistent external locks remain explicit | +| REG-004, CADDY-001, CADDY-005, SHIM-003 | 3.1 | Final `nub run check`; strict main-spec/roadmap/schema checks; independent MiMo opinions and parent repairs below; whitespace and unstaged review. | Slice checks pass; global gate/archive blocked below | + +Final parent Node gate: `nub run check` passes typecheck, ESLint, Prettier and V8 +coverage: 26 test files, **352 tests passed, two Unix-native tests skipped**. +Own TS/TSX lines are **896/930 (96.34%)**. Final LCOV inspection confirms the +Caddy/owned-command slice has **435/464 covered lines (93.75%)**, including +adapter **14/14**, preparation **43/43**, validation **20/20** and owned-command +**93/106**. Fully covered adapter/preparation files are omitted from the compact +text table, not the coverage denominator. Embedded C# statements are not measured +by V8; their guarantees have native behavioral tests, not a claimed C# percentage. + +Focused parent repair checks: + +- `nub run test -- test/caddy-resolution.test.ts -t 'caller cancellation|resolver close|quoted absolute'`: three pass. +- `nub run test -- test/caddy-owned-process.test.ts`: 12 pass, one skip. +- `nub run test -- test/caddy-preparation.test.ts -t 'staging cleanup|orphan|descendant'`: six pass. +- `nub run test -- test/caddy-resolution.test.ts -t 'qualification|root-relative|caller cancellation|resolver close|quoted absolute|Scoop'`: 12 pass. + +An initial new cancellation regression failed because the asynchronous Node +preload fixture still let Node interpret `version` as a script. The test-only +fixture now uses the existing asynchronous fixture's Module.runMain override. +Concurrent test promises are observed on failure. The corrected regression and +full gate above pass; the failed attempt is not counted as passing evidence. + +## Independent review and repairs + +MiMo Flash verified final-product Sandbox sequencing, retained Caddy/configuration +requirements and native cleanup constraints. The Windows taskkill orphan/pipe +reproducer confirmed its main concern. The approved narrow PowerShell/.NET Job +adapter creates the native child suspended and already assigned to its Job, +performs pre/post image verification before execution, preserves native argv and +binary streams, and kills only that Job. No addon, distributed launcher, generic +broker, dependency, tooling-policy or host privilege change was introduced. + +MiMo Pro reviewed actual source/tests independently: PASS WITH NOTES, including +one P1. That verdict was not treated as unconditional acceptance. Parent fixes: + +- P1 cancellation poisoning: shared probes now belong to a resolver-owned abort + lifetime. A cancelled caller stops its wait without cancelling another caller + or caching an abort as image evidence. Close cancels the owned pending probe. + New native regressions cover mid-probe cancellation, concurrent/later callers + and resolver shutdown; genuine failed version/modules remain sticky. +- P2 quoted PATH: Windows parsing preserves quoted absolute entries, including + semicolons, without accepting relative entries. A native regression passes. +- P2 post-exit pipes: leader exit starts bounded stream settlement; lingering + pipes fail with a stream diagnostic rather than the operation timeout. The + escaped-Unix fixture is added but skipped on this Windows host; reclaiming an + escaped group is not claimed. A subsequent primary failure clears the existing + settle timer before replacing it, preserving the primary error without a + leftover timer. +- P2 owned teardown/staging: the Windows helper checks the Job's active process + count reaches zero within four seconds before reporting success. Staging uses + three built-in removal retries with 100 ms linear backoff; external-lock + failure remains bounded and never produces validated success. +- P2 scope: RUN-003 is removed from slice completion tags. Daemon/server shutdown + remains roadmap 3.8. Validation's empty success diagnostics are not advertised + as warning-diagnostics support. +- Fresh-eyes parity repair: Windows root-relative paths are not fully qualified + absolute paths. Configuration, image and source-path checks now match that + distinction; native and modeled regressions pass. + +Parent fresh-eyes/simplification retained the single existing process helper and +made no new framework. Active LSP probes were not uniformly conclusive; auxiliary +lint rules also flagged intentional parser throws and stylistic constructs. +Malformed module/OS metadata parsing propagates through the owning async failure +path rather than yielding success. These are not an LSP-clean claim. Final +TypeScript compiler and repository lint checks pass. + +## Repository and archive gate + +- `openspec schema validate implementation`: pass. +- `openspec validate --specs --strict --no-interactive`: 15 pass. +- `openspec validate reset-cadder-architecture --strict --no-interactive`: pass. +- `git diff --check`: pass. `git diff --cached --name-only`: empty. +- `mise run check`: Rust formatting, workspace/all-target Clippy with `-D warnings` + and all **300 Rust tests pass**, then the existing OpenSpec 1.5 implementation + schema limitation blocks the combined gate: 17 items pass, two implementation + changes fail. Docs/npm/cargo-dist steps after that failure were not reached. +- `openspec validate migrate-caddy-preparation --type change --strict --no-interactive`: + fails with `Change must have at least one delta. No deltas found.` The accepted + custom schema intentionally implements existing IDs without delta specs. No + fake deltas, checker replacement or tool-policy workaround was added. + +- [x] Every focused implementation task and its stated slice checks is complete. +- [x] In-scope requirement aspects have the passing evidence above. +- [x] Focused and full Node checks pass above the 85% line threshold. +- [x] Strict main-spec, roadmap and implementation schema checks pass. +- [x] Documentation distinguishes implementation from native/product acceptance. +- [ ] Global `mise run check` / strict implementation validation passes. +- [ ] Archive is authorized and validation-compatible. + +## Residual risk + +Native Linux/macOS image/group execution, real installed Caddy, packed npm/SEA, +other Windows/filesystem matrices and final-product Sandbox remain unverified. +Normal Windows owner tests do not establish owner-to-elevated/other-account +acceptance. Main CADDY-005 and RUN-003 are not closed as whole requirements. + +Unix detached groups cannot reclaim descendants that deliberately leave the +group; the added stream-settle regression does not grant process enumeration or +prove escape cleanup. Image verification is check-and-detect, not atomic +pathname-exec immunity; Windows anchors detect mutation rather than reproducing +Rust share-mode prevention. PowerShell/Add-Type startup costs remain within each +command deadline. Persistent external locks can leave owned staging residue, +reported as a cleanup failure without terminating unrelated holders. + +Child direct artifact writes were denied with `Blocked write (Arcantry project +boundary): user confirmation required but no UI available.` Complete reports +were delivered through native runtime persistence; no boundary bypass or alternate +CLI execution was used. This is an orchestration limitation, not a passing test. + +Composition, apply/rollback/queue, mTLS/Admin API, server lifecycle, operator +surfaces, distribution and release remain the approved later roadmap. No commit, +staging, publication, Sandbox, UAC/account action or existing Rust product cutover +was performed in this slice. diff --git a/openspec/changes/migrate-caddy-transactions/.openspec.yaml b/openspec/changes/migrate-caddy-transactions/.openspec.yaml new file mode 100644 index 0000000..79e0856 --- /dev/null +++ b/openspec/changes/migrate-caddy-transactions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-06 diff --git a/openspec/changes/migrate-caddy-transactions/design.md b/openspec/changes/migrate-caddy-transactions/design.md new file mode 100644 index 0000000..99d2775 --- /dev/null +++ b/openspec/changes/migrate-caddy-transactions/design.md @@ -0,0 +1,112 @@ +## Evidence and ownership + +CADDY-003 and the roadmap design require one prepare/adapt/compose/validate/apply/ +verify/persist/publish queue. Retained Rust references are +crates/cadder-daemon/src/state/registrations.rs execute_registration_transaction +and apply_runtime_transition, plus runtime/process/receipts.rs rollback. They +separate candidate registration/coordinator state from final publication and +restore prior runtime configuration after persistence failure. Do not copy the +Rust mock backend or swallow failed rollback as acceptance. + +Existing Node CaddyPort has validation and an apply hash receipt, but neither +independent active observation nor definite-rejection/ambiguity classification. +StoragePort persists stable DesiredState only. Composition yields a route plan, +not a runnable secure CaddyConfig. Do not reinterpret that plan as an apply-ready +configuration or implement insecure administration to close this orchestration. + +## Internal contracts + +A transaction snapshot contains a complete CaddyConfig or null (idle), stable +DesiredState and current in-memory registration DTOs. The daemon supplies an +already independently observed, durably committed initial snapshot; this module +is not startup or lease restoration. Persist only DesiredState, never registration leases, +nonces or process ownership. Future handlers construct candidate state inside +preparation using the latest committed snapshot and existing adaptation/composition. + +Use the existing validation and persistence signatures without editing shared +ports. A small internal runtime interface supplies: + +- Apply a complete config or the idle/null target; return explicit applied, + definitely rejected or ambiguous outcome. Unexpected throws and outcomes without + an explicit no-transition guarantee are ambiguous, including typed failures + from the existing CaddyPort. Error kinds alone never prove rejection. + Null is a desired runtime target, not a mock success; the later owned lifecycle + backend must implement it. +- Independently read the observed active effective-config hash, or null only for + positively observed idle. Unavailable or undeterminable state is an observation + failure, never a successful null value. + Observation comes from protected runtime state, not cached receipts, candidate + bytes or desired intent. Backend normalization must make the observed hash + comparable with the target; real readback/mTLS implementation remains later. + +No product RPC, CaddyPort method or production backend is added. All dependencies +are required; missing runtime/storage adapters cannot become successful defaults. + +## Queue and publication + +Preparation callbacks run only after preceding queue work settles and receive +an isolated committed snapshot. Copy/validate/freeze candidate data before any +side effect; protect it from caller and adapter mutation. Validate complete +bounded/hash-consistent JSON using existing preparation checks and verify a +validator receipt matches that target. Skip config validation only for idle/null. + +Serialize apply -> authoritative observation -> atomic storage commit -> one +synchronous internal snapshot publication. There is no fallible external publish +callback after persistence; getters/results expose isolated snapshots. Read-only +queries see last committed data, plus a separate fence diagnostic when necessary, +not a pending state masquerading as authoritative. + +Keep the queue tail settled after errors, and reject queued mutations at execution +if an earlier operation fenced the runtime. An explicit close/drain may refuse new +admissions and await owned work; it does not release runtime exclusion or claim +product shutdown. Do not add a generic queue framework, event bus, background +worker, timer-based lease, receipt store or new configurable workflow machinery. +Existing phase adapters own deadlines and atomic settlement. Do not race a timer +and start later mutations while an older adapter can still perform side effects. + +## Failure state decisions + +- Prepare, shape/integrity or validation rejection: no apply, persistence or + publication; retain the prior committed snapshot and continue the queue. +- Definite apply rejection: backend guarantees no runtime transition; preserve + last-known-good without publishing or fencing solely for that rejection. +- Ambiguous apply/throw or failed/mismatched observation: keep committed state, + report a bounded typed diagnostic, fence mutations and require reconciliation. +- Persistence failure after verified apply: restore the prior complete config or + idle target and independently verify it. Keep the old committed snapshot and + return the storage failure. Failed/uncertain rollback remains fenced. +- Reconciliation is explicitly enqueued through the module's reconcile method by + the daemon caller, not automatically appended by a failure. FIFO mutations + before reconciliation are refused while fenced; a later queued mutation may + execute only after successful reconciliation clears the fence. This adds no RPC. + Observe current runtime; if it is not last-known-good, restore that target, then + observe again. Only independent proof of last-known-good clears the + fence. Never publish the abandoned candidate merely because it was observed. + Observation/restoration failure preserves the fence and last committed state. + +The storage adapter must satisfy STO-002: failed/rejected persistence is atomic +and leaves prior durable intent committed. This slice tests orchestration under +that contract, not the future SQLite worker's integrity/commit implementation. +Typed and thrown errors are bounded/redacted; arbitrary exception text must not +leak through results. A success receipt alone never proves apply or rollback. + +## Verification and migration + +Dedicated tests use fake adapters only in test code, with explicit deferred +promises rather than sleeps to prove single flight, fresh prepare inputs and +pending-read isolation. Cover every rejection/throw/mismatch, persistence failure, +first-apply idle rollback, rollback rejection/ambiguity/observation failure, +queued fencing/reconciliation, retry after recovery, mutation isolation and queue +drain. Include a prepare path consuming existing pure composition so callback +serialization is not merely a bare promise scheduler test. + +MiMo Flash critiques only this bounded ownership/failure plan; MiMo Pro reviews +completed source/tests independently. Parent integrates concrete findings, +performs simplification/fresh-eyes review and runs focused/full Node checks, +strict main/roadmap/schema and diff inspection. No real Caddy, SQLite worker, +registration handler, native privilege or public-product proof is inferred. + +Additive daemon source leaves the Rust baseline and previous Node leaf modules +unchanged. Actual secure backend/handler integration remains 3.6-3.9 and groups +4-5. Known OpenSpec implementation-schema archive failures remain explicit; +no fake delta requirements or custom validator repair is included. diff --git a/openspec/changes/migrate-caddy-transactions/proposal.md b/openspec/changes/migrate-caddy-transactions/proposal.md new file mode 100644 index 0000000..2577684 --- /dev/null +++ b/openspec/changes/migrate-caddy-transactions/proposal.md @@ -0,0 +1,67 @@ +## Why + +Preserve the existing project Caddyfile -> managed shim -> daemon -> operator +journey, including project/domain activation and rejected configuration changes. +The next capability serializes a complete configuration transaction through typed +adapters and exposes only a verified, durably committed snapshot. Failures retain +last-known-good or fence later mutations until runtime reconciliation succeeds. + +## Requirement IDs + +- `CADDY-003`: Serialized preparation, validation, apply, independent verification, + persistence and publication; rejection, rollback and ambiguous-outcome fencing. + +CADDY-002/004/006 and REG-002/004 supply composition and runtime dependencies; +STO-002 requires atomic persistence failure. This slice does not close their +whole-product acceptance or implement their missing backends. + +## Scope + +Implement roadmap 3.4-3.5 in a small daemon-owned transaction module with dedicated +concurrency/fault tests. Reuse CaddyConfig, DesiredState, registration DTOs, typed +ProtocolError/PortResult and existing validator/storage signatures. Introduce only +an internal runtime transaction boundary for explicit apply outcomes, authoritative +active observation and the idle/null target needed by first-apply rollback. + +Preparation is a domain callback executed inside the same queue, so adaptation, +composition and handler checks cannot overlap a preceding transaction. Publication +is one internal committed-snapshot assignment after verification and persistence, +not an external notification callback that can fail after durable commit. + +Completed protocol, preparation and composition changes remain unarchived because +of the known custom-schema validation blocker. This module consumes their leaf +contracts without modifying those sources. Only one source writer uses the +checkout; parent-owned planning/evidence is separate from worker-owned new files. + +## Non-goals + +No RPC/catalog/shared-port changes, product handlers, new Caddy parser, HTTP/Admin +API, certificates, secure config assembly, server process/lifecycle implementation, +SQLite application worker/schema, real installed Caddy, CLI/TUI, dependency, +packaging/tooling changes, Sandbox or host privilege/account actions. No production +mock or apply-success stub. Actual backends and product journeys remain later work. + +## Success criteria + +- Concurrent submissions serialize every phase and prepare against the newest + committed snapshot; one failed operation cannot poison the queue tail. +- Caller/adapters cannot mutate queued, pending or committed state by sharing + objects. Reads retain the old snapshot until verified persistence succeeds. +- Prepare/validation rejection and definite apply rejection preserve last-known-good. +- Successful apply is followed by independent active-state observation, never + receipt-only success. Storage failure restores and verifies last-known-good, + including the initially idle runtime, without publishing the rejected candidate. +- Unknown apply results, failed verification and failed rollback fence subsequent + mutations. Reconciliation uses the same queue, observes/restores/verifies the + last committed runtime target and alone may clear the fence. +- Focused tests cover phase order, controlled concurrent promises, all failures, + null/first-apply rollback, read isolation, poisoning, fencing and recovery; + full relevant Node checks pass above 85% own-code lines and MiMo findings are + reconciled without claiming actual backend/native/release acceptance. + +## Impact + +New daemon transaction source and dedicated tests; focused OpenSpec planning and +evidence, then existing roadmap 3.4-3.5 completion rows only after their checks. +No new requirements, public commands, RPC methods or publication authority. +G1/G2/G3 and final-product Sandbox remain open. diff --git a/openspec/changes/migrate-caddy-transactions/tasks.md b/openspec/changes/migrate-caddy-transactions/tasks.md new file mode 100644 index 0000000..4aacd4f --- /dev/null +++ b/openspec/changes/migrate-caddy-transactions/tasks.md @@ -0,0 +1,17 @@ +## 1. Serialized commits + +- [x] 1.1 `[CADDY-003]` Define and implement the narrow internal runtime observation/apply-outcome boundary and one transaction queue over existing carriers/validator/storage signatures (verification: explicit phase order, fresh committed prepare inputs and controlled concurrent promises). +- [x] 1.2 `[CADDY-003]` Isolate pending/caller/adapter data and publish only after independent verification and atomic persistence (verification: pending reads, input/result mutation, malformed/hash-mismatched candidate and receipt rejection; a failed operation does not poison the tail). + +## 2. Last-known-good recovery + +- [x] 2.1 `[CADDY-003]` Restore and verify the previous runtime target after persistence failure, including an initially idle runtime (verification: no rejected candidate publication and rollback apply/readback failure matrices). +- [x] 2.2 `[CADDY-003]` Fence ambiguous outcomes and failed recovery; serialize explicit active-state reconciliation before another mutation (verification: queued mutation refusal, observation/restoration errors, last-known-good restoration, clearing only after verification and post-recovery success). + +## 3. Evidence and closeout + +- [x] 3.1 `[CADDY-003]` Reconcile MiMo opinions/review, focused/full Node checks and requirement-to-evidence coverage (verification: >=85% own-code lines, strict main/roadmap/schema, diff review and explicit backend/native/archive limits). + +Checked tasks deliver roadmap 3.4-3.5 orchestration only. Caddy secure administration, +owned server lifecycle, SQLite application storage, product handlers and whole G3 +acceptance remain open. No production fake backend or ready-state stub is permitted. diff --git a/openspec/changes/migrate-caddy-transactions/verification.md b/openspec/changes/migrate-caddy-transactions/verification.md new file mode 100644 index 0000000..66565b6 --- /dev/null +++ b/openspec/changes/migrate-caddy-transactions/verification.md @@ -0,0 +1,116 @@ +## Summary + +Roadmap 3.4-3.5 orchestration and the focused implementation checks pass. +src/daemon/configuration-transactions.ts owns one queue from preparation through +internal committed-snapshot publication. Dedicated tests are in +test/configuration-transactions.test.ts. Required adapters perform actual +validation, runtime apply/readback and atomic DesiredState persistence; there are +no successful production defaults or mock backends. + +ConfigurationTransactions exposes submit, reconcile, readCommitted, readFence +and close. Initial state must already be independently observed and durably +committed. This is not startup readiness, a registration handler, a Caddy process, +a protected Admin API implementation or a SQLite application worker. + +Evidence applies to the uncommitted checkout based on HEAD +93942af84f555eab2bd68e0a4a9db487ff82d040. Existing dirty work is preserved. +The source/test additions and OpenSpec planning/evidence are the only surfaces +changed by this slice. No shared-port/RPC, dependency, Rust production, version, packaging, +host privilege/account, Sandbox, staging, commit or publication change occurred. + +This is not archive-ready. OpenSpec 1.5 still rejects the four spec-free +implementation changes with the known missing-delta mismatch. CADDY-003 as a +whole and G1/G2/G3 remain open pending backend/product/native acceptance. + +## Evidence + +| Requirement ID | Task | Evidence | Result | +| --- | --- | --- | --- | +| CADDY-003 | 1.1 | `nub run test -- test/configuration-transactions.test.ts`: explicit phase order, controlled concurrent promises, preparation against the latest committed snapshot, in-queue pure composition consumption, settled queue tail and close/drain ordering. | Pass for orchestration; no real Caddy execution | +| CADDY-003 | 1.2 | Same suite: pending reads stay old during every phase; initial/caller/adapter/return snapshots are private and frozen; strict candidate shape/hash/byte checks; validator hash mismatch; rejection/throws redact exception text and preserve the tail. | Pass; publication is one internal assignment after persistence | +| CADDY-003 | 2.1 | Same suite: rejected/thrown/invalid atomic persistence results restore complete last-known-good or idle/null, independently observe it and never publish the rejected candidate; rollback failure matrix sets a fence while retaining the storage failure. | Pass under STO-002 atomic-failure contract; SQLite backend not implemented | +| CADDY-003 | 2.2 | Same suite: explicit definite rejection versus ambiguous/throw/unknown/receipt-shaped apply outcomes, observation errors/mismatches, FIFO fenced refusal, explicit queued reconciliation, restoration failures and verified post-recovery changes. | Pass under truthful independent readback contract | +| CADDY-003 | 2.2 | Added first-apply regression: candidate is actually live in the fake adapter, unavailable observation remains fenced despite idle last-known-good, later truthful readback restores/verifies idle without publishing/persisting the abandoned candidate. | Pass; null means positively observed idle, never unknown | +| CADDY-003 | 3.1 | MiMo Flash plan critique, MiMo Pro source/test review, parent contract clarification/fresh-eyes pass and final focused/full Node/schema/main/roadmap/diff checks below. | Slice checks pass; global/archive/backend gates remain open | + +Final focused suite: **65 tests pass**. Worker increments passed 22 then 64 tests; +the parent added the idle/unknown-observation case and reran the full focused suite. + +Final parent `nub run check` passes TypeScript, ESLint, Prettier and V8 coverage: +**480 tests pass, two existing Unix-native tests skip, 28 test files pass**. +Own TS/TSX coverage is **1155/1191 lines (96.97%)**. Coverage-summary inspection +confirms configuration-transactions.ts has **92/93 lines (98.92%)** and +**50/50 branches (100%)**. The uncovered line is the queue-tail rejection +continuation, not a claimed 100% line/function result. Generated coverage remains +outside the checkout. + +## Independent review and disposition + +MiMo Flash raised three plan-level concerns: + +- Reconciliation admission: the implementation has an explicit queued reconcile + method, not automatic retries. FIFO work before reconciliation is refused while + fenced; a later operation runs only after verified clearing. The design now + states who calls/enqueues reconciliation and does not introduce another RPC. + Flash's proposed automatic recovery was not adopted as product authority. +- Unknown observation versus idle: MiMo Pro confirmed one P2 wording omission. + The source interface and design now require null only for positively observed + idle; undeterminable state must be an observation failure. The added regression + exercises that failure while an ambiguous first candidate is live and prevents + false fence clearing. No backend implementation or algorithm widening was added. +- Typed apply failures: only an explicit definitely-rejected outcome guarantees + no transition. Throws, malformed/receipt-shaped results and outcomes without + that guarantee are ambiguous; ProtocolError kinds are not certainty evidence. + Source schema/tests already enforce this, and design wording is now explicit. + +MiMo Pro independently read completed source/tests and returned **OK with notes**, +no P0/P1 and the single P2 contract-wording item above. The parent resolved that +item and tested it; this does not claim a separate later reviewer verdict. +The reviewer confirmed single flight, immutable candidates, independent readback, +DesiredState-only persistence, publication point, initial idle rollback, fencing +and bounded error settlement. Both reviews completed in the native workflow. + +Parent simplification/fresh-eyes inspection retained one domain-specific owner +rather than splitting a coherent transaction across a generic framework. No +additional substantive defect was found. All fake adapters live only in tests; +no test-only seam became a production success path. + +## Gate + +- [x] Every focused task and its stated orchestration checks is complete. +- [x] In-scope CADDY-003 aspects have evidence above. +- [x] Focused and full Node checks pass above 85% own-code lines. +- [x] `openspec schema validate implementation` passes. +- [x] `openspec validate --specs --strict --no-interactive`: 15 pass. +- [x] `openspec validate reset-cadder-architecture --strict --no-interactive` passes. +- [x] `git diff --check` passes; nothing is staged; no generated JS/DLL/EXE appears in src/test. +- [ ] `mise run openspec-check` passes: currently 17 items pass and four spec-free implementation changes fail strict delta validation. +- [ ] Archive is authorized and validation-compatible. + +OpenSpec verification instructions returned the known custom-schema unknown +`specs` rule warning along with the usable artifact instructions. No fake deltas, +checker replacement or validation-policy workaround was added. Active LSP probes +were not uniformly conclusive and auxiliary rules reported runtime-type and +large-class hints; maintained TypeScript/lint checks pass, not an LSP-clean claim. +The combined Rust/docs/npm/cargo-dist gate was not rerun for this Node-only slice; +previous Rust baseline evidence remains separate, not a new passing combined gate. + +## Residual risk + +Actual secure Caddy apply/readback and idle transitions must implement the internal +runtime boundary in 3.6-3.9. Hash readback must be independent and comparably +normalized; unavailable observation cannot be reported as idle. The future storage +worker must prove STO-002 atomic failure, including thrown/rejected persistence; +these test fakes do not prove SQLite commit or durability. + +The daemon caller must establish the independently observed/durable initial state +and explicitly enqueue reconciliation when recovering from a fence. Future +registration/heartbeat handlers must join or coordinate with the same ownership +boundary; this slice does not prove live lease merging or product RPC integration. +A fenced diagnostic describes recovery, but is not permission to automatically +retry an uncertain user operation. Phase adapters own bounded settlement: the +queue does not start later side effects while earlier work remains active. + +Native product, real Caddy, mTLS denial, distribution and final-product Windows +Sandbox acceptance remain open. The released Rust 1.0.5 operational baseline is +unchanged. No migration-complete or release-readiness claim is made. diff --git a/openspec/changes/migrate-node-project-runtime/.openspec.yaml b/openspec/changes/migrate-node-project-runtime/.openspec.yaml new file mode 100644 index 0000000..60f3b45 --- /dev/null +++ b/openspec/changes/migrate-node-project-runtime/.openspec.yaml @@ -0,0 +1,2 @@ +schema: implementation +created: 2026-10-20 diff --git a/openspec/changes/migrate-node-project-runtime/design.md b/openspec/changes/migrate-node-project-runtime/design.md new file mode 100644 index 0000000..ffd61f7 --- /dev/null +++ b/openspec/changes/migrate-node-project-runtime/design.md @@ -0,0 +1,75 @@ +## Context and requirements + +The accepted reset roadmap supplies protocol 3 and ConfigurationTransactions. +CaddyRuntime already implements verified apply, idle and owned teardown. This +slice supplies the missing product orchestration against those contracts. + +## Ownership and data flow + +The daemon holds the existing installation lock before opening one SQLite worker. +Worker startup validates owner protection, schema and integrity. A bounded job +mailbox serializes intent/log work; graceful close drains jobs and closes SQLite +before exclusion is released. Existing Rust paths are never inspected. + +Registration uses canonical source paths for stable project identity. The daemon +adapts the source itself and derives domains rather than trusting supplied route +metadata. The live session nonce owns heartbeat/unregister; durable intent holds +only source paths and activation. All registration and activation changes prepare +inside ConfigurationTransactions. Reads expose committed snapshots. Expiration +removes live routes without forgetting user activation choices. + +Existing secure policy assembly handles each route plan using one runtime identity +and admin endpoint. Idle readiness opens protected application storage first; +the first active registration initializes Caddy/PKI lazily. Subsequent mutations +reuse that protected identity. CaddyRuntime and ConfigurationTransactions remain the only +apply/lifecycle queue. Shutdown stops admission, drains transactions, closes owned +Caddy/storage resources, then releases local IPC/exclusion. + +## Decisions and alternatives + +Use node:sqlite and worker_threads, not a new persistence library or job framework. +Store validated stable intent as one JSON row and bounded structured diagnostic +rows. Redact before insertion and sanitize terminal output at presentation. +Keep module/import/error conventions and installed Zod/Vitest tools. + +## Failure and recovery + +Reject unsupported/partial/corrupt schemas without repair. SQLite writes rollback +on failure; pending worker requests fail if the worker exits. The existing queue +restores last-known-good after persistence failure and fences uncertain recovery. +Do not restore session nonces or process ownership from storage. Worker teardown +failure must not release exclusion while owned work remains unsettled. + +Explicit launch resolves the daemon from the physical npm package or SEA sibling, +never PATH. Only its post-launch readiness loop waits through incomplete Windows +ACL publication; every RPC still refuses unsafe paths before reading the secret +or authenticating. Pre-existing unsafe paths fail immediately. Failed readiness +terminates only the newly spawned child. Mutation RPCs allow bounded cold Caddy +preparation (120 seconds); reads remain bounded at 15 seconds. + +## Operator and packaging + +Commander and routes-only Ink use one client service. The managed shim owns its +session nonce, heartbeat and unregister; delegated upstream commands retain raw +streams. Socket inspection and explicit expected-PID termination stay in the +operator, outside daemon-owned process teardown. + +Use tsc for the single compiled npm tarball and esbuild with Node v26.12.0 for +three standalone SEA entries. Embed the same SQLite worker as a SEA asset. +Stage only in system temp, include source identity and checksums, and retain +Node/bundled dependency notices. Reuse installed packages and platform tools. +Documentation moves to exact Nub locks; the existing Astro output directory and +publication workflow stay intact while the Rust release remains the baseline. + +## Verification + +Prefer database and handler behavior tests covering persistence, retention, +redaction, stale ownership, conflicts, toggles and restart over declaration or +source-string assertions. Keep security/fault regressions. Run focused checks +after increments, then the complete Node gate. Native product/distribution checks +remain separate evidence; local fixtures cannot accept unexecuted platforms. + +## Open questions + +None for these accepted contracts. Cross-platform acceptance and distribution +cutover remain governed by the parent roadmap. diff --git a/openspec/changes/migrate-node-project-runtime/proposal.md b/openspec/changes/migrate-node-project-runtime/proposal.md new file mode 100644 index 0000000..3c5cd32 --- /dev/null +++ b/openspec/changes/migrate-node-project-runtime/proposal.md @@ -0,0 +1,46 @@ +## Why + +The Node backend can prepare and apply Caddy configurations, but no product +handler registers projects or persists their intent. Preserve the released +Caddyfile -> managed shim -> daemon journey by joining the existing boundaries. + +## Requirement IDs + +- CADDY-003, CADDY-006: verified transactions and protected administration. +- REG-001 through REG-004: live session ownership and durable intent. +- STO-001 through STO-004: bounded SQLite state/log worker, no old-data import. +- OBS-001 through OBS-004: redacted bounded diagnostic streams. +- IPC-003, RUN-001 through RUN-003: existing RPC and owned lifetime. +- CLI-001, TUI-001 through TUI-004, TOP-002: shared client and current operator journey. +- SHIM-001 through SHIM-003, INSPECT-001 through INSPECT-004: command policy, managed sessions and local inspection. +- DIST-001/002/004/005, QT-001/003/006: compiled npm and native SEA candidates. +- DOC-001 through DOC-004: locked Nub documentation and candidate installation guidance. + +## Scope + +Application storage, registration handlers and daemon integration, followed by +the existing packaged CLI/TUI/shim journey, operator inspection and the two +non-publishing pack processes. Reuse the resolver, composition, +security, IPC and transaction queue. Remove self-testing contract mocks rather +than replacing them with another mock layer. + +## Non-goals + +No new RPC, state migration, feature expansion, publication or premature Rust +removal. Native acceptance remains open until it is executed. + +## Success criteria + +Registration, heartbeat, toggles, expiry and unregister share one transaction +boundary; stale sessions cannot replace a live owner. Restart restores intent +without routes. Rejected changes retain committed state. Diagnostic storage is +redacted, bounded and owner-protected. Focused integration and full checks pass. +Actual local/global npm wrappers and extracted Windows SEA entries work outside +the checkout. Record native evidence by platform and source rather than treating +prepared CI as execution. Full matrix, interactive privilege acceptance and the +Rust cutover remain parent-roadmap gates. + +## Impact + +Node daemon, storage and client/shim entrypoints; English implementation notes. +No additional production dependency or change to accepted contracts. diff --git a/openspec/changes/migrate-node-project-runtime/tasks.md b/openspec/changes/migrate-node-project-runtime/tasks.md new file mode 100644 index 0000000..0a252f1 --- /dev/null +++ b/openspec/changes/migrate-node-project-runtime/tasks.md @@ -0,0 +1,25 @@ +## 1. Durable application state + +- [x] 1.1 Implement bounded worker-owned SQLite state/log storage, exact schema/integrity validation and safe close (STO-001..004; verify real database restart, refusal and worker integration). +- [x] 1.2 Redact before insertion, enforce deterministic retention and ordered canonical-stream queries; keep terminal controls inert (OBS-001..004; verify actual retained/query rows and output). + +## 2. Product integration + +- [x] 2.1 Implement current registration/heartbeat/expiry and activation through the existing transaction queue, retaining durable intent without live lease restoration (REG-001..004, CADDY-003; verify handler journeys, conflicts, stale sessions and restart). +- [x] 2.2 Join protected storage, secure Caddy and IPC in the product daemon with owned shutdown (CADDY-006, IPC-003, RUN-001..003; verify compiled product start/contact/shutdown and failure cleanup). + +## 3. Current operator and shim journey + +- [x] 3.1 Implement shared lifecycle/state client, released Commander grammar and routes Ink controls (IPC-003, CLI-001, TUI-001..004; execute the frozen argv catalog and keyboard interaction tests, plus Windows native terminal smoke). +- [x] 3.2 Implement managed nonce/heartbeat/reconnect/cleanup and trusted delegation (REG-001/003, SHIM-001..003; execute policy/session tests and actual multi-project shim registration in Sandbox). +- [x] 3.3 Implement platform port inspection and expected-PID revalidation (INSPECT-001..004; verify CLI boundaries and actual Windows socket ownership/refusal). Linux/macOS branches require their parent native gates. + +## 4. Non-publishing distributions and documentation + +- [x] 4.1 Pack compiled npm with a consumer-only manifest and three bins (DIST-004, QT-003/006; verify actual local lifecycle and local/global wrappers without a TS loader). +- [x] 4.2 Pin the independently verified Node 26 builder and build SEA entries with the embedded worker, source identity, licenses and checksums (DIST-001/002/005; verify extracted Windows archive without Node/npm on product PATH). The remaining native variants stay in parent G6. +- [x] 4.3 Move docs dependencies/tasks to exact Nub locks and document the unpublished candidate's equal channels and transition (DOC-001..004; run Astro check and build). + +## 5. Verification and simplification + +- [x] 5.1 Replace declaration/self-mock tests with behavior evidence, review changed boundaries, run the final Node check and record exact results and remaining native gates (STO-001, REG-001, IPC-003, QT-003). Keep failed overall validation and unexecuted acceptance open; do not archive or remove Rust. diff --git a/openspec/changes/migrate-node-project-runtime/verification.md b/openspec/changes/migrate-node-project-runtime/verification.md new file mode 100644 index 0000000..f31e0b4 --- /dev/null +++ b/openspec/changes/migrate-node-project-runtime/verification.md @@ -0,0 +1,26 @@ +# Product implementation evidence + +This slice delivers the released Caddyfile -> shim -> daemon -> operator journey +against accepted main requirements. Parent G1-G7 and Rust removal remain open. +The complete dated commands, candidate identities and failed/skipped checks are +recorded in `../reset-cadder-architecture/verification.md` under "Node product +implementation checkpoint (2026-10-20)". This slice is not archive-ready while +the schema-blind OpenSpec gate and final native acceptance remain unresolved. + +| Tasks | Requirements | Executed evidence | +| --- | --- | --- | +| 1.1 | STO-001..004 | Actual SQLite schema creation/refusal, integrity, contention rollback, restart and protected worker tests; packed npm/SEA worker runs | +| 1.2 | OBS-001..004 | SQL row/retention/order assertions, before-write credential redaction, native line ingestion/attribution, overflow and terminal sanitization | +| 2.1 | REG-001..004, CADDY-003 | Existing real transaction queue with handler conflicts, session ownership, toggles, failed persistence, expiry and intent-only restart | +| 2.2 | CADDY-006, IPC-003, RUN-001..003 | Protected product daemon integration, initializer/readiness/teardown ordering and retained exclusion on failed ownership cleanup; native product evidence is qualified in the parent checkpoint | +| 3.1 | CLI-001, IPC-003, TUI-001..004, TOP-002 | Released argv catalog through actual Commander; typed shared client and Ink keyboard behavior; real Windows bare-help/TUI start/stop/quit | +| 3.2 | REG-001/003, SHIM-001..003 | Actual policy/registration/session tests; compiled multi-project managed shim in Windows Sandbox; complete native delegation fidelity remains parent G4 | +| 3.3 | INSPECT-001..004 | CLI boundaries and real host TCP ownership with changed-PID refusal; Linux/macOS branches remain unexecuted locally | +| 4.1 | DIST-004, QT-003/006 | Actual tsc/npm pack allowlist, local lifecycle and three local/global npm wrappers on Node 24.18.0 | +| 4.2 | DIST-001/002/005 | Pinned Node 26.12.0 build, worker asset, licenses and archive inventory/checksums; extracted Windows and supplementary WSL Linux native lifecycle without product Node PATH | +| 4.3 | DOC-001..004 | Exact Nub docs lock/tasks, explicit unpublished candidate instructions, Astro check and 14-page build | +| 5.1 | QT-003, STO-001, REG-001, IPC-003 | Fresh-eyes repair/review, removed redundant mock/declaration tests, 830 passing cases/two skips, 88.74% own-line coverage; failed aggregate and native checks remain recorded | + +No acceptance is inferred from artifact existence or CI preparation. The parent +roadmap retains native TUI/error/cancellation, full channel parity, final account +boundaries, clean-revision acceptance, Rust cutover and release preparation. diff --git a/openspec/changes/reset-cadder-architecture/.openspec.yaml b/openspec/changes/reset-cadder-architecture/.openspec.yaml new file mode 100644 index 0000000..f534b2b --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/.openspec.yaml @@ -0,0 +1,5 @@ +schema: spec-driven +created: 2026-07-07 +goal: Define the complete Cadder 2.0 TypeScript/Node migration contract, ordered + acceptance gates and equal npm/Node SEA distributions before further product + implementation, including verified removal of the Rust product. diff --git a/openspec/changes/reset-cadder-architecture/README.md b/openspec/changes/reset-cadder-architecture/README.md new file mode 100644 index 0000000..5df9960 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/README.md @@ -0,0 +1,40 @@ +# Cadder 2.0 migration roadmap + +Start with [acceptance.md](acceptance.md) for the approved-scope mapping and gate +order, then [tasks.md](tasks.md) for the current implementation task. This change +must replace the Rust product, not leave two permanent implementations. Functional +scope is the current released Rust 1.0.5 product in TypeScript, not superseded +history/IIS/autostart or machine-output workflows. Bare cadder retains help; +cadder tui retains the routes workspace and explicit --start-daemon option. + +| Artifact | Responsibility | +| --- | --- | +| proposal.md | User journey, full replacement scope, affected capabilities | +| node-migration.md | Approved migration constraints and exclusions | +| design.md | Node module contracts, ownership, security, packaging and cutover | +| specs/ | Reviewed stable-ID deltas synchronized into main specs; retained provenance | +| tasks.md | Only Node implementation checklist, grouped by dependency gate | +| acceptance.md | Approved-plan coverage, evidence levels and final completion | +| release.md | Planned consumer outcomes and major release impact | +| verification.md | Executed evidence and remaining acceptance gaps | +| behavior-baseline.md | Current and pinned historical behavior provenance | +| windows-runtime-gate.md | Isolated real Windows privilege-gate procedure | + +The original Rust reset is archived separately. Its earlier active task snapshot +is recoverable at `f850418:openspec/changes/reset-cadder-architecture/tasks.md`. +Historical Rust completions must not count as Node progress. + +G1/G2 acceptance remains open. The user-approved sequence permits groups 3-6 +implementation against delivered contract/runtime boundaries and schedules Windows +Sandbox for the completed product under 2.8/7.4. G1/G2 must still close before +G7 acceptance, Rust removal or release. The runtime foundation is not a shippable +2.0 application. Both npm +and every standalone SEA variant must pass before Rust/Cargo/legacy packaging +removal, followed by a complete Node-only recheck. + +Follow the repository's Arcantry/OpenSpec contract -> reviewed delta sync -> +focused implementation slice -> evidence -> closeout workflow. Completing these +planning artifacts does not close implementation gates, update release +manifests/changelog, or authorize commit/push/tag/publication. The reviewed +spec-only synchronization is recorded separately in verification.md; this +roadmap remains active. diff --git a/openspec/changes/reset-cadder-architecture/acceptance.md b/openspec/changes/reset-cadder-architecture/acceptance.md new file mode 100644 index 0000000..898f5c4 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/acceptance.md @@ -0,0 +1,138 @@ +# Acceptance and scope ledger + +This is the implementation navigation map, not test evidence. `tasks.md` owns +checkboxes; `verification.md` owns executed results. Read this alongside +`node-migration.md`, `design.md` and the delta specs. No completion percentage +substitutes for a missing requirement. + +## Source of truth and dependencies + +The current released Rust 1.0.5 product and main specs define functional scope. +The user corrected the migration to preserve that product in TypeScript, not +restore superseded history, IIS, autostart or machine-output workflows. Older +snapshots may explain retained behavior but cannot expand the accepted scope. + +The reviewed migration deltas are synchronized into main specs; verification.md +records the contract-sync checks. The released Rust 1.0.5 product remains the +operational baseline. Synchronization accepts the target, not the unfinished +implementation roadmap. Open tasks remain planning. Focused implementation slices +reference stable requirement IDs and record evidence rather than duplicating this +roadmap. + +| Approved scope | Contract and design owner | Tasks / exit | +| --- | --- | --- | +| One TS product; no Rust/Node IPC; daemon state owner | TOP-001/002, IPC-002/003; module table | 1.3-1.5, 5.10, 8.3-8.7 | +| Installation-specific, owner-protected v2 endpoints/databases | TOP-001, RUN-001; runtime identity | 1.3, 2.5, 7.2 | +| Equal npm and standalone SEA, three entries, same version | DIST-001/002/004/005 | 6.1-6.7, 7.6, 9.2-9.4 | +| npm Node 24 LTS >=24.18.0, separate pinned Node 26 SEA builder | DIST-004, QT-001; build policy | 1.2, 6.1-6.4 | +| TS/TSX ESM; no tracked generated JS; tsc/npm and esbuild/SEA in external staging | QT-003/006 | 6.1-6.5, 7.5, 8.6 | +| Exact dependencies, Nub, TS 6.0.3, Vitest/V8, Ink tests, ESLint/Prettier | QT-001/002/003; library decisions | 1.2, 6.8, 7.1 | +| Preserve released TOML/Caddyfiles, precedence and trusted selectors | REG-004, CADDY-001/005, RUN-004; behavior catalog | 1.3, 3.1-3.2, 5.6 | +| Owner-protected secret, protocol 3/security 2, HMAC, no unsafe ACL repair | IPC-001/002, RUN-005 | 2.1-2.5, 2.7-2.9, 7.3 | +| Lifetime SQLite exclusion, diagnostic metadata, recovery after actual lock | RUN-001; lock design | 2.2, 2.5-2.9 | +| Same-owner ordinary client/elevated daemon; explicit Unix owner; deny other users/remote | RUN-005, IPC-001 | 2.7-2.9, 7.3-7.4 | +| Retain exactly eight product operations with closed Zod schemas | IPC-003; current IPC catalog | 1.3, 1.5, 2.4, 5.5 | +| Serial config queue, last-known-good and ambiguous-apply reconciliation | CADDY-003, REG-002 | 3.3-3.5, 3.9, 7.2 | +| Loopback mTLS admin, internal CA, no OS trust/plain admin/project override | CADDY-006 | 3.6-3.7, 7.3 | +| Owned Caddy only, graceful then bounded forced shutdown | CADDY-002, RUN-003 | 3.8-3.9, 7.2 | +| Retained shim command policy, argv/streams/status and heartbeat cleanup | SHIM-001/002/003, REG-001/003 | 4.1-4.3, 4.10, 6.7, 7.2 | +| Operator port/Caddyfile inspection and explicit PID revalidation | INSPECT-001..004, CLI-001 | 1.3, 4.4, 4.10, 5.6 | +| One SQLite state/log worker; no old-data import or live-session restoration | STO-001..004, REG-003 | 5.1-5.2, 9.1 | +| Bounded redaction-before-write logs, one canonical stream, CLI-only diagnostics | OBS-001..004 | 5.3-5.4, 7.1 | +| Shared client/models; real state; current lifecycle/projects/domains/inspection | TOP-002, CLI-001, TUI-001/003 | 5.5-5.10, 6.7 | +| Human-readable output; state/inspection attach-only; explicit bounded launch paths | CLI-001, RUN-002 | 4.2, 5.6-5.7 | +| Bare help with/without TTY; cadder tui and --start-daemon; offline start | CLI-001, TUI-003/004 | 5.7-5.9 | +| Routes-first TUI, tree rows, quiet header, Space/Enter and terminal recovery | TUI-001..004 | 5.8-5.9 | +| Reject profiles, machine output, history, export, tail, watch, IIS and autostart | CLI-001, TOP-002; explicit non-goals | 1.3, 5.6, 7.4-7.6 | +| Single tarball; no optional native packages/install build/download/real Caddy | DIST-004, QT-006 | 6.1-6.2, 8.2-8.4 | +| Four SEA variants, native CLI/TUI/state-log worker/child smoke without Node/npm PATH | DIST-001/002, QT-006 | 6.3-6.7 | +| Astro/Starlight docs, Nub workflow, equal verified installation guidance | DOC-001..004 | 6.8-6.9, 8.5 | +| >=85% own line coverage; contracts/fakes and native runtime/product integration | QT-003/004 | 7.1-7.3 | +| Real Windows Sandbox UAC and account-boundary acceptance, no IIS/autostart mutation | RUN-005, IPC-001 | 2.8, 7.4 | +| Both channels pass identical current-scope scenarios; clean packaging | DIST-005, QT-006 | 6.7, 7.5-7.6, 8.6 | +| Remove Rust/Cargo/old packaging after parity; preserve unrelated untracked npm content | QT-005, TOP-001 | 8.1-8.7 | +| Stop old daemon, re-register; preserve old data/releases; RC then final; separate publication authority | DIST-003/005, STO-004, QT-007 | 9.1-9.4 | +| No Bun/WASM/native runtime addons/Web/Tauri/new installers | TOP-002; non-goals | 7.5, 8.7 | + +Unmodified requirements remain binding: RUN-004, CADDY-002/004/005, REG-002/003, +OBS-001..004, TUI-002/004, DOC-003, INSPECT-001..004 and conditional QT-008. +Changing a runtime does not waive current security, provenance or audience rules. +Former IIS/AUTO/OBS-005 additions are withdrawn, not accepted product contracts. + +## Gate order and stopping rules + +| Gate | Requires | Evidence required to proceed | +| --- | --- | --- | +| G1 | Reviewed corrected plan | Complete contracts/design, released-behavior fixtures and reproducible TS tooling | +| G2 | G1 foundation | Native exclusion/recovery/authentication and owner/elevated/other-account checks, including actual Windows Sandbox | +| G3 | Implemented contract/runtime boundaries (1.2, 1.5, 2.1-2.4), with open G1/G2 evidence tracked | Real Caddy multi-project transactions, mTLS denial, rollback/reconcile and owned teardown | +| G4 | G3 | Shim fidelity, session cleanup and native operator inspection/PID revalidation | +| G5 | G4 | Real Node application journey, state/log persistence and current routes CLI/TUI parity | +| G6 | G5 | Actual packed npm and every native SEA target, matching functionality/version and verified docs | +| G7 | G6 plus completed G1/G2 acceptance | Complete coverage, security/system matrix, actual product Windows Sandbox and one-revision requirements ledger | +| G8 | G7 | Node-only checkout/consumers pass again after removing Rust and obsolete packaging | +| G9 | G8 | Matched RC artifacts and transition evidence; final version only after RC acceptance | + +The user approved completing the product before Windows Sandbox execution. +Groups 3-6 may be implemented against the delivered contract/runtime boundaries; +open G1/G2 acceptance does not block that implementation. Actual TTY and remaining +native runtime evidence stay tracked, and Sandbox runs against the completed +product under 2.8/7.4. G1/G2 must be accepted before G7, Rust removal or release. +This changes execution order, not owner/elevated access or other security criteria. + +Fake adapters prove policy/contract behavior. Native child fixtures prove runtime +mechanics. Actual packed-product tests prove distribution behavior. Interactive +Sandbox proves UAC/account boundaries. None may be relabeled as another. +If a check lacks execution authority or a native runner, record the gap and keep +its acceptance open. Continue only implementation permitted by the dependency +rules above; deferred checks are not passes. Material contract changes return +for an explicit user decision. + +## Reusable functional catalog + +For npm and SEA, run the same versioned scenario data and compare observable +results, exit status, streams and final daemon state. Both must cover: + +1. Three entrypoint help/version and correct packaged daemon launch. +2. Bare help on TTY/non-TTY, cadder tui with/without --start-daemon, offline start + and terminal cleanup, with no implicit TUI on bare invocation. +3. Multi-project managed run, heartbeat, domain conflict/activation and detach. +4. Invalid update, verified reload, last-known-good, post-apply storage failure + and ambiguous apply reconciled before a later mutation. +5. Shared CLI/TUI state, human-readable output, routes-only TUI and bounded CLI + diagnostic logs; removed command/option rejection. +6. Trusted Caddy resolution with wrappers/SEA on PATH and no recursion. +7. Owner/elevated access, wrong proofs/replay/spoofed endpoint, unsafe permissions + and Caddy admin refusal without an authorized certificate. +8. Independent installation runtimes and state/log persistence without restoring + live leases or importing/deleting old Rust data. +9. Concurrent start/crash recovery, Ctrl+C, bounded stop/restart and no unrelated + process termination, including guarded operator PID semantics. +10. Node-free SEA workers/children and clean local/global npm consumer installs. + +## Evidence format and closeout + +Record requirement IDs and task/gate, exact source revision, OS/architecture, +runtime/builder/package version, command or manual scenario, artifact checksum, +actual result and unresolved/skipped cases in verification.md or a linked +implementation artifact. Do not copy private workstation/account details into +shared evidence. Recheck stale evidence after affected source changes. + +The final gate requires no unresolved current-scope parity/security/distribution +case, no Cargo runtime/build dependency and no tracked generated JS. OpenSpec +artifact status "done" means the document exists, not that the application works. +No automatic sync, archive, release-version bump or publication occurs while +correcting this plan. + +## Source-to-release outcome ledger + +| Planned outcome | Category / visibility | Components / impact | Source | +| --- | --- | --- | --- | +| Equal single-package npm/Node and Node-free downloads | Changed / developers and operators | distribution; major installation contract | DIST-001/002/004/005 | +| Explicit isolated-runtime transition without old-data import | Changed / operators | daemon/storage; major IPC/data transition | IPC-002, STO-004, DIST-003 | +| Protected Caddy administration and verified rejected-update recovery | Fixed / developers and operators | Caddy/runtime | CADDY-003/006 | + +Current CLI/TUI behavior is retained, not an Added outcome. Internal Rust +replacement is not a separate consumer changelog entry. release.md describes +these planned outcomes; release/version/changelog writes remain deferred to G9. +Current Cargo and legacy npm version sources remain unchanged. diff --git a/openspec/changes/reset-cadder-architecture/behavior-baseline.md b/openspec/changes/reset-cadder-architecture/behavior-baseline.md new file mode 100644 index 0000000..883662d --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/behavior-baseline.md @@ -0,0 +1,44 @@ +# Behavior baseline and migration evidence + +Rust task completion is not Node implementation evidence. The source paths and +test names in the historical table below refer to Rust snapshot `a10c63a`, +accessible with `git show a10c63a:`, not necessarily the current checkout. +That snapshot is reference evidence only, not authority for functional scope. +The released Rust 1.0.5 workspace and current main specs define retained behavior, +including security fixes and release tooling. The superseded reset is archived +under `openspec/changes/archive/`; IIS, autostart, history and machine output +from older intent must not be restored by this migration. + +## Current product authority + +- `openspec/specs/operator-cli/spec.md` and + `crates/cadder-client/src/cli/mod.rs`: current grammar, bare help, human output, + explicit `tui --start-daemon` and removed-command rejection. +- `openspec/specs/operator-tui/spec.md`: routes-first tree table, no activity/log + view, quiet daemon/Caddy header, explicit lifecycle and keyboard behavior. +- `openspec/specs/local-control-plane/spec.md`: eight operations, not an expanded + history/platform RPC catalog. +- `openspec/specs/observability/spec.md`: bounded canonical-stream diagnostic logs, + without history, export, subscriptions or new all-log/source/severity filters. +- `openspec/specs/product-topology/spec.md`: independent installation runtimes. + +Task 1.3 must freeze fixtures from these current sources and the released Rust +implementation. Historical evidence below is usable only for retained behavior. + +## Historical evidence (not current scope) + +| Contract | Rust source/test evidence | Node evidence | +| --- | --- | --- | +| Runtime override and default/dev profile aliases | `crates/cadder-daemon/src/paths.rs`, `resolve_override_derives_stable_socket_and_runtime_paths`, `runtime_profile_parser_rejects_unknown_values` | `test/paths.test.ts`; v2 isolation is an intentional change | +| Exclusive ownership despite stale/misleading PID metadata | `crates/cadder-daemon/src/runtime_lock.rs`, `raw_lock_rejects_second_owner`, `runtime_lock_recovers_stale_owner_metadata` | `test/runtime-lock.test.ts`, `test/runtime-system.test.ts`; SQLite replaces fs4 | +| Same-owner account can contact elevated endpoint | `crates/cadder-daemon/src/ipc_security.rs`, `IpcSecurityPolicy::evaluate` | Owner ACL plus HMAC replaces native peer inspection; elevated system acceptance pending | +| Config file/env/CLI precedence | `crates/cadder-daemon/src/config.rs`, `crates/cadder-daemon/src/caddy.rs`, `RealCaddyResolver::selected_command` | Pending Node port | +| Managed shim starts missing daemon, never unmanaged fallback | `crates/cadder-shim/src/main.rs`, `run_managed_does_not_delegate_to_real_caddy_when_backend_is_missing`, `open_managed_run_target_starts_missing_daemon_when_fallback_is_skipped` | Pending Node port | +| Registration heartbeat and unregister on shutdown | `crates/cadder-shim/src/main.rs`, `run_managed_registers_heartbeats_and_unregisters_on_shutdown` | Pending Node port | +| Multiple-project composition and last-known-good | `crates/cadder-daemon/src/caddy.rs`, `crates/cadder-daemon/src/state/config_apply.rs` | Serial transaction and ambiguity reconciliation are new acceptance requirements; pending | +| Operator views use real daemon state | `crates/cadder-operator/src/`; snapshot `crates/cadder` TUI contains mocks | Node CLI/TUI must use the shared client service and current routes-only scope; pending | +| Historical IIS preview, handoff and restore (superseded) | `crates/cadder-daemon/src/iis.rs` and daemon IIS state modules | Excluded; not a Node parity task | + +`test/fixtures/runtime-child.ts` is a real child-process fixture, not a production +daemon. Its status/shutdown handlers exist solely to prove runtime exclusion, +crash recovery and authenticated OS transport before porting product handlers. diff --git a/openspec/changes/reset-cadder-architecture/design.md b/openspec/changes/reset-cadder-architecture/design.md new file mode 100644 index 0000000..64f00d7 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/design.md @@ -0,0 +1,273 @@ +## Context + +This is the executable design for the approved Cadder 2.0 migration. The approved +scope is summarized in `node-migration.md`; normative deltas live in `specs/`. +`tasks.md` is the only implementation checklist and `acceptance.md` is the +requirements-to-evidence ledger. `verification.md` contains actual evidence, not +future acceptance claims. + +The retained operational baseline is Rust 1.0.5 from master. The released CLI/TUI +and main specs define functional scope. `behavior-baseline.md` distinguishes +current evidence from superseded historical behavior; the historical snapshot +is not authority to restore removed features. The original Rust reset and its +checkboxes remain recoverable in the archived change and Git snapshot `f850418`; +they are not Node progress. Current Node code supplies runtime/IPC fixtures, +not product handlers, CLI/TUI or releasable distributions. + +## Goals / Non-Goals + +**Goals:** Replace the product, not add a permanent second implementation; +preserve project and operator workflows; have one daemon state owner; keep both +distribution channels equally functional; simplify by using focused libraries +and Node standard APIs; record proof at each end-to-end gate. + +**Non-Goals:** Rust/Node interoperability, old-data import, Bun, custom WASM, +native runtime addons, Web/Tauri surfaces, new system installers, history, IIS, +autostart, machine output, profiles, export, watch, continuous tail, rewriting +unrelated tools or publishing during implementation. No fixture is a substitute +for the actual application or real Windows privilege acceptance. + +## Decisions + +### Module ownership and contracts + +| Module | Owns | Boundary and tests | +| --- | --- | --- | +| protocol | Zod request/result/error schemas, protocol 3, HMAC framing | Pure schema/authentication tests; no storage or process imports | +| daemon | Runtime exclusion, registries, mutation queue, snapshots, RPC handlers | Fake Caddy/platform/storage ports plus real local IPC tests | +| caddy adapter | Trusted executable resolution, adapt/validate, composition, mTLS admin, owned child | Fake process/admin fixtures, followed by real Caddy integration | +| platform adapters | Filesystem permissions, Windows ACL, Unix owner and operator socket/PID inspection | Small OS-specific adapters; fake policies then native acceptance | +| storage | One application-state/diagnostic-log SQLite worker and separate lifetime lock connection | Transaction, recovery, retention and close tests | +| client service | Typed RPC, shared models, selectors and lifecycle coordination | Fake transport/model tests; no database or Caddy admin access | +| CLI | Commander grammar, formatting, attach-only policy, exit status | Consumer argument/stream/output tests | +| TUI | Ink/React rendering and user interaction through the client service | ink-testing-library fixtures and real terminal smoke | +| packaging | Staging manifest, tsc npm emission, esbuild/SEA, archives/checksums | Packed-consumer verification; no application policy duplicated here | + +Freeze method names, typed payloads, error categories, command arguments and +exit codes in a behavior/contract fixture catalog before adding product handlers. +Retain current status, daemon, projects, domains, port, Caddyfile, diagnostics, +bounded logs and TUI commands from released Rust 1.0.5. Preserve bare help on +TTY and non-TTY, explicit cadder tui --start-daemon, human-readable output and +unsupported-command rejection. The eight-operation released IPC catalog remains +the product boundary. Historical history/IIS/autostart and machine-output code +is superseded, not compatibility authority. Unknown or ambiguous current behavior +requires a recorded decision before implementation rather than an undocumented +choice. + +Zod validates all method payloads and results. Responses correlate request IDs +and contain exactly one result or typed error. The existing generic runtime +fixture schema must be tightened into the product catalog before it is treated +as a completed RPC contract. Authentication, transport, unsupported protocol, +config/conflict and storage failures remain distinct. + +### Source and dependency policy + +Track TS/TSX, tests and tool configuration. Keep ESM; do not confuse it with a +second JS application. `tsc --noEmit` checks types; Vitest/V8 measures own source +lines; `tsx` is development-only for TSX/TUI. Consumer installs need no loader, +compiler, postinstall download or build. + +Nub manages the root and documentation dependencies with exact versions and +locks. TypeScript is 6.0.3. Use Commander, Zod, smol-toml, Ink/React and +ink-testing-library for their narrow CLI/schema/config/rendering jobs instead of +custom equivalents. Use @peculiar/x509 with Node WebCrypto for certificate +creation and verification: Node provides crypto/TLS, while the library avoids +hand-written ASN.1/X.509 generation. Use node:sqlite instead of a native addon +or custom durable format; its release-candidate status is accepted. ESLint, +Prettier and Vitest/V8 own their respective checks. Do not add tsup. + +The npm runtime is Node 24 LTS, minimum 24.18.0. The SEA builder is a separately +pinned Node 26 patch. Verify and record that patch when the SEA packaging task +is executed; do not guess a patch or change the npm minimum to match it. The +same source tree and runtime schemas feed both builds. No new framework or +general-purpose task runner is needed. Retain current mise/Rust tooling while +the baseline is needed, then remove obsolete responsibilities, not blindly +translate them into custom JS. + +### Runtime ownership and security + +Use an owner-protected, installation-specific v2 directory separate from the +Rust runtime and a separate SQLite lock database. Keep separate installation +identities and verify independent endpoints/databases; owner-specific protection +must not collapse distinct installations into one runtime. A dedicated lock +connection holds `BEGIN EXCLUSIVE` for the daemon lifetime; it is not the state/log +worker. Diagnostic PID metadata, +timestamps or leases never grant ownership. Acquire the actual lock before +crash-residue recovery, then validate/create protected runtime files, bind the +endpoint and publish readiness. Track resources acquired during startup so any +failure closes only those resources and releases the lock. + +The secret comes from a cryptographic generator and stays in owner-protected +storage. Unix uses owner-only modes; Windows validates owner SID and protected +ACLs through the small PowerShell/.NET adapter. Reject unsafe existing paths, +links, owner or ACL rather than weakening/repairing security implicitly. Root on +Unix must receive an explicit owner UID and runtime directory. Same-owner +ordinary clients can authenticate to an elevated daemon; other accounts cannot. +No remote clients or cross-owner access are supported. + +NDJSON travels over Unix sockets or Windows named pipes. Enforce bounded frames, +connections and timeouts, exact protocol 3/security policy 2 and mutual HMAC +using fresh direction-bound challenges. Secret bytes never cross IPC. Validate +authenticated frame sequences and reject replay/reflection/tampering or RPC +before authentication. Do not add capability negotiation or Rust compatibility. + +The delivered contract/runtime boundaries support Caddy integration implementation +while G1/G2 acceptance evidence remains open. Under the user-approved sequencing, +Windows Sandbox runs on the completed product, not midway through implementation. +G2 must still prove native exclusion/crash recovery and both privilege models +before G7 acceptance, Rust removal or release. The existing Windows Sandbox +guide/package is preparation only; UAC approval, cancellation, same-owner elevated +contact and different-account denial require real isolated execution, not mocked +claims or host policy changes. + +### Configuration transactions and Caddy + +Preserve `cadder.toml`, Caddyfiles, argument parsing and documented precedence; +classify older executable selectors against the released trusted-source policy +in the fixture catalog. Do not reintroduce a project-controlled Caddy executable +override to emulate obsolete code. Resolve the real executable from trusted +explicit daemon configuration or safe PATH. Detect npm wrappers, SEA entrypoints +and aliases/file identity to prevent recursion. Pin the selected executable for +the daemon lifetime. Keep adaptation bounds and exact active-host/loopback guards +from CADDY-004/005; do not lose master's security fixes during the port. + +One queue owns prepare -> adapt/compose -> validate -> apply -> verify -> persist +-> publish. Project/domain activation, registration changes and reload must enter +that ownership boundary. Pure reads may use the last committed snapshot; +they cannot publish a pending state. +Validation/conflict rejection leaves the previous committed model/config intact. +If apply may have succeeded but its result is unknown, fence subsequent changes, +query the active protected Caddy state and reconcile before accepting another +mutation. If persistence fails after apply, restore and verify last-known-good; +a failed restore stays fenced and reports a recoverable diagnostic. + +Create protected root/intermediate CA and authorized client material using +WebCrypto/X.509. Caddy's native internal issuer obtains and renews the admin +server certificate from that CA; do not inject a server leaf into CertMagic +storage or add a proxy/custom issuer. Keep Caddy's identity storage inside the +owner-protected runtime boundary. Bind the admin channel to loopback with mTLS, +validate server identity and require the authorized client. Do not install this +CA in OS trust. +Override/reject project admin settings before validation; no plaintext admin +listener may remain. Verify no-cert/wrong-cert denial with real Caddy. Lifecycle +control uses only the daemon's owned child handle: graceful stop, bounded wait, +then force only that child. Never scan for or kill unrelated Caddy processes. + +### Shim and platform boundaries + +Keep managed/read-only/passthrough/unsupported command policy. Preserve argument +arrays, stdin/stdout/stderr and exit codes; use direct spawning, not shell-built +commands. Managed `caddy run` may start a missing daemon, but never independent +Caddy. Session nonces own registration, heartbeat/reconnect and cleanup on +Ctrl+C, lost connection, clean exit or daemon restart. Stale sessions cannot +detach or mutate a replacement owner. + +Platform adapters are limited to runtime permissions/identity and current +operator socket/PID inspection. Same-owner contact with an explicitly elevated +daemon remains a runtime-security requirement, not an IIS feature. Real UAC, +cancellation and other-account denial acceptance runs in Windows Sandbox, never +by silently changing host accounts or policy. No IIS mutation helper, restore +metadata or autostart provider is part of this migration. + +Preserve INSPECT-001 through INSPECT-004: operator port/socket inspection and +explicit PID-revalidated termination stay in the operator process, behind small +platform adapters without native runtime addons. This does not grant the daemon +ownership of unrelated processes. + +### Storage, logs and operator surfaces + +One worker owns the Node SQLite application-state/diagnostic-log connection, +migrations, bounded jobs and orderly close. Validate schema/integrity before +mutations; +rollback transactions on failure, settle pending jobs before teardown and keep +the exclusion connection alive until resources are released. Store stable +intent and bounded diagnostic logs, never resurrect sessions or process ownership +as live. Do not read, migrate, delete or clean up the Rust database/runtime +implicitly. + +Redact before persistence or response and retain deterministic bounded streams +(baseline: 1,000 per stream, 5,000 global; queries bounded to 200). Each query +selects one canonical runtime, entrypoint/project or domain stream and returns +the newest requested rows in ascending sequence order. Preserve +actual domain attribution, not presentation-only labels. Do not add all-log, +source/severity, cursor, paging, tail, subscription, history or export filters. +Bounded logs remain explicit CLI diagnostics, not a TUI view. Terminal rendering +neutralizes control characters. + +Commander CLI and Ink/React TUI share the client service and models. Production +never uses mock data or random status. Implement current lifecycle, projects, +domains, bounded CLI logs/diagnostics and inspection commands. Preserve +human-readable output and stable error/exit behavior; +reject machine output and the other removed commands. State/inspection commands +are attach-only. Explicit daemon start/restart, managed run and cadder tui +--start-daemon retain their bounded launch paths. Bare cadder prints help without +starting TUI or daemon, on TTY and non-TTY. Ordinary cadder tui opens the routes +workspace with explicit offline start. Preserve project/domain tree rows, separate +daemon/Caddy header state, Space/Enter toggles, offline Enter-to-start, confirmed +stop and ordered restart. No separate activity, logs, history, IIS or autostart +views are added. Screens stay quiet, keyboard-operated and resize/exit safe. + +### Packaging and CI + +Every Node artifact is generated into system/runner temp outside checkout. +For npm, `tsc` emits JS with relative import-extension rewriting and a staging +manifest exposes the three bins. Pack one tarball with no platform +optionalDependencies, install scripts, generated source-side JS or real Caddy. +Test the actual tarball via local/global installs and PATH fixtures outside +checkout; publication must consume the exact artifact that passed this process. + +For SEA, esbuild bundles the same modules/entrypoints and a pinned Node 26 builds +three standalone entries plus required assets. If dispatch/shared assets are +used, prove all three invocation names and owned child/worker launches from the +archive. No spawned Node/npm lookup may be required by standalone runtime. Test +SQLite state/log workers, TUI and child processes on Windows x64, Linux x64 and +both macOS architectures with Node/npm absent from PATH. Write checksums and +retain release provenance/attestation and least-privilege staging safeguards. + +CI must reuse non-publishing pack commands for verification, enforce >=85% own +line coverage including hard runtime modules, run Windows/Linux/macOS native +integration, and gate all four SEA variants. Use one functional scenario catalog +for npm and SEA. Astro/Starlight docs move from Bun/legacy workflows to Nub and +describe both channels equally only after the documented flows pass. Artifact +identity includes exact source commit/version; npm and SEA versions match. + +## Risks / Trade-offs + +- Node SQLite and experimental SEA are accepted dependencies, not permission + to waive coverage, native worker or distribution parity gates. +- Uncertain Caddy apply needs active-state reconciliation; retries alone are + unsafe and must not turn a lost response into a second uncoordinated mutation. +- Windows runtime ACL behavior needs real UAC and another account. Fake adapter + tests cannot close that gate. Missing execution authority blocks that test, + not permission to weaken policy or mark it complete. +- Removing Rust too early would leave no working product. Keeping it permanently + would fail the migration. G8 requires both complete Node channels, then proves + operation from a checkout and consumer environment without Cargo/Rust. + +## Migration Plan + +G1 establishes contracts/fixtures and tooling. Implemented contract/runtime +boundaries permit G3 Caddy work while remaining G1/G2 evidence stays open. G3 +ports and verifies transactional Caddy. G4 ports shim and current inspection/platform +boundaries. G5 completes persistence and real CLI/TUI. G6 builds and verifies both +channels and docs. Complete G1/G2 acceptance, including actual-product Windows +Sandbox, before G7 closes cross-platform/security/parity acceptance. G8 removes +Rust and legacy packaging and reruns the Node-only gates. G9 prepares matching +release candidates without publishing. See task dependencies and evidence in +`acceptance.md`; a checkpoint does not close the whole migration. + +Before a user switches, stop the Rust daemon. Re-register projects from existing +files. Leave old runtime data and releases intact. No IIS/autostart transition +steps apply to the released product. Downgrade uses the old matched release set, +with the Node daemon stopped; no automatic old-data import or deprecation occurs. +Commit, push, tag, registry publication and GitHub release need separate authority. + +## Open Questions + +There is no unresolved choice about language, ESM/compilation, Node minimum, +equal distribution rank, privilege models or the final removal of Rust. Remaining +execution-time discoveries are the exact released 1.0.5 command/DTO fixture catalog, +the verified Node 26 builder patch and native SEA asset/worker layout. Tasks 1.3, +6.3 and 6.4 own these decisions and their evidence. A discovery that changes the +approved public contract or requires a different runtime/dependency policy must +return for a user decision before implementation. diff --git a/openspec/changes/reset-cadder-architecture/node-migration.md b/openspec/changes/reset-cadder-architecture/node-migration.md new file mode 100644 index 0000000..d0df184 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/node-migration.md @@ -0,0 +1,88 @@ +# Cadder 2.0 Node migration + +This is the approved scope summary. `design.md` defines module contracts and +decisions, `specs/` contains normative deltas, `tasks.md` is the Node-only roadmap, +and `acceptance.md` maps the approved plan to requirements, gates and evidence. +`release.md` records planned consumer outcomes; `verification.md` records only +executed results. The original Rust design is historical, not the active target. + +## Preserved contracts + +The daemon owns state, configuration, diagnostic logs and only the Caddy process it +starts. Preserve the current released Rust 1.0.5 scope: Caddyfiles, `cadder.toml`, +command policy, human-readable output, project registration, heartbeat, domain +controls, diagnostics, bounded CLI logs and port/Caddyfile inspection. Preserve +separate installation runtimes and the current eight-operation IPC catalog. +State/inspection CLI commands attach without starting the daemon; explicit daemon +start/restart, managed `caddy run` and `cadder tui --start-daemon` retain their +bounded launch paths. CLI and TUI share a client service. Bare `cadder` prints +help on TTY and non-TTY; `cadder tui` opens the routes-first workspace with explicit +offline start and no separate activity/log views. There are no mock production +statuses. Superseded historical behavior is not the migration target. + +## Changed contracts + +- One product with protocol, daemon, Caddy, platform, client, CLI and TUI modules. +- Sources stay `.ts`/`.tsx`, ESM; `tsc --noEmit` checks types. Vitest/V8 covers at + least 85% of own lines including runtime modules. `tsx` is development-only. +- npm is a single `cadder` tarball with three bins, Node 24 LTS minimum 24.18.0. +- GitHub Releases supplies equal standalone SEA archives: Windows x64, Linux + x64, macOS x64 and arm64. All contain `cadder`, `cadderd`, `caddy`, assets and + checksums. Both channels have the same version and functionality. +- npm uses `tsc` with relative import extension rewriting; SEA uses esbuild and + pinned Node 26. All emission and packaging occurs in system/runner temporary + staging outside checkout. No install-time compilation, binary download, + platform optional packages, or bundled real Caddy. +- Nub manages exact dependency versions and lockfile, including Astro/Starlight + docs. Commander, Zod, smol-toml, Ink/React, @peculiar/x509/Node WebCrypto and + node:sqlite replace custom glue. No tsup. +- Runtime is isolated in `v2`, protocol 3, security policy 2. Unix sockets and + Windows named pipes carry bounded NDJSON. Owner-protected secret material + supports mutual HMAC authentication with fresh challenges and direction-bound + transcripts. Secret bytes never travel over IPC; RPC follows authentication. +- Refuse unsafe permissions/ACLs rather than silently weakening security. + Unix root startup requires an explicit runtime owner and runtime directory. + Same-owner user clients can contact an elevated daemon; other users cannot. +- A separate SQLite database holds `BEGIN EXCLUSIVE` for process lifetime. + Metadata is diagnostic only. Crash recovery starts after acquiring this lock, + never from PID checks or expiry. Application state and diagnostic logs use one + dedicated SQLite worker. +- One mutation queue prepares, validates, applies, reconciles and commits config. + Rejection keeps last-known-good; ambiguous outcomes block further mutations + until active Caddy state is reconciled. +- Caddy local plaintext admin is disabled. Loopback mTLS uses an internal CA and + protected client certificate; no system trust-store installation. Project + configuration cannot override admin policy. Shutdown is graceful then forced + against the owned process only. + +## Deferred and excluded + +No history, IIS, autostart, machine-output (JSON/JSONL), profiles, export, watch, +continuous tail, expanded log filters or separate TUI activity/log views. These +are not missing parity tasks; they are outside the current product scope. +No mixed Rust/Node IPC, old-data import, Bun, custom WASM, native runtime addons, +Web UI, Tauri or new system installers. SEA experimental status concerns its +packaging mechanism, not distribution priority. + +## Verification and transition gates + +Follow implemented module dependencies while tracking open acceptance evidence. +The user approved groups 3-6 implementation against the delivered contract/runtime +boundaries without first executing Windows Sandbox. Sandbox tests the completed +product under tasks 2.8/7.4; G1/G2 acceptance remains mandatory before G7, Rust +removal or release. Deferred execution is not a security waiver or a passed gate. +Cover concurrent starts, crash recovery, authentication failures, replay, fake +endpoints, unsafe ACLs and same-owner elevated access. Also cover multi-project +reload/rollback, heartbeat, Ctrl+C, shutdown, actual-product native privilege +boundaries and identical npm/SEA consumer scenarios. Test SEA without +Node/npm on PATH on each native platform, including the routes TUI, state/log +SQLite worker and subprocesses. Packaging must leave tracked files unchanged +and no generated JS in checkout. + +Retain Cargo, Rust code and existing release tooling until functionality and both +distributions pass their gates. Do not delete the pre-existing untracked `npm/`. +Before user transition, stop the Rust daemon. Do not delete old runtime data; +re-register existing projects. +Prepare 2.0.0-rc.1 in both channels, then 2.0.0 after acceptance. Old releases stay +available for rollback. Commit, push, tag and publication require separate +authorization. Checkboxes record progress, not independent implementation proof. diff --git a/openspec/changes/reset-cadder-architecture/proposal.md b/openspec/changes/reset-cadder-architecture/proposal.md new file mode 100644 index 0000000..be554d3 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/proposal.md @@ -0,0 +1,81 @@ +## Why + +Cadder's approved migration replaces the Rust product with TypeScript/Node to +reduce language-boundary and implementation overhead. Preserve the existing +project Caddyfile -> managed shim -> daemon -> operator journey, with the current +released Rust 1.0.5 command scope and routes-first TUI, while delivering the same +product through npm and standalone downloads. + +The delivered foundation supplies authenticated, exclusively owned Node runtime +boundaries. The next capability selects trusted real Caddy and adapts/validates an +existing project Caddyfile with bounded child cleanup. The user approved testing +Windows Sandbox on the completed product; its acceptance remains mandatory before +G7, Rust removal or release, not before Caddy integration implementation. + +## What Changes + +- **BREAKING** Replace the Rust application, Cargo workspace and native npm + launcher packages with one TypeScript product after parity and release gates. +- npm requires Node 24 LTS >=24.18.0; GitHub Releases provides equally required + Node SEA standalone archives without an installed Node/npm prerequisite. +- Keep the three entrypoints, project configuration, Caddyfiles, shim policy, + owner/elevated access models and installation isolation. Preserve the current + CLI/TUI scope and human-readable output through one client service, including + bare help and explicit cadder tui --start-daemon; do not restore removed features. +- **BREAKING** Isolate runtime v2 and protocol 3/security policy 2. Use mutual + HMAC over local NDJSON and a lifetime SQLite exclusion lock. Do not support + Rust/Node IPC or import old runtime data. +- Apply Caddy updates transactionally through an owned process and a protected + loopback mTLS admin channel, with last-known-good and ambiguity reconciliation. +- Build only into outside-checkout staging. Both channels must pass the same + consumer scenarios before Rust or its existing release path is removed. + +## Capabilities + +### New Capabilities + +- `caddy-shim-integration`: explicit command policy, transport/exit fidelity and + npm/SEA-aware recursion prevention (SHIM-001 through SHIM-003). + +### Modified Capabilities + +- `product-topology`: one Node product and daemon-owned state (TOP-001/002). +- `daemon-lifecycle`: SQLite ownership, managed launch, bounded teardown and + explicit owner/elevated access (RUN-001/002/003 and RUN-005). +- `local-control-plane`: mutual authentication and the retained eight-operation + RPC catalog without Rust compatibility (IPC-001/002/003). +- `caddy-runtime`: verified transactions and protected admin transport while + retaining trusted resolution, local routes and bounded adaptation + (CADDY-001/003/006; CADDY-002/004/005 remain required). +- `project-registration`: live session ownership and preserved project formats + (REG-001/004; REG-002/003 remain required). +- `runtime-storage`: Node SQLite state/log worker, schema validation and no + old-data migration or cleanup (STO-001 through STO-004). +- `operator-cli`: released command, human-output and invocation parity (CLI-001). +- `operator-tui`: real shared-model workflows and explicit offline start + (TUI-001/003; TUI-002/004 remain required). +- `distribution-and-upgrades`: single npm tarball, four equal SEA downloads and + explicit transition (DIST-001 through DIST-005). +- `quality-tooling`: Nub/TS tooling, native gates, release security and verified + Rust removal (QT-001 through QT-007; conditional QT-008 remains required). +- `documentation-experience`: verified equal installation paths and shared + contributor workflow (DOC-001/002/004; DOC-003 remains required). + +## Impact + +The implementation touches protocol, daemon, Caddy adapter, platform adapters, +client service, CLI/TUI, packaging, CI and Astro/Starlight documentation. It +removes Cargo, Rust crates, legacy native platform packages and old release +orchestration only at the final cutover. Existing 1.0.5 releases, runtime and +database remain available for rollback. + +No history, IIS, autostart, machine-output, profiles, export, watch, continuous +tail, expanded log filters or separate TUI activity/log views are included. +No Bun, custom WASM, native runtime addons, Web UI, Tauri, new system installers +or unrelated feature expansion is included. OBS-001 through OBS-004 remain +unchanged; older historical code is evidence, not authority to restore features. +Port/Caddyfile inspection and guarded operator process control retain INSPECT-001 +through INSPECT-004. The reviewed contract deltas are synchronized into main +specs without archiving this open roadmap. That accepts the target, not migration +completion. Released versions, commits, pushes, tags and publication remain +separate actions. diff --git a/openspec/changes/reset-cadder-architecture/release.md b/openspec/changes/reset-cadder-architecture/release.md new file mode 100644 index 0000000..4a26e6c --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/release.md @@ -0,0 +1,43 @@ +--- +impact: major +components: + - distribution-and-upgrades + - daemon-lifecycle + - operator-cli + - operator-tui +audiences: + - developers + - operators +observable_impact: user-felt +changelog: include +--- + +These are planned 2.0 outcomes, not shipped functionality. Both channels target +2.0.0-rc.1 first and 2.0.0 only after acceptance and separate publication approval. + +## Changed + +### Install one application through npm or standalone downloads + +Cadder 2.0 will provide the same operator, daemon and Caddy shim through one npm +package requiring Node 24 LTS >=24.18.0 or standalone GitHub downloads requiring +no installed Node/npm. Upstream Caddy will remain a separate prerequisite. + +### Switch runtimes without deleting the previous installation's data + +Before switching, users will stop the previous daemon. Projects will register +again from their existing configuration files. Previous runtime data and releases +will remain available, but the new daemon will not import the old database or +communicate with the Rust daemon. + +The current command scope will remain: bare `cadder` prints help, `cadder tui` +opens the routes workspace, and `cadder tui --start-daemon` starts or attaches +before opening it. Logs and diagnostics remain explicit CLI commands. The +migration will not add history, IIS, autostart or machine-output commands. + +## Fixed + +### Keep Caddy administration within the authenticated daemon boundary + +Cadder 2.0 will protect its local Caddy administration channel with mutual TLS +and retain the previous verified configuration when an update is rejected. diff --git a/openspec/changes/reset-cadder-architecture/specs/caddy-runtime/spec.md b/openspec/changes/reset-cadder-architecture/specs/caddy-runtime/spec.md new file mode 100644 index 0000000..02f24d3 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/caddy-runtime/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: CADDY-001: Real Caddy comes only from trusted sources +The daemon SHALL select real Caddy from a trusted explicit override/configuration or safe PATH, pin the executable for its lifetime and reject untrusted project/shim selectors. Resolution SHALL recognize npm wrappers, SEA entries, aliases and file identity to avoid recursive Cadder execution. + +#### Scenario: PATH starts with the npm or SEA shim +- **WHEN** an executable candidate resolves to a Cadder wrapper or entrypoint +- **THEN** resolution SHALL skip or reject it and continue safely or fail clearly +- **AND** it SHALL NOT recursively launch the shim + +### Requirement: CADDY-003: Runtime transitions are verified +One queue SHALL serialize preparation, adaptation/composition, validation, apply, active-state verification, SQLite commit and publication. Rejection SHALL retain last-known-good. Persistence failure after apply SHALL restore and verify the previous configuration; failed rollback SHALL fence further mutations. An ambiguous apply outcome SHALL require active-state reconciliation before the next mutation. Pending state SHALL NOT become authoritative prematurely. + +#### Scenario: Apply response is lost +- **WHEN** the daemon cannot determine whether Caddy applied a candidate +- **THEN** it SHALL fence later changes and reconcile active state +- **AND** only verified, persisted state SHALL be published as committed + +#### Scenario: Database commit fails after successful apply +- **WHEN** Caddy accepted a candidate but persistence fails +- **THEN** the daemon SHALL restore last-known-good and retain prior state +- **AND** failed restore SHALL remain fenced with a recoverable diagnostic + +## ADDED Requirements + +### Requirement: CADDY-006: Administration uses protected loopback mutual TLS +The daemon-Caddy admin channel SHALL use loopback mTLS with an internal CA, validated server identity and an owner-protected authorized client certificate. The CA SHALL NOT be installed in system trust. Plaintext local admin SHALL be disabled, and project input SHALL NOT override the administration policy. + +#### Scenario: Caller has no authorized certificate +- **WHEN** a caller contacts Caddy admin without the correct client certificate +- **THEN** Caddy SHALL refuse administrative access +- **AND** there SHALL be no unprotected listener fallback + +#### Scenario: Project supplies admin settings +- **WHEN** project configuration attempts to replace admin transport or trust +- **THEN** the daemon SHALL reject or override it with the protected policy +- **AND** no system trust-store mutation SHALL be needed diff --git a/openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md b/openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md new file mode 100644 index 0000000..e76a0ae --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: SHIM-001: Shim policy preserves command fidelity +The shim SHALL classify commands as managed, read-only inspection, explicit passthrough or unsupported and preserve argument arrays, standard streams and exit codes. Unknown commands SHALL fail rather than silently pass through. + +#### Scenario: Explicit passthrough command exits +- **WHEN** a supported passthrough command completes +- **THEN** the shim SHALL preserve its output streams and exit status +- **AND** it SHALL NOT mutate daemon-managed state through an undocumented path + +### Requirement: SHIM-002: Managed run never starts independent Caddy +Managed caddy run SHALL use daemon registration/heartbeat and MAY start a missing packaged daemon. Failure SHALL not become an independent Caddy run. Read-only inspection MAY use safely resolved real Caddy and SHALL identify its result as inspection rather than daemon state. + +#### Scenario: Managed startup fails +- **WHEN** the daemon cannot become ready for managed run +- **THEN** the shim SHALL fail with recovery guidance without unmanaged fallback + +### Requirement: SHIM-003: Both distributions avoid recursive resolution +Real Caddy resolution SHALL detect npm wrappers, SEA entries and equivalent file/alias identities, preventing recursion in local/global npm and standalone PATH layouts. The real upstream server SHALL remain separately installed. + +#### Scenario: Globally installed shim precedes upstream Caddy +- **WHEN** PATH resolves Cadder before upstream Caddy +- **THEN** the shim SHALL skip its own wrappers/entries and find a trusted candidate +- **AND** failure SHALL be bounded without recursively spawning Cadder diff --git a/openspec/changes/reset-cadder-architecture/specs/daemon-lifecycle/spec.md b/openspec/changes/reset-cadder-architecture/specs/daemon-lifecycle/spec.md new file mode 100644 index 0000000..d82be92 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/daemon-lifecycle/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: RUN-001: One installation admits one daemon +One owner runtime v2 SHALL admit one daemon through a separate SQLite lock database holding BEGIN EXCLUSIVE for the process lifetime. Metadata SHALL be diagnostic only. Startup SHALL acquire the actual lock before crash recovery, bind/publish readiness only after protected startup succeeds, and release acquired resources on failure. PID checks or expiring leases SHALL NOT grant ownership. + +#### Scenario: Concurrent start after a crash +- **WHEN** two starts encounter stale or misleading owner metadata +- **THEN** only the actual SQLite lock owner SHALL recover residue and become ready +- **AND** the other SHALL observe the existing owner or report bounded contention + +### Requirement: RUN-002: Managed run may start the daemon +Managed caddy run SHALL use the version-matched packaged daemon and MAY start it when missing through the supported npm or SEA path and authenticated readiness. It SHALL NOT discover an unrelated daemon through PATH or start independent Caddy. State/inspection CLI commands SHALL remain attach-only; explicit daemon start/restart and cadder tui --start-daemon SHALL retain the released bounded launch behavior. + +#### Scenario: Packaged daemon cannot start +- **WHEN** the packaged daemon fails to become authenticated and ready +- **THEN** the shim SHALL report the failure without unmanaged Caddy fallback +- **AND** ordinary inspection commands SHALL NOT implicitly start a daemon + +### Requirement: RUN-003: Shutdown is bounded and owned +Shutdown SHALL stop admission, settle or revoke owned work, close the application-state and diagnostic-log worker, gracefully stop the owned Caddy child, then force only that child after bounded deadlines. It SHALL release endpoint resources and the SQLite exclusion connection only after owned teardown. It SHALL NOT enumerate or kill unrelated Caddy processes. + +#### Scenario: Graceful child shutdown stalls +- **WHEN** the owned Caddy child exceeds the graceful deadline +- **THEN** the daemon SHALL terminate only its owned process and complete teardown +- **AND** unrelated Caddy processes SHALL remain untouched + +## ADDED Requirements + +### Requirement: RUN-005: Owner clients can contact explicitly elevated daemons +Normal operation SHALL run at user privilege. A same-owner ordinary client SHALL retain authenticated access to an explicitly elevated daemon. Unix root startup SHALL require an explicit runtime directory and owner UID. Other users and remote clients SHALL be denied without weakening filesystem or ACL policy. + +#### Scenario: Ordinary owner contacts elevated endpoint +- **WHEN** the ordinary owner authenticates to an explicitly elevated daemon +- **THEN** documented operations SHALL remain available under the same policy +- **AND** a different account SHALL be rejected before product handlers execute diff --git a/openspec/changes/reset-cadder-architecture/specs/distribution-and-upgrades/spec.md b/openspec/changes/reset-cadder-architecture/specs/distribution-and-upgrades/spec.md new file mode 100644 index 0000000..5b13a86 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/distribution-and-upgrades/spec.md @@ -0,0 +1,45 @@ +## RENAMED Requirements + +- FROM: `### Requirement: DIST-004: npm installs the coherent native application` +- TO: `### Requirement: DIST-004: npm installs one Node application package` + +## MODIFIED Requirements + +### Requirement: DIST-001: Release binaries are version matched +Cadder SHALL have equal, required npm and Node SEA GitHub Release channels with the same version and cadder/cadderd/caddy functionality. Standalone targets SHALL include Windows x64, Linux x64, macOS x64 and macOS arm64. Both channels SHALL pass acceptance before migration completion or final release readiness. + +#### Scenario: One channel is ready +- **WHEN** npm passes but any supported standalone variant remains unverified +- **THEN** the migration SHALL remain incomplete rather than treating SEA as optional + +### Requirement: DIST-002: Archives are portable and verifiable +Each standalone archive SHALL contain three version-matched Node SEA entries and required accepted assets, a SHA-256 checksum and release-source provenance. Every target SHALL be extracted outside checkout and pass native CLI/TUI, state/log SQLite worker and owned subprocess smoke without Node/npm on PATH. Standalone SHALL NOT require installed Node, download runtime components or bundle real Caddy. Existing release attestation verification SHALL bind assets to their exact tag/source commit/repository/signer before publication. + +#### Scenario: Consumer has no Node installation +- **WHEN** a supported standalone archive is used with Node/npm absent from PATH +- **THEN** all three entries, TUI, SQLite worker and owned child workflows SHALL work +- **AND** checksum/provenance verification SHALL apply to the exact tested archive + +### Requirement: DIST-003: Upgrades replace the coherent executable set +Transition guidance SHALL require stopping the old daemon before switching the coherent release set. Node SHALL re-register projects from existing files without importing or deleting old runtime data. Old releases SHALL remain available for rollback without automatic deprecation. Mixed Rust/Node IPC SHALL not be supported. Transition SHALL NOT require IIS handoff or autostart operations absent from the released product. + +#### Scenario: User switches to Node and later rolls back +- **WHEN** the user follows the documented transition with each daemon stopped +- **THEN** existing configuration and old data/releases SHALL remain available +- **AND** rollback SHALL use the matched old set, not mixed IPC or imported old data + +### Requirement: DIST-004: npm installs one Node application package +npm SHALL deliver one cadder tarball with three bin entries on Node 24 LTS, minimum 24.18.0. It SHALL contain compiled JS and required assets from the shared TS sources, not platform optionalDependencies or native Rust executables. Installation SHALL require no TS loader/compiler, install-time build, postinstall runtime download or bundled upstream Caddy. Local/global installs SHALL preserve arguments, streams, status and non-recursive PATH behavior. + +#### Scenario: Clean consumer installs the packed tarball +- **WHEN** a supported consumer installs locally or globally without build tools +- **THEN** cadder, cadderd and the caddy shim SHALL run from the one package +- **AND** upstream Caddy SHALL remain a separately configured prerequisite + +### Requirement: DIST-005: npm and portable archives share release identity +npm and SEA SHALL be built from the same source revision with one matching release version and pass the same functional catalog. Their artifact bytes need not match because packaging differs. A verified 2.0.0-rc.1 in both channels SHALL precede 2.0.0 acceptance. Tagging and publication SHALL require separate explicit authorization; artifact preparation alone SHALL NOT publish either channel. + +#### Scenario: Candidate channels are compared +- **WHEN** packed npm and extracted SEA are accepted for a candidate +- **THEN** source revision, reported version and observable functional outcomes SHALL match +- **AND** no publication SHALL occur without its separate authority diff --git a/openspec/changes/reset-cadder-architecture/specs/documentation-experience/spec.md b/openspec/changes/reset-cadder-architecture/specs/documentation-experience/spec.md new file mode 100644 index 0000000..ca743d5 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/documentation-experience/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: DOC-001: User documentation follows the retained journey +Astro/Starlight documentation SHALL describe verified Node routes-first TUI, existing CLI lifecycle/project/domain/inspection commands, bounded diagnostic logs and coherent transition workflows in English, without exposing private/test-only context. Published 2.0 instructions SHALL only advertise flows that pass actual product acceptance. + +#### Scenario: User follows a Node guide +- **WHEN** a user follows a documented 2.0 workflow +- **THEN** commands SHALL exist in the tested product rather than fixture handlers +- **AND** old Rust data SHALL remain protected during the documented transition + +### Requirement: DOC-002: Contributor tooling is direct and reproducible +Contributor documentation SHALL use the exact Node/Nub TS, OpenSpec, Astro, coverage and npm/SEA workflow, replacing Bun and obsolete Rust tasks after the cutover gate. Sources SHALL remain TS/TSX and generated artifacts SHALL not be committed. SEA's separate Node builder SHALL not change npm's Node minimum. + +#### Scenario: Contributor prepares artifacts +- **WHEN** a contributor follows the documented pack verification workflow +- **THEN** both channels SHALL use the shared sources and outside-checkout staging +- **AND** no loader/build SHALL be required during consumer npm installation + +### Requirement: DOC-004: Installation guidance distinguishes npm, Cadder, and Caddy +Verified npm and standalone GitHub downloads SHALL be presented as equal channels with the same functionality/version. npm SHALL document Node 24 LTS minimum 24.18.0; standalone SHALL require no installed Node/npm. Both SHALL identify upstream Caddy as separate. Unpublished/unverified versions SHALL not be presented as available, and SEA's mechanism status SHALL not demote its channel. + +#### Scenario: Consumer chooses standalone +- **WHEN** the consumer selects a supported GitHub archive instead of npm +- **THEN** instructions SHALL offer the same product workflow without Node installation +- **AND** neither channel SHALL be described as secondary or optional diff --git a/openspec/changes/reset-cadder-architecture/specs/local-control-plane/spec.md b/openspec/changes/reset-cadder-architecture/specs/local-control-plane/spec.md new file mode 100644 index 0000000..ea9f6b5 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/local-control-plane/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: IPC-001: Local transport is owner authenticated and bounded +Cadder SHALL use NDJSON frames no larger than 1 MiB over Unix sockets or Windows named pipes with bounded connections/timeouts, owner-only protected runtime/secret storage and security policy 2. Both peers SHALL authenticate using fresh direction-bound HMAC challenges before RPC. Secret bytes SHALL NOT cross IPC. Unsafe ownership, links, modes or ACLs SHALL fail closed rather than be silently repaired or weakened. + +#### Scenario: Spoofed endpoint or unsafe secret file +- **WHEN** endpoint proof fails or the secret/runtime protection is unsafe +- **THEN** the client or daemon SHALL reject contact without performing RPC +- **AND** it SHALL NOT transmit the secret or repair the unsafe boundary implicitly + +#### Scenario: Replay or reflected proof +- **WHEN** a prior proof or authenticated RPC frame is replayed or reflected +- **THEN** fresh challenge or directional sequence validation SHALL reject it +- **AND** no product mutation SHALL execute + +### Requirement: IPC-002: Handshake requires one exact version +Every connection SHALL validate protocol 3 and security policy 2 during mutual authentication and SHALL reject mismatches before product dispatch. The Node product SHALL NOT communicate with Rust IPC or negotiate legacy capabilities. + +#### Scenario: Rust daemon remains running +- **WHEN** a Node client encounters an old Rust endpoint or incompatible peer +- **THEN** it SHALL NOT attach or mutate it +- **AND** isolated v2 discovery SHALL preserve the old runtime and its data + +### Requirement: IPC-003: One typed envelope carries eight operations +RPC SHALL preserve the eight released operations: register, unregister, heartbeat, query state, set entrypoint activation, set domain activation, query logs and shutdown. Zod SHALL validate their closed method/payload/result/error catalog; unknown methods/fields SHALL be rejected. Each response SHALL correlate the request and contain exactly one result or typed error. CLI/TUI SHALL use one shared client service. Migration SHALL NOT add history, IIS, autostart or presentation-specific RPC methods. + +#### Scenario: Malformed product request +- **WHEN** a request violates its method schema or names an unknown method +- **THEN** it SHALL receive a bounded typed rejection without invoking a handler +- **AND** valid responses SHALL contain exactly one correlated outcome diff --git a/openspec/changes/reset-cadder-architecture/specs/operator-cli/spec.md b/openspec/changes/reset-cadder-architecture/specs/operator-cli/spec.md new file mode 100644 index 0000000..8374ca1 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/operator-cli/spec.md @@ -0,0 +1,21 @@ +## MODIFIED Requirements + +### Requirement: CLI-001: The operator exposes focused developer workflows +Commander-based cadder SHALL preserve the released 1.0.5 help/version, status, daemon lifecycle, project/domain controls, port/Caddyfile inspection, diagnostics and bounded redacted log commands, with stable arguments, human-readable output and exit status through the shared client service. Bare cadder SHALL print help successfully without starting TUI or the daemon, regardless of TTY. cadder tui SHALL open the routes workspace without implicit daemon startup; its explicit --start-daemon option SHALL use the shared attach-first bounded launch contract. Explicit daemon start/restart and managed caddy run SHALL retain their launch policy. Other state and inspection commands SHALL remain attach-only. Profile, machine-output, history, export, continuous tail, watch, IIS and autostart commands SHALL be rejected as unsupported. + +#### Scenario: Bare invocation +- **WHEN** cadder has no subcommand, with or without a TTY +- **THEN** it SHALL print help successfully without starting a daemon or interactive session + +#### Scenario: Explicit TUI startup option +- **WHEN** the user runs cadder tui --start-daemon +- **THEN** Cadder SHALL start or attach through the bounded shared launch path before opening the TUI + +#### Scenario: CLI state command while daemon is offline +- **WHEN** a state command cannot attach +- **THEN** it SHALL report actionable offline state with the stable exit contract +- **AND** it SHALL NOT start a daemon implicitly + +#### Scenario: Unsupported expansion is requested +- **WHEN** the user requests machine output, history, export, continuous tail, watch, profiles, IIS or autostart +- **THEN** Cadder SHALL reject the unsupported input rather than restoring an older command surface diff --git a/openspec/changes/reset-cadder-architecture/specs/operator-tui/spec.md b/openspec/changes/reset-cadder-architecture/specs/operator-tui/spec.md new file mode 100644 index 0000000..2ca23bf --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/operator-tui/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: TUI-001: One routes-first workspace exposes current state +Ink/React TUI SHALL render real daemon-backed projects and domains in one routes-first primary table through the same client service used by CLI, without production mocks or random statuses. Domain rows SHALL use tree connectors beneath their project, with no empty separator rows between projects. Collections SHALL support scrolling without fixed lengths. The TUI SHALL NOT add separate activity, logs, history, IIS or autostart views; explicit diagnostics and bounded logs SHALL remain CLI surfaces. + +#### Scenario: Operator changes a domain +- **WHEN** the user changes activation in the routes workspace +- **THEN** the TUI SHALL render committed daemon state without unrelated diagnostic details +- **AND** fixture data SHALL be limited to tests/development, not production + +### Requirement: TUI-003: Lifecycle actions are explicit +TUI SHALL offer idempotent explicit start while offline, confirmed bounded stop and ordered restart with pending/success/failure states. Startup SHALL follow the shared client launch boundary. Opening cadder tui without --start-daemon SHALL NOT imply daemon start; the explicit --start-daemon option SHALL start or attach before rendering. + +#### Scenario: Offline TUI opens +- **WHEN** the user opens cadder tui without --start-daemon and no daemon is running +- **THEN** TUI SHALL display offline state and offer an explicit start action +- **AND** restart SHALL observe prior shutdown before starting the replacement diff --git a/openspec/changes/reset-cadder-architecture/specs/product-topology/spec.md b/openspec/changes/reset-cadder-architecture/specs/product-topology/spec.md new file mode 100644 index 0000000..749b797 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/product-topology/spec.md @@ -0,0 +1,22 @@ +## MODIFIED Requirements + +### Requirement: TOP-001: Three executables form one installation +Cadder SHALL provide version-matched cadder, cadderd and caddy entrypoints from one TypeScript product in both npm and standalone distributions. The daemon SHALL be the sole runtime/state/process owner. Runtime v2 SHALL be owner-protected and installation-specific, with deliberately isolated dev/test runtimes; separate installation directories SHALL retain independent endpoints and application databases. Rust/Cargo SHALL be removed from the final product only after complete Node functionality and both release gates. + +#### Scenario: Distribution channel changes +- **WHEN** the owner uses npm or standalone Cadder of the same version +- **THEN** both SHALL expose the same product contracts and owner runtime policy +- **AND** clients SHALL NOT create independent Caddy or persistent state owners + +#### Scenario: Separate installations +- **WHEN** the same owner runs Cadder from two different installation directories +- **THEN** each installation SHALL use an independent endpoint and application database +- **AND** each installation's three entrypoints SHALL agree on its runtime identity + +### Requirement: TOP-002: Product and diagnostic surfaces remain distinct +Cadder SHALL expose CLI and TUI workflows through one client service/model boundary, with diagnostics requested explicitly. Production state SHALL come from the daemon, not mocks or random values. The current routes-first TUI and bounded CLI diagnostic surfaces SHALL remain distinct. This migration SHALL NOT restore superseded history, IIS, autostart, machine-output, profile, export, watch or continuous-tail surfaces, or include MCP, Web UI, Tauri, Bun, custom WASM, native runtime addons or new installers. + +#### Scenario: Client renders project state +- **WHEN** CLI or TUI renders a project, domain or lifecycle result +- **THEN** both SHALL use the same daemon-backed model and canonical state +- **AND** neither SHALL inspect daemon storage or Caddy administration directly diff --git a/openspec/changes/reset-cadder-architecture/specs/project-registration/spec.md b/openspec/changes/reset-cadder-architecture/specs/project-registration/spec.md new file mode 100644 index 0000000..34394ff --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/project-registration/spec.md @@ -0,0 +1,18 @@ +## MODIFIED Requirements + +### Requirement: REG-001: Registration belongs to one live shim session +Managed run SHALL register a validated entrypoint owned by a session nonce, renew it through heartbeat, reconnect safely and unregister on clean exit or lost ownership. Ctrl+C, expired heartbeat and daemon restart SHALL not leave a stale active route. Another session SHALL NOT alter the current owner's lease. + +#### Scenario: Old shim detaches a replacement session +- **WHEN** a stale shim sends heartbeat or unregister for a replacement owner +- **THEN** the daemon SHALL reject it and preserve current registration + +## ADDED Requirements + +### Requirement: REG-004: Existing project formats and controls remain compatible +The Node product SHALL preserve existing cadder.toml and Caddyfiles, documented configuration precedence, multi-project registration, domain filtering and activation workflows. It SHALL validate inputs through the shared contract and reject active domain conflicts without replacing the last committed owner. + +#### Scenario: Existing project is registered after transition +- **WHEN** the user registers an existing configuration through Node managed run +- **THEN** the daemon SHALL accept its retained format without a project rewrite +- **AND** active domain conflicts SHALL preserve the previous committed state diff --git a/openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md b/openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md new file mode 100644 index 0000000..ce12b10 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/quality-tooling/spec.md @@ -0,0 +1,55 @@ +## MODIFIED Requirements + +### Requirement: QT-001: Declarative contributor environment +The Node product SHALL declare exact dependencies, tool versions and locks, managed by Nub, without private paths/credentials. TypeScript SHALL be 6.0.3; the npm-tested Node minimum SHALL be 24.18.0 on Node 24 LTS. SEA SHALL use a separately verified, exact Node 26 builder pin. Required tools SHALL work on Windows/Linux/macOS without a custom compiled task runner after cutover. + +#### Scenario: Fresh Node-only checkout +- **WHEN** a contributor follows the declared setup after Rust removal +- **THEN** Node tests/build/docs/packaging SHALL work without Cargo/Rust or private setup + +### Requirement: QT-002: One discoverable task surface +Node validation/build/pack tasks SHALL be discoverable as Nub package scripts, with focused commands and a complete check. Existing tool provisioning MAY remain as a thin adapter during cutover, not a competing policy framework. Custom scripts SHALL be limited to Cadder-specific packaging/acceptance seams. + +#### Scenario: Contributor runs complete checks +- **WHEN** the documented Node check/pack verification commands run +- **THEN** each SHALL delegate to its owning maintained tool +- **AND** there SHALL be no replacement general-purpose custom command dispatcher + +### Requirement: QT-003: Maintained tools own validation and release formats +tsc noEmit, ESLint, Prettier, Vitest/V8 and ink-testing-library SHALL own their checks; own TS/TSX line coverage SHALL be at least 85%, including hard runtime modules without selective exclusion. npm emission SHALL use tsc with relative import rewriting; SEA bundling SHALL use esbuild and the pinned Node builder. All generated Node JS/bundles/archives/coverage SHALL go to system or runner temp outside checkout, never beside source or into tracked files. Astro/Starlight SHALL own documentation checks/builds with Nub-managed dependencies. + +#### Scenario: Pack verification runs +- **WHEN** the same source is checked and packed for either channel +- **THEN** the supported tools SHALL produce only outside-checkout Node artifacts +- **AND** tracked files SHALL remain unchanged with no emitted source-side JS + +### Requirement: QT-004: Local and CI task parity +CI SHALL invoke the same checks and non-publishing packing process as local verification, with native Windows/Linux/macOS system/security integration and every SEA target. PR jobs SHALL not publish or receive publication credentials. Native exact-artifact verification SHALL precede attest/publish stages with least privilege and source/tag validation. Documentation examples SHALL be tested. + +#### Scenario: Candidate PR is validated +- **WHEN** CI verifies a migration or release candidate +- **THEN** it SHALL check contracts, coverage, native product behavior and both channels +- **AND** artifact preparation SHALL NOT create a public release or registry publication + +### Requirement: QT-005: Evidence-gated task-runner removal +The Rust/Cargo product and obsolete native npm/release packaging SHALL be removed only after complete Node functionality and both distributions pass acceptance at the candidate revision. Each old responsibility SHALL have a verified replacement or explicitly approved removal. Unrelated pre-existing untracked npm content SHALL be inspected and preserved. After removal, the full Node-only gate SHALL pass again without Rust/Cargo installed. + +#### Scenario: Rust removal is proposed +- **WHEN** one required capability, platform or distribution still lacks evidence +- **THEN** Rust removal SHALL wait and migration SHALL remain incomplete +- **AND** a foundation checkpoint SHALL NOT be represented as the finished migration + +### Requirement: QT-006: npm packages pass clean-room verification +The gate SHALL verify the actual single npm tarball and every extracted SEA archive outside checkout using the publication packing process without publishing. It SHALL verify exact allowed files, version/license/repository identity, all three entries, local/global npm installs, argument/stream/exit fidelity and no wrapper recursion. npm SHALL have no platform optional packages or install scripts; SEA SHALL pass real TUI/SQLite/worker/child smoke without Node/npm PATH. + +#### Scenario: Clean consumer verification fails +- **WHEN** either channel fails its native packed-consumer catalog +- **THEN** neither the whole migration nor matching final release SHALL be accepted + +### Requirement: QT-007: npm publication uses staged trusted publishing +After separate publication approval, the single npm package SHALL retain short-lived OIDC trusted publishing from the exact protected GitHub workflow, staged publishing and maintainer 2FA review, without long-lived npm tokens. The consumed candidate SHALL match the verified source revision/version and complete npm/SEA evidence. The obsolete native-platform-package approval order SHALL not remain a dependency of the new single package. + +#### Scenario: Authorized candidate reaches publishing +- **WHEN** the separately approved matched candidate enters the publishing workflow +- **THEN** it SHALL stage the exact verified single package with trusted provenance +- **AND** a maintainer SHALL approve publication without an embedded persistent token diff --git a/openspec/changes/reset-cadder-architecture/specs/runtime-storage/spec.md b/openspec/changes/reset-cadder-architecture/specs/runtime-storage/spec.md new file mode 100644 index 0000000..20e9ba1 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/specs/runtime-storage/spec.md @@ -0,0 +1,39 @@ +## RENAMED Requirements + +- FROM: `### Requirement: STO-003: Schema compatibility is exact before 1.0` +- TO: `### Requirement: STO-003: The Node schema is validated before mutation` +- FROM: `### Requirement: STO-004: Legacy cleanup is allowlisted` +- TO: `### Requirement: STO-004: Old runtime data remains untouched` + +## MODIFIED Requirements + +### Requirement: STO-001: One owner-protected SQLite database is authoritative +The Node daemon SHALL own a protected v2 application-state and diagnostic-log database using node:sqlite, separate from the lifetime exclusion database. No client SHALL directly access either database. Initial schema creation SHALL be transactional and finish before product operations are accepted. + +#### Scenario: Empty v2 application database +- **WHEN** the Node daemon first creates application-state and diagnostic-log storage +- **THEN** it SHALL create and validate its schema under owner-only protection +- **AND** the separate SQLite exclusion connection SHALL remain held + +### Requirement: STO-002: Database work is single flight and bounded +One worker SHALL serialize application-state and diagnostic-log database work, with bounded jobs, transaction rollback, integrity checks and orderly close. Worker failures SHALL settle pending requests with typed errors and SHALL NOT release daemon exclusion while owned work/resources remain active. + +#### Scenario: Worker or transaction fails +- **WHEN** persistence cannot finish successfully +- **THEN** uncommitted data SHALL be rolled back and pending callers informed +- **AND** shutdown SHALL settle worker work before releasing runtime exclusion + +### Requirement: STO-003: The Node schema is validated before mutation +Startup SHALL initialize only an empty uninitialized Node database, and SHALL reject partial, corrupt or unsupported/newer schemas before runtime mutation. It SHALL NOT delete unrelated entries to recover from schema failure. + +#### Scenario: Partial schema exists +- **WHEN** database objects or integrity do not match the supported Node schema +- **THEN** startup SHALL fail with a storage diagnostic without destructive repair + +### Requirement: STO-004: Old runtime data remains untouched +The Node migration SHALL NOT import, delete or implicitly clean up the Rust runtime or database. Projects SHALL re-register from existing configuration. Old releases and data SHALL remain available for rollback; stable intent SHALL NOT resurrect a live session. Durable logs and state SHALL NOT introduce a history command or view. + +#### Scenario: Node first starts beside old data +- **WHEN** old Rust runtime data exists during Node startup +- **THEN** Node SHALL use its separate v2 storage and leave the old data untouched +- **AND** project routes SHALL become live only through current registration diff --git a/openspec/changes/reset-cadder-architecture/tasks.md b/openspec/changes/reset-cadder-architecture/tasks.md new file mode 100644 index 0000000..6989161 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/tasks.md @@ -0,0 +1,110 @@ +# Cadder 2.0 implementation tasks + +Only Node migration work is counted here. Previous N1-N9 map to groups 1-9; +historical Rust checkboxes are available in Git snapshot `f850418` and the +archived Rust reset. Checked foundation tasks cite existing evidence, not a +completed product. Every remaining task needs its stated verification before +its checkbox can be checked. Gate dependencies are in `acceptance.md`. + +## 1. Contract, behavior catalog and TypeScript base (G1) + +- [x] 1.1 Reconcile and strictly validate proposal, Node design, delta specs, release story, tasks and acceptance against the corrected released-1.0.5 functional scope; review that superseded features no longer govern this change. Documentation-only evidence is in verification.md under Scope correction verification; prior broader planning evidence is superseded. +- [x] 1.2 Establish the root TS/TSX ESM workspace with exact Nub dependencies/lock, TypeScript 6.0.3, typecheck, lint, formatter, Vitest/V8 and outside-checkout coverage; foundation evidence is in verification.md. +- [ ] 1.3 Freeze fixtures for released 1.0.5 CLI grammar/exit codes and human output, bare help, tui --start-daemon, unsupported-command rejection, configuration precedence, shim argument streams, project/domain schemas and eight-operation IPC; compare expected Node behavior with current source evidence, not superseded historical scope (CLI-001, REG-004, SHIM-001, INSPECT-001..004). +- [x] 1.4 Review and archive the contract deltas into main specs before further product implementation; preserve the open roadmap as planning, not completed implementation, and create only one focused implementation slice at a time, referencing stable requirement IDs rather than duplicating contracts. Spec-only synchronization, independent delta-fidelity review and strict validation are recorded in verification.md; close-node-rpc-catalog references IPC-001..003 without duplicating requirements. +- [x] 1.5 Define mockable Caddy, platform, storage and client-service ports, typed method/result/error catalog and ownership/import boundaries; prove with contract tests before handlers depend on them (TOP-001/002, IPC-003). Parent 130-test/96.85% check and independent MiMo Flash PASS are recorded in verification.md under Ports and lifecycle cleanup verification. + +G1 exit: reviewed target, reproducible tooling and compatibility fixture catalog. +Task 1.3 and runtime gates remain open; contract-test evidence closes 1.5 only. + +## 2. Runtime, exclusion and authenticated IPC (G2) + +- [x] 2.1 Implement isolated v2 paths, owner-only secret creation and fail-closed Unix/Windows permission adapters; unit/regression evidence exists (IPC-001, RUN-005). +- [x] 2.2 Hold a separate SQLite BEGIN EXCLUSIVE lifetime lock; recover diagnostic residue only after acquisition and release resources after failed start/shutdown; exclusion and crash tests exist (RUN-001). +- [x] 2.3 Implement protocol 3/security policy 2 mutual HMAC with fresh directional challenges and bounded, sequenced NDJSON transport; fake endpoint, wrong proof, replay and pre-auth RPC tests exist (IPC-001/002). +- [x] 2.4 Tighten the generic fixture RPC into the closed product catalog with Zod, bounded params/results, unknown-method/field rejection and exactly one correlated result/error; test malformed and incompatible peers (IPC-003). Parent check, Windows compiled smoke and independent MiMo reviews are recorded in verification.md under Closed RPC verification; this does not close G2. +- [ ] 2.5 Verify startup/teardown phase failure cleanup, runtime link/owner/ACL denial and full disk/storage failure without claiming unsafe state ready; verify independent endpoints/databases for separate installations using targeted fault tests plus native filesystem cases (TOP-001, RUN-001/003, STO-003). +- [x] 2.6 Run compiled-JS child/process/SQLite/socket gates on Windows x64, Linux x64 and macOS arm64 without a TS loader; existing CI evidence is linked in verification.md. +- [x] 2.7 Verify explicit-owner root daemon contact by a normal owner and denial to another account on native Linux/macOS; existing system evidence is linked in verification.md (RUN-005). +- [ ] 2.8 Execute the isolated Windows Sandbox gate on the completed product for real UAC, same-SID user-to-elevated contact, different-account denial and cancellation; share final product evidence with 7.4 and record results without changing host UAC/accounts/IIS (RUN-005, IPC-001). User-approved sequencing defers execution until the product is ready; this is not a passed or waived requirement. +- [ ] 2.9 Record G2 acceptance for the exact final product source revision, with concurrency, forced crash/restart, stale/misleading metadata, replay, spoofed endpoint and unsafe ACL cases; require all native/security evidence before G7 acceptance, Rust removal or release, not before group 3 implementation. + +G2 is currently open. Prepared Sandbox scripts/packages do not satisfy 2.8. +The approved continuation implements groups 3-6 against delivered contract/runtime +boundaries while tracking open G1/G2 evidence. Deferred acceptance is not permission +to weaken security or skip checks for changed behavior. + +## 3. Caddy adapter and transactional updates (G3, requires implemented contract/runtime boundaries) + +- [x] 3.1 Port smol-toml configuration and Caddyfile adaptation/validation with fixture-proven precedence and bounded output/process cleanup; test invalid files and retained formats (REG-004, CADDY-001/005). Focused migrate-caddy-preparation evidence: final 352-test/96.34% Node check, native Windows owned Job and bounded staging tests; native Unix/real-Caddy acceptance remains 3.9/G3, not inferred. +- [x] 3.2 Port trusted real-Caddy resolution, executable pinning and npm-wrapper/SEA/file-identity recursion rejection; test aliases, PATH ordering and untrusted selectors (CADDY-001, SHIM-003). Focused migrate-caddy-preparation evidence includes hardlink/reparse, mutation/replacement, quoted/relative PATH and shared-pin cancellation regressions; built distribution proof remains G6. +- [x] 3.3 Port multi-project composition, canonical domain ownership/filtering and exact-host/loopback guards; test disabled domains, conflicting projects and hostless sibling routes (CADDY-002/004, REG-002). Focused migrate-caddy-composition evidence: 63 pure JSON tests, final 415-test/96.81% Node check and independent MiMo review with terminal-chain repair re-review; this delivers a route plan, not apply/server or whole G3 acceptance. +- [x] 3.4 Implement the single prepare/validate/apply/verify/persist/publish queue and pending-state isolation; use concurrent fake-adapter mutations to prove serial ordering (CADDY-003). Focused migrate-caddy-transactions evidence: 65 fake-adapter tests, final 480-test/96.97% Node check, immutable committed snapshots and independent readback before DesiredState-only persistence/publication. Actual secure Caddy/storage/handler integration remains later work. +- [x] 3.5 Keep last-known-good on rejection and restore after post-apply persistence failure; test rollback failure fencing and active-state reconciliation after an ambiguous response before another change (CADDY-003). Focused evidence covers initial idle rollback, recovery failures, FIFO fenced refusal and explicit queued reconciliation; MiMo Pro found no P0/P1, and its null-observation contract note was clarified and regression-tested. Whole CADDY-003/G3 acceptance remains open. +- [ ] 3.6 Generate protected root/intermediate CA and authorized client material with X.509/WebCrypto; let Caddy's native internal issuer obtain and renew the admin server certificate, with its identity storage inside the protected runtime boundary. Test certificate validation and material ownership without system trust installation (CADDY-006). +- [ ] 3.7 Configure loopback mTLS admin and prevent project admin overrides/plaintext fallback; prove real Caddy denies no/wrong certificates and accepts only the authorized client (CADDY-006). +- [ ] 3.8 Port start/reload/inspect/graceful stop and bounded forced teardown using owned child handles only; prove unrelated processes survive success, error, Ctrl+C and shutdown (RUN-003, CADDY-002). Focused migrate-caddy-lifecycle evidence: owned backend, existing-endpoint refusal, explicit queue recovery, Windows Node ownership fixtures and parent 780-test/96.14% Node check; independent F1/F2 corrections accepted. Native real-Caddy lifecycle, Unix execution and full daemon/shim Ctrl+C/shutdown acceptance remain open; see the focused verification.md. +- [ ] 3.9 Pass multi-project registration/reload/rejection/rollback/ambiguity scenarios with fake adapters and real Caddy on supported native platforms; record G3 evidence. + +## 4. Shim and current inspection adapters (G4, requires G3) + +- [ ] 4.1 Freeze and port managed/read-only/passthrough/unsupported command policy; test unknown command refusal, argument arrays, stdin/stdout/stderr and exact exit status (SHIM-001). +- [ ] 4.2 Add managed missing-daemon startup with version-matched packaged entry resolution; prove no independent Caddy fallback and no PATH daemon substitution in npm or SEA (RUN-002, SHIM-002). +- [ ] 4.3 Port registration session nonce, heartbeat, reconnect, expiry and cleanup; test stale owner, simultaneous projects, Ctrl+C and daemon restart (REG-001/003). +- [ ] 4.4 Port operator port/Caddyfile inspection and PID-revalidated termination into small non-native-addon platform adapters; prove attach-only behavior and daemon ownership isolation (INSPECT-001..004). +- [ ] 4.10 Verify native shim/inspection parity and record G4 evidence, including lost-session cleanup and PID revalidation without expanding daemon ownership (SHIM-001..003, REG-001, INSPECT-001..004). + +Former tasks 4.5-4.9 are withdrawn, not completed: IIS and autostart are outside +the corrected migration scope. Retain existing task IDs for evidence continuity. + +## 5. State, diagnostic logs, shared client and routes CLI/TUI (G5, requires G4) + +- [x] 5.1 Implement the single node:sqlite application-state/diagnostic-log worker with schema creation, integrity validation, bounded jobs and orderly close; test corrupt/newer schemas, rollback and worker errors (STO-001/002/003). +- [x] 5.2 Persist stable desired state and bounded diagnostic logs without restoring live sessions or introducing history queries; prove the old Rust runtime/database is neither imported nor deleted (STO-004, REG-003, OBS-001/002). +- [x] 5.3 Port pre-write redaction and deterministic log retention, including supported text/JSON/URL credentials and private keys; test boundaries and terminal-safe presentation (OBS-001/002/004). +- [x] 5.4 Preserve bounded canonical runtime/project/domain stream queries and correct domain attribution; test 1-200 limits, newest-row selection and ascending order, without source/severity/all-log filters or TUI log views (OBS-003). +- [x] 5.5 Implement shared client-service methods/models over the eight-operation RPC catalog for current lifecycle, projects, domains, diagnostics and bounded CLI logs; test offline/malformed states without importing daemon storage or adding RPC methods (IPC-003, TOP-002). +- [x] 5.6 Implement Commander released-command parity and human-readable output/exit contracts against the group 1 catalog; test arguments, snapshots and rejection of removed commands/options from fake client responses (CLI-001). +- [x] 5.7 Preserve attach-only state/inspection commands and explicit daemon start/restart, managed shim startup, tui --start-daemon and offline-TUI start; test bounded launch behavior and bare help with/without TTY never spawning a daemon (CLI-001, RUN-002). +- [ ] 5.8 Implement the current routes-first Ink/React TUI through the same client service with real state, tree rows and no separate activity/logs/history/platform views; remove production mocks/random statuses and test Space/Enter toggles, offline Enter-to-start, pending actions and small terminals (TUI-001/002/003/004). +- [ ] 5.9 Preserve bare cadder help on TTY/non-TTY and explicit cadder tui with optional --start-daemon; prove terminal restoration on normal exit, error and Ctrl+C with rendering tests plus a real terminal smoke (CLI-001, TUI-004). +- [ ] 5.10 Pass an actual Node daemon/shim/operator journey with multiple projects, domain changes, bounded CLI logs and shutdown; record G5 parity against the catalog, not only fixture-runtime success. + +## 6. Equal npm/SEA distributions and documentation (G6, requires G5) + +- [x] 6.1 Emit npm JS using tsc import-extension rewriting exclusively into external staging; generate the consumer manifest and three bin entries with matching version/license/repository/assets (DIST-004, QT-003). +- [ ] 6.2 Pack one npm tarball with the publication packer and verify its file allowlist, no optional platform packages/postinstall/download/build/loader/real Caddy; test local/global installation and PATH recursion outside checkout (QT-006, DIST-004). +- [x] 6.3 Select, verify and pin the Node 26 SEA builder patch independently of the Node 24.18.0 npm minimum; record source/runtime compatibility and builder inputs (QT-001, DIST-001). +- [x] 6.4 Bundle the same sources with esbuild and build three SEA entries plus assets outside checkout; prove state/log SQLite workers and owned child launch without a system Node executable (DIST-002). +- [ ] 6.5 Build Windows x64, Linux x64, macOS x64 and arm64 archives with checksums/provenance and exact accepted asset sets; reject version/source drift (DIST-001/002/005). +- [ ] 6.6 Run native SEA smoke for every variant with Node/npm absent from PATH, covering all entries, actual CLI/TUI, SQLite workers, Caddy adapter children and platform helper launch (DIST-002, QT-006). +- [ ] 6.7 Run the same current-scope functional catalog through packed npm and extracted SEA on native runners, including local/global npm wrappers and daemon start/restart; record cross-channel parity (DIST-005). +- [x] 6.8 Move Astro/Starlight dependencies and tasks to Nub with exact locks; replace Bun/legacy instructions and document both channels equally, including separate Caddy prerequisite and safe transition (DOC-001/002/004). +- [ ] 6.9 Build/check docs from the supported workflow and verify each public command/example against real product acceptance, without fixture/private/hidden diagnostic seams (DOC-003/004). +- [ ] 6.10 Make CI validate the same non-publishing pack process used for publication, with native archive verification and least-privilege/provenance safeguards; record G6 only after both channels pass (QT-004/006/007). + +## 7. Whole-product acceptance (G7, requires G6) + +- [ ] 7.1 Enforce >=85% line coverage for own TS/TSX, including hard runtime/platform/queue modules; run contract/config/log/CLI/TUI tests with fake adapters and publish the exact source coverage evidence (QT-003). +- [ ] 7.2 Pass Windows/Linux/macOS native integration for independent installation runtimes, simultaneous starts, forced crash recovery, many projects, reload/rollback, heartbeat, Ctrl+C and bounded shutdown (TOP-001, RUN-001/003, REG-001, CADDY-003). +- [ ] 7.3 Pass wrong-secret, replay, spoofed endpoint, other-user, unsafe ACL and ordinary-owner-to-elevated-daemon cases, plus real Caddy mTLS denial without a client certificate (IPC-001/002, RUN-005, CADDY-006). +- [ ] 7.4 Close real Windows Sandbox owner/elevated access, UAC cancellation and other-account denial for the completed actual product, not only the runtime fixture; use the same final product evidence to close 2.8, and verify that no IIS/autostart operations were introduced (RUN-005, IPC-001, TOP-002). This is mandatory final acceptance, deferred from intermediate implementation by the user's decision. +- [ ] 7.5 Verify pack/build leaves tracked files unchanged and emits no generated Node JS/bundles/archives in checkout; review dependency exactness and prohibited runtime dependencies (QT-003/006). +- [ ] 7.6 Reconcile every requirement and approved-plan row in acceptance.md with actual evidence at one release-candidate revision; any missing channel/platform/capability keeps G7 open. + +## 8. Replace and remove the Rust product (G8, requires G7) + +- [ ] 8.1 Inventory Cargo/workspace/crates, old npm native packages, release scripts/workflows, Rust fixtures and documentation references; map each retained behavior to Node evidence or an approved removal, preserving original Git history (QT-005). +- [ ] 8.2 Inspect and classify the pre-existing npm directory before replacing its tracked legacy content; preserve unrelated untracked files and private operational state (QT-005). +- [ ] 8.3 Remove Rust source/workspace, Cargo manifests/lock/toolchain/tasks and obsolete packaging only after 8.1/8.2; retain necessary non-generated behavioral fixtures in the Node tests (TOP-001, QT-005). +- [ ] 8.4 Replace Cargo CI/release gates with Node/npm/SEA validation and small Cadder-specific packaging scripts; move shared release version-source configuration from retired native manifests to the Node product after verifying the release adapter (QT-003/004/007). +- [ ] 8.5 Update shared contributor/architecture guidance and remove stale Rust/Bun/xtask claims from the current product workflow while preserving historical archives (DOC-002). +- [ ] 8.6 Re-run complete tests, coverage, docs, packed npm and every SEA gate from the Node-only source revision and consumers without Rust/Cargo installed; ensure no package/build/runtime task depends on deleted code (QT-005). +- [ ] 8.7 Record G8: no permanent parallel Rust/Node product, no tracked generated JS, all approved functionality present and both equal distributions ready. Do not mark the migration complete at an earlier foundation checkpoint. + +## 9. Release preparation and explicit publication boundary (G9, requires G8) + +- [ ] 9.1 Verify the consumer transition checklist: stop the Rust daemon and re-register existing configurations; test leaving old runtime/database and rollback releases intact without adding IIS/autostart steps (DIST-003, STO-004). +- [ ] 9.2 Prepare matching 2.0.0-rc.1 npm/SEA artifacts and source-based release outcome/version manifests through the configured Arcantry adapter after its version sources are updated; no tag or publication (DIST-005). +- [ ] 9.3 Review artifact checksums, native evidence, docs and release story; reconcile the requirement ledger and separately request any missing commit/push/tag/npm/GitHub publication authority (QT-004/007). +- [ ] 9.4 Prepare 2.0.0 only after RC acceptance passes in both channels; retain old releases for rollback with no automatic deprecation, and publish only under separate explicit approval. diff --git a/openspec/changes/reset-cadder-architecture/verification.md b/openspec/changes/reset-cadder-architecture/verification.md new file mode 100644 index 0000000..7f7cfd4 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/verification.md @@ -0,0 +1,919 @@ +# Node migration verification + +## Current scope correction + +The user corrected functional scope to the released Rust 1.0.5 product in +TypeScript. Earlier planning results below describe the superseded broader plan +and are not validation of the corrected contract. Runtime evidence remains +historical implementation evidence only; this documentation change does not +re-run it or close any runtime gate. Current scope and checks are recorded in +`Scope correction verification` below. + +The Node-only roadmap is now in tasks.md and its contract/evidence mapping is in +acceptance.md. Previous N1-N9 correspond to groups 1-9. Historical Rust checkbox +counts are not evidence; original planning/task text remains in Git and archives. +The results below predate this documentation-only planning completion and must +be rechecked for affected implementation changes. New planning validation is +recorded separately rather than being counted as application acceptance. + +## Scope implemented + +The Node implementation currently covers the runtime/IPC foundation, not a +shippable 2.0 application. Product handlers, Caddy, shim, state/log storage, +current CLI/TUI and npm/SEA distributions are still pending. History, IIS, +autostart and machine output are excluded, not unfinished parity tasks. +The released Rust 1.0.5 workspace and packaging are retained from `master`; older Rust behavior evidence +is pinned in `behavior-baseline.md` rather than restored into production code. + +Implemented: isolated v2 paths and profiles, owner-only Unix modes and Windows +ACLs, SQLite `BEGIN EXCLUSIVE` lifetime lock, crash residue recovery only after +lock acquisition, protected persistent secret, protocol 3/security policy 2 +mutual HMAC, fresh directional challenges and sequenced authenticated RPC. +Framing, connection count and timeouts are bounded. A local runtime coordinator +owns startup, listener cleanup and lock release. Diagnostic metadata is not lock +authority and cannot authorize another account. + +## Current local evidence + +- Node 24.18.0 on Windows x64: typecheck, ESLint, formatting and strict OpenSpec + validation pass. +- Vitest: 29 tests pass, V8 own-code line coverage 90.30%. Runtime modules are + included; subprocess coverage is not used to inflate the percentage. +- Packed ESM fixtures run on plain Node, with `NODE_OPTIONS` removed and without + a TS loader. Windows x64 native gate passes. +- Linux x64 native gate passes in a local Linux environment: real Unix socket, + real file permissions, child-process exclusion, forced crash and recovery, + authenticated contact and orderly shutdown. +- Linux x64 privilege gate passes with a root daemon explicitly assigned to the + normal user's runtime. The normal user authenticates successfully; an existing + different account is denied; authenticated shutdown releases the runtime. +- Authentication tests cover fake server proof, wrong/reflected client proof, + proof replay, RPC replay/tampering and RPC before authentication. +- Unsafe ACLs are rejected instead of repaired. A failed discovery publication + leaves the pre-existing unsafe file unchanged and releases the listener/lock. + +## Required remaining runtime gate + +The execution-order restriction in this historical checkpoint is superseded by +`Approved final-product Sandbox sequencing` below. Its results and missing +privilege evidence are unchanged; no deferred test is recorded as passed. + +The [native CI matrix](https://github.com/MrMaxie/cadder/actions/runs/37324338555) +passes on Windows x64, Linux x64 and macOS arm64. It verifies typechecking, +linting, formatting, native compiled-JS IPC/SQLite/process smoke and coverage. +Own-code line coverage is 90.30% on Windows and 87.95% on Linux and macOS, with +runtime modules included. Unix runners also pass real same-owner normal-user +contact with a root daemon and different-account denial. + +Real same-owner user-to-elevated contact and different-user rejection remain +unverified on Windows, including Windows Sandbox. The interactive fixture and +read-only Sandbox package are prepared; preparation is not execution evidence. +Follow `windows-runtime-gate.md`. Do not mark N2 complete or port Caddy before +the Windows privilege gate passes. No host UAC policy, accounts, IIS bindings +or autostart settings are changed by preparing the package. + +## Commands + +```sh +nub ci +nub run typecheck +nub run lint +nub run format:check +nub run coverage +node scripts/prepare-runtime-gate.ts +``` + +The last command prints an outside-checkout staging directory containing test +fixtures. Run `node /runtime-smoke.mjs /runtime-child.mjs` with the +native platform Node 24.18.0. These fixtures are not npm or SEA release artifacts. +On Unix, run `node /runtime-privilege-smoke.mjs /runtime-child.mjs +/runtime-denied-client.mjs` as a non-root user with noninteractive sudo. +Only disposable test runtime files and fixture processes are used. +Coverage output also stays in system/runner temp. No publication is performed. + +## Prior planning completion - 2026-10-05 (superseded scope) + +This earlier checkpoint changed only OpenSpec planning documents and closed the +then-current task 1.1, not G1, G2 or the migration. Its broader functional scope +has since been withdrawn. Current task 1.1 requires validation of the corrected +plan; the counts and results below are historical, not current-plan acceptance. + +- Compared the approved seven-stage migration and transition rules with the + acceptance ledger, nine dependency groups and 69 uniquely numbered tasks. + Historical Rust completions no longer count as Node implementation progress. +- Reviewed the shared Arcantry configuration and local OpenSpec schema/templates. + The major-impact release artifact records planned consumer outcomes, without + changing released version sources, manifests or the managed changelog. +- Verified requirement references against main and delta specs, exact rename + provenance, absence of duplicate IDs/task numbers and roadmap entry links. +- Verified that `openspec show reset-cadder-architecture --json --deltas-only` + retains all 45 full requirement statements across 15 capabilities, plus four + explicit rename deltas. Requirement paragraphs use one physical line because + the installed parser otherwise exposes only their first line. +- `openspec doctor`, both local schema validations, strict change validation and + `openspec validate --all --strict --no-interactive` pass: 16 items, zero failures. + `git diff --check` passes. Artifact readiness means documents exist, not that + implementation or consumer distributions have passed acceptance. + +The configured local Arcantry launcher cannot run because its Windows platform +package is missing. Configuration/templates were reviewed directly; no Arcantry +CLI release validation, installation or release writes were performed. + +No application tests, typechecks, builds or runtime execution were performed at +this documentation-only checkpoint. Runtime code, dependencies, CI and released +versions are unchanged. No spec sync/archive, commit, push, tag or publication +was performed. + +## Scope correction verification + +This documentation-only correction follows the user's decision to preserve the +current released product, not restore superseded features. It was checked on the +working tree based on `f850418a40991bb54c45e5fb56df1232d286c746`; the corrected +OpenSpec files are uncommitted. There are no new runtime or artifact results. + +- Compared current main CLI/TUI, IPC, observability, topology and storage specs + with the migration deltas and the released CLI grammar. Preserved bare help, + explicit tui --start-daemon, routes-only TUI, human output, eight operations, + bounded canonical log streams and independent installation runtimes. +- Withdrew IIS/AUTO/OBS-005 deltas and former tasks 4.5-4.9; removed their parity, + documentation and transition claims. Historical planning evidence above is + explicitly superseded rather than relabeled as current acceptance. +- Reviewed this correction against a pre-edit working-tree snapshot, preserving + unrelated existing edits. Updated design, roadmap, acceptance and planned + release outcomes consistently; no main-spec sync or archive was performed. +- `openspec doctor`, `openspec schema validate implementation` and + `openspec schema validate spec-driven` pass. +- `openspec validate reset-cadder-architecture --strict --no-interactive` and + `openspec validate --all --strict --no-interactive` pass; the whole view reports + 16 items, zero failures. `git diff --check` passes. +- `openspec show reset-cadder-architecture --json --deltas-only` exposes 39 + requirement statements across 12 capabilities and three rename deltas, with + nonempty requirement text and scenarios. An in-memory scope check confirms + unique requirement IDs and 65 unique task IDs, no withdrawn delta directories + and no IIS/AUTO/OBS-005 task references. +- Active Markdown LSP probes were unavailable because no server was ready; + they are not a clean diagnostic result. OpenSpec validation and manual review + are the completed documentation checks. + +This closes corrected planning task 1.1 only. Tasks 1.3-1.5, G1/G2 and all product +acceptance gates remain open. No application tests, builds, Sandbox execution, +version changes, commits, pushes, tags or publication were performed. + +## Reviewed contract synchronization and compatibility draft + +Source baseline: `93942af84f555eab2bd68e0a4a9db487ff82d040` on +`feat/node-runtime-migration`, with uncommitted spec/catalog changes. These are +working-tree checks, not exact-revision G2 acceptance or a released artifact. + +- Applied all 39 accepted delta blocks into main specs: 33 modified and six + added requirements, including three title renames. Main specs now contain 60 + requirements. The active migration roadmap was not archived. +- Independent MiMo review compared every changed block to its delta and confirmed + unchanged requirement content. Appending new requirements adds only a blank + separator after three previously final blocks; their content is unchanged. +- `openspec validate --specs --strict --no-interactive` passes: 15 specs. + `openspec validate --all --strict --no-interactive` passes: 17 items before + creation of the focused RPC implementation slice. +- Parent `nub run check` passes: 40 tests, 90.3% own-code line coverage, + typecheck/lint/format checks pass. The synchronized CLI text anchor was updated + without changing the retained behavior catalog. +- Built current untouched Rust 1.0.5 with + `cargo build --locked -p cadder-client -p cadder-shim`. No Rust source or Cargo + manifest/lock changes occurred. Redirected Windows x64 process captures show + bare help and missing nested subcommands exit 0 on stderr; explicit help and + version exit 0 on stdout; removed inputs exit 2 on stderr. Seven exact output + snapshots are retained in `test/compatibility/cli-output.json`. +- After correcting those measured stream/exit expectations, the focused catalog + suite passes 12 tests; typecheck, fixture formatting and git diff --check pass. + No TTY capture, populated process-output snapshots, real shim stream test or + Caddyfile adaptation result is claimed by these source-backed fixtures. +- `close-node-rpc-catalog` is the focused IPC-001..003 implementation slice. + It does not duplicate normative requirements or modify the separate retained + Rust release implementation change. + +After the focused slice was created, `mise run openspec-check` reports 17 passes +and one failure: OpenSpec 1.5 requires at least one delta for the configured +spec-free implementation schema. `cargo xtask openspec-check` is unavailable in +this checkout (no cargo alias or xtask manifest). No fake delta or new validator +was added. This is an open tooling limitation, not a failed main-spec sync. +The latest parent `nub run check` passes 41 tests with 90.3% line coverage. + +Task 1.4 is delivered. Task 1.3 remains open for missing compatibility evidence; +1.5, 2.4, 2.5, 2.8, 2.9 and G1/G2 remained open at that checkpoint. No Caddy port, +Sandbox privilege execution, version change, commit, push, tag or publication was +included. + +## Closed RPC verification + +Source baseline remains `93942af84f555eab2bd68e0a4a9db487ff82d040`; Node/spec +changes are uncommitted. Windows x64 uses Node 24.18.0 and Nub 0.7.5. This is +working-tree evidence, not one-revision native G2 acceptance. + +- The catalog admits exactly eight operations with strict Zod payload/result/error + DTOs and method-indexed client types. Unknown fields/methods do not invoke + handlers. Requests, nested results/errors and envelopes correlate IDs, with + exactly one result or error. Business rejection remains accepted:false. +- Decode accepts missing/null serde Option inputs. Canonical skip-serializing + fields omit null; other nullable fields are normalized as designed. Log input + default/clamping policy remains separate from the CLI default. +- Runtime fixtures use query-state and shutdown. Recovery is fixture stdout, not + a product snapshot property. No product handler, Caddy adapter, storage worker, + Rust interoperability or production mock was introduced. +- Independent MiMo Pro review passed the complete RPC seam. A subsequent parent + review found oversized valid responses could lose the correlated outcome. + The repair preserves send sequence on synchronous failure and sends one small + correlated frame error. MiMo Flash's resumed narrow review passed the repair; + its earlier timed-out attempt produced no acceptance evidence. +- Parent `nub run check` passes 67 tests in 12 files; own-code line coverage is + 91.78%. Typecheck, lint and formatting pass. The typecheck resolves the + reviewer's speculative unused-ts-expect-error concern: no TS2578 is reported. +- Parent `node scripts/prepare-runtime-gate.ts`, followed by + `node /runtime-smoke.mjs /runtime-child.mjs`, passes on win32/x64 + without a TS loader. Emission and disposable runtime files remain outside + checkout. This smoke is a fixture runtime gate, not npm/SEA product acceptance. +- Parent `openspec validate --specs --strict --no-interactive` passes 15 specs; + git diff --check passes. The spec-free implementation validation mismatch is + unchanged and is not hidden by adding duplicate deltas. + +This delivers task 2.4 only. Four port declarations exist but task 1.5 awaits +final review. Catalog gaps in 1.3, runtime failures/isolation in 2.5, real Sandbox +in 2.8, exact-revision/native reconciliation in 2.9 and G1/G2 remain open. A +non-frame send-failure test remains a review-noted coverage gap; source inspection +shows it fails closed. No Caddy port or external delivery action is authorized. + +## Ports and lifecycle cleanup verification + +At the same source baseline with intentional working-tree changes, the parent +reported `nub run check` passing 130 tests with 96.85% own-code line coverage. +Independent MiMo Flash review passed the repaired four-port contract: project +and domain activation round-trip, full adapted JSON carrier, method/result/error +types and import allowlist. This closes task 1.5, not the pending handler or +adapter implementation. + +Independent MiMo Pro review passed the lifecycle cleanup repairs. Parent tests +cover failed startup and shutdown cleanup for the runtime lock, protected files, +listener and discovery publication. These results are supplied review/parent +evidence; they are not a new native privilege or full-disk gate. + +## Installation identity and native filesystem boundaries + +The working tree still starts from `93942af84f555eab2bd68e0a4a9db487ff82d040`. +Before the identity change, a Windows source reproducer supplied two real +installation directories and measured identical directory, endpoint, lock and +application-database paths. The repaired default retains the per-user OS base +and adds `v2/<16-hex digest>` of the canonical physical installation root. +Physical aliases agree. npm derives the nearest cadder package anchor above the +shared module, not the shared Node executable, argv shim or working directory. +SEA identity follows the real executable's directory. Missing roots/anchors fail +closed. Explicit runtime-directory precedence, empty overrides and profile +behavior remain unchanged; the root-owner startup policy is untouched. + +Windows x64 source tests prove distinct endpoint, secret, SQLite lock, metadata, +discovery and application-database paths for two roots. Two real SQLite lifetime +locks and local listeners run simultaneously with authenticated clients; stopping +and restarting one leaves the other's client contact and exclusion intact. +Secret bytes and published diagnostics remain installation-specific. Application +storage independence is **path-only** until task 5.1; the two live databases in +this proof are lock databases, not application storage. + +Native Windows tests reject direct directory-junction/reparse substitutions at +the runtime directory, secret, lock, metadata and discovery boundaries. File +artifact substitutions use directory junctions, not privileged file symlinks. +Direct protection checks classify each link as unsafe; startup rejects the +metadata-directory substitution earlier with native EISDIR. Broad and inherited +DACLs are denied at all five boundaries. Existing sentinel content and ACL +snapshots are unchanged after denial. Owner checks read real ACLs against a +mismatched expected SID without creating accounts or transferring ownership; +this is not different-account privilege acceptance. Unix mode/link/UID branches +are present in the tests but were not executed on native Unix in this run. + +The first focused attempt reported 27 passes and 12 failures: ten test-fixture +Set-Acl operations requested SeSecurityPrivilege, one empty-environment setup +selected the retained cwd override semantics, and one junction assertion +expected the protection error rather than the earlier native EISDIR. After +approval, fixture-only DACL updates use System.IO access/owner sections without +SACL operations or privilege changes. One broad and one inherited ACL case then +passed; the focused suite passed all 39 tests before the empty-override regression +was added. Final focused validation passes 40 tests in four files. Final +`nub run check` passes 162 tests in 20 files, with 98.85% own-code line coverage, +typecheck, lint and formatting passing. Fresh-eyes review repaired the initial +empty-override append condition and added a regression; simplification removed +redundant test assertions. `git diff --check` passes. No production ACL adapter +change was needed for these direct cases. + +Plain-Node source reproduction initially hit unsupported TypeScript parameter +property syntax. Installed tsx was used for that source reproducer; native gates +use esbuild-compiled ESM without a loader and with NODE_OPTIONS removed. The +existing crash/restart/shutdown smoke and new simultaneous-installation smoke +both pass on win32/x64. Bundles and disposable runtimes are outside checkout and +were removed afterward. These are fixture gates, not npm/SEA release artifacts. + +Remaining blockers keep task 2.5, 2.8, 2.9 and G2 open: + +- The reproduced ancestor-junction escape is denied by the ancestor preflight + follow-up below. This is a snapshot link check, not atomic path identity or + arbitrary ancestor ACL hardening. Check-to-use races and same-owner path swaps + remain residual risks. +- Unix sockets reject paths over 103 UTF-8 bytes before runtime creation, with an + actionable shorter-runtime-directory error. Tests retain 103/104-byte and + multibyte coverage. The socket-budget follow-up removes only the cosmetic + `install-` prefix on every platform, retaining the full 16-hex digest, per-user + base, v2 and dev suffix. The modeled normal macOS default drops from 107 to 99 + bytes; long/multibyte homes and long dev-profile paths still fail closed. The + placement-only Darwin fixture skip is removed, but native macOS default + usability remains unproven, not inferred from Windows tests. +- Native Unix reruns, all-native isolation matrices, actual npm/SEA distributions, + application storage behavior, full-disk cases and Windows Sandbox same-owner + elevation/different-account privilege acceptance remain unproven here. No host + accounts, UAC, IIS, security policy or privileged host operations were changed. + +### Socket-budget follow-up + +MiMo Pro passed the delivered identity/boundary slice without closing task 7 or +G2. This follow-up removes eight cosmetic bytes, not hash entropy, and declares +`installationRoot` in the internal runtime-start options. Overrides are unchanged; +there is no public flag, socket relocation or platform-specific production namespace. + +Windows x64 focused validation passes 42 tests in four files. Final +`nub run check` passes 164 tests in 20 files with 98.85% own-code line coverage; +typecheck, lint and formatting pass. Actual-platform path coherence, sibling +resolver agreement, normal modeled macOS placement and long/multibyte denial are +covered. Both external compiled Windows smokes pass again, including simultaneous +locks/listeners, authenticated contact and independent stop/restart. Emitted +bundles and disposable runtimes were removed. `git diff --check` passes. + +The Darwin integration fixture is prepared with an exclusively created short +synthetic home, restored environment and cleanup after all runtime finalizers. +It never writes to the real user's home. This test-only setup is not native +macOS user-default acceptance or an exception to unresolved ancestor-link policy. +One initial full check rejected a cleanup throw under no-unsafe-finally; the +fixture now awaits all finalizers before propagating rejection and removing its +owned root. Fresh-eyes/simplifier review found no further scoped defect at that +checkpoint. Ancestor escape and task 2.5/2.8/2.9 were still open then; the ancestor +follow-up below delivers only the reproduced link-boundary repair. + +## Compatibility fixture follow-up — released 1.0.5 source and executable evidence + +Baseline: `93942af84f555eab2bd68e0a4a9db487ff82d040` on +`feat/node-runtime-migration`, Windows x64. This follow-up changes only +`test/compatibility/**` plus this evidence ledger; no product implementation, +Caddy port, Sandbox/UAC/account setup, or host runtime settings were changed. + +The compatibility catalog now includes concrete `cadder.toml` TOML cases, +multiline supported and malformed Caddyfile cases, captured CLI snapshots, +eight reusable request/result payload pairs for the eight-operation Node DTO +catalog, one concrete fixture per operation, nested registration identity, +optional fields, RPC correlation, CLI/RPC log defaults, and populated output +rows. Supported TOML snippets are parsed with installed `smol-toml`; all eight +request/result fixtures are parsed with the existing closed `src/protocol/rpc.ts` +Zod schemas. No duplicate Caddyfile parser or precedence engine was added. + +Focused released Rust 1.0.5 tests (all passed; exactly 67 tests) were run as +source/parser/fixture authority: + +- `cargo test -p cadder-ipc`: 17 tests (13 unit + 4 wire compatibility). +- `cargo test -p cadder-daemon --lib config::tests`: 6 tests. +- `cargo test -p cadder-shim --bin cadder-shim command_policy::tests`: 1 test. +- `cargo test -p cadder-shim --bin cadder-shim registration::tests`: 4 tests. +- `cargo test -p cadder-client --bin cadder-client cli::output::tests`: 6 tests. +- `cargo test -p cadder-daemon --lib caddy::tests::trusted_caddy_source`: 18 tests. +- `cargo test -p cadder-daemon --lib caddy::tests::mock_adapter`: 2 tests. +- `cargo test -p cadder-daemon --lib caddy::tests::adapter_`: 5 tests. +- `cargo test -p cadder-shim --test shim_binary`: 8 tests, including success and nonzero released-shim passthrough. + +The initial package-target attempts using `--lib` for `cadder-shim` and the +wrong `cadder` bin name for `cadder-client` were target-selection errors, not +zero-test evidence; corrected commands above supplied the evidence. No filtered +zero-test run is counted. + +Node fixture checks passed with Nub: `nub exec vitest run + test/compatibility/catalog.test.ts` (14 tests), `nub run typecheck`, `nub run +lint`, and Prettier. Final `nub run check` passes 227 tests in 22 files with +98.92% own-code line coverage; typecheck, lint and formatting pass. Full +`cargo test --locked --workspace` passes 300 tests; workspace clippy with +`-D warnings`, Rust formatting, and `git diff --check` pass. + +Actual CLI evidence remains the seven redirected non-TTY captures in +`test/compatibility/cli-output.json`, built from fresh 1.0.5 `cadder-client` +and `cadder-shim` targets: bare invocation and missing nested groups exit 0 on +stderr, explicit help/version exit 0 on stdout, and rejected arguments exit 2 +on stderr. This follow-up adds released-shim passthrough proof through the +existing native `cadder-test-process` fixture: copied fixture argv preserves +`fmt`, spaces, Unicode and quotes; piped stdin bytes are recorded; stdout and +stderr markers remain independent; and child exit codes 0 and 23 are returned +unchanged. The test uses an isolated copied released shim plus trusted portable +`cadder.toml`, with a hermetic child-only PATH and temp paths; stdin is +explicitly closed before a bounded ten-second child wait with kill/wait cleanup; +no daemon or real Caddy is launched. No TTY execution is claimed. The Windows Sandbox +package and launcher were prepared and launched before the user's deferral; +UAC, account-boundary acceptance, task 2.8 and G2 remain explicitly deferred +and open. Task 1.3 remains open pending actual TTY evidence. Tasks 1.4 and 1.5, +2.4 and 2.5 are not changed by this entry. + +## Runtime ancestor preflight + +The IPC-001 repair preserves installation-root alias identity while denying +arbitrary linked runtime ancestors before directory creation, protected-file +open or leaf trust. Only actual Darwin top-level /tmp, /var and /etc links owned +by UID 0 and resolving to their corresponding fixed /private directories are +recognized. Nested, user-owned, wrong-target or other root-owned links are denied. +Ordinary real parents retain their existing owner/mode/ACL policy; there is no +new anchor, port input, namespace, persistent trust state or metadata authority. +The inspection cursor may follow only those verified OS aliases; original +runtime, endpoint and diagnostic path spelling remains unchanged. + +Native Windows red testing first showed a protected leaf through an ancestor +junction was accepted. The initial guard then passed 63 focused cases, but +fresh-eyes review reproduced a missing/../junction escape in owned temp paths: +both directory and file creation incorrectly succeeded. After approval, the same +helper was replaced by a finite component cursor that inspects each prefix before +consuming later dot/dot-dot components and continues after missing prefixes. +Both regressions are now green, with no target content/ACL change, no listener or +publication, and no mkdir/open through the linked parent. Ordinary real-directory +dot/dot-dot paths still work. Direct leaf link/ACL denial is retained. + +Five existing fault tests initially failed because once-only lstat mocks injected +leaf-phase errors into new ancestor checks; a sixth case passed prematurely. +Authorized path-specific corrections in protected-file-faults.test.ts and +unix-security-policy.test.ts preserve the original failure/cleanup expectations +and explicitly pin the intended leaf, write/sync/close and mkdir/protection phases. +No production bypass was added to accommodate mock ordering. + +Final Windows x64 focused validation passes 95 tests in eight files. Final +`nub run check` passes 209 tests in 22 files, with 98.91% own-code line coverage; +typecheck, lint and formatting pass. Modeled Unix/Darwin alias cases and modeled +Windows drive/UNC/mixed-separator cases are not native macOS/Linux or network-share +acceptance. External compiled Windows gates pass for crash/restart/shutdown, +simultaneous installation locks/listeners/authentication and ancestor denial, +without a TS loader. Disposable runtimes and compiled bundles were removed. +Fresh-eyes/simplifier review found no further scoped defect; git diff --check passes. + +This does not close task 2.5/2.9 or G2. Native Unix matrices, full-disk cases and +actual npm/SEA distributions remain unproven; application database independence +is still path-only until task 5.1. Windows Sandbox privilege acceptance remains +user-deferred, not executed. Check-to-use races, including same-owner swaps, remain; +no atomic openat or inode/metadata binding is claimed. No host accounts, UAC, +security policy, privileges, Rust or normative requirements were changed. + +### Trailing-separator leaf correction + +MiMo Pro passed the ancestor slice and identified a trailing-separator leaf issue: +POSIX lstat can follow a directory link when the leaf ends in a slash. Modeled +POSIX prepareRuntime/assertProtected regressions both failed before correction; +native Windows junction cases already denied the same spellings, so no Windows +escape or native Unix execution is claimed from that red run. + +The correction trims only trailing leaf separators for parent computation and +leaf lstat, preserving POSIX, drive and UNC roots and every dot/dot-dot component. +Caller, ACL and diagnostic spelling remains unchanged. Reconsideration after the +local defects retained the existing necessary finite link preflight; this is a +small syntax correction, not another trust layer or algorithm redesign. Darwin +alias recognition and missing/dot-dot denial remain intact. + +Final Windows x64 focused validation passes 113 tests in eight files; full +`nub run check` passes 227 tests in 22 files with 98.92% own-code line coverage, +typecheck, lint and formatting passing. Native junction target content/ACLs are +unchanged after denial; ordinary real directories still work. Root controls are +modeled, without modifying host root permissions. External compiled Windows +runtime, installation and ancestor/trailing-leaf smokes pass without a TS loader; +disposable runtimes/bundles were removed. Fresh-eyes/simplifier review found no +further scoped defect; git diff --check passes. TOCTOU/same-owner swaps, native +Unix, application-storage/distribution proof and the remaining task7/G2 gates +remain open; Windows Sandbox remains user-deferred. + +## Integrated nine-task checkpoint + +The parent verified the final working-tree source/test set over base HEAD +`93942af84f555eab2bd68e0a4a9db487ff82d040`. This is uncommitted evidence, +not acceptance of a new committed revision. The SHA-256 fingerprint is +`fe62f295286fd9af23425ad3d16bde28da884f219329faf51c3bc67afbcc4141`. +It covers 198 source, test, script, main-spec and manifest/configuration files. +The receipt lists each repository-relative path and its file SHA-256; the +aggregate hashes sorted `pathlowercase SHA256` entries as UTF-8. +Planning/evidence documents, generated artifacts and private files are excluded. +The receipt is retained outside checkout and introduces no project tooling. + +Final parent checks for this source set: + +- `nub run check`: 227 tests in 22 files; 98.92% own-code line coverage; + typecheck, lint and formatting pass. +- `cargo test --locked --workspace`: 300 tests pass. `cargo fmt --check` and + workspace/all-target Clippy with `-D warnings` pass. Rust production behavior + and release manifests remain unchanged; two additional passthrough tests and + modes in the existing native fixture supply baseline evidence. +- Final `cargo test --locked -p cadder-shim --test shim_binary`: 8 tests pass + after the stdin/deadline/hermetic-PATH correction, including exact argument + arrays, input bytes, separate output streams and child exit codes 0 and 23. + These are actual redirected binary runs, not TTY acceptance. An earlier long + tool notification was not counted as a successful execution by itself. +- A newly compiled plain-JS runtime smoke passes on win32/x64 without a TS + loader. Its disposable staging directory was removed. +- Strict main-spec validation: 15 pass. Strict all-item validation: 17 pass, + one fails for the existing spec-free implementation change's missing-delta + mismatch. No duplicate requirements or validation-policy workaround was added. +- `git diff --check` passes; nothing is staged. An active LSP probe did not + conclusively certify all 12 source files; auxiliary AST findings are not a + replacement for the passing TypeScript compiler and maintained lint checks. + +The nine approved outcomes are reconciled individually: + +| Task | Outcome at this checkpoint | Remaining evidence or decision | +| --- | --- | --- | +| 1: CLI/shim catalog | Partial: argument/output fixtures and native passthrough proof exist; the follow-up below adds populated Windows renderer captures | Actual TTY remains unproven; renderer captures are not full operator journeys; 1.3 stays open | +| 2: configuration/model catalog | Partial: TOML/Caddyfile examples, released parser/resolver tests and all eight typed payload fixtures exist | Caddyfile examples are not a claim of a newly implemented Node parser or full adaptation parity; full 1.3 closure remains open | +| 3: spec synchronization | Delivered; strict main specs and independent delta-fidelity review pass | Roadmap remains open, not archived as completed migration | +| 4: four ports | Delivered; both material contract defects were repaired and independently reviewed | Production handlers/adapters are later work | +| 5: closed RPC | Delivered; eight methods, strict schemas, correlated outcomes and oversized-response handling are tested/reviewed | No product-handler or Rust/Node interoperability claim | +| 6: startup/teardown cleanup | Delivered for current runtime phases, including SQLite/file I/O faults and subsequent finalizers | Application-storage worker and native full-disk proof are not supplied by this slice | +| 7: installation/filesystem boundaries | Partial: live independent lock DBs/listeners and Windows leaf/ancestor/ACL denial pass | Application DB independence is path-only; current-source native Unix matrices and remaining storage evidence are missing | +| 8: Windows Sandbox privilege gate | Explicitly deferred by the user, not passed | A package and launcher were prepared/started before deferral; no real UAC or different-account acceptance was obtained | +| 9: one-revision G1/G2 reconciliation | Evidence collected and gaps identified, not gate acceptance | Compatibility catalog, native/security/storage proof and a finalized source revision remain incomplete | + +G1 and G2 remain open. The user-deferred Sandbox is not silently waived from the +accepted gate. Caddy porting, staging, commits, tags, pushes and publication were +not performed. This checkpoint does not declare the nine-task stage or the Node +migration complete. + +Final independent MiMo Pro review passes the integrated closed-RPC/four-port +slice and the nine-row evidence ledger without completion inflation. Only +`close-node-rpc-catalog` final verification task 3.1 is now complete; its own +verification artifact retains the archive/tooling blockers. The review also +records that teardown may close a received-but-undispatched request without a +correlated shutdown envelope. This is transport cancellation, not a guaranteed +RPC delivery result; no shutdown redesign was introduced. Broader tasks and +G1/G2 remain open. + +### Current-source Linux fixture evidence + +The parent additionally ran the existing compiled runtime smoke and exported +installation-runtime smoke on linux/x64 using the already-cached +`node:24.18.0-bookworm-slim` image. Node version 24.18.0 was checked for the +installation run. No image was pulled and no tools or packages were installed. +Each disposable container used UID/GID 1000, disabled networking, dropped all +capabilities, enabled no-new-privileges and a read-only root/input mount, and +kept runtime files in a writable temporary `/tmp`. Containers and external +compiled staging were removed after execution. + +Both gates pass: real Linux Unix sockets, SQLite exclusion, child crash/restart, +authenticated catalog RPC and shutdown; and two simultaneous installation +locks/listeners with independent authenticated contact and stop/restart. This +is current-source Linux fixture evidence, not npm/SEA product acceptance. +It does not supply the full Unix security/fault matrix, privileged-owner gate, +macOS native execution or application-storage behavior. The local WSL runner +had no available Node and the cached container had no sudo; neither environment +was modified to manufacture privilege evidence. The source fingerprint is +unchanged. G1/G2 and the remaining approved-task gaps stay open. + +## Executed Windows human-output catalog + +The continuation preserves the released operator's human-readable output; it +freezes renderer results for later Node parity without changing production Rust +or implementing a Node operator. Base HEAD remains +`93942af84f555eab2bd68e0a4a9db487ff82d040`; all migration work is uncommitted. + +`test/compatibility/human-output.json` contains four executed renderer captures: + +| Exact Rust test name | Covered output | UTF-8 bytes, including final LF | +| --- | --- | --- | +| list_and_status_outputs_cover_empty_and_populated_snapshots | Empty/populated status, projects and domains | 702 | +| port_output_covers_socket_and_registration_availability | Socket owners, matched routes and unavailable/empty states | 805 | +| route_outputs_cover_registered_unregistered_and_socket_notes | Registered/unregistered Caddyfiles, domains and socket notes | 2058 | +| diagnostics_and_logs_cover_empty_and_detailed_reports | Empty/populated diagnostics and bounded logs | 757 | + +The capture command for each name is: + +```sh +cargo test --locked -p cadder-client --bin cadder-client cli::output::tests:: -- --exact --nocapture --test-threads=1 --color never +``` + +The parent repeated all four commands on Windows x64 and compared the renderer +stdout between libtest markers with each fixture, preserving whitespace and the +final LF. All four comparisons pass. The initial worker captures omitted that +last LF; this was corrected before acceptance. The fixture file SHA-256 is +`eafad435c510eccb77febd4046dcbd1cbbd69f2d557e3328870832a6c3ab3bfa`. +These are renderer-test process captures, not operator IPC journeys or real TTY +runs. Windows path separators are part of the goldens; cross-platform parity +requires native captures or explicit path normalization, not reuse as portable +byte-exact output. + +The only Rust change in this continuation fixes existing test-input timestamps +to 2026-01-01; it is inside cfg(test). The Node catalog consumes the captures, +checks their final LF and absence of actual CR characters, and ties status/log +expectations to captured output. Independent MiMo Pro review identified the CR +guard and unverified status/log entries; the parent repaired them. The parent +also added explicit citations for precedence and Caddyfile examples. The retained +hostless sibling composition test proves adapted-route composition, not acceptance +of the example Caddyfile. Mock malformed-input tests and local/remote upstream +inspection tests likewise prove their named aspects, not real-Caddy parser parity. +No duplicate parser, renderer, terminal harness or validation-policy workaround +was added. + +Parent validation after these repairs: + +- `cargo test --locked -p cadder-client --bin cadder-client cli::output::tests -- --nocapture --test-threads=1`: six tests pass. +- Four exact capture commands above: one selected test passes per command and all fixture comparisons pass. +- `nub run check`: 227 tests in 22 files, including 14 catalog tests; own-code line coverage 98.92%; typecheck, lint and formatting pass. +- `cargo test --locked --workspace --quiet`: 300 tests pass; Rust formatting and workspace/all-target Clippy with `-D warnings` pass. +- Strict main-spec validation: 15 pass. Strict all-item validation: 17 pass and the same spec-free implementation missing-delta failure remains. +- Active LSP checks on the three changed code files were inconclusive, not certified clean; compiler/lint results above supply the completed checks. + +The updated source/test fingerprint uses the same receipt format as the integrated +checkpoint, adding the new human-output fixture: 199 files, SHA-256 +`cf86d7bfbf49057b24552304cd08c265dc872468f6a9fea432d2c95ec02181aa`. +The receipt stays outside checkout. Earlier runtime smokes were not rerun for +these test-only changes and are not relabeled as new native acceptance. + +MiMo Flash's independent scope assessment separates G1 catalog freezing from +later Node product execution. No bounded existing Windows TTY capture mechanism +was available for this run; source inspection is not a substitute for actual +terminal execution. Actual bare-help TTY acceptance remains missing, so 1.3/G1 +stay open. Full Node operator journeys and platform/channel parity remain later +gate obligations. G2 still lacks its remaining native/security/storage evidence; +Windows Sandbox remains user-deferred, not waived. No Caddy integration migration, +staging, commit, publication or host privilege/account changes were performed. + +Final retained MiMo Pro re-review: PASS. All five earlier findings are resolved; +the reviewer independently checked the fixture hash, byte lengths, CR/LF endings, +source citations and test-only Rust boundary and found no new defect. This closes +the rendered-catalog repair slice only. The complete evidence carrier is this +ledger entry, not the incomplete superseded worker report. Actual TTY, 1.3/G1 +and G2 remain open. + +## Approved final-product Sandbox sequencing + +The user approved completing the product before testing Windows Sandbox, and +explicitly authorized the next configuration/Caddy-preparation implementation. +This is a planning-order change, not executed security evidence or a change to +owner/elevated access requirements. Actual TTY and incomplete native/storage +checks remain open; G1/G2 have not passed. + +Updated roadmap, acceptance, design, Node scope, proposal, roadmap README and +agent guidance permit groups 3-6 implementation against delivered contract/runtime +boundaries. Tasks 2.8 and 7.4 share actual completed-product Sandbox evidence; +2.9 records final-source G2 acceptance before G7, Rust removal or release. The +retained Sandbox guide is labeled as a reference runtime-fixture procedure, +not a current implementation prerequisite or final-product proof. + +The next bounded slice preserves existing project Caddyfiles and trusted +cadder.toml selectors: resolve real Caddy, adapt project configuration and +validate JSON through bounded owned child processes. Composition, apply/rollback, +mTLS server setup and Caddy server lifecycle remain later tasks. No Sandbox, +UAC/account action, requirement waiver, public command, dependency, version, +commit or publication change is included in this sequencing decision. + +## Node Caddy preparation implementation (3.1-3.2) + +The focused `migrate-caddy-preparation/verification.md` records configuration, +trusted image pinning, bounded adaptation/validation, Windows Job ownership and +independent MiMo review followed by parent repairs. Roadmap 3.1-3.2 implementation +checks pass: final `nub run check` has 352 passing tests, two Unix-native skips and +96.34% own TS/TSX lines; the Caddy/owned-command slice has 93.75% lines. Final LCOV +includes adapter/preparation even though the compact text table hides those fully +covered files. Embedded C# has behavioral evidence, not V8 statement coverage. + +The shared-pin caller-cancellation defect and quoted Windows PATH regression +were repaired; bounded stream settlement, complete owned Job termination and +transient staging retries were verified on Windows. Root-relative Windows path +qualification was also corrected. No apply/server/CLI integration is claimed. + +`mise run check` passes Rust formatting, Clippy and 300 tests, then remains blocked +by the known OpenSpec 1.5 missing-delta failure for the two spec-free implementation +changes. Strict main specs (15), roadmap and implementation schema pass; downstream +docs/npm/cargo-dist steps were not reached. Both completed source slices remain +unarchived; no fake deltas or validation-policy workaround were introduced. + +G1/G2/G3, RUN-003 server shutdown and whole CADDY-005 acceptance remain open. +Native Unix/group-escape behavior, real Caddy, built npm/SEA and final-product +Sandbox proof remain pending. Existing dirty work is preserved and nothing was +staged, committed, published or cut over from Rust. + +## Node Caddy route composition implementation (3.3) + +The focused `migrate-caddy-composition/verification.md` records pure canonical +ownership and guarded route-plan composition in src/caddy/domains.ts and +composition.ts, with 63 dedicated JSON tests. Final `nub run check` passes +415 tests in 27 files, with two existing Unix-native skips and 96.81% own TS/TSX +line coverage; the new modules cover 167/168 lines (99.40%). + +MiMo Flash checked retained behavior and boundary risks. MiMo Pro independently +reviewed source/tests and identified two P2 notes, no P0/P1. The parent reproduced +and repaired terminal-parent pruning so it preserves chain position after its +subroute handlers disappear; a second fixture confirms opaque proxy-shaped +payloads do not contribute upstream metadata. The same reviewer rechecked those +changes and returned OK. Original upstream/timeout failures remained failures; +exact-run native resumes supplied the completed opinions without protocol fallback. + +Roadmap 3.3 closes its composition checks only. A route plan is not runnable +CaddyConfig: project admin/listener/TLS policy cannot enter it, and no validation, +apply, server or active-state integration is added. Real Caddy terminal execution +and exhaustive Node/Rust IDNA parity are not inferred from these fixtures. + +Strict main specs (15), roadmap, schema and whitespace checks pass. The maintained +OpenSpec gate still reports 17 pass and three spec-free implementation changes +rejected by the known missing-delta validator; no fake deltas or tooling-policy +workaround was added. Focused changes remain unarchived. G1/G2/G3, transactional +queue/rollback, protected mTLS, lifecycle, native/distribution acceptance and +final-product Sandbox remain open. Nothing was staged, committed or published. + +## Node configuration transaction orchestration (3.4-3.5) + +The focused `migrate-caddy-transactions/verification.md` records one daemon-owned +queue in src/daemon/configuration-transactions.ts: preparation against the latest +committed snapshot, validation, explicit apply outcomes, independent observation, +DesiredState-only atomic persistence and internal publication. Pending data remains +isolated. Persistence failure restores and verifies last-known-good, including +idle; uncertainty/failed rollback fences mutations until explicit queued recovery. + +The final parent `nub run check` passes 480 tests in 28 files, with two existing +Unix-native skips and 1155/1191 own TS/TSX lines (96.97%). The dedicated suite has +65 passing tests; the new module has 92/93 lines (98.92%) and 50/50 branches. +MiMo Flash critiqued the plan and MiMo Pro independently reviewed actual source/tests: +no P0/P1, one P2 wording item. The parent clarified that null observation means +positively observed idle, never unknown/unavailable state, and added a first-apply +regression proving failed readback stays fenced while a candidate is live. + +Roadmap 3.4-3.5 closes orchestration checks, not real runtime/storage integration. +Required production adapters have no successful mock defaults. Secure Caddy +readback/idle transitions, the SQLite worker's atomic failure contract, independently +observed initial state and registration/heartbeat ownership integration remain +later dependencies. Whole CADDY-003 and G1/G2/G3 acceptance remain open. + +Strict main specs (15), roadmap, implementation schema and whitespace checks pass. +The maintained OpenSpec gate reports 17 pass and four spec-free implementation +changes failing the known missing-delta validator. No fake delta or checker +workaround was introduced; focused changes remain unarchived. mTLS, owned server +lifecycle, native/distribution and final-product Sandbox proof remain pending. +No stage, commit, publication, real Caddy or host privilege/account action occurred. + +## Node product implementation checkpoint (2026-10-20) + +The `migrate-node-project-runtime` slice joins the existing queue, protected local +runtime and Caddy backend to application SQLite storage, the eight RPC handlers, +shared client, Commander CLI, routes Ink TUI and managed/delegated shim. It also +adds non-publishing npm/SEA packers and native candidate CI. These are implemented +capabilities, not G1-G7 acceptance or permission to remove Rust. + +### Executed local checks + +- `nub run check`: 44 files, 830 passing cases and two existing Unix-only skips; + 2436/2745 own-source lines, 88.74%. Types, lints and formatting pass. Coverage + still includes entrypoints, workers and non-host platform branches; their child + execution is separate evidence, not silently excluded from V8's denominator. +- `mise run node-check`: locked Nub reinstall plus the same full check passes. + A prior concurrent run with Windows Sandbox had two 15-second native-test + timeouts. The serial rerun passes without increasing test limits. +- `mise run check`: Rust formatting, Clippy and workspace tests pass, followed by + the known OpenSpec 1.5 missing-delta failure. The run preceded the addition of + its Node-check delegation. Strict main specs pass 15/15; the reset change passes; + the full delta validator reports 17 passing items and seven implementation-only + changes without deltas. Downstream legacy npm/cargo-dist steps were not reached. + No fake deltas or validation-policy bypass were added. +- In `docs/site`, `nub run check` reports zero errors/warnings/hints and + `nub run build` builds 14 pages. Existing empty-i18n/missing-404 build warnings + remain. Nub owns the exact docs lock; Astro's established ignored output and + Pages workflow stay in place during the retained Rust baseline. +- `nub run verify:node`: compiled npm local-install daemon/SQLite/CLI lifecycle, + three local and global bin wrappers, removed-option rejection and clean stop + pass on Node 24.18.0. The consumer has no TS loader, install scripts or optional + native packages. +- `node scripts/verify-sea.ts `: extracted Windows x64 SEA passes all + three versions, exact file/checksum inventory, authenticated IPC, SQLite worker, + logs, restart and stop with only Windows system tools on product PATH. +- Real Windows PTY: bare help succeeds without creating runtime state; routes TUI + opens offline, explicitly starts the daemon, confirms stop, returns offline, + quits successfully and restores the terminal. This does not prove every error, + Ctrl+C, viewport or Unix TTY case. +- Supplementary Fedora 44/WSL2 x64 proof builds native Linux SEA entries from the + same platform-neutral bundles with the checksum-verified Linux Node 26.12.0 + builder. The native no-Node-PATH lifecycle/SQLite/restart probe passes under + Node 24.18.0 as the external test driver. This is not native CI, the complete + Linux pack process, real-Caddy routing or Unix privilege acceptance. + +### Candidate identity and actual Caddy evidence + +The local npm and Windows SEA candidates share base revision +`81c120b1b67d640b2c344abc040943c4cd2b3435`, dirty source/build-input digest +`f298de6361cd2f750eca5562cfff92b0d7e116a64981a7920ee2c7c04334de2d` +and version `2.0.0-rc.1`. They are local candidates, not published releases or a +clean candidate revision. Their SHA-256 values are: + +- npm tarball: `591fbebe5a403f3d818992b335644b253dacc02df5d9989ec940313d9d623a8f`. +- Windows archive: `e2983e3bbcec0bf98f1507ae46606985c3488fa2a127167d157277849b72ee62`. + +A prior Windows Sandbox product candidate with source digest +`c88a3550b1a250e9a7cc0763b246f06851b60288d6d084a25d6d82d67fce80e8` +passed actual daemon/shim/operator registration, simultaneous projects, HTTP and +CA-verified HTTPS, project/domain toggles, conflicts/malformed-update rejection +with last-known-good, heartbeat/crashed-session expiry, unauthenticated admin +denial and owned shutdown, using separate stock Caddy 2.11.4 and no Node on the +product PATH. A repeat against the f298 candidate while the host full suite ran +failed after shim crash with port 80 unavailable. That repeat is a failure, +not a final-candidate pass; serial/native CI diagnosis remains open. + +The subsequent serial Sandbox repeat against that same f298 Windows archive +passes the complete shared SEA journey with stock Caddy 2.11.4 and no Node on +product PATH, including crashed-alpha expiry while beta continues serving. +The earlier concurrent failure remains recorded; no lease or test deadline was +relaxed. This repeat still does not provide interactive UAC/account evidence. + +The startup consumer probe also reproduced a Windows ACL-publication race. +Post-launch readiness now waits for protection to finish without reading an +unsafe secret or authenticating; pre-existing unsafe paths still fail directly. +A failed readiness attempt terminates only its own newly launched child. The +regression and a fresh local/global npm consumer run pass. Log overflow now +recognizes case-insensitive private-key markers; failed worker posting settles +requests and terminates that worker. + +### Test scope and remaining gates + +Removed over 700 lines of declaration/source-string/self-mock checks from the +port and compatibility suites. Retained the frozen behavior data and security, +process-ownership, schema-refusal and rollback regressions. New tests exercise +real SQLite, handlers, CLI grammar, Ink input and host socket ownership. The +total case count grows because the missing product is now implemented; a lower +test count is not an acceptance criterion. + +G4/G5 native fidelity, full shared npm/SEA catalog, macOS variants, final Unix +owner/elevated behavior, interactive Windows same-SID/UAC/cancellation/different- +account checks and one clean-revision G7 ledger remain open. Candidate CI now +runs the shared real-Caddy journey for both distributions with a separately +downloaded checksum-pinned upstream fixture. Configuring that workflow is not +execution evidence. Rust deletion, release-tool cutover and publication remain +gated. The user explicitly authorized commit and push for native CI; this does +not authorize a PR, tag, registry publication or GitHub release. + +### First native CI pass and fixture corrections + +Commit `5b4daee` was pushed to the existing migration PR with explicit authority. +Docs CI passes. The first native Node matrix fails: Linux exposes a fixture file +named `upstream` colliding with its later directory, macOS exposes uncanonical +`/var` versus `/private/var` expectations, a modeled Linux test incorrectly walks +macOS aliases, and a namespace-only fixture exceeds the separately enforced +Unix socket limit. Windows also exposes short/long TEMP spelling expectations +and fixture timeouts under parallel native helper compilation. The fixes use +physical fixture paths, distinct file/directory names and serialized Windows +files, preserving existing runtime permission and timeout policy. + +A local 32-MiB+1 stderr fixture occasionally exited before its pending pipe +output settled. Disposable fake-Caddy preloads now exit naturally after output; +all eight exact/overflow stream-bound cases pass. macOS native socket inspection +still fails and now reports its actual owner rows for diagnosis. None of these +failed native runs closes G4/G6/G7. The duplicate push-trigger matrix is removed; +the existing PR matrix remains, alongside master push/manual triggers. + +The product Sandbox preparer now also emits a separate interactive privilege +configuration and the `windows-product-gate.md` procedure. Its four PowerShell +blocks parse and the generated Sandbox XML loads correctly. This is preparation +only: no UAC/account pass is claimed. + +### Native follow-up + +The Linux x64 Node job passes at `86eb714` and again at `0180d68`, including +coverage, compiled runtime/privilege fixtures, the packed npm local/global +consumer, real-Caddy multi-project HTTP/HTTPS and extracted SEA lifecycle plus +the same real-Caddy journey. These runs precede the final candidate-asset and +post-apply storage-failure additions; they do not close the final revision gate. + +macOS diagnostics identify a concrete adapter defect: lsof can return exit 1 +with valid TCP output when its UDP selector has no match. The adapter now parses +that stdout while still rejecting stderr errors. The existing native test checks +real TCP and UDP ownership separately; temporary raw-command diagnostics are +removed. Earlier intermittent macOS process-group observation failures remain +failures; OS causes are retained for diagnosis and native fixture files run +serially rather than competing during helper startup. No product deadline or +ownership check was weakened. + +Both candidate packages now carry the same portable README and configuration +example; standalone inventory/checksums include these assets. The source digest +also covers them. Local npm local/global verification and Astro check/build pass +after these changes. The shared real-Caddy catalog additionally injects one +SQLite desired-state write refusal, verifies actual post-apply rollback, removes +its test-only trigger and checks that a later mutation works. It introduces no +production fault flag or exported private runtime data. + +### Exact npm candidate and native product follow-up + +At `e3be920`, CI packs one npm tarball and distributes those exact bytes to +the four native jobs, verifying clean source revision and version before use. +The shared catalog now checks actual delegated stdin/stdout/stderr/exit codes, +shim-first startup and Unix PTY help/TUI cleanup. Native execution remains open: +Linux exposes asynchronous certificate issuance before the first verified HTTPS +request; macOS requires an elevated daemon for web ports and exposes root-owned +new runtime ancestors; Windows still has a short/long TEMP comparison fixture. +The fixture comparison uses physical paths on both sides. + +A fresh Windows Sandbox shim-first run fails at the original 30-second startup +budget. A separate actual-product probe records failure after 34.6 seconds and +successful foreground readiness after another 28.6 seconds. Client startup now +allows 60 seconds for cold native ACL preparation; unsafe permissions still +refuse authentication and failed readiness still stops only its own child. +The existing client regression now crosses the old 30-second boundary. + +Root startup now assigns only newly created runtime ancestors to the explicitly +selected Unix owner. The compiled native Linux privilege probe passes with a +previously absent runtime parent, ordinary-owner access and different-account +denial. macOS product probes retain ordinary clients, start the actual daemon +with explicit owner under sudo, read only the public Caddy CA for verified TLS, +and clean their own elevated temporary data. HTTPS probes wait for actual +certificate readiness. These corrections require a fresh full native matrix and +Windows product repeat; they do not close G7 or authorize Rust removal. diff --git a/openspec/changes/reset-cadder-architecture/windows-product-gate.md b/openspec/changes/reset-cadder-architecture/windows-product-gate.md new file mode 100644 index 0000000..5990060 --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/windows-product-gate.md @@ -0,0 +1,106 @@ +# Interactive Windows product privilege acceptance + +Use the `privilege.wsb` produced by `scripts/prepare-product-sandbox.ts` for the +exact candidate being accepted. The package mounts candidate executables and +separate upstream Caddy read-only, disables networking, and writes only reports +back to the host. This procedure changes accounts only inside disposable Sandbox. +Do not execute its account block on the host. + +The automated routing report does not pass this gate. A real non-elevated client, +same-SID elevated daemon, UAC cancellation/approval and a successfully launched +different-account client are required. If Sandbox cannot provide a non-elevated +shell and a real UAC prompt, record BLOCKED. Do not bypass or disable UAC. + +## Same owner, cancellation and elevation + +In a non-elevated PowerShell inside Sandbox: + +```powershell +$cadderIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() +$cadderPrincipal = New-Object Security.Principal.WindowsPrincipal($cadderIdentity) +if ($cadderPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Use a non-elevated client shell.' } +$cadderGateRuntime = Join-Path $env:TEMP ('cadder-product-uac-' + [guid]::NewGuid().ToString('N')) +$env:CADDER_RUNTIME_DIR = $cadderGateRuntime +$cadderArguments = @('--runtime-dir', ('"' + $cadderGateRuntime + '"'), '--real-caddy', 'C:\CadderTools\caddy.exe') +Start-Process -FilePath C:\CadderProduct\cadderd.exe -Verb RunAs -WindowStyle Hidden -ArgumentList $cadderArguments +``` + +Cancel the actual UAC prompt. Verify that no discovery file exists and the actual +operator reports offline (exit 3): + +```powershell +if (Test-Path -LiteralPath (Join-Path $cadderGateRuntime 'v2\cadder-ipc.json')) { throw 'Cancelled elevation published a daemon.' } +& C:\CadderProduct\cadder.exe status +if ($LASTEXITCODE -ne 3) { throw 'Cancelled startup was not offline.' } +``` + +Repeat `Start-Process`, approve the prompt, then use the same non-elevated shell: + +```powershell +$cadderDeadline = (Get-Date).AddSeconds(45) +do { + & C:\CadderProduct\cadder.exe status + if ($LASTEXITCODE -eq 0) { break } + Start-Sleep -Milliseconds 300 +} while ((Get-Date) -lt $cadderDeadline) +if ($LASTEXITCODE -ne 0) { throw 'Ordinary client could not contact the product daemon.' } +$cadderDiscovery = Get-Content -LiteralPath (Join-Path $cadderGateRuntime 'v2\cadder-ipc.json') -Raw | ConvertFrom-Json +if (!$cadderDiscovery.elevated -or $cadderDiscovery.owner -ne $cadderIdentity.User.Value) { throw 'Expected the same SID and an elevated daemon.' } +if ($cadderPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'The client must remain non-elevated.' } +'SAME_OWNER_ELEVATED_PRODUCT_CONTACT_PASSED' +``` + +Record the candidate version/checksum and the observed cancellation/approval. +Do not export runtime secrets, account passwords or private key material. + +## Different account + +In an elevated PowerShell inside Sandbox, set `$cadderGateRuntime` to the exact +path from the first shell, then run: + +```powershell +if ($env:USERNAME -ne 'WDAGUtilityAccount') { throw 'Run only inside disposable Windows Sandbox.' } +$ErrorActionPreference = 'Stop' +$cadderAccount = $null +$cadderOutsider = 'cadder-gate-' + [guid]::NewGuid().ToString('N').Substring(0, 6) +$cadderPassword = ConvertTo-SecureString ('A9!' + [guid]::NewGuid().ToString('N')) -AsPlainText -Force +$cadderInputs = Join-Path $env:PUBLIC ('cadder-product-gate-' + [guid]::NewGuid().ToString('N')) +try { + $cadderAccount = New-LocalUser -Name $cadderOutsider -Password $cadderPassword + $cadderUsers = Get-LocalGroup -SID 'S-1-5-32-545' + Add-LocalGroupMember -Group $cadderUsers.Name -Member $cadderAccount + New-Item -ItemType Directory -Path $cadderInputs | Out-Null + Copy-Item -LiteralPath C:\CadderProduct\cadder.exe -Destination $cadderInputs + $cadderQuotedRuntime = $cadderGateRuntime.Replace("'", "''") + $cadderProbeSource = '$ErrorActionPreference = ''Continue''; $env:CADDER_RUNTIME_DIR = ''' + $cadderQuotedRuntime + '''; & (Join-Path $PSScriptRoot ''cadder.exe'') status; exit $LASTEXITCODE' + Set-Content -LiteralPath (Join-Path $cadderInputs 'probe.ps1') -Value $cadderProbeSource + $cadderInputAcl = Get-Acl -LiteralPath $cadderInputs + $cadderInputAcl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($cadderAccount.SID, 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + [IO.Directory]::SetAccessControl($cadderInputs, $cadderInputAcl) + foreach ($cadderReportName in @('out.txt', 'err.txt')) { + $cadderReport = Join-Path $cadderInputs $cadderReportName + New-Item -ItemType File -Path $cadderReport | Out-Null + $cadderReportAcl = Get-Acl -LiteralPath $cadderReport + $cadderReportAcl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($cadderAccount.SID, 'Write', 'Allow'))) + [IO.File]::SetAccessControl($cadderReport, $cadderReportAcl) + } + $cadderCredential = New-Object Management.Automation.PSCredential(($env:COMPUTERNAME + '\' + $cadderOutsider), $cadderPassword) + $cadderDenied = Start-Process -FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $cadderInputs 'probe.ps1')) -Credential $cadderCredential -LoadUserProfile -WorkingDirectory $cadderInputs -WindowStyle Hidden -RedirectStandardOutput (Join-Path $cadderInputs 'out.txt') -RedirectStandardError (Join-Path $cadderInputs 'err.txt') -Wait -PassThru + if ($cadderDenied.ExitCode -ne 7) { throw ('Expected access denial from the actual CLI, got exit ' + $cadderDenied.ExitCode) } + 'DIFFERENT_ACCOUNT_PRODUCT_DENIED' +} finally { + if ($null -ne $cadderAccount) { Remove-LocalUser -SID $cadderAccount.SID } + $cadderResolvedInputs = [IO.Path]::GetFullPath($cadderInputs) + $cadderPublicRoot = [IO.Path]::GetFullPath($env:PUBLIC).TrimEnd('\') + '\' + if (!$cadderResolvedInputs.StartsWith($cadderPublicRoot, [StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($cadderResolvedInputs) -notmatch '^cadder-product-gate-[0-9a-f]{32}$') { throw 'Unsafe test cleanup path.' } + if (Test-Path -LiteralPath $cadderResolvedInputs) { Remove-Item -LiteralPath $cadderResolvedInputs -Recurse -Force } +} +``` + +A failed account/process launch does not pass. Only exit 7 from the actual +operator proves the expected access-denied boundary. Back in the original +non-elevated shell, verify `cadder.exe status` still succeeds, then stop with +`C:\CadderProduct\cadder.exe daemon stop` and close Sandbox. + +Record all actual results before closing parent tasks 2.8 and 7.4. Preparation +and an elevated-client success alone do not constitute acceptance. diff --git a/openspec/changes/reset-cadder-architecture/windows-runtime-gate.md b/openspec/changes/reset-cadder-architecture/windows-runtime-gate.md new file mode 100644 index 0000000..f6ee80d --- /dev/null +++ b/openspec/changes/reset-cadder-architecture/windows-runtime-gate.md @@ -0,0 +1,107 @@ +# Interactive Windows runtime gate + +Execution is deferred by the user's approved sequencing until the actual product +is complete. Tasks 2.8 and 7.4 require final product evidence before G7 acceptance, +Rust removal or release. The runtime-fixture procedure below is retained for +reference; it does not replace actual-product acceptance and must not be launched +during the current implementation stage. + +This package tests the Node runtime foundation only. It is not an npm or SEA +release and does not contain product commands, Caddy, IIS or autostart mutations. +The host package is mounted read-only, networking is disabled, and all runtime +files and account changes below stay inside the disposable Sandbox. + +Prepare on the host with Node 24.18.0: + +```powershell +node scripts/prepare-windows-runtime-sandbox.ts +``` + +Open the printed `.wsb` file when ready to interact with UAC. Do not run the +account-creation commands below on the host. If this Sandbox image cannot +provide a non-elevated shell and a real UAC prompt, record the gate as blocked, +not passed. Do not disable or bypass UAC to obtain a passing result. + +## Same owner, different privilege + +In a non-elevated PowerShell inside Sandbox: + +```powershell +Set-Location C:\CadderRuntimeGate +$cadderGateRuntime = Join-Path $env:TEMP ("cadder-uac-" + [guid]::NewGuid().ToString('N')) +.\node.exe .\runtime-windows-contact.mjs prepare $cadderGateRuntime +Start-Process -FilePath C:\CadderRuntimeGate\node.exe -Verb RunAs -WindowStyle Hidden -ArgumentList @('C:\CadderRuntimeGate\runtime-child.mjs', ('"' + $cadderGateRuntime + '"'), '--interactive-gate') +``` + +First cancel UAC. Verify that no daemon starts and `v2/cadder-ipc.json` does not +exist. Repeat `Start-Process`, approve the real prompt, then run: + +```powershell +.\node.exe .\runtime-windows-contact.mjs elevated-status $cadderGateRuntime +``` + +The expected marker is `SAME_OWNER_ELEVATED_CONTACT_PASSED`. The fixture verifies +that the client is not elevated, the daemon is elevated, both have the same SID, +and an authenticated RPC succeeds. An elevated client does not pass this test. +The daemon fixture stops automatically after five minutes. + +## Different account + +Open an elevated PowerShell inside Sandbox, set `$cadderGateRuntime` to the exact +disposable path used above, and execute this block. It creates one temporary +standard account, grants access only to public test inputs and report files, +checks denial, and removes that account and its test inputs in `finally`. + +```powershell +if ($env:USERNAME -ne 'WDAGUtilityAccount') { throw 'Run only inside the default disposable Windows Sandbox.' } +$ErrorActionPreference = 'Stop' +$cadderAccount = $null +$cadderOutsider = 'cadder-gate-' + [guid]::NewGuid().ToString('N').Substring(0, 6) +$cadderPassword = ConvertTo-SecureString ('A9!' + [guid]::NewGuid().ToString('N')) -AsPlainText -Force +$cadderInputs = Join-Path $env:PUBLIC ('cadder-gate-' + [guid]::NewGuid().ToString('N')) +try { + $cadderAccount = New-LocalUser -Name $cadderOutsider -Password $cadderPassword + New-Item -ItemType Directory -Path $cadderInputs | Out-Null + Copy-Item -LiteralPath C:\CadderRuntimeGate\node.exe -Destination $cadderInputs + Copy-Item -LiteralPath C:\CadderRuntimeGate\runtime-denied-client.mjs -Destination $cadderInputs + $cadderInputAcl = Get-Acl -LiteralPath $cadderInputs + $cadderReadRule = New-Object System.Security.AccessControl.FileSystemAccessRule($cadderAccount.SID, 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $cadderInputAcl.AddAccessRule($cadderReadRule) + [System.IO.Directory]::SetAccessControl($cadderInputs, $cadderInputAcl) + foreach ($cadderReportName in @('out.txt', 'err.txt')) { + $cadderReport = Join-Path $cadderInputs $cadderReportName + New-Item -ItemType File -Path $cadderReport | Out-Null + $cadderReportAcl = Get-Acl -LiteralPath $cadderReport + $cadderReportAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($cadderAccount.SID, 'Write', 'Allow'))) + [System.IO.File]::SetAccessControl($cadderReport, $cadderReportAcl) + } + $cadderCredential = New-Object System.Management.Automation.PSCredential(($env:COMPUTERNAME + '\' + $cadderOutsider), $cadderPassword) + $cadderDeniedProcess = Start-Process -FilePath (Join-Path $cadderInputs 'node.exe') -ArgumentList @((Join-Path $cadderInputs 'runtime-denied-client.mjs'), ('"' + $cadderGateRuntime + '"')) -Credential $cadderCredential -LoadUserProfile -WorkingDirectory $cadderInputs -WindowStyle Hidden -RedirectStandardOutput (Join-Path $cadderInputs 'out.txt') -RedirectStandardError (Join-Path $cadderInputs 'err.txt') -Wait -PassThru + if ($cadderDeniedProcess.ExitCode -ne 0 -or (Get-Content -LiteralPath (Join-Path $cadderInputs 'out.txt') -Raw).Trim() -ne 'DENIED') { + throw ('Different-account gate failed: ' + (Get-Content -LiteralPath (Join-Path $cadderInputs 'err.txt') -Raw)) + } + 'DIFFERENT_ACCOUNT_DENIED' +} finally { + if ($null -ne $cadderAccount) { Remove-LocalUser -SID $cadderAccount.SID } + $cadderResolvedInputs = [System.IO.Path]::GetFullPath($cadderInputs) + $cadderPublicRoot = [System.IO.Path]::GetFullPath($env:PUBLIC).TrimEnd('\') + '\' + if (!$cadderResolvedInputs.StartsWith($cadderPublicRoot, [StringComparison]::OrdinalIgnoreCase) -or [System.IO.Path]::GetFileName($cadderResolvedInputs) -notmatch '^cadder-gate-[0-9a-f]{32}$') { throw 'Unsafe test cleanup path.' } + if (Test-Path -LiteralPath $cadderResolvedInputs) { Remove-Item -LiteralPath $cadderResolvedInputs -Recurse -Force } +} +``` + +A failure to launch Node under the other account is a failed gate, not evidence +that IPC denied the account. The expected denial must come from the test client. + +Back in the original non-elevated shell: + +```powershell +.\node.exe .\runtime-windows-contact.mjs elevated-status $cadderGateRuntime +.\node.exe .\runtime-windows-contact.mjs shutdown $cadderGateRuntime +``` + +Record the three markers, UAC cancellation/approval, and any errors. Close +Sandbox to discard its runtime, test account profile and other disposable state. +Only reviewed actual-product results can close tasks 2.8 and 7.4. Runtime-fixture +results alone do not satisfy those tasks. This interactive gate has not been +verified merely by preparing the package. diff --git a/openspec/config.yaml b/openspec/config.yaml index c648fb7..05d13f1 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,13 +1,15 @@ schema: spec-driven context: | - Cadder is a Rust workspace for coordinating Caddy-backed local development - runtimes. OpenSpec is the canonical source for product requirements, design + Cadder 2.0 is a TypeScript/Node product for coordinating Caddy-backed local + development runtimes, with equal npm and Node SEA release channels. Rust is + retained as a migration baseline until both distribution gates pass. + OpenSpec is the canonical source for product requirements, design decisions, and implementation plans. The codebase should favor small modules, explicit ownership, independently testable boundaries, and clear contracts between runtime, integration, and - presentation layers. Prefer proven Rust crates and established tooling over + presentation layers. Prefer proven libraries and established tooling over custom infrastructure when they fit the job. Cadder is cross-platform by default. Platform-specific behavior must live @@ -38,6 +40,11 @@ rules: - "Define test seams, fakes, and verification strategy before describing implementation details." - "For a new abstraction or dependency, compare the standard library, a focused library, and a framework by net concepts, code, maintenance, and migration risk." - "Classify information as product, operational, diagnostic, or local, and keep local tools, mock backends, private paths, and hidden flags out of public contracts." + specs: + - "Use the OpenSpec delta format correctly: ADDED, MODIFIED, REMOVED, or RENAMED Requirements as appropriate." + - "For MODIFIED requirements, include the full updated requirement block, not a partial patch." + - "Write normative SHALL/MUST requirements with observable behavior and at least one scenario using the required heading level." + - "Keep specs focused on what the system must do; put implementation choices in design unless they are part of the user-facing contract." tasks: - "Break work into independently verifiable slices." - "Keep cleanup and implementation tasks separate when cleanup has migration risk." diff --git a/openspec/specs/caddy-runtime/spec.md b/openspec/specs/caddy-runtime/spec.md index 78b89a6..833d4c7 100644 --- a/openspec/specs/caddy-runtime/spec.md +++ b/openspec/specs/caddy-runtime/spec.md @@ -6,11 +6,12 @@ Define trusted real-Caddy resolution, route composition, and process ownership. ## Requirements ### Requirement: CADDY-001: Real Caddy comes only from trusted sources -Cadder MUST resolve real Caddy from an explicit foreground override, trusted configuration, or safe native PATH discovery that excludes the shim by file identity. +The daemon SHALL select real Caddy from a trusted explicit override/configuration or safe PATH, pin the executable for its lifetime and reject untrusted project/shim selectors. Resolution SHALL recognize npm wrappers, SEA entries, aliases and file identity to avoid recursive Cadder execution. -#### Scenario: Project attempts selection -- **WHEN** a project file, working directory, environment value, or shim flag attempts to select real Caddy -- **THEN** the daemon ignores or rejects that untrusted selector +#### Scenario: PATH starts with the npm or SEA shim +- **WHEN** an executable candidate resolves to a Cadder wrapper or entrypoint +- **THEN** resolution SHALL skip or reject it and continue safely or fail clearly +- **AND** it SHALL NOT recursively launch the shim ### Requirement: CADDY-002: One owned process serves composed routes The daemon SHALL adapt validated project Caddyfiles, compose active non-conflicting routes, and apply them through the one real Caddy child it owns. @@ -20,11 +21,17 @@ The daemon SHALL adapt validated project Caddyfiles, compose active non-conflict - **THEN** Cadder neither adopts nor terminates it ### Requirement: CADDY-003: Runtime transitions are verified -A desired-state mutation MUST publish only after Caddy accepts the candidate configuration and SQLite commits it; failure SHALL retain or restore the previous verified runtime. +One queue SHALL serialize preparation, adaptation/composition, validation, apply, active-state verification, SQLite commit and publication. Rejection SHALL retain last-known-good. Persistence failure after apply SHALL restore and verify the previous configuration; failed rollback SHALL fence further mutations. An ambiguous apply outcome SHALL require active-state reconciliation before the next mutation. Pending state SHALL NOT become authoritative prematurely. -#### Scenario: Persistence fails after apply -- **WHEN** Caddy accepted a candidate but SQLite cannot commit it -- **THEN** Cadder rolls Caddy back and keeps the previous authoritative state +#### Scenario: Apply response is lost +- **WHEN** the daemon cannot determine whether Caddy applied a candidate +- **THEN** it SHALL fence later changes and reconcile active state +- **AND** only verified, persisted state SHALL be published as committed + +#### Scenario: Database commit fails after successful apply +- **WHEN** Caddy accepted a candidate but persistence fails +- **THEN** the daemon SHALL restore last-known-good and retain prior state +- **AND** failed restore SHALL remain fenced with a recoverable diagnostic ### Requirement: CADDY-004: Owned listeners and routes remain local Cadder SHALL bind its owned HTTP and HTTPS servers only to IPv4 and IPv6 loopback addresses on the existing ports. Each registration's complete adapted route tree MUST remain behind an outer matcher containing only that registration's active canonical hosts, and project input MUST NOT widen either boundary. @@ -43,3 +50,16 @@ Cadder MUST capture at most 32 MiB from each `caddy adapt` output stream, reject #### Scenario: Adapter exceeds an output limit - **WHEN** `caddy adapt` writes more than 32 MiB to stdout or stderr - **THEN** Cadder reports a bounded failure without applying configuration and cleans up the owned adapter process tree if it remains active + +### Requirement: CADDY-006: Administration uses protected loopback mutual TLS +The daemon-Caddy admin channel SHALL use loopback mTLS with an internal CA, validated server identity and an owner-protected authorized client certificate. The CA SHALL NOT be installed in system trust. Plaintext local admin SHALL be disabled, and project input SHALL NOT override the administration policy. + +#### Scenario: Caller has no authorized certificate +- **WHEN** a caller contacts Caddy admin without the correct client certificate +- **THEN** Caddy SHALL refuse administrative access +- **AND** there SHALL be no unprotected listener fallback + +#### Scenario: Project supplies admin settings +- **WHEN** project configuration attempts to replace admin transport or trust +- **THEN** the daemon SHALL reject or override it with the protected policy +- **AND** no system trust-store mutation SHALL be needed diff --git a/openspec/specs/caddy-shim-integration/spec.md b/openspec/specs/caddy-shim-integration/spec.md new file mode 100644 index 0000000..fefc4ce --- /dev/null +++ b/openspec/specs/caddy-shim-integration/spec.md @@ -0,0 +1,29 @@ +# Caddy shim integration + +## Purpose +Define the PATH-facing Caddy shim command and managed runtime contract. + +## Requirements + +### Requirement: SHIM-001: Shim policy preserves command fidelity +The shim SHALL classify commands as managed, read-only inspection, explicit passthrough or unsupported and preserve argument arrays, standard streams and exit codes. Unknown commands SHALL fail rather than silently pass through. + +#### Scenario: Explicit passthrough command exits +- **WHEN** a supported passthrough command completes +- **THEN** the shim SHALL preserve its output streams and exit status +- **AND** it SHALL NOT mutate daemon-managed state through an undocumented path + +### Requirement: SHIM-002: Managed run never starts independent Caddy +Managed caddy run SHALL use daemon registration/heartbeat and MAY start a missing packaged daemon. Failure SHALL not become an independent Caddy run. Read-only inspection MAY use safely resolved real Caddy and SHALL identify its result as inspection rather than daemon state. + +#### Scenario: Managed startup fails +- **WHEN** the daemon cannot become ready for managed run +- **THEN** the shim SHALL fail with recovery guidance without unmanaged fallback + +### Requirement: SHIM-003: Both distributions avoid recursive resolution +Real Caddy resolution SHALL detect npm wrappers, SEA entries and equivalent file/alias identities, preventing recursion in local/global npm and standalone PATH layouts. The real upstream server SHALL remain separately installed. + +#### Scenario: Globally installed shim precedes upstream Caddy +- **WHEN** PATH resolves Cadder before upstream Caddy +- **THEN** the shim SHALL skip its own wrappers/entries and find a trusted candidate +- **AND** failure SHALL be bounded without recursively spawning Cadder diff --git a/openspec/specs/daemon-lifecycle/spec.md b/openspec/specs/daemon-lifecycle/spec.md index 69a0e2f..a10d388 100644 --- a/openspec/specs/daemon-lifecycle/spec.md +++ b/openspec/specs/daemon-lifecycle/spec.md @@ -6,25 +6,28 @@ Define singleton ownership, startup, and bounded shutdown for one installation r ## Requirements ### Requirement: RUN-001: One installation admits one daemon -`cadderd` MUST claim the owner-protected IPC endpoint before initializing storage or Caddy, and a second start SHALL attach to the healthy owner rather than creating competing state. +One owner runtime v2 SHALL admit one daemon through a separate SQLite lock database holding BEGIN EXCLUSIVE for the process lifetime. Metadata SHALL be diagnostic only. Startup SHALL acquire the actual lock before crash recovery, bind/publish readiness only after protected startup succeeds, and release acquired resources on failure. PID checks or expiring leases SHALL NOT grant ownership. -#### Scenario: Concurrent starts -- **WHEN** two starts race for one installation directory -- **THEN** exactly one process becomes the daemon and the other observes its readiness +#### Scenario: Concurrent start after a crash +- **WHEN** two starts encounter stale or misleading owner metadata +- **THEN** only the actual SQLite lock owner SHALL recover residue and become ready +- **AND** the other SHALL observe the existing owner or report bounded contention ### Requirement: RUN-002: Managed run may start the daemon -When attachment reports that no daemon is running, supported `caddy run` MUST launch the version-matched sibling `cadderd`, poll the exact handshake to readiness, and retry attachment. Automatic managed startup MUST NOT discover `cadderd` through PATH. An explicit daemon path MAY remain available to focused tests and foreground diagnostics. +Managed caddy run SHALL use the version-matched packaged daemon and MAY start it when missing through the supported npm or SEA path and authenticated readiness. It SHALL NOT discover an unrelated daemon through PATH or start independent Caddy. State/inspection CLI commands SHALL remain attach-only; explicit daemon start/restart and cadder tui --start-daemon SHALL retain the released bounded launch behavior. -#### Scenario: Startup fails -- **WHEN** the sibling daemon cannot be found or become ready within the bounded deadline -- **THEN** the shim reports actionable diagnostics and never launches an unrelated PATH executable or delegates an unmanaged run +#### Scenario: Packaged daemon cannot start +- **WHEN** the packaged daemon fails to become authenticated and ready +- **THEN** the shim SHALL report the failure without unmanaged Caddy fallback +- **AND** ordinary inspection commands SHALL NOT implicitly start a daemon ### Requirement: RUN-003: Shutdown is bounded and owned -Shutdown SHALL stop accepting work, drain or revoke owned operations, close SQLite, terminate only the Cadder-owned Caddy child, and release the endpoint within bounded phase deadlines. +Shutdown SHALL stop admission, settle or revoke owned work, close the application-state and diagnostic-log worker, gracefully stop the owned Caddy child, then force only that child after bounded deadlines. It SHALL release endpoint resources and the SQLite exclusion connection only after owned teardown. It SHALL NOT enumerate or kill unrelated Caddy processes. -#### Scenario: Work exceeds a phase -- **WHEN** an owned operation exceeds its shutdown phase -- **THEN** Cadder escalates only that owned work and continues teardown +#### Scenario: Graceful child shutdown stalls +- **WHEN** the owned Caddy child exceeds the graceful deadline +- **THEN** the daemon SHALL terminate only its owned process and complete teardown +- **AND** unrelated Caddy processes SHALL remain untouched ### Requirement: RUN-004: Trusted configuration is immutable per start The daemon SHALL load one non-profile configuration snapshot from trusted sources at startup and pin the selected real Caddy executable until Restart. @@ -32,3 +35,11 @@ The daemon SHALL load one non-profile configuration snapshot from trusted source #### Scenario: Configuration changes while running - **WHEN** a trusted file changes after readiness - **THEN** the active daemon retains its existing snapshot until Restart + +### Requirement: RUN-005: Owner clients can contact explicitly elevated daemons +Normal operation SHALL run at user privilege. A same-owner ordinary client SHALL retain authenticated access to an explicitly elevated daemon. Unix root startup SHALL require an explicit runtime directory and owner UID. Other users and remote clients SHALL be denied without weakening filesystem or ACL policy. + +#### Scenario: Ordinary owner contacts elevated endpoint +- **WHEN** the ordinary owner authenticates to an explicitly elevated daemon +- **THEN** documented operations SHALL remain available under the same policy +- **AND** a different account SHALL be rejected before product handlers execute diff --git a/openspec/specs/distribution-and-upgrades/spec.md b/openspec/specs/distribution-and-upgrades/spec.md index d5b50b5..3c50a1d 100644 --- a/openspec/specs/distribution-and-upgrades/spec.md +++ b/openspec/specs/distribution-and-upgrades/spec.md @@ -4,74 +4,40 @@ Define maintained portable releases and coherent manual upgrades. ## Requirements ### Requirement: DIST-001: Release binaries are version matched -Cadder SHALL version and release the `cadder`, `cadderd`, and `caddy` executables together in one application archive for Windows x64, Linux x64, Intel macOS, and Apple Silicon macOS. +Cadder SHALL have equal, required npm and Node SEA GitHub Release channels with the same version and cadder/cadderd/caddy functionality. Standalone targets SHALL include Windows x64, Linux x64, macOS x64 and macOS arm64. Both channels SHALL pass acceptance before migration completion or final release readiness. -#### Scenario: Unified tag is planned -- **WHEN** cargo-dist plans tag `v1.0.0` -- **THEN** it includes one 1.0.0 application with all three executables on all four targets +#### Scenario: One channel is ready +- **WHEN** npm passes but any supported standalone variant remains unverified +- **THEN** the migration SHALL remain incomplete rather than treating SEA as optional ### Requirement: DIST-002: Archives are portable and verifiable -Each cargo-dist archive MUST contain exactly the three expected executables, README, changelog, Apache-2.0 license, and sample configuration and MUST have a SHA-256 checksum. +Each standalone archive SHALL contain three version-matched Node SEA entries and required accepted assets, a SHA-256 checksum and release-source provenance. Every target SHALL be extracted outside checkout and pass native CLI/TUI, state/log SQLite worker and owned subprocess smoke without Node/npm on PATH. Standalone SHALL NOT require installed Node, download runtime components or bundle real Caddy. Existing release attestation verification SHALL bind assets to their exact tag/source commit/repository/signer before publication. -Before publication, each archive MUST be extracted into a fresh directory outside the source checkout on its native operating system. The verifier MUST check the checksum, exact file set, executable permissions where applicable, and successful `--version` and `--help` behavior for all three executables. Published archives, checksums, source archive, cargo-dist manifest MUST carry GitHub artifact attestations from the release workflow. A downstream package workflow MUST bind attestation verification to the selected tag ref, its exact source commit, the Cadder repository, and that signer workflow. - -#### Scenario: User downloads a release -- **WHEN** an archive and checksum are obtained from one release -- **THEN** the checksum can be verified before extraction without an installer -- **AND** a GitHub attestation can associate the asset with the exact release source and workflow - -#### Scenario: Release archive is verified -- **WHEN** a release archive is evaluated before publication or downstream packaging -- **THEN** only the accepted binaries and static release files are present -- **AND** each executable reports the planned version and exposes help outside the source checkout -- **AND** Unix executables retain their executable permissions -- **AND** downstream verification rejects an attestation for a different ref, commit, repository, or signer workflow +#### Scenario: Consumer has no Node installation +- **WHEN** a supported standalone archive is used with Node/npm absent from PATH +- **THEN** all three entries, TUI, SQLite worker and owned child workflows SHALL work +- **AND** checksum/provenance verification SHALL apply to the exact tested archive ### Requirement: DIST-003: Upgrades replace the coherent executable set -Public guidance MUST instruct the user to stop through the TUI, replace all three executables from one release, and restart through managed run or the TUI. - -#### Scenario: Mixed versions connect -- **WHEN** only part of the executable set was replaced -- **THEN** the exact handshake rejects the mismatch before mutation - -### Requirement: DIST-004: npm installs the coherent native application - -Cadder SHALL publish one public `cadder` launcher package and one platform package for each supported release target. Every package in one release MUST use the same version, and the root package MUST depend on the matching platform packages by exact optional dependency versions. - -The root package MUST expose `cadder`, `cadderd`, and the Cadder `caddy` shim. It MUST select only the package matching the current operating system, architecture, and C runtime, launch the native executable without a shell, preserve standard streams and exit status, and report an actionable error for an unsupported platform or omitted optional dependency. - -The npm installation MUST NOT bundle, download, replace, or claim to provide the independently installed upstream Caddy server. +Transition guidance SHALL require stopping the old daemon before switching the coherent release set. Node SHALL re-register projects from existing files without importing or deleting old runtime data. Old releases SHALL remain available for rollback without automatic deprecation. Mixed Rust/Node IPC SHALL not be supported. Transition SHALL NOT require IIS handoff or autostart operations absent from the released product. -#### Scenario: One-off operator invocation +#### Scenario: User switches to Node and later rolls back +- **WHEN** the user follows the documented transition with each daemon stopped +- **THEN** existing configuration and old data/releases SHALL remain available +- **AND** rollback SHALL use the matched old set, not mixed IPC or imported old data -- **WHEN** a user runs `npx cadder` on a supported platform with optional dependencies enabled -- **THEN** the matching native `cadder` executable runs with the requested arguments -- **AND** its version matches the root and platform package versions +### Requirement: DIST-004: npm installs one Node application package +npm SHALL deliver one cadder tarball with three bin entries on Node 24 LTS, minimum 24.18.0. It SHALL contain compiled JS and required assets from the shared TS sources, not platform optionalDependencies or native Rust executables. Installation SHALL require no TS loader/compiler, install-time build, postinstall runtime download or bundled upstream Caddy. Local/global installs SHALL preserve arguments, streams, status and non-recursive PATH behavior. -#### Scenario: Global installation - -- **WHEN** a user installs `cadder` globally on a supported platform -- **THEN** `cadder`, `cadderd`, and the Cadder `caddy` shim are available as commands -- **AND** all three native executables report the same release version - -#### Scenario: Upstream Caddy remains separate - -- **WHEN** the npm-installed Cadder shim needs a real Caddy executable -- **THEN** the npm launcher cannot be selected as upstream Caddy -- **AND** real Caddy resolution continues through Cadder's existing trusted sources - -#### Scenario: Optional package is unavailable - -- **WHEN** the platform package was omitted or the current platform is unsupported -- **THEN** the launcher exits unsuccessfully without downloading or compiling an executable -- **AND** the diagnostic identifies the missing package or unsupported target +#### Scenario: Clean consumer installs the packed tarball +- **WHEN** a supported consumer installs locally or globally without build tools +- **THEN** cadder, cadderd and the caddy shim SHALL run from the one package +- **AND** upstream Caddy SHALL remain a separately configured prerequisite ### Requirement: DIST-005: npm and portable archives share release identity +npm and SEA SHALL be built from the same source revision with one matching release version and pass the same functional catalog. Their artifact bytes need not match because packaging differs. A verified 2.0.0-rc.1 in both channels SHALL precede 2.0.0 acceptance. Tagging and publication SHALL require separate explicit authorization; artifact preparation alone SHALL NOT publish either channel. -Every npm platform package SHALL contain the exact three executable files taken from the corresponding verified cargo-dist release archive. npm packaging MUST preserve executable permissions where applicable and MUST NOT alter the native executable bytes. - -#### Scenario: Release channel comparison - -- **WHEN** a native executable from an npm platform package is compared with the corresponding verified GitHub Release archive -- **THEN** their cryptographic digests match -- **AND** both report the same Cadder release version +#### Scenario: Candidate channels are compared +- **WHEN** packed npm and extracted SEA are accepted for a candidate +- **THEN** source revision, reported version and observable functional outcomes SHALL match +- **AND** no publication SHALL occur without its separate authority diff --git a/openspec/specs/documentation-experience/spec.md b/openspec/specs/documentation-experience/spec.md index cdd01db..1370ec0 100644 --- a/openspec/specs/documentation-experience/spec.md +++ b/openspec/specs/documentation-experience/spec.md @@ -6,18 +6,20 @@ Define accurate, audience-scoped documentation for users and contributors. ## Requirements ### Requirement: DOC-001: User documentation follows the retained journey -User documentation SHALL describe portable extraction, trusted real-Caddy configuration, managed `caddy run`, the TUI, and coherent manual upgrades without removed CLI, profile, alias installer, autostart, history, export, tail, watch, mock, or hidden-flag claims. +Astro/Starlight documentation SHALL describe verified Node routes-first TUI, existing CLI lifecycle/project/domain/inspection commands, bounded diagnostic logs and coherent transition workflows in English, without exposing private/test-only context. Published 2.0 instructions SHALL only advertise flows that pass actual product acceptance. -#### Scenario: New user follows Quick Start -- **WHEN** a user follows the published steps -- **THEN** every command exists in Cadder 1.0 and no repository-only seam is required +#### Scenario: User follows a Node guide +- **WHEN** a user follows a documented 2.0 workflow +- **THEN** commands SHALL exist in the tested product rather than fixture handlers +- **AND** old Rust data SHALL remain protected during the documented transition ### Requirement: DOC-002: Contributor tooling is direct and reproducible -Contributor documentation SHALL use pinned mise tasks and direct maintained tools for Rust, OpenSpec, Astro, coverage, and cargo-dist. +Contributor documentation SHALL use the exact Node/Nub TS, OpenSpec, Astro, coverage and npm/SEA workflow, replacing Bun and obsolete Rust tasks after the cutover gate. Sources SHALL remain TS/TSX and generated artifacts SHALL not be committed. SEA's separate Node builder SHALL not change npm's Node minimum. -#### Scenario: Contributor discovers tasks -- **WHEN** a contributor runs `mise tasks` -- **THEN** formatting, linting, tests, specs, docs, coverage, TUI web preview, and distribution tasks are visible +#### Scenario: Contributor prepares artifacts +- **WHEN** a contributor follows the documented pack verification workflow +- **THEN** both channels SHALL use the shared sources and outside-checkout staging +- **AND** no loader/build SHALL be required during consumer npm installation ### Requirement: DOC-003: Private context does not leak Published documentation MUST NOT contain personal absolute paths, `.local`, credentials, mock-only commands, or agent-only operational details. @@ -27,24 +29,9 @@ Published documentation MUST NOT contain personal absolute paths, `.local`, cred - **THEN** only portable reader-facing content is included ### Requirement: DOC-004: Installation guidance distinguishes npm, Cadder, and Caddy +Verified npm and standalone GitHub downloads SHALL be presented as equal channels with the same functionality/version. npm SHALL document Node 24 LTS minimum 24.18.0; standalone SHALL require no installed Node/npm. Both SHALL identify upstream Caddy as separate. Unpublished/unverified versions SHALL not be presented as available, and SEA's mechanism status SHALL not demote its channel. -Public installation guidance SHALL present `npx cadder`, global npm installation, and portable GitHub archives only when the corresponding channel has passed its release verification. It MUST explain that npm installs the Cadder operator, daemon, and `caddy` shim while the upstream Caddy web server remains a separate prerequisite. - -The hero and getting-started path MUST give users adjacent actions for npm installation or download and documentation without presenting an unpublished package as available. When npm is available, the hero MUST keep the upstream Caddy prerequisite beside a complete first-run Cadder command that starts or attaches to the daemon and opens the operator without an undisclosed interaction. - -#### Scenario: npm channel is not yet public - -- **WHEN** the current Cadder version is not verified in the public npm registry -- **THEN** published documentation does not instruct users to install that version from npm - -#### Scenario: npm channel is verified - -- **WHEN** the exact Cadder version passes clean-room registry installation on every supported platform -- **THEN** the hero and installation guide may present npm as a supported option -- **AND** they retain a direct GitHub download path and the separate upstream Caddy requirement - -#### Scenario: New user follows the hero - -- **WHEN** a new user reads the npm-enabled hero -- **THEN** the user can identify the separate upstream Caddy prerequisite -- **AND** copy one Cadder command that starts or attaches to the daemon and opens the TUI without another required interaction +#### Scenario: Consumer chooses standalone +- **WHEN** the consumer selects a supported GitHub archive instead of npm +- **THEN** instructions SHALL offer the same product workflow without Node installation +- **AND** neither channel SHALL be described as secondary or optional diff --git a/openspec/specs/local-control-plane/spec.md b/openspec/specs/local-control-plane/spec.md index fc299dc..76a6037 100644 --- a/openspec/specs/local-control-plane/spec.md +++ b/openspec/specs/local-control-plane/spec.md @@ -6,22 +6,30 @@ Define the owner-authenticated exact-version IPC contract. ## Requirements ### Requirement: IPC-001: Local transport is owner authenticated and bounded -Cadder MUST use owner-protected local IPC with peer authentication and newline-delimited JSON frames no larger than 1 MiB. +Cadder SHALL use NDJSON frames no larger than 1 MiB over Unix sockets or Windows named pipes with bounded connections/timeouts, owner-only protected runtime/secret storage and security policy 2. Both peers SHALL authenticate using fresh direction-bound HMAC challenges before RPC. Secret bytes SHALL NOT cross IPC. Unsafe ownership, links, modes or ACLs SHALL fail closed rather than be silently repaired or weakened. -#### Scenario: Different user connects -- **WHEN** the peer identity does not match the runtime owner -- **THEN** Cadder rejects the connection without disclosing private account data +#### Scenario: Spoofed endpoint or unsafe secret file +- **WHEN** endpoint proof fails or the secret/runtime protection is unsafe +- **THEN** the client or daemon SHALL reject contact without performing RPC +- **AND** it SHALL NOT transmit the secret or repair the unsafe boundary implicitly + +#### Scenario: Replay or reflected proof +- **WHEN** a prior proof or authenticated RPC frame is replayed or reflected +- **THEN** fresh challenge or directional sequence validation SHALL reject it +- **AND** no product mutation SHALL execute ### Requirement: IPC-002: Handshake requires one exact version -Every connection MUST begin with one exact protocol-version handshake, and a mismatch MUST close before dispatching an operation. +Every connection SHALL validate protocol 3 and security policy 2 during mutual authentication and SHALL reject mismatches before product dispatch. The Node product SHALL NOT communicate with Rust IPC or negotiate legacy capabilities. -#### Scenario: Mixed binaries -- **WHEN** a client and daemon use different protocol versions -- **THEN** the daemon returns a correlated incompatibility error and performs no mutation +#### Scenario: Rust daemon remains running +- **WHEN** a Node client encounters an old Rust endpoint or incompatible peer +- **THEN** it SHALL NOT attach or mutate it +- **AND** isolated v2 discovery SHALL preserve the old runtime and its data ### Requirement: IPC-003: One typed envelope carries eight operations -The protocol SHALL use one typed request envelope and one correlated result-or-error response envelope for register, unregister, heartbeat, query state, set entrypoint activation, set domain activation, query logs, and shutdown. +RPC SHALL preserve the eight released operations: register, unregister, heartbeat, query state, set entrypoint activation, set domain activation, query logs and shutdown. Zod SHALL validate their closed method/payload/result/error catalog; unknown methods/fields SHALL be rejected. Each response SHALL correlate the request and contain exactly one result or typed error. CLI/TUI SHALL use one shared client service. Migration SHALL NOT add history, IIS, autostart or presentation-specific RPC methods. -#### Scenario: Unknown operation or field -- **WHEN** a request names an operation or payload field outside the exact contract -- **THEN** Cadder returns a typed rejection and does not invoke an ad hoc decoder +#### Scenario: Malformed product request +- **WHEN** a request violates its method schema or names an unknown method +- **THEN** it SHALL receive a bounded typed rejection without invoking a handler +- **AND** valid responses SHALL contain exactly one correlated outcome diff --git a/openspec/specs/operator-cli/spec.md b/openspec/specs/operator-cli/spec.md index a4292c6..2c2fa16 100644 --- a/openspec/specs/operator-cli/spec.md +++ b/openspec/specs/operator-cli/spec.md @@ -4,37 +4,21 @@ Define the intentionally small command surface of the operator executable. ## Requirements ### Requirement: CLI-001: The operator exposes focused developer workflows -`cadder` MUST support help, version, `tui`, daemon lifecycle and status, project listing and activation, domain listing and activation, port inspection and guarded process termination, Caddyfile inspection, explicit diagnostics, and bounded redacted log reads. The TUI command MUST support an explicit `--start-daemon` option that reuses the existing attach-first daemon launch contract before opening the operator. It MUST reject profile, machine-output, history, export, continuous tail, watch, and autostart commands. +Commander-based cadder SHALL preserve the released 1.0.5 help/version, status, daemon lifecycle, project/domain controls, port/Caddyfile inspection, diagnostics and bounded redacted log commands, with stable arguments, human-readable output and exit status through the shared client service. Bare cadder SHALL print help successfully without starting TUI or the daemon, regardless of TTY. cadder tui SHALL open the routes workspace without implicit daemon startup; its explicit --start-daemon option SHALL use the shared attach-first bounded launch contract. Explicit daemon start/restart and managed caddy run SHALL retain their launch policy. Other state and inspection commands SHALL remain attach-only. Profile, machine-output, history, export, continuous tail, watch, IIS and autostart commands SHALL be rejected as unsupported. #### Scenario: Bare invocation -- **WHEN** a user runs `cadder` without a subcommand -- **THEN** Cadder prints help successfully without starting the TUI +- **WHEN** cadder has no subcommand, with or without a TTY +- **THEN** it SHALL print help successfully without starting a daemon or interactive session -#### Scenario: TUI invocation -- **WHEN** a user runs `cadder tui` -- **THEN** Cadder opens the interactive operator without changing the existing daemon lifecycle +#### Scenario: Explicit TUI startup option +- **WHEN** the user runs cadder tui --start-daemon +- **THEN** Cadder SHALL start or attach through the bounded shared launch path before opening the TUI -#### Scenario: TUI invocation with daemon startup -- **WHEN** a user runs `cadder tui --start-daemon` -- **THEN** Cadder starts or attaches to `cadderd` through the existing bounded launch path -- **AND** opens the interactive operator after the daemon is ready without requiring another interaction +#### Scenario: CLI state command while daemon is offline +- **WHEN** a state command cannot attach +- **THEN** it SHALL report actionable offline state with the stable exit contract +- **AND** it SHALL NOT start a daemon implicitly -#### Scenario: General state inspection -- **WHEN** a user runs `cadder status`, `cadder projects list`, or `cadder domains list` -- **THEN** Cadder reports current daemon-backed state using developer-facing project and domain terminology - -#### Scenario: Activation management -- **WHEN** a user explicitly enables or disables a selected project or domain -- **THEN** Cadder applies the mutation through the daemon and reports the result - -#### Scenario: Daemon lifecycle management -- **WHEN** a user explicitly starts, stops, or restarts `cadderd` -- **THEN** Cadder uses the existing attach-first, bounded stop, or ordered restart operation and reports the outcome - -#### Scenario: On-demand diagnostics -- **WHEN** a user requests diagnostics or bounded logs for the runtime, a project, or a domain -- **THEN** Cadder reports the selected diagnostic data without adding it back to the primary TUI - -#### Scenario: Removed command -- **WHEN** a user invokes a profile, machine-output, history, export, continuous tail, watch, or autostart command -- **THEN** Cadder rejects it as unsupported CLI input +#### Scenario: Unsupported expansion is requested +- **WHEN** the user requests machine output, history, export, continuous tail, watch, profiles, IIS or autostart +- **THEN** Cadder SHALL reject the unsupported input rather than restoring an older command surface diff --git a/openspec/specs/operator-tui/spec.md b/openspec/specs/operator-tui/spec.md index 2a083ec..35ce9bc 100644 --- a/openspec/specs/operator-tui/spec.md +++ b/openspec/specs/operator-tui/spec.md @@ -6,11 +6,12 @@ Define the keyboard-operated Cadder 1.0 interface. ## Requirements ### Requirement: TUI-001: One routes-first workspace exposes current state -The TUI SHALL render every project and domain in one primary table and SHALL support collections of arbitrary length without fixed row-count assumptions. Domain rows SHALL use tree connectors beneath their project, and projects SHALL follow one another without empty separator rows. The TUI SHALL NOT expose a separate activity or logs view. +Ink/React TUI SHALL render real daemon-backed projects and domains in one routes-first primary table through the same client service used by CLI, without production mocks or random statuses. Domain rows SHALL use tree connectors beneath their project, with no empty separator rows between projects. Collections SHALL support scrolling without fixed lengths. The TUI SHALL NOT add separate activity, logs, history, IIS or autostart views; explicit diagnostics and bounded logs SHALL remain CLI surfaces. -#### Scenario: Many registrations -- **WHEN** more projects or domains exist than fit in the viewport -- **THEN** selection and scrolling remain bounded by the actual collection length +#### Scenario: Operator changes a domain +- **WHEN** the user changes activation in the routes workspace +- **THEN** the TUI SHALL render committed daemon state without unrelated diagnostic details +- **AND** fixture data SHALL be limited to tests/development, not production ### Requirement: TUI-002: Routine state is quiet and exceptional state is actionable The TUI SHALL keep `cadderd` connection state and Caddy runtime state separately visible in the header, SHALL use filled and empty shape markers for route activation, and SHALL omit diagnostic identifiers, redundant counts, and repeated state labels from the primary workspace. Neutral colors SHALL carry routine structure while the green accent is reserved for focus, enabled state, and available keys. @@ -24,11 +25,12 @@ The TUI SHALL keep `cadderd` connection state and Caddy runtime state separately - **THEN** the interface presents the failure and available recovery guidance inline ### Requirement: TUI-003: Lifecycle actions are explicit -The primary workspace MUST provide idempotent Start, confirmed bounded Stop, and ordered Restart actions with pending, offline, failure, and success states. +TUI SHALL offer idempotent explicit start while offline, confirmed bounded stop and ordered restart with pending/success/failure states. Startup SHALL follow the shared client launch boundary. Opening cadder tui without --start-daemon SHALL NOT imply daemon start; the explicit --start-daemon option SHALL start or attach before rendering. -#### Scenario: Restart -- **WHEN** the user confirms Restart -- **THEN** the TUI observes shutdown before launching and confirming the new daemon +#### Scenario: Offline TUI opens +- **WHEN** the user opens cadder tui without --start-daemon and no daemon is running +- **THEN** TUI SHALL display offline state and offer an explicit start action +- **AND** restart SHALL observe prior shutdown before starting the replacement ### Requirement: TUI-004: Rendering remains accessible and recoverable The TUI MUST support keyboard-only navigation, visible focus, shape-based state meaning independent of color, bounded layouts, and terminal restoration after success, error, panic, or cancellation. Space and Enter SHALL both toggle the selected project or domain while Enter SHALL start `cadderd` when the daemon is offline. diff --git a/openspec/specs/product-topology/spec.md b/openspec/specs/product-topology/spec.md index 8eefc13..04ad431 100644 --- a/openspec/specs/product-topology/spec.md +++ b/openspec/specs/product-topology/spec.md @@ -4,19 +4,22 @@ Define the minimal Cadder 1.0 product boundary, executable set, and separation between everyday and diagnostic surfaces. ## Requirements ### Requirement: TOP-001: Three executables form one installation -Cadder SHALL ship version-matched `cadder`, `cadderd`, and `caddy` executables that derive one runtime identity from their installation directory. +Cadder SHALL provide version-matched cadder, cadderd and caddy entrypoints from one TypeScript product in both npm and standalone distributions. The daemon SHALL be the sole runtime/state/process owner. Runtime v2 SHALL be owner-protected and installation-specific, with deliberately isolated dev/test runtimes; separate installation directories SHALL retain independent endpoints and application databases. Rust/Cargo SHALL be removed from the final product only after complete Node functionality and both release gates. + +#### Scenario: Distribution channel changes +- **WHEN** the owner uses npm or standalone Cadder of the same version +- **THEN** both SHALL expose the same product contracts and owner runtime policy +- **AND** clients SHALL NOT create independent Caddy or persistent state owners #### Scenario: Separate installations -- **WHEN** the same user runs executables from two different installation directories -- **THEN** each installation uses an independent endpoint and database +- **WHEN** the same owner runs Cadder from two different installation directories +- **THEN** each installation SHALL use an independent endpoint and application database +- **AND** each installation's three entrypoints SHALL agree on its runtime identity ### Requirement: TOP-002: Product and diagnostic surfaces remain distinct -Managed `caddy run`, the read-oriented and activation-oriented `cadder` CLI, and `cadder tui` SHALL be the user-facing 1.0 surfaces, while foreground `cadderd`, bounded logs, and explicit process details SHALL remain diagnostic surfaces. MCP SHALL NOT be presented as a Cadder product surface. - -#### Scenario: User opens the operator -- **WHEN** the user runs `cadder tui` -- **THEN** Cadder opens the routes workspace with daemon, Caddy, and activation state +Cadder SHALL expose CLI and TUI workflows through one client service/model boundary, with diagnostics requested explicitly. Production state SHALL come from the daemon, not mocks or random values. The current routes-first TUI and bounded CLI diagnostic surfaces SHALL remain distinct. This migration SHALL NOT restore superseded history, IIS, autostart, machine-output, profile, export, watch or continuous-tail surfaces, or include MCP, Web UI, Tauri, Bun, custom WASM, native runtime addons or new installers. -#### Scenario: Developer inspects current state -- **WHEN** the user runs a supported status, project, domain, port, or Caddyfile command -- **THEN** Cadder reports the requested current state without exposing unrelated local diagnostics +#### Scenario: Client renders project state +- **WHEN** CLI or TUI renders a project, domain or lifecycle result +- **THEN** both SHALL use the same daemon-backed model and canonical state +- **AND** neither SHALL inspect daemon storage or Caddy administration directly diff --git a/openspec/specs/project-registration/spec.md b/openspec/specs/project-registration/spec.md index 99548dc..24fa0fb 100644 --- a/openspec/specs/project-registration/spec.md +++ b/openspec/specs/project-registration/spec.md @@ -6,11 +6,11 @@ Define live shim-owned entrypoints and exclusive domains. ## Requirements ### Requirement: REG-001: Registration belongs to one live shim session -Each managed run MUST register a validated entrypoint with a session nonce, renew it by heartbeat, reconnect safely, and unregister it on clean exit or connection loss. +Managed run SHALL register a validated entrypoint owned by a session nonce, renew it through heartbeat, reconnect safely and unregister on clean exit or lost ownership. Ctrl+C, expired heartbeat and daemon restart SHALL not leave a stale active route. Another session SHALL NOT alter the current owner's lease. -#### Scenario: Heartbeat owner differs -- **WHEN** a heartbeat or detach uses the wrong session nonce -- **THEN** Cadder rejects it without changing the registration +#### Scenario: Old shim detaches a replacement session +- **WHEN** a stale shim sends heartbeat or unregister for a replacement owner +- **THEN** the daemon SHALL reject it and preserve current registration ### Requirement: REG-002: Domains have one active owner Canonical domain ownership MUST be exclusive across active entrypoints, regardless of input case or international-domain spelling. @@ -25,3 +25,11 @@ Cadder MAY restore stable entrypoint identity and desired activation after resta #### Scenario: Daemon restarts - **WHEN** SQLite contains a previously committed entrypoint - **THEN** it remains inactive until a current shim registers it + +### Requirement: REG-004: Existing project formats and controls remain compatible +The Node product SHALL preserve existing cadder.toml and Caddyfiles, documented configuration precedence, multi-project registration, domain filtering and activation workflows. It SHALL validate inputs through the shared contract and reject active domain conflicts without replacing the last committed owner. + +#### Scenario: Existing project is registered after transition +- **WHEN** the user registers an existing configuration through Node managed run +- **THEN** the daemon SHALL accept its retained format without a project rewrite +- **AND** active domain conflicts SHALL preserve the previous committed state diff --git a/openspec/specs/quality-tooling/spec.md b/openspec/specs/quality-tooling/spec.md index 647d95e..1804da9 100644 --- a/openspec/specs/quality-tooling/spec.md +++ b/openspec/specs/quality-tooling/spec.md @@ -4,119 +4,58 @@ Define the maintained contributor toolchain, validation gate, and release automation boundary. ## Requirements ### Requirement: QT-001: Declarative contributor environment -Cadder SHALL define its supported development-tool versions, task entrypoints, task dependencies, working directories, and non-secret development environment values in committed `mise` configuration with a committed lockfile. +The Node product SHALL declare exact dependencies, tool versions and locks, managed by Nub, without private paths/credentials. TypeScript SHALL be 6.0.3; the npm-tested Node minimum SHALL be 24.18.0 on Node 24 LTS. SEA SHALL use a separately verified, exact Node 26 builder pin. Required tools SHALL work on Windows/Linux/macOS without a custom compiled task runner after cutover. -The configuration MUST work from a fresh checkout on Windows, Linux, and macOS without requiring a repository-specific compiled task runner. It MUST NOT contain credentials, personal paths, `.local` paths, or values that alter ordinary product behavior outside an explicitly scoped development task. - -#### Scenario: Fresh contributor setup -- **WHEN** a contributor installs `mise` and prepares the declared project environment from a fresh checkout -- **THEN** the pinned Rust, documentation, specification, coverage, and release tools become available -- **AND** no `xtask`, `just`, Nushell, or private workstation configuration is required - -#### Scenario: Development-only environment -- **WHEN** a contributor runs a task that requires the mock Caddy backend -- **THEN** the mock setting applies only to that task and its children -- **AND** an ordinary Cadder command does not inherit the mock setting from committed global configuration +#### Scenario: Fresh Node-only checkout +- **WHEN** a contributor follows the declared setup after Rust removal +- **THEN** Node tests/build/docs/packaging SHALL work without Cargo/Rust or private setup ### Requirement: QT-002: One discoverable task surface -The supported repository task surface SHALL be discoverable through `mise tasks` and SHALL use `mise run ` as its stable documented invocation. - -Tasks MUST remain small adapters to an owning tool. The repository MUST NOT add a second general command runner, embed substantial shell programs in task configuration, or recreate a command-dispatch framework in Rust or another language. +Node validation/build/pack tasks SHALL be discoverable as Nub package scripts, with focused commands and a complete check. Existing tool provisioning MAY remain as a thin adapter during cutover, not a competing policy framework. Custom scripts SHALL be limited to Cadder-specific packaging/acceptance seams. -#### Scenario: Contributor discovers validation commands -- **WHEN** a contributor lists project tasks -- **THEN** the output identifies focused formatting, linting, testing, specification, documentation, coverage, complete-check, and release-verification tasks -- **AND** each task delegates its work to the tool that owns that responsibility - -#### Scenario: New automation is proposed -- **WHEN** a proposed task would parse a maintained tool's format or reproduce its policy -- **THEN** the proposal is rejected in favor of that tool's supported command or configuration -- **AND** any claimed exception requires an explicit Cadder product invariant and a focused verification owner +#### Scenario: Contributor runs complete checks +- **WHEN** the documented Node check/pack verification commands run +- **THEN** each SHALL delegate to its owning maintained tool +- **AND** there SHALL be no replacement general-purpose custom command dispatcher ### Requirement: QT-003: Maintained tools own validation and release formats -Cargo SHALL own Rust formatting, linting, builds, and tests; `cargo-llvm-cov` SHALL own coverage calculation and the configured threshold; the official OpenSpec CLI SHALL own specification validation; npm and Astro SHALL own documentation dependency and build checks; and `cargo-dist` SHALL own release planning, portable archives, included release files, SHA-256 checksums, and generated GitHub Release automation. - -Cadder MAY apply one deterministic, focused postprocessor to cargo-dist's generated workflow only for security invariants that cargo-dist cannot express. The adapter MUST fail when its expected generated structure changes, MUST be verified by the supported repository task surface, and MUST NOT recreate release planning, artifact construction, checksums, or publication policy. +tsc noEmit, ESLint, Prettier, Vitest/V8 and ink-testing-library SHALL own their checks; own TS/TSX line coverage SHALL be at least 85%, including hard runtime modules without selective exclusion. npm emission SHALL use tsc with relative import rewriting; SEA bundling SHALL use esbuild and the pinned Node builder. All generated Node JS/bundles/archives/coverage SHALL go to system or runner temp outside checkout, never beside source or into tracked files. Astro/Starlight SHALL own documentation checks/builds with Nub-managed dependencies. -Cadder-specific executable behavior MUST be verified by tests in the package that owns the executable. Repository checks that only restate a native manifest, generated release plan, or accepted OpenSpec declaration MUST NOT be retained as independent policy engines. - -#### Scenario: Complete repository gate -- **WHEN** a contributor or CI runs the complete supported check task -- **THEN** each required validation runs through its owning maintained tool or focused product test -- **AND** a failure identifies the owning validation boundary - -#### Scenario: Portable release is planned -- **WHEN** release automation plans a Cadder version -- **THEN** `cargo-dist` describes one portable application for every supported target with the accepted binaries, included files, and SHA-256 checksums -- **AND** the committed workflow equals cargo-dist's generated output after the focused security hardening pass +#### Scenario: Pack verification runs +- **WHEN** the same source is checked and packed for either channel +- **THEN** the supported tools SHALL produce only outside-checkout Node artifacts +- **AND** tracked files SHALL remain unchanged with no emitted source-side JS ### Requirement: QT-004: Local and CI task parity -Continuous integration SHALL invoke the same supported task definitions used locally for platform-independent repository validation. Platform-specific jobs MAY add only the runner, target, permissions, or external service evidence required by their operating-system contract. - -Release pull requests MUST build and upload the complete cargo-dist artifact plan without publishing a release. Planning and artifact-build jobs MUST have read-only repository permissions and MUST NOT receive a publication token. Release tag values MUST be validated as data outside generated shell source. Release publication MUST remain separate from general validation, MUST use the source revision and artifact plan accepted by the release gate, MUST verify the exact tagged archives on their native operating-system runners before announce, and MUST grant write permission only to the stages that attest or publish immutable release assets. - -#### Scenario: Pull request validation -- **WHEN** a pull request runs repository validation -- **THEN** CI installs the pinned project environment and invokes the documented `mise run` tasks -- **AND** the jobs do not receive release publication credentials +CI SHALL invoke the same checks and non-publishing packing process as local verification, with native Windows/Linux/macOS system/security integration and every SEA target. PR jobs SHALL not publish or receive publication credentials. Native exact-artifact verification SHALL precede attest/publish stages with least privilege and source/tag validation. Documentation examples SHALL be tested. -#### Scenario: Release candidate pull request -- **WHEN** cargo-dist evaluates a release pull request -- **THEN** it builds and uploads the complete portable artifact set for inspection with read-only repository access -- **AND** it does not create or modify a public GitHub Release - -#### Scenario: Platform-specific evidence -- **WHEN** a requirement needs Windows, Linux, macOS, or Docker evidence -- **THEN** CI runs the focused task on the required runner or service -- **AND** the platform job does not introduce an alternative repository task implementation - -#### Scenario: Tagged release publication -- **WHEN** a validated supported release tag is built -- **THEN** every target archive is verified on its native runner from a fresh directory outside the checkout -- **AND** GitHub Release publication waits for all verification jobs to succeed and receives write permission only in the publishing path +#### Scenario: Candidate PR is validated +- **WHEN** CI verifies a migration or release candidate +- **THEN** it SHALL check contracts, coverage, native product behavior and both channels +- **AND** artifact preparation SHALL NOT create a public release or registry publication ### Requirement: QT-005: Evidence-gated task-runner removal -The `xtask` package and command surface SHALL be removed only after every retained responsibility is mapped to a maintained tool, a focused owning-package test, or an explicit accepted removal rationale. - -The replacement release path MUST demonstrate the complete supported artifact matrix without publishing, and the replacement complete-check path MUST pass on the same source revision before the old task framework is deleted. +The Rust/Cargo product and obsolete native npm/release packaging SHALL be removed only after complete Node functionality and both distributions pass acceptance at the candidate revision. Each old responsibility SHALL have a verified replacement or explicitly approved removal. Unrelated pre-existing untracked npm content SHALL be inspected and preserved. After removal, the full Node-only gate SHALL pass again without Rust/Cargo installed. -#### Scenario: Migration inventory is reviewed -- **WHEN** removal of `xtask` is proposed -- **THEN** every existing command has a recorded replacement owner or removal rationale -- **AND** no command is translated into a new general-purpose script solely to preserve the old structure - -#### Scenario: Replacement release path is incompatible -- **WHEN** cargo-dist cannot produce the accepted one-application artifact contract without substantial custom glue -- **THEN** implementation stops before deleting the existing release path -- **AND** a follow-up OpenSpec decision chooses a new package model or explicitly scoped adapter +#### Scenario: Rust removal is proposed +- **WHEN** one required capability, platform or distribution still lacks evidence +- **THEN** Rust removal SHALL wait and migration SHALL remain incomplete +- **AND** a foundation checkpoint SHALL NOT be represented as the finished migration ### Requirement: QT-006: npm packages pass clean-room verification +The gate SHALL verify the actual single npm tarball and every extracted SEA archive outside checkout using the publication packing process without publishing. It SHALL verify exact allowed files, version/license/repository identity, all three entries, local/global npm installs, argument/stream/exit fidelity and no wrapper recursion. npm SHALL have no platform optional packages or install scripts; SEA SHALL pass real TUI/SQLite/worker/child smoke without Node/npm PATH. -The npm package gate SHALL verify the exact packed file set, platform metadata, exact optional dependency versions, absence of install lifecycle scripts, and matching license and repository identity before any package is staged. - -On every supported native runner, the gate MUST install locally packed packages in a fresh consumer directory outside the checkout and MUST exercise `--version` and `--help` for `cadder`, `cadderd`, and the Cadder `caddy` shim. The gate MUST prove that the npm `caddy` launcher cannot resolve itself as upstream Caddy. - -#### Scenario: Package candidate is verified - -- **WHEN** npm package tarballs are prepared for a release -- **THEN** every supported platform passes native clean-room installation and command checks -- **AND** unexpected files, lifecycle scripts, version drift, or launcher recursion fail the gate before registry staging +#### Scenario: Clean consumer verification fails +- **WHEN** either channel fails its native packed-consumer catalog +- **THEN** neither the whole migration nor matching final release SHALL be accepted ### Requirement: QT-007: npm publication uses staged trusted publishing -Cadder npm publication SHALL use npm trusted publishing from the exact GitHub-hosted workflow and protected release environment through short-lived OIDC credentials. The trusted publisher MUST allow staged publishing only, package publishing access MUST disallow traditional tokens, and no long-lived npm publish credential may be stored in the repository or GitHub Actions. - -The workflow MUST stage packages only after it verifies the corresponding GitHub Release assets, SHA-256 checksums, and attestations. Every accepted asset attestation MUST identify the selected release tag, its exact source commit, and Cadder's release workflow as signer. npm provenance MUST identify the public repository and publishing workflow. A maintainer MUST review and approve each stage with 2FA, approving all platform packages before the root package. - -#### Scenario: Automated staging -- **WHEN** a verified tagged release reaches the npm workflow -- **THEN** GitHub Actions checks out that exact tag and verifies every consumed asset against its ref, commit digest, repository, and release workflow -- **AND** it obtains a short-lived OIDC publishing identity and stages rather than directly publishes all version-matched packages -- **AND** no npm access token is available to the job +After separate publication approval, the single npm package SHALL retain short-lived OIDC trusted publishing from the exact protected GitHub workflow, staged publishing and maintainer 2FA review, without long-lived npm tokens. The consumed candidate SHALL match the verified source revision/version and complete npm/SEA evidence. The obsolete native-platform-package approval order SHALL not remain a dependency of the new single package. -#### Scenario: Human publication approval -- **WHEN** the staged package set is ready for publication -- **THEN** a maintainer reviews the staged artifacts and approves them with 2FA -- **AND** the root package cannot become public before every referenced platform package +#### Scenario: Authorized candidate reaches publishing +- **WHEN** the separately approved matched candidate enters the publishing workflow +- **THEN** it SHALL stage the exact verified single package with trusted provenance +- **AND** a maintainer SHALL approve publication without an embedded persistent token ### Requirement: QT-008: New npm names use an explicit bootstrap diff --git a/openspec/specs/runtime-storage/spec.md b/openspec/specs/runtime-storage/spec.md index e88e339..3392f36 100644 --- a/openspec/specs/runtime-storage/spec.md +++ b/openspec/specs/runtime-storage/spec.md @@ -6,29 +6,32 @@ Define the single SQLite durability boundary, its ownership rules, and the guara ## Requirements ### Requirement: STO-001: One owner-protected SQLite database is authoritative -Cadder MUST store application state only in `data/cadder.sqlite3` beneath the installation runtime using the bundled SQLite engine and owner-only filesystem permissions. +The Node daemon SHALL own a protected v2 application-state and diagnostic-log database using node:sqlite, separate from the lifetime exclusion database. No client SHALL directly access either database. Initial schema creation SHALL be transactional and finish before product operations are accepted. -#### Scenario: Database is created -- **WHEN** the daemon initializes an empty runtime -- **THEN** it creates schema version 1 in one transaction before accepting operations +#### Scenario: Empty v2 application database +- **WHEN** the Node daemon first creates application-state and diagnostic-log storage +- **THEN** it SHALL create and validate its schema under owner-only protection +- **AND** the separate SQLite exclusion connection SHALL remain held ### Requirement: STO-002: Database work is single flight and bounded -One `tokio-rusqlite` connection SHALL serialize database closures behind a pre-call permit with an interrupt handle, rollback journal, full synchronous commits, foreign keys, busy timeout, integrity validation, and bounded close. +One worker SHALL serialize application-state and diagnostic-log database work, with bounded jobs, transaction rollback, integrity checks and orderly close. Worker failures SHALL settle pending requests with typed errors and SHALL NOT release daemon exclusion while owned work/resources remain active. -#### Scenario: Operation deadline expires -- **WHEN** a database closure exceeds its deadline -- **THEN** Cadder interrupts it and waits for a terminal result before releasing the permit +#### Scenario: Worker or transaction fails +- **WHEN** persistence cannot finish successfully +- **THEN** uncommitted data SHALL be rolled back and pending callers informed +- **AND** shutdown SHALL settle worker work before releasing runtime exclusion -### Requirement: STO-003: Schema compatibility is exact before 1.0 -Cadder MUST initialize version zero only when the database is otherwise empty and MUST reject partial, corrupt, or newer schemas before cleanup or runtime mutation. +### Requirement: STO-003: The Node schema is validated before mutation +Startup SHALL initialize only an empty uninitialized Node database, and SHALL reject partial, corrupt or unsupported/newer schemas before runtime mutation. It SHALL NOT delete unrelated entries to recover from schema failure. -#### Scenario: Partial database exists -- **WHEN** required tables or indexes do not match schema version 1 -- **THEN** startup fails with a storage diagnostic without deleting unrelated data +#### Scenario: Partial schema exists +- **WHEN** database objects or integrity do not match the supported Node schema +- **THEN** startup SHALL fail with a storage diagnostic without destructive repair -### Requirement: STO-004: Legacy cleanup is allowlisted -After endpoint ownership and healthy database validation, Cadder MAY delete only the named pre-1.0 storage artifacts beneath `data`, MUST refuse link traversal, and MUST preserve unknown entries and the database. +### Requirement: STO-004: Old runtime data remains untouched +The Node migration SHALL NOT import, delete or implicitly clean up the Rust runtime or database. Projects SHALL re-register from existing configuration. Old releases and data SHALL remain available for rollback; stable intent SHALL NOT resurrect a live session. Durable logs and state SHALL NOT introduce a history command or view. -#### Scenario: Cleanup partially fails -- **WHEN** one allowlisted artifact cannot be removed -- **THEN** startup continues with a redacted diagnostic and retries that artifact on the next start +#### Scenario: Node first starts beside old data +- **WHEN** old Rust runtime data exists during Node startup +- **THEN** Node SHALL use its separate v2 storage and leave the old data untouched +- **AND** project routes SHALL become live only through current registration diff --git a/package.json b/package.json new file mode 100644 index 0000000..4bbb712 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "cadder", + "version": "2.0.0-rc.1", + "description": "Daemon-backed Caddy coordination with CLI and TUI clients", + "type": "module", + "private": true, + "packageManager": "nub@0.7.5", + "engines": { + "node": ">=24.18.0 <25" + }, + "scripts": { + "dev": "tsx src/entrypoints/cadder.ts", + "pack:node": "node scripts/pack-node.ts", + "pack:sea": "node scripts/pack-sea.ts", + "verify:node": "node scripts/verify-node.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "coverage": "vitest run --coverage", + "lint": "eslint --flag unstable_native_nodejs_ts_config \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\" \"scripts/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\" \"scripts/**/*.ts\" package.json tsconfig.json vitest.config.ts eslint.config.ts", + "format": "prettier --write \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\" \"scripts/**/*.ts\" package.json tsconfig.json vitest.config.ts eslint.config.ts", + "check": "nub run typecheck && nub run lint && nub run format:check && nub run coverage" + }, + "dependencies": { + "@peculiar/x509": "2.1.0", + "commander": "15.0.0", + "ink": "8.0.0", + "react": "19.3.0", + "reflect-metadata": "0.2.2", + "smol-toml": "1.9.0", + "zod": "4.6.5" + }, + "allowBuilds": { + "esbuild": true + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/node": "24.19.1", + "@types/react": "19.3.0", + "@vitest/coverage-v8": "5.0.3", + "esbuild": "0.28.2", + "eslint": "10.0.3", + "ink-testing-library": "4.0.0", + "prettier": "3.8.3", + "tsx": "4.23.0", + "typescript": "6.0.3", + "typescript-eslint": "8.57.0", + "vitest": "5.0.3" + } +} diff --git a/scripts/licenses/yoga-layout-3.2.1-LICENSE b/scripts/licenses/yoga-layout-3.2.1-LICENSE new file mode 100644 index 0000000..b96dcb0 --- /dev/null +++ b/scripts/licenses/yoga-layout-3.2.1-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/scripts/pack-node.ts b/scripts/pack-node.ts new file mode 100644 index 0000000..9c1e329 --- /dev/null +++ b/scripts/pack-node.ts @@ -0,0 +1,114 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { access, chmod, copyFile, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { sourceIdentity } from './source-identity.ts'; +import { VERSION } from '../src/protocol/version.ts'; + +const execute = promisify(execFile); +export const repositoryRoot = fileURLToPath(new URL('../', import.meta.url)); +export async function npmCli(): Promise { + for (const candidate of [ + join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), + join(dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js'), + ]) { + try { + await access(candidate); + return candidate; + } catch { + /* Try the other standard Node layout. */ + } + } + throw new Error('The Node installation must include npm to pack consumer artifacts.'); +} + +/** Compile and pack the actual consumer package; all outputs stay outside checkout. */ +export async function packNode(): Promise<{ stage: string; archive: string }> { + const temporary = await mkdtemp(join(tmpdir(), 'cadder-node-pack-')); + const stage = join(temporary, 'package'); + await mkdir(stage); + const manifest = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')) as { + name: string; + version: string; + description: string; + engines: Record; + dependencies: Record; + }; + const config = join(temporary, 'tsconfig.json'); + if (manifest.version !== VERSION) + throw new Error('Package and protocol release versions must match.'); + await writeFile( + config, + JSON.stringify({ + extends: join(repositoryRoot, 'tsconfig.json'), + include: ['src/**/*.ts', 'src/**/*.tsx'].map((path) => + join(repositoryRoot, path).replaceAll('\\', '/'), + ), + compilerOptions: { + noEmit: false, + rootDir: join(repositoryRoot, 'src'), + outDir: join(stage, 'lib'), + typeRoots: [join(repositoryRoot, 'node_modules/@types')], + declaration: false, + sourceMap: false, + }, + }), + ); + await execute( + process.execPath, + [join(repositoryRoot, 'node_modules/typescript/bin/tsc'), '--project', config], + { cwd: repositoryRoot }, + ); + await writeFile( + join(stage, 'package.json'), + JSON.stringify( + { + name: manifest.name, + version: manifest.version, + description: manifest.description, + engines: manifest.engines, + dependencies: manifest.dependencies, + type: 'module', + license: 'Apache-2.0', + repository: { type: 'git', url: 'git+https://github.com/MrMaxie/cadder.git' }, + cadderBuild: await sourceIdentity(repositoryRoot), + bin: { + cadder: 'lib/entrypoints/cadder.js', + cadderd: 'lib/entrypoints/cadderd.js', + caddy: 'lib/entrypoints/caddy.js', + }, + files: ['lib', 'LICENSE', 'README.md', 'cadder.toml.example'], + }, + null, + 2, + ) + '\n', + ); + await copyFile(join(repositoryRoot, 'LICENSE'), join(stage, 'LICENSE')); + await copyFile(join(repositoryRoot, 'docs/NODE-CANDIDATE.md'), join(stage, 'README.md')); + await copyFile(join(repositoryRoot, 'cadder.toml.example'), join(stage, 'cadder.toml.example')); + for (const entry of ['cadder', 'cadderd', 'caddy']) + await chmod(join(stage, 'lib/entrypoints', `${entry}.js`), 0o755); + const packed = await execute( + process.execPath, + [await npmCli(), 'pack', stage, '--pack-destination', temporary, '--json', '--ignore-scripts'], + { cwd: temporary, maxBuffer: 1024 * 1024 }, + ); + type PackResult = { filename: string; files: { path: string }[] }; + const report = JSON.parse(packed.stdout) as PackResult[] | Record; + const result = Array.isArray(report) ? report[0] : report[manifest.name]; + if ( + !result || + result.files.some( + ({ path }) => + !['package.json', 'README.md', 'LICENSE', 'cadder.toml.example'].includes(path) && + (!path.startsWith('lib/') || !path.endsWith('.js')), + ) + ) + throw new Error('Unexpected npm package contents.'); + return { stage, archive: join(temporary, result.filename) }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) + process.stdout.write(JSON.stringify(await packNode()) + '\n'); diff --git a/scripts/pack-sea.ts b/scripts/pack-sea.ts new file mode 100644 index 0000000..5be1f82 --- /dev/null +++ b/scripts/pack-sea.ts @@ -0,0 +1,183 @@ +import { build } from 'esbuild'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { promisify } from 'node:util'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { sourceIdentity } from './source-identity.ts'; +import { VERSION } from '../src/protocol/version.ts'; + +const execute = promisify(execFile); +// Verified against https://nodejs.org/en/download/archive/v26.12.0 . +export const seaBuilderVersion = 'v26.12.0'; +const builder = process.argv[2] ? resolve(process.argv[2]) : process.execPath; +if ((await execute(builder, ['--version'])).stdout.trim() !== seaBuilderVersion) + throw new Error( + `SEA requires the verified ${seaBuilderVersion} builder. Pass its absolute executable path.`, + ); +const stage = await mkdtemp(join(tmpdir(), 'cadder-sea-')); +const payload = join(stage, 'cadder'); +await mkdir(payload); +const root = fileURLToPath(new URL('../', import.meta.url)); +const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { + version: string; +}; +if (manifest.version !== VERSION) + throw new Error('Package and protocol release versions must match.'); +const banner = + "import { createRequire as cadderCreateRequire } from 'node:module'; const require = cadderCreateRequire(process.execPath);"; +// Ink guards this optional developer tool behind an installed-peer check. +const common = { + bundle: true, + platform: 'node' as const, + format: 'esm' as const, + target: 'node26', + absWorkingDir: root, + metafile: true as const, + external: ['./devtools.js'], + banner: { js: banner }, + define: { 'process.env.NODE_ENV': '"production"' }, +}; +const worker = join(stage, 'storage-worker.mjs'); +const workerBuild = await build({ + ...common, + entryPoints: [join(root, 'src/storage/worker.ts')], + outfile: worker, +}); +const inputs = new Set(Object.keys(workerBuild.metafile.inputs)); +const artifacts: string[] = []; +for (const entry of ['cadder', 'cadderd', 'caddy']) { + const bundle = join(stage, `${entry}.mjs`); + const bundled = await build({ + ...common, + entryPoints: [join(root, `src/entrypoints/${entry}.ts`)], + outfile: bundle, + }); + for (const input of Object.keys(bundled.metafile.inputs)) inputs.add(input); + const output = join(payload, process.platform === 'win32' ? `${entry}.exe` : entry); + const config = join(stage, `${entry}-sea.json`); + await writeFile( + config, + JSON.stringify({ + main: bundle, + mainFormat: 'module', + output, + disableExperimentalSEAWarning: true, + useCodeCache: false, + useSnapshot: false, + execArgvExtension: 'none', + assets: { 'cadder-storage-worker': worker }, + }), + ); + await execute(builder, ['--build-sea', config], { timeout: 120000, maxBuffer: 1024 * 1024 }); + if (process.platform === 'darwin') await execute('/usr/bin/codesign', ['--sign', '-', output]); + artifacts.push(output); +} +await copyFile(join(root, 'LICENSE'), join(payload, 'LICENSE')); +await copyFile(join(root, 'docs/NODE-CANDIDATE.md'), join(payload, 'README.md')); +await copyFile(join(root, 'cadder.toml.example'), join(payload, 'cadder.toml.example')); +let nodeLicense: string | undefined; +for (const candidate of ['LICENSE', '../LICENSE', '../share/doc/node/LICENSE']) { + try { + nodeLicense = await readFile(join(dirname(builder), candidate), 'utf8'); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} +if (!nodeLicense) throw new Error('The pinned Node builder must include its distribution LICENSE.'); +await writeFile(join(payload, 'NODE-LICENSE'), nodeLicense); +const packages = new Map(); +for (const input of inputs) { + if (!input.replaceAll('\\', '/').includes('node_modules/')) continue; + let directory = dirname(resolve(root, input)); + while (directory !== dirname(directory)) { + try { + const metadata = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as { + name?: string; + version?: string; + }; + if (metadata.name && metadata.version) { + packages.set(`${metadata.name}@${metadata.version}`, directory); + break; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + directory = dirname(directory); + } +} +const notices: string[] = []; +for (const [name, directory] of [...packages].sort(([left], [right]) => + left.localeCompare(right), +)) { + const licenses = (await readdir(directory, { withFileTypes: true })).filter( + (entry) => entry.isFile() && /^(licen[cs]e|copying|notice)([.-]|$)/i.test(entry.name), + ); + if (name === 'yoga-layout@3.2.1' && !licenses.length) { + // The published package omits its upstream MIT text; retain the exact release license. + notices.push( + `${name} - https://github.com/facebook/yoga/blob/v3.2.1/LICENSE\n\n${await readFile(join(root, 'scripts/licenses/yoga-layout-3.2.1-LICENSE'), 'utf8')}`, + ); + continue; + } + if (!licenses.length) throw new Error(`Missing bundled dependency license: ${name}`); + for (const license of licenses) + notices.push( + `${name} - ${license.name}\n\n${await readFile(join(directory, license.name), 'utf8')}`, + ); +} +await writeFile(join(payload, 'THIRD-PARTY-NOTICES'), notices.join('\n\n') + '\n'); +await writeFile( + join(payload, 'BUILD.json'), + JSON.stringify( + { + version: manifest.version, + builder: seaBuilderVersion, + target: `${process.platform}-${process.arch}`, + source: await sourceIdentity(root), + }, + null, + 2, + ) + '\n', +); +const checksums: string[] = []; +for (const path of [ + ...artifacts, + ...[ + 'LICENSE', + 'NODE-LICENSE', + 'THIRD-PARTY-NOTICES', + 'BUILD.json', + 'README.md', + 'cadder.toml.example', + ].map((name) => join(payload, name)), +]) + checksums.push( + `${createHash('sha256') + .update(await readFile(path)) + .digest('hex')} ${path.slice(payload.length + 1)}`, + ); +await writeFile(join(payload, 'SHA256SUMS'), checksums.join('\n') + '\n'); +const archive = join( + stage, + `cadder-${manifest.version}-${process.platform}-${process.arch}${process.platform === 'win32' ? '.zip' : '.tar.gz'}`, +); +await execute( + process.platform === 'win32' + ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32/tar.exe') + : 'tar', + [process.platform === 'win32' ? '-acf' : '-czf', archive, '-C', stage, 'cadder'], +); +process.stdout.write( + JSON.stringify({ + stage: payload, + archive, + version: manifest.version, + builder: seaBuilderVersion, + target: `${process.platform}-${process.arch}`, + artifacts, + }) + '\n', +); diff --git a/scripts/prepare-caddy-gate.ts b/scripts/prepare-caddy-gate.ts new file mode 100644 index 0000000..b1e371c --- /dev/null +++ b/scripts/prepare-caddy-gate.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +// Pinned from caddyserver/caddy v2.11.4's published SHA-512 checksums. +const targets: Record = { + 'win32-x64': [ + 'windows_amd64.zip', + 'cd5ccfd86a4b40732cf715890d0dca5bf3f63adefec5a7914de85adf240c60ce7e5d2791631b88ef9758e46b23bb1730e020b9c5d696889740b284ffd4788e35', + ], + 'linux-x64': [ + 'linux_amd64.tar.gz', + '8220d1f013b6f27510247b2360c9e0ca9f018feebd82515f07635318b34ff9777ccc8fd0b6e6f2486ce3a33fe389fbb7db12d05baa474f4587509fb4f5ebf1c9', + ], + 'darwin-x64': [ + 'mac_amd64.tar.gz', + 'e04eb10f9ce7e2e079bc9bff1bd5d3a3164888d1edbb1a49e5d15be4eab691b57e89ed36bb29c65ba43f1ba8d9279e0967b1003991c13fe4cb78384c3caf25de', + ], + 'darwin-arm64': [ + 'mac_arm64.tar.gz', + '3190ae0df98b59ab4b6021556fa35adc3c526a4f3e138776b0eaec8a037cc26121cbbb1ad53453f565551b47d37d5ba4755e2c2c3652256737fe2ce9e53c8ec0', + ], +}; +const target = targets[`${process.platform}-${process.arch}`]; +assert.ok(target, 'Unsupported native acceptance target.'); +const root = await mkdtemp(join(tmpdir(), 'cadder-upstream-gate-')); +const filename = `caddy_2.11.4_${target[0]}`; +const response = await fetch( + `https://github.com/caddyserver/caddy/releases/download/v2.11.4/${filename}`, + { signal: AbortSignal.timeout(120000) }, +); +assert.ok(response.ok, `Upstream acceptance download failed: ${response.status}`); +const bytes = Buffer.from(await response.arrayBuffer()); +assert.equal(createHash('sha512').update(bytes).digest('hex'), target[1]); +const archive = join(root, filename); +await writeFile(archive, bytes); +await promisify(execFile)( + process.platform === 'win32' + ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32/tar.exe') + : 'tar', + ['-xf', archive, '-C', root], +); +process.stdout.write(join(root, process.platform === 'win32' ? 'caddy.exe' : 'caddy') + '\n'); diff --git a/scripts/prepare-product-sandbox.ts b/scripts/prepare-product-sandbox.ts new file mode 100644 index 0000000..0c4ea7a --- /dev/null +++ b/scripts/prepare-product-sandbox.ts @@ -0,0 +1,78 @@ +import { build } from 'esbuild'; +import { copyFile, mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +if (process.platform !== 'win32') throw new Error('Prepare the product Sandbox on Windows.'); +const [artifact, upstream] = process.argv.slice(2); +if (!artifact || !upstream) + throw new Error('Pass the SEA artifact directory and separate native Caddy.'); +const root = await mkdtemp(join(tmpdir(), 'cadder-product-sandbox-')); +const input = join(root, 'input'); +const reports = join(root, 'reports'); +await mkdir(input); +await mkdir(reports); +await copyFile(process.execPath, join(input, 'node.exe')); +await copyFile(resolve(upstream), join(input, 'caddy.exe')); +await copyFile( + fileURLToPath( + new URL( + '../openspec/changes/reset-cadder-architecture/windows-product-gate.md', + import.meta.url, + ), + ), + join(input, 'README.md'), +); +await build({ + entryPoints: [fileURLToPath(new URL('./verify-product-journey.ts', import.meta.url))], + outfile: join(input, 'journey.mjs'), + bundle: true, + platform: 'node', + target: 'node24.18', + format: 'esm', +}); +await writeFile( + join(input, 'bootstrap.ps1'), + ` +$ErrorActionPreference = 'Stop' +Set-Content -LiteralPath 'C:\\CadderReports\\started.txt' -Value 'Product Sandbox started' +try { + $probe = Start-Process -FilePath 'C:\\CadderTools\\node.exe' -ArgumentList @('C:\\CadderTools\\journey.mjs','C:\\CadderProduct','C:\\CadderTools\\caddy.exe') -WindowStyle Hidden -Wait -PassThru -RedirectStandardOutput 'C:\\CadderReports\\result.json' -RedirectStandardError 'C:\\CadderReports\\stderr.txt' + Set-Content -LiteralPath 'C:\\CadderReports\\exit.txt' -Value $probe.ExitCode +} catch { + Set-Content -LiteralPath 'C:\\CadderReports\\error.txt' -Value $_.Exception.Message + Set-Content -LiteralPath 'C:\\CadderReports\\exit.txt' -Value 1 +} +`, +); +const escape = (value: string): string => + value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +const config = join(root, 'product.wsb'); +const configuration = ` + DisableDisableDisable + DisableDisableDisable + + ${escape(resolve(artifact))}C:\\CadderProducttrue + ${escape(input)}C:\\CadderToolstrue + ${escape(reports)}C:\\CadderReportsfalse + + powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\\CadderTools\\bootstrap.ps1 +`; +await writeFile(config, configuration); +const privilegeConfig = join(root, 'privilege.wsb'); +await writeFile( + privilegeConfig, + configuration.replace( + 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\\CadderTools\\bootstrap.ps1', + 'powershell.exe -NoProfile -NoExit -Command "Set-Location C:\\CadderTools; Get-Content README.md"', + ), +); +process.stdout.write( + JSON.stringify({ + config, + privilegeConfig, + reports, + scope: 'Actual SEA product routing; final UAC/different-account acceptance remains separate.', + }) + '\n', +); diff --git a/scripts/prepare-runtime-gate.ts b/scripts/prepare-runtime-gate.ts new file mode 100644 index 0000000..7c11230 --- /dev/null +++ b/scripts/prepare-runtime-gate.ts @@ -0,0 +1,31 @@ +import { build } from 'esbuild'; +import { chmod, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const stage = await mkdtemp(join(tmpdir(), 'cadder-runtime-gate-')); +await build({ + entryPoints: { + 'runtime-child': fileURLToPath(new URL('../test/fixtures/runtime-child.ts', import.meta.url)), + 'runtime-smoke': fileURLToPath(new URL('../test/fixtures/runtime-smoke.ts', import.meta.url)), + 'runtime-denied-client': fileURLToPath( + new URL('../test/fixtures/runtime-denied-client.ts', import.meta.url), + ), + 'runtime-privilege-smoke': fileURLToPath( + new URL('../test/fixtures/runtime-privilege-smoke.ts', import.meta.url), + ), + 'runtime-windows-contact': fileURLToPath( + new URL('../test/fixtures/runtime-windows-contact.ts', import.meta.url), + ), + }, + outdir: stage, + outExtension: { '.js': '.mjs' }, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node24.18', +}); +// This directory contains shared source bundles only, never runtime secrets. +if (process.platform !== 'win32') await chmod(stage, 0o755); +process.stdout.write(`${stage}\n`); diff --git a/scripts/prepare-windows-runtime-sandbox.ts b/scripts/prepare-windows-runtime-sandbox.ts new file mode 100644 index 0000000..06dd9d6 --- /dev/null +++ b/scripts/prepare-windows-runtime-sandbox.ts @@ -0,0 +1,53 @@ +import { execFile } from 'node:child_process'; +import { copyFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +if (process.platform !== 'win32') throw new Error('Prepare this test package on Windows.'); +if (process.versions.node !== '24.18.0') throw new Error('Use Node 24.18.0 for this runtime gate.'); +const environment = { ...process.env }; +delete environment.NODE_OPTIONS; +const result = await promisify(execFile)( + process.execPath, + [fileURLToPath(new URL('./prepare-runtime-gate.ts', import.meta.url))], + { env: environment }, +); +const stage = result.stdout.trim(); +await copyFile(process.execPath, join(stage, 'node.exe')); +await copyFile( + fileURLToPath( + new URL( + '../openspec/changes/reset-cadder-architecture/windows-runtime-gate.md', + import.meta.url, + ), + ), + join(stage, 'README.md'), +); +const configuration = join(stage, 'runtime-gate.wsb'); +await writeFile( + configuration, + ` + Disable + Disable + Disable + Disable + Disable + + + ${escapeXml(stage)} + C:\\CadderRuntimeGate + true + + + + powershell.exe -NoProfile -NoExit -Command "Set-Location 'C:\\CadderRuntimeGate'; Get-Content README.md" + + +`, +); +process.stdout.write(`${configuration}\n`); + +function escapeXml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} diff --git a/scripts/source-identity.ts b/scripts/source-identity.ts new file mode 100644 index 0000000..7518fe9 --- /dev/null +++ b/scripts/source-identity.ts @@ -0,0 +1,34 @@ +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { readFile, readdir } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +export async function sourceIdentity(root: string) { + const files: string[] = []; + for (const directory of ['src', 'scripts']) + for (const entry of await readdir(join(root, directory), { + recursive: true, + withFileTypes: true, + })) + if (entry.isFile() && (/\.tsx?$/.test(entry.name) || entry.name.endsWith('-LICENSE'))) + files.push(join(entry.parentPath, entry.name)); + files.push( + ...[ + 'package.json', + 'nub.lock', + 'tsconfig.json', + 'LICENSE', + 'docs/NODE-CANDIDATE.md', + 'cadder.toml.example', + ].map((name) => join(root, name)), + ); + const hash = createHash('sha256'); + for (const file of files.sort()) + hash.update(relative(root, file).replaceAll('\\', '/') + '\0').update(await readFile(file)); + const execute = promisify(execFile); + const revision = (await execute('git', ['rev-parse', 'HEAD'], { cwd: root })).stdout.trim(); + const dirty = + (await execute('git', ['status', '--porcelain'], { cwd: root })).stdout.trim() !== ''; + return { revision, digest: hash.digest('hex'), dirty }; +} diff --git a/scripts/terminal-smoke.ts b/scripts/terminal-smoke.ts new file mode 100644 index 0000000..72db744 --- /dev/null +++ b/scripts/terminal-smoke.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +/** Native Unix PTY; Windows acceptance uses the actual ConPTY terminal. */ +export async function terminalSmoke( + file: string, + prefix: string[], + environment: NodeJS.ProcessEnv, +): Promise { + if (process.platform === 'win32') return; + const execute = promisify(execFile); + for (const scenario of ['help', 'lifecycle', 'explicit', 'interrupt']) { + const args = scenario === 'help' ? [] : ['tui']; + if (scenario === 'explicit') args.push('--start-daemon'); + const command = [file, ...prefix, ...args]; + // System Python provides the native PTY; the product inherits only its restricted PATH. + const running = execute( + '/usr/bin/python3', + [ + '-c', + 'import os,pty,sys;sys.exit(os.waitstatus_to_exitcode(pty.spawn(sys.argv[1:])))', + ...command, + ], + { + env: { ...environment, CI: 'false', TERM: 'xterm-256color' }, + timeout: 20000, + maxBuffer: 1024 * 1024, + }, + ); + let frame = ''; + let step = scenario === 'explicit' ? 1 : 0; + running.child.stdout?.on('data', (bytes: Buffer) => { + frame = (frame + bytes.toString()).slice(-8192); + if (scenario === 'help') return; + let input: string | undefined; + if (step === 0 && frame.includes('Press Enter')) { + input = scenario === 'interrupt' ? '\x03' : '\r'; + } else if (step === 1 && frame.includes('No projects registered')) { + input = 's'; + } else if (step === 2 && frame.includes('Stop cadderd')) { + input = 'y'; + } else if (step === 3 && frame.includes('Press Enter')) { + input = 'q'; + } + if (input !== undefined) { + frame = ''; + step++; + running.child.stdin?.write(input); + } + }); + const { stdout } = await running; + assert.match(stdout, args.length ? /Press Enter/ : /Usage: cadder/); + if (args.length) { + assert.equal(step, scenario === 'interrupt' ? 1 : 4); + assert.ok(stdout.includes('\x1b[?25h'), 'TUI must restore cursor visibility.'); + } + } +} diff --git a/scripts/verify-node.ts b/scripts/verify-node.ts new file mode 100644 index 0000000..fa0b824 --- /dev/null +++ b/scripts/verify-node.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { packNode, npmCli, repositoryRoot } from './pack-node.ts'; +import { fileURLToPath } from 'node:url'; +import { terminalSmoke } from './terminal-smoke.ts'; +import { sourceIdentity } from './source-identity.ts'; +import { VERSION } from '../src/protocol/version.ts'; + +const execute = promisify(execFile); +const archive = process.argv[3] ? resolve(process.argv[3]) : (await packNode()).archive; +const consumer = await mkdtemp(join(tmpdir(), 'cadder-node-consumer-')); +const environment = { ...process.env, CADDER_RUNTIME_DIR: join(consumer, 'runtime') }; +let command: string | undefined; +try { + await execute( + process.execPath, + [ + await npmCli(), + 'install', + '--prefix', + consumer, + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + archive, + ], + { timeout: 120000, maxBuffer: 1024 * 1024 }, + ); + command = join(consumer, 'node_modules/cadder/lib/entrypoints/cadder.js'); + const manifest = JSON.parse( + await readFile(join(consumer, 'node_modules/cadder/package.json'), 'utf8'), + ) as { + name: string; + version: string; + license: string; + cadderBuild: { revision: string; dirty: boolean }; + }; + assert.equal(manifest.name, 'cadder'); + assert.equal(manifest.version, VERSION); + assert.equal(manifest.license, 'Apache-2.0'); + assert.equal(manifest.cadderBuild.revision, (await sourceIdentity(repositoryRoot)).revision); + if (process.argv[3]) assert.equal(manifest.cadderBuild.dirty, false); + if (process.argv[2]) + await execute( + process.execPath, + [ + fileURLToPath(new URL('./verify-product-journey.ts', import.meta.url)), + join(consumer, 'node_modules/cadder'), + process.argv[2], + process.execPath, + ], + { timeout: 900000, maxBuffer: 1024 * 1024 }, + ).then((result) => process.stdout.write(result.stdout)); + const run = (args: string[]) => + execute(process.execPath, [command!, ...args], { + cwd: consumer, + env: environment, + timeout: 45000, + maxBuffer: 1024 * 1024, + }); + assert.equal((await run(['--version'])).stdout.trim(), VERSION); + assert.match((await run([])).stderr, /Usage: cadder/); + await assert.rejects( + run(['status']), + (error: unknown) => (error as { code?: unknown }).code === 3, + ); + const prefix = join(consumer, 'global'); + await terminalSmoke(process.execPath, [command], environment); + await execute( + process.execPath, + [ + await npmCli(), + 'install', + '--global', + '--prefix', + prefix, + '--ignore-scripts', + '--no-audit', + '--no-fund', + archive, + ], + { timeout: 120000, maxBuffer: 1024 * 1024 }, + ); + const wrapperDirectory = process.platform === 'win32' ? prefix : join(prefix, 'bin'); + for (const name of ['cadder', 'cadderd', 'caddy']) { + const arguments_ = [name === 'caddy' ? '--cadder-shim-info' : '--version']; + for (const directory of [join(consumer, 'node_modules/.bin'), wrapperDirectory]) { + const wrapper = join(directory, process.platform === 'win32' ? `${name}.cmd` : name); + const result = + process.platform === 'win32' + ? await execute( + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32/cmd.exe'), + ['/d', '/s', '/c', `""${wrapper}" ${arguments_[0]}"`], + { env: environment, windowsVerbatimArguments: true }, + ) + : await execute(wrapper, arguments_, { env: environment }); + assert.equal( + name === 'caddy' + ? (JSON.parse(result.stdout) as { version: string }).version + : result.stdout.trim(), + VERSION, + ); + } + } + const globalPackage = join( + prefix, + process.platform === 'win32' ? 'node_modules/cadder' : 'lib/node_modules/cadder', + ); + const installed = JSON.parse(await readFile(join(globalPackage, 'package.json'), 'utf8')) as { + scripts?: unknown; + optionalDependencies?: unknown; + }; + assert.equal(installed.scripts, undefined); + assert.equal(installed.optionalDependencies, undefined); + assert.match((await run(['daemon', 'start'])).stdout, /running/); + assert.match((await run(['status'])).stdout, /cadderd {2}running/); + assert.match((await run(['projects', 'list'])).stdout, /No projects registered/); + assert.match((await run(['logs', 'runtime', '--limit', '1'])).stdout, /Daemon started/); + await assert.rejects( + run(['logs', 'runtime', '--tail']), + (error: unknown) => (error as { code?: unknown }).code === 2, + ); + assert.match((await run(['daemon', 'stop'])).stdout, /stopped/); + await assert.rejects( + run(['status']), + (error: unknown) => (error as { code?: unknown }).code === 3, + ); + process.stdout.write( + JSON.stringify({ + archive, + result: 'PASS', + scope: + 'compiled npm local lifecycle, local/global three-bin wrappers, actual SQLite worker and CLI, no TS loader', + }) + '\n', + ); +} catch (error) { + const failure = error as { message?: string; stdout?: string; stderr?: string }; + process.stderr.write( + [failure.message, failure.stdout, failure.stderr].filter(Boolean).join('\n') + '\n', + ); + throw error; +} finally { + if (command) + await execute(process.execPath, [command, 'daemon', 'stop'], { + env: environment, + timeout: 20000, + }).catch(() => {}); + await rm(consumer, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch( + (error: unknown) => { + process.stderr.write( + `Consumer cleanup failed: ${error instanceof Error ? error.message : 'unknown failure'}\n`, + ); + process.exitCode = 1; + }, + ); +} diff --git a/scripts/verify-product-journey.ts b/scripts/verify-product-journey.ts new file mode 100644 index 0000000..d1fb33c --- /dev/null +++ b/scripts/verify-product-journey.ts @@ -0,0 +1,298 @@ +import assert from 'node:assert/strict'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { get } from 'node:http'; +import { get as getTls } from 'node:https'; +import { setTimeout as delay } from 'node:timers/promises'; +import { DatabaseSync } from 'node:sqlite'; + +// Run on an isolated native machine with free loopback ports 80 and 443. +const [directory, upstream, node] = process.argv.slice(2); +assert.ok( + directory && upstream, + 'Pass the product directory, separate real Caddy, and Node for npm only.', +); +const root = await mkdtemp(join(tmpdir(), 'cadder-product-journey-')); +const stage = resolve(directory); +const binary = (name: string): string => + join(stage, process.platform === 'win32' ? `${name}.exe` : name); +const command = (name: string, args: string[]) => + node + ? { file: resolve(node), args: [join(stage, `lib/entrypoints/${name}.js`), ...args] } + : { file: binary(name), args }; +await mkdir(join(root, 'empty-path')); +const environment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'path'), +); +environment.PATH = node + ? dirname(resolve(node)) + : process.platform === 'win32' + ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') + : join(root, 'empty-path'); +if (node && process.platform === 'win32') + environment.PATH += ';' + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32'); +environment.CADDER_RUNTIME_DIR = join(root, 'runtime'); +environment.PATH = [ + node ? join(stage, '../.bin') : stage, + dirname(resolve(upstream)), + environment.PATH, +].join(process.platform === 'win32' ? ';' : ':'); +const execute = promisify(execFile); +const run = (args: string[]) => { + const entry = command('cadder', args); + return execute(entry.file, entry.args, { + env: environment, + cwd: root, + timeout: 45000, + maxBuffer: 1024 * 1024, + }); +}; +const children: ReturnType[] = []; +let daemonErrors = ''; +function child(name: string, args: string[], cwd: string) { + const entry = command(name, args); + const process_ = spawn(entry.file, entry.args, { + env: environment, + cwd, + windowsHide: true, + stdio: ['ignore', 'ignore', 'pipe'], + }); + process_.stderr!.on('data', (chunk: Buffer) => { + daemonErrors = (daemonErrors + `${name} (${cwd}): ` + chunk.toString()).slice(-8192); + }); + children.push(process_); + return process_; +} +async function until(check: () => Promise, timeout = 120000): Promise { + const deadline = Date.now() + timeout; + let last: unknown; + while (Date.now() < deadline) { + try { + await check(); + return; + } catch (error) { + last = error; + await delay(200); + } + } + throw new Error(`Product scenario deadline exceeded: ${String(last)}\n${daemonErrors}`); +} +async function request(host: string, tls = false): Promise<{ status: number; body: string }> { + const certificate = join( + environment.CADDER_RUNTIME_DIR!, + 'v2/caddy/data/caddy/pki/authorities/local/root.crt', + ); + let ca: Buffer | string | undefined; + if (tls) { + // The elevated Caddy owns its storage; export only the public CA certificate to this probe. + ca = + process.platform === 'darwin' + ? (await execute('/usr/bin/sudo', ['-n', '/bin/cat', certificate])).stdout + : await readFile(certificate); + } + return new Promise((resolveResponse, reject) => { + const request_ = (tls ? getTls : get)( + { + hostname: '127.0.0.1', + port: tls ? 443 : 80, + path: '/', + headers: { host }, + ...(tls ? { servername: host, ca } : {}), + }, + (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + body = (body + chunk).slice(0, 4096); + }); + response.on('end', () => resolveResponse({ status: response.statusCode ?? 0, body })); + }, + ); + request_.on('error', reject); + request_.setTimeout(5000, () => request_.destroy(new Error('HTTP deadline exceeded.'))); + }); +} +async function project(name: string, body: string) { + const path = join(root, name); + await mkdir(path); + const config = join(path, 'Caddyfile'); + await writeFile(config, body); + return { path, config, shim: child('caddy', ['run', '--config', config], path) }; +} +try { + const delegated = async (file: string, args: string[], input = '') => { + const operation = execute(file, args, { env: environment, cwd: root, timeout: 45000 }); + operation.child.stdin?.end(input); + return operation.then( + ({ stdout, stderr }) => ({ code: 0, stdout, stderr }), + (error: { code: unknown; stdout: string; stderr: string }) => ({ + code: error.code, + stdout: error.stdout, + stderr: error.stderr, + }), + ); + }; + for (const args of [ + ['fmt', '-'], + ['fmt', '--invalid-cadder-acceptance-flag'], + ]) { + const entry = command('caddy', args); + const input = args[1] === '-' ? 'stream.localhost {\nrespond "retained input"\n}\n' : ''; + const original = await delegated(resolve(upstream), args, input); + assert.equal(original.code, args[1] === '-' ? 0 : 1); + if (args[1] === '-') assert.match(original.stdout, /respond "retained input"/); + assert.deepEqual(await delegated(entry.file, entry.args, input), original); + } + const inspection = command('caddy', ['version']); + const inspected = await delegated(inspection.file, inspection.args); + assert.equal(inspected.code, 0); + assert.match(inspected.stdout, /v2\.11\.4/); + assert.match(inspected.stderr, /real-Caddy inspection/); + if (process.platform === 'darwin') { + // Native macOS web ports require an elevated daemon; clients retain their user identity. + const entry = command('cadderd', [ + '--runtime-owner', + String(process.getuid!()), + '--runtime-dir', + environment.CADDER_RUNTIME_DIR!, + '--real-caddy', + resolve(upstream), + ]); + const elevated = spawn('/usr/bin/sudo', ['-n', entry.file, ...entry.args], { + env: environment, + stdio: ['ignore', 'ignore', 'pipe'], + }); + elevated.stderr.on('data', (bytes: Buffer) => { + daemonErrors = (daemonErrors + bytes.toString()).slice(-8192); + }); + children.push(elevated); + await until(async () => assert.match((await run(['status'])).stdout, /cadderd {2}running/)); + const discovery = JSON.parse( + await readFile(join(environment.CADDER_RUNTIME_DIR!, 'v2/cadder-ipc.json'), 'utf8'), + ) as { elevated: boolean; owner: string }; + assert.equal(discovery.elevated, true); + assert.equal(discovery.owner, `uid:${process.getuid!()}`); + } + const alpha = await project('alpha', 'alpha.localhost {\n respond "alpha"\n}\n'); + await until(async () => { + assert.equal(alpha.shim.exitCode, null); + assert.match((await run(['status'])).stdout, /cadderd {2}running/); + }); + await until(async () => assert.equal((await request('alpha.localhost')).body, 'alpha')); + await until(async () => assert.equal((await request('alpha.localhost', true)).body, 'alpha')); + const beta = await project('beta', 'beta.localhost {\n respond "beta"\n}\n'); + await until(async () => assert.equal((await request('beta.localhost')).body, 'beta')); + assert.equal((await request('alpha.localhost')).body, 'alpha'); + await until(async () => assert.equal((await request('beta.localhost', true)).body, 'beta')); + assert.notEqual((await request('unknown.localhost')).body, 'alpha'); + await run(['domains', 'disable', 'alpha.localhost']); + assert.notEqual((await request('alpha.localhost')).body, 'alpha'); + await run(['domains', 'enable', 'alpha.localhost']); + assert.equal((await request('alpha.localhost')).body, 'alpha'); + await run(['projects', 'disable', beta.config]); + assert.notEqual((await request('beta.localhost')).body, 'beta'); + await run(['projects', 'enable', beta.config]); + assert.equal((await request('beta.localhost')).body, 'beta'); + const database = new DatabaseSync(join(environment.CADDER_RUNTIME_DIR!, 'v2/runtime.sqlite3'), { + timeout: 1000, + }); + try { + database.exec( + "CREATE TRIGGER gate_reject_desired BEFORE UPDATE ON desired BEGIN SELECT RAISE(FAIL, 'acceptance persistence refusal'); END", + ); + await assert.rejects(run(['projects', 'disable', beta.config])); + assert.equal((await request('beta.localhost')).body, 'beta'); + } finally { + database.exec('DROP TRIGGER IF EXISTS gate_reject_desired'); + database.close(); + } + await run(['projects', 'enable', beta.config]); + assert.equal((await request('beta.localhost')).body, 'beta'); + const conflict = await project('conflict', 'alpha.localhost {\n respond "conflict"\n}\n'); + await until(async () => assert.equal(conflict.shim.exitCode, 1)); + assert.equal((await request('alpha.localhost')).body, 'alpha'); + await writeFile(alpha.config, 'not a valid { Caddyfile'); + await assert.rejects(run(['projects', 'enable', alpha.config])); + assert.equal((await request('alpha.localhost')).body, 'alpha'); + await writeFile(alpha.config, 'alpha.localhost {\n respond "alpha"\n}\n'); + await delay(5500); + assert.match((await run(['domains', 'list'])).stdout, /alpha.localhost/); + alpha.shim.kill(); + await until(async () => assert.notEqual((await request('alpha.localhost')).body, 'alpha'), 25000); + assert.equal((await request('beta.localhost')).body, 'beta'); + const admin = /Admin\s+https:\/\/127\.0\.0\.1:(\d+)/.exec((await run(['diagnostics'])).stdout); + assert.ok(admin); + await assert.rejects( + new Promise((resolveResponse, reject) => { + const attempt = getTls( + { + hostname: '127.0.0.1', + port: Number(admin[1]), + path: '/config/', + rejectUnauthorized: false, + }, + (response) => { + response.resume(); + if ((response.statusCode ?? 0) >= 400) reject(new Error('Unauthorized client refused.')); + else resolveResponse(response.statusCode); + }, + ); + attempt.on('error', reject); + attempt.setTimeout(5000, () => + attempt.destroy(new Error('Administration deadline exceeded.')), + ); + }), + ); + await run(['daemon', 'stop']); + await assert.rejects(request('beta.localhost')); + beta.shim.kill(); + process.stdout.write( + JSON.stringify({ + result: 'PASS', + platform: process.platform, + distribution: node ? 'npm' : 'sea', + checks: [ + 'actual daemon/shim/operator', + 'shim stdin/stdout/stderr/exit fidelity and non-recursive PATH', + 'multi-project HTTP/verified HTTPS', + 'domain/project toggles', + 'conflict and malformed rejection with last-known-good', + 'post-apply SQLite failure restores last-known-good', + 'heartbeat and crashed-session expiry', + 'unauthorized admin denial', + 'owned shutdown', + ], + nodeOnProductPath: Boolean(node), + }) + '\n', + ); +} catch (error) { + for (const args of [['status'], ['projects', 'list'], ['logs', 'runtime', '--limit', '20']]) { + try { + process.stderr.write((await run(args)).stdout); + } catch { + /* The original failure remains authoritative. */ + } + } + process.stderr.write( + JSON.stringify( + children.map((entry) => ({ + pid: entry.pid, + exitCode: entry.exitCode, + signal: entry.signalCode, + })), + ) + + '\n' + + daemonErrors + + '\n', + ); + throw error; +} finally { + await run(['daemon', 'stop']).catch(() => {}); + for (const process_ of children) if (process_.exitCode === null) process_.kill(); + if (process.platform === 'darwin') + await execute('/usr/bin/sudo', ['-n', '/bin/rm', '-rf', '--', root]); + else await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); +} diff --git a/scripts/verify-sea.ts b/scripts/verify-sea.ts new file mode 100644 index 0000000..2d48dd7 --- /dev/null +++ b/scripts/verify-sea.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { createHash } from 'node:crypto'; +import { lstat, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { terminalSmoke } from './terminal-smoke.ts'; + +const execute = promisify(execFile); +let stage = resolve(process.argv[2] ?? ''); +assert.ok(process.argv[2], 'Pass the extracted SEA artifact directory.'); +const runtime = await mkdtemp(join(tmpdir(), 'cadder-sea-consumer-')); +if (/\.(?:zip|tar\.gz)$/.test(stage)) { + const unpacked = join(runtime, 'unpacked'); + await mkdir(unpacked); + await execute( + process.platform === 'win32' + ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32/tar.exe') + : 'tar', + ['-xf', stage, '-C', unpacked], + ); + stage = join(unpacked, 'cadder'); +} +await mkdir(join(runtime, 'empty-path')); +const environment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'path'), +); +environment.PATH = + process.platform === 'win32' + ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') + : join(runtime, 'empty-path'); +environment.CADDER_RUNTIME_DIR = join(runtime, 'runtime'); +const binary = (name: string): string => + join(stage, process.platform === 'win32' ? `${name}.exe` : name); +const run = (args: string[]) => + execute(binary('cadder'), args, { + env: environment, + cwd: runtime, + timeout: 45000, + maxBuffer: 1024 * 1024, + }); +try { + const identity = JSON.parse(await readFile(join(stage, 'BUILD.json'), 'utf8')) as { + version: string; + target: string; + }; + assert.equal(identity.target, `${process.platform}-${process.arch}`); + const allowed = new Set( + ['cadder', 'cadderd', 'caddy'] + .map((name) => (process.platform === 'win32' ? `${name}.exe` : name)) + .concat([ + 'LICENSE', + 'NODE-LICENSE', + 'THIRD-PARTY-NOTICES', + 'BUILD.json', + 'README.md', + 'cadder.toml.example', + ]), + ); + assert.deepEqual((await readdir(stage)).sort(), [...allowed, 'SHA256SUMS'].sort()); + const checksums = (await readFile(join(stage, 'SHA256SUMS'), 'utf8')).trim().split('\n'); + assert.equal(checksums.length, allowed.size); + for (const line of checksums) { + const hash = line.slice(0, 64); + const name = line.slice(66); + assert.match(hash, /^[a-f0-9]{64}$/); + assert.ok(allowed.delete(name)); + const path = join(stage, name); + const info = await lstat(path); + assert.ok(info.isFile() && !info.isSymbolicLink()); + assert.equal( + createHash('sha256') + .update(await readFile(path)) + .digest('hex'), + hash, + ); + } + assert.equal((await run(['--version'])).stdout.trim(), identity.version); + assert.equal( + (await execute(binary('cadderd'), ['--version'], { env: environment })).stdout.trim(), + identity.version, + ); + assert.equal( + JSON.parse( + (await execute(binary('caddy'), ['--cadder-shim-info'], { env: environment })).stdout, + ).version, + identity.version, + ); + await assert.rejects( + run(['status']), + (error: unknown) => (error as { code?: unknown }).code === 3, + ); + assert.match((await run(['daemon', 'start'])).stdout, /running/); + assert.match((await run(['status'])).stdout, /cadderd {2}running/); + assert.match((await run(['logs', 'runtime', '--limit', '1'])).stdout, /Daemon started/); + assert.match((await run(['daemon', 'restart'])).stdout, /running/); + assert.match((await run(['daemon', 'stop'])).stdout, /stopped/); + await terminalSmoke(binary('cadder'), [], environment); + process.stdout.write( + JSON.stringify({ + stage, + result: 'PASS', + scope: + 'native SEA entries, authenticated daemon, SQLite worker and restart without Node/npm on PATH', + }) + '\n', + ); +} finally { + await run(['daemon', 'stop']); + await rm(runtime, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); +} diff --git a/src/caddy/adapter.ts b/src/caddy/adapter.ts new file mode 100644 index 0000000..6747896 --- /dev/null +++ b/src/caddy/adapter.ts @@ -0,0 +1,62 @@ +import type { CaddyConfig, PortResult } from '../contracts/ports.ts'; +import type { EntrypointRegistration } from '../protocol/dto.ts'; +import { RealCaddyResolver } from './resolver.ts'; +import { absoluteCaddyPath } from './executable.ts'; +import { + CaddyPreparationError, + assertJsonBody, + configHash, + configurationCommandOptions, + decodeOutput, + preparationFailure, + type PreparationOptions, +} from './preparation.ts'; + +export type CaddyAdaptInput = Pick< + EntrypointRegistration, + 'sourceConfigPath' | 'sourceWorkingDirectory' | 'shimRun' +>; + +/** Adapt only; no registration mutation, composition or server lifecycle. */ +export class CaddyConfigAdapter { + constructor( + private readonly resolver: RealCaddyResolver, + private readonly options: PreparationOptions = {}, + ) {} + + async adapt(input: CaddyAdaptInput, signal?: AbortSignal): Promise> { + try { + const cwd = input.sourceWorkingDirectory.canonical ?? input.sourceWorkingDirectory.raw; + const path = input.sourceConfigPath.canonical ?? input.sourceConfigPath.raw; + const adapter = input.shimRun?.adapter ?? 'caddyfile'; + if ( + !absoluteCaddyPath(cwd) || + !path || + cwd.includes('\0') || + path.includes('\0') || + adapter.includes('\0') || + (input.sourceConfigPath.canonical !== null && !absoluteCaddyPath(path)) + ) + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Caddy adaptation requires an absolute source directory and a valid configuration path.', + ); + const image = await this.resolver.pin(signal); + const output = await image.run(['adapt', '--config', path, '--adapter', adapter], { + ...configurationCommandOptions(this.options, signal), + cwd, + }); + const body = decodeOutput(output, 'adapt'); + assertJsonBody(body); + return { + ok: true, + value: Object.freeze({ + effectiveConfigHash: configHash(body), + adaptedConfig: Object.freeze({ format: 'json', body }), + }), + }; + } catch (error) { + return { ok: false, error: preparationFailure(error, 'adapt') }; + } + } +} diff --git a/src/caddy/admin-client.ts b/src/caddy/admin-client.ts new file mode 100644 index 0000000..7fd54d7 --- /dev/null +++ b/src/caddy/admin-client.ts @@ -0,0 +1,266 @@ +import type { ClientRequest, IncomingMessage } from 'node:http'; +import { request } from 'node:https'; +import type { CaddyConfig, PortResult } from '../contracts/ports.ts'; +import type { ConfigurationApplyOutcome } from '../daemon/configuration-transactions.ts'; +import { normalizeCaddyConfig, type SecureAdminPolicy } from './admin-policy.ts'; +import { + assertCandidate, + caddyConfigurationTimeoutMs, + maxCaddyConfigurationBytes, +} from './preparation.ts'; + +export type AdminClientOptions = Readonly<{ + timeoutMs?: number; + maxResponseBytes?: number; + maxHeaderBytes?: number; +}>; +type PreparedPolicy = Pick; +type Reply = Readonly<{ status: number; body: string }>; + +function bounded(value: number | undefined, maximum: number): number { + if (value === undefined) return maximum; + if (!Number.isInteger(value) || value < 1 || value > maximum) + throw new Error('Invalid administration client bounds.'); + return value; +} + +/** Compare only the prepared security envelope; route assembly remains the authority. */ +function securityEnvelope(config: CaddyConfig): string { + const value = JSON.parse(config.adaptedConfig.body) as { + admin?: unknown; + apps?: { pki?: unknown }; + }; + if (!value.admin || !value.apps?.pki) + throw new Error('Missing administration security envelope.'); + return normalizeCaddyConfig(JSON.stringify({ admin: value.admin, pki: value.apps.pki })) + .adaptedConfig.body; +} + +/** + * HTTPS transport/readback only, not an owned-process ConfigurationRuntime. + * A completed /load acknowledgement is not active-state proof. The transaction + * queue must independently read back and compare before publishing a change. + */ +export class CaddyAdminClient { + readonly #client: SecureAdminPolicy['client']; + readonly #security: string; + readonly #timeoutMs: number; + readonly #maxResponseBytes: number; + readonly #maxHeaderBytes: number; + readonly #pending = new Set<() => void>(); + #closed = false; + + constructor(policy: PreparedPolicy, options: AdminClientOptions = {}) { + try { + const { host, port, servername, rejectUnauthorized, ca, cert, key } = policy.client; + if ( + host !== '127.0.0.1' || + !Number.isInteger(port) || + port < 1 || + port > 65535 || + servername !== 'localhost' || + rejectUnauthorized !== true || + [ca, cert, key].some((value) => typeof value !== 'string' || value.length === 0) + ) + throw new Error(); + this.#client = Object.freeze({ + host: '127.0.0.1', + port, + servername: 'localhost', + rejectUnauthorized: true, + ca, + cert, + key, + }); + assertCandidate(policy.config); + this.#security = securityEnvelope(normalizeCaddyConfig(policy.config.adaptedConfig.body)); + this.#timeoutMs = bounded(options.timeoutMs, caddyConfigurationTimeoutMs); + this.#maxResponseBytes = bounded(options.maxResponseBytes, maxCaddyConfigurationBytes); + this.#maxHeaderBytes = bounded(options.maxHeaderBytes, 16 * 1024); + } catch { + throw new Error('Invalid prepared administration policy or client bounds.'); + } + } + + /** Shared lifecycle guard, evaluated before validation or native creation. */ + accepts(config: CaddyConfig): boolean { + try { + assertCandidate(config); + const normalized = normalizeCaddyConfig(config.adaptedConfig.body); + return ( + normalized.adaptedConfig.body === config.adaptedConfig.body && + securityEnvelope(normalized) === this.#security + ); + } catch { + return false; + } + } + + async apply(config: CaddyConfig, signal?: AbortSignal): Promise { + let body: string; + try { + // Idle transitions belong to the owned-process lifecycle, not HTTPS administration. + if (this.#closed || config == null) return { status: 'definitely-rejected' }; + // Copy primitives before validation/network; never retain a caller-owned candidate. + const copy: CaddyConfig = { + adaptedConfig: { format: config.adaptedConfig.format, body: config.adaptedConfig.body }, + effectiveConfigHash: config.effectiveConfigHash, + }; + if (!this.accepts(copy)) return { status: 'definitely-rejected' }; + body = copy.adaptedConfig.body; + } catch { + return { status: 'definitely-rejected' }; + } + try { + const reply = await this.#request('POST', '/load', body, signal); + if (reply && reply.status >= 200 && reply.status < 300) return { status: 'applied' }; + // Caddy /load documents rollback on completed load/adapt failure (HTTP 400). + if (reply?.status === 400) return { status: 'definitely-rejected' }; + } catch { + // An unexpected transport exception cannot prove that no transition occurred. + } + return { status: 'ambiguous' }; + } + + /** A receipt is not process settlement; the owned runtime must still wait/force. */ + async stop(signal?: AbortSignal): Promise { + const reply = await this.#request('POST', '/stop', undefined, signal); + return { status: reply && reply.status >= 200 && reply.status < 300 ? 'applied' : 'ambiguous' }; + } + + async readActive(signal?: AbortSignal): Promise> { + try { + const reply = await this.#request('GET', '/config/', undefined, signal); + if (reply && reply.status >= 200 && reply.status < 300) + return { ok: true, value: normalizeCaddyConfig(reply.body).effectiveConfigHash }; + } catch { + // Unavailable, incomplete or invalid observation is never idle/null. + } + return { + ok: false, + error: { + kind: 'caddyRuntime', + code: 'caddy_admin_readback_failed', + message: 'Cannot observe the active Caddy configuration.', + guidance: null, + retryable: false, + requestId: null, + }, + }; + } + + /** Stops admissions and aborts only this client's in-flight requests. No retry. */ + close(): void { + this.#closed = true; + for (const abort of this.#pending) abort(); + } + + #request( + method: 'POST' | 'GET', + path: '/load' | '/config/' | '/stop', + body?: string, + signal?: AbortSignal, + ): Promise { + if (this.#closed || signal?.aborted) return Promise.resolve(null); + return new Promise((resolve) => { + let req: ClientRequest | undefined; + let response: IncomingMessage | undefined; + let settled = false; + const deadline = performance.now() + this.#timeoutMs; + const finish = (reply: Reply | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + this.#pending.delete(abort); + signal?.removeEventListener('abort', abort); + response?.destroy(); + req?.destroy(); + resolve(reply); + }; + const abort = (): void => finish(null); + const timer = setTimeout(abort, this.#timeoutMs); + this.#pending.add(abort); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) { + abort(); + return; + } + try { + req = request( + { + ...this.#client, + method, + path, + agent: false, + maxHeaderSize: this.#maxHeaderBytes, + headers: + body === undefined + ? {} + : { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body, 'utf8'), + }, + }, + (res) => { + response = res; + if (settled) { + res.destroy(); + return; + } + res.on('error', abort); + res.on('aborted', abort); + res.on('close', () => { + if (!res.complete) abort(); + }); + const declared = res.headers['content-length']; + if ( + (declared !== undefined && + (!/^\d+$/.test(declared) || Number(declared) > this.#maxResponseBytes)) || + (res.headers['content-encoding'] !== undefined && + res.headers['content-encoding'] !== 'identity') + ) { + abort(); + return; + } + const chunks: Buffer[] = []; + let bytes = 0; + res.on('data', (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > this.#maxResponseBytes || performance.now() >= deadline) { + abort(); + return; + } + chunks.push(chunk); + }); + res.on('end', () => { + if (settled) return; + if ( + !res.complete || + performance.now() >= deadline || + (declared !== undefined && Number(declared) !== bytes) + ) { + abort(); + return; + } + try { + const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode( + Buffer.concat(chunks, bytes), + ); + finish({ status: res.statusCode ?? 0, body: text }); + } catch { + abort(); + } + }); + }, + ); + req.on('error', abort); + req.on('close', () => { + if (!settled) abort(); + }); + req.end(body); + } catch { + abort(); + } + }); + } +} diff --git a/src/caddy/admin-policy.ts b/src/caddy/admin-policy.ts new file mode 100644 index 0000000..cb44a0e --- /dev/null +++ b/src/caddy/admin-policy.ts @@ -0,0 +1,431 @@ +import { isAbsolute, join, parse, resolve } from 'node:path'; +import { types } from 'node:util'; +import type { CaddyConfig, PortResult, RuntimeOwner } from '../contracts/ports.ts'; +import { assertProtected, prepareRuntime } from '../platform/runtime-security.ts'; +import { loadOrCreateAdminMaterial } from './certificates.ts'; +import type { CaddyRoutePlan } from './composition.ts'; +import { canonicalizeDomain, compareText, type JsonObject, type JsonValue } from './domains.ts'; +import { + assertCandidate, + assertJsonBody, + CaddyPreparationError, + configHash, + maxCaddyConfigurationBytes, +} from './preparation.ts'; + +export type SecureAdminPolicyOptions = Readonly<{ + runtimeBase: string; + owner: RuntimeOwner; + materialDirectory: string; + adminPort: number; + plan: CaddyRoutePlan; + baseEnvironment?: Readonly; +}>; +export type SecureAdminPolicy = Readonly<{ + config: CaddyConfig; + paths: Readonly<{ + base: string; + data: string; + config: string; + scratch: string; + home: string; + defaultStorage: string; + autosaveDirectory: string; + }>; + environment: Readonly; + /** Use normal Node HTTPS identity verification, never a custom bypass or ambient CA. */ + client: Readonly<{ + host: '127.0.0.1'; + port: number; + servername: 'localhost'; + rejectUnauthorized: true; + ca: string; + cert: string; + key: string; + }>; +}>; + +function invalidCandidate(): never { + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Expected a complete, bounded JSON object with safe JSON values.', + ); +} + +/** No stringify(object), getters, toJSON, prototype assignment or proxy traversal. */ +function canonicalBody(input: unknown): string { + const parts: string[] = []; + const active = new WeakSet(); + let bytes = 0; + let visits = 0; + function emit(text: string): void { + bytes += Buffer.byteLength(text, 'utf8'); + if (bytes > maxCaddyConfigurationBytes) invalidCandidate(); + parts.push(text); + } + function visit(value: unknown, depth: number): void { + // Explicit bounds precede native recursion/serialization limits. + if (depth > 128 || ++visits > 1_000_000) invalidCandidate(); + if (value === null || typeof value === 'boolean') { + emit(String(value)); + } else if (typeof value === 'string') { + if (Buffer.byteLength(value, 'utf8') > maxCaddyConfigurationBytes) invalidCandidate(); + emit(JSON.stringify(value)); + } else if (typeof value === 'number' && Number.isFinite(value)) { + emit(JSON.stringify(value)); + } else if (typeof value === 'object') { + if (types.isProxy(value) || active.has(value)) invalidCandidate(); + const array = Array.isArray(value); + const prototype: unknown = Object.getPrototypeOf(value); + if ( + array ? prototype !== Array.prototype : prototype !== null && prototype !== Object.prototype + ) + invalidCandidate(); + const keys = Reflect.ownKeys(value); + if (keys.length > 1_000_000) invalidCandidate(); + const descriptors = Object.getOwnPropertyDescriptors(value); + if (keys.some((key) => typeof key !== 'string')) invalidCandidate(); + active.add(value); + if (array) { + const length = descriptors.length!.value as number; + if (length > 1_000_000 || keys.length !== length + 1) invalidCandidate(); + emit('['); + for (let index = 0; index < length; index++) { + const entry = descriptors[String(index)]; + if (!entry || !entry.enumerable || !('value' in entry)) invalidCandidate(); + if (index > 0) emit(','); + visit(entry.value, depth + 1); + } + emit(']'); + } else { + emit('{'); + const ordered = (keys as string[]).sort(compareText); + for (let index = 0; index < ordered.length; index++) { + const key = ordered[index]!; + const entry = descriptors[key]!; + if (!entry.enumerable || !('value' in entry)) invalidCandidate(); + if (index > 0) emit(','); + if (Buffer.byteLength(key, 'utf8') > maxCaddyConfigurationBytes) invalidCandidate(); + emit(JSON.stringify(key)); + emit(':'); + visit(entry.value, depth + 1); + } + emit('}'); + } + active.delete(value); + } else invalidCandidate(); + } + try { + visit(input, 0); + return parts.join(''); + } catch { + // Native failures and attacker-controlled exception messages never enter diagnostics. + return invalidCandidate(); + } +} + +function candidate(body: string): CaddyConfig { + const config: CaddyConfig = Object.freeze({ + adaptedConfig: Object.freeze({ format: 'json', body }), + effectiveConfigHash: configHash(body), + }); + assertCandidate(config); + return config; +} + +function routeApplications(plan: CaddyRoutePlan) { + return { + http: { + servers: { + cadder_http: { ...plan.servers.cadder_http, automatic_https: { disable: true } }, + cadder_https: { + ...plan.servers.cadder_https, + automatic_https: { disable: true }, + tls_connection_policies: [{}], + }, + }, + }, + tls: { + automation: { + policies: plan.tlsSubjects.length + ? [{ subjects: plan.tlsSubjects, issuers: [{ module: 'internal', ca: 'local' }] }] + : [], + }, + certificates: { automate: plan.tlsSubjects }, + }, + }; +} + +/** Reuse one verified identity; only the guarded HTTP/TLS route plan changes. */ +export function configForRoutePlan(policy: SecureAdminPolicy, input: CaddyRoutePlan): CaddyConfig { + const plan = guardedPlan(input); + const root = object(JSON.parse(policy.config.adaptedConfig.body) as unknown); + return candidate( + canonicalBody({ ...root, apps: { ...object(root.apps), ...routeApplications(plan) } }), + ); +} + +/** + * Normalize a complete submitted or independently fetched JSON object, not a receipt. + * Object keys sort lexically (including numeric keys); arrays and opaque data stay intact. + * No Caddy default-field elision: native effective-config differences remain differences. + * Depth >128 and >1M visited values are refused even below the 32 MiB byte bound. + */ +export function normalizeCaddyConfig(body: string): CaddyConfig { + try { + if (typeof body !== 'string') invalidCandidate(); + assertJsonBody(body); + const value: unknown = JSON.parse(body); + object(value); + return candidate(canonicalBody(value)); + } catch { + return invalidCandidate(); + } +} + +function object(value: unknown): JsonObject { + if (value === null || typeof value !== 'object' || Array.isArray(value)) invalidCandidate(); + return value as JsonObject; +} +function exactKeys(value: JsonObject, expected: readonly string[]): void { + const keys = Object.keys(value).sort(compareText); + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) + invalidCandidate(); +} +function sameArray(value: JsonValue | undefined, expected: readonly string[]): boolean { + return ( + Array.isArray(value) && + value.length === expected.length && + value.every((v, i) => v === expected[i]) + ); +} + +/** Only the existing outer composeRoutePlan guards are policy; nested handlers are opaque. */ +function guardedPlan(input: CaddyRoutePlan): CaddyRoutePlan { + const plan = object(JSON.parse(canonicalBody(input)) as unknown); + exactKeys(plan, ['kind', 'servers', 'tlsSubjects']); + if (plan.kind !== 'caddy-route-plan' || !Array.isArray(plan.tlsSubjects)) invalidCandidate(); + const subjects = plan.tlsSubjects; + if ( + subjects.some((host) => typeof host !== 'string' || !host || canonicalizeDomain(host) !== host) + ) + invalidCandidate(); + const subjectSet = new Set(subjects as string[]); + const sorted = [...subjectSet].sort(compareText); + if (!sameArray(subjects, sorted)) invalidCandidate(); + const servers = object(plan.servers); + exactKeys(servers, ['cadder_http', 'cadder_https']); + const guards: string[][][] = []; + for (const [name, port] of [ + ['cadder_http', 80], + ['cadder_https', 443], + ] as const) { + const server = object(servers[name]); + exactKeys(server, ['listen', 'routes']); + if ( + !sameArray(server.listen, [`127.0.0.1:${port}`, `[::1]:${port}`]) || + !Array.isArray(server.routes) + ) + invalidCandidate(); + const seen = new Set(); + const hosts: string[][] = []; + for (const entry of server.routes) { + const route = object(entry); + exactKeys(route, ['handle', 'match', 'terminal']); + if ( + route.terminal !== true || + !Array.isArray(route.match) || + route.match.length !== 1 || + !Array.isArray(route.handle) || + route.handle.length !== 1 + ) + invalidCandidate(); + const matcher = object(route.match[0]); + const handler = object(route.handle[0]); + exactKeys(matcher, ['host']); + exactKeys(handler, ['handler', 'routes']); + if ( + !Array.isArray(matcher.host) || + matcher.host.length === 0 || + handler.handler !== 'subroute' || + !Array.isArray(handler.routes) || + handler.routes.some( + (child) => child === null || typeof child !== 'object' || Array.isArray(child), + ) + ) + invalidCandidate(); + for (const host of matcher.host) { + if (typeof host !== 'string' || !subjectSet.has(host) || seen.has(host)) invalidCandidate(); + seen.add(host); + } + hosts.push(matcher.host as string[]); + } + guards.push(hosts); + } + if (canonicalBody(guards[0]) !== canonicalBody(guards[1])) invalidCandidate(); + // SAFETY: the private JSON tree passed the exact route-plan/listener/guard checks above. + return plan as unknown as CaddyRoutePlan; +} + +const isolatedKeys = new Set([ + 'XDG_DATA_HOME', + 'XDG_CONFIG_HOME', + 'XDG_CACHE_HOME', + 'XDG_STATE_HOME', + 'XDG_RUNTIME_DIR', + 'HOME', + 'USERPROFILE', + 'HOMEDRIVE', + 'HOMEPATH', + 'APPDATA', + 'LOCALAPPDATA', + 'TEMP', + 'TMP', + 'TMPDIR', + 'CADDY_ADMIN', +]); +function childEnvironment( + paths: SecureAdminPolicy['paths'], + base: Readonly, +): Readonly { + const environment: NodeJS.ProcessEnv = Object.create(null) as NodeJS.ProcessEnv; + // Also remove differently cased keys on Unix: do not leave Windows aliases in the carrier. + for (const [key, value] of Object.entries(base)) { + if (!isolatedKeys.has(key.toUpperCase())) environment[key] = value; + } + Object.assign(environment, { + XDG_DATA_HOME: paths.data, + XDG_CONFIG_HOME: paths.config, + XDG_CACHE_HOME: paths.data, + XDG_STATE_HOME: paths.data, + XDG_RUNTIME_DIR: paths.scratch, + HOME: paths.home, + TEMP: paths.scratch, + TMP: paths.scratch, + TMPDIR: paths.scratch, + }); + if (process.platform === 'win32') { + const drive = parse(paths.home).root.replace(/[\\/]+$/, ''); + Object.assign(environment, { + USERPROFILE: paths.home, + HOMEDRIVE: drive, + HOMEPATH: paths.home.slice(drive.length), + APPDATA: paths.config, + LOCALAPPDATA: paths.data, + }); + } + return Object.freeze(environment); +} + +/** + * Caller holds the runtime lifetime lock. No server, port allocation or new lock is created. + * Pinned Caddy 2.11.4 uses XDG_* on ALL OSes for DefaultStorage/admin identity and autosave. + * Caddy owns admin leaf issuance/renewal; startup, mTLS denial and generated-file protection + * still need native acceptance. Directory checks here never repair existing unsafe paths. + */ +export async function prepareSecureAdminPolicy( + options: SecureAdminPolicyOptions, +): Promise> { + try { + if ( + !Number.isInteger(options.adminPort) || + options.adminPort < 1 || + options.adminPort > 65535 || + [80, 443].includes(options.adminPort) + ) + throw new CaddyPreparationError( + 'caddy_admin_policy_invalid', + 'Expected an explicit non-route administration port.', + ); + if (!isAbsolute(options.runtimeBase) || !isAbsolute(options.materialDirectory)) + throw new CaddyPreparationError( + 'caddy_admin_policy_invalid', + 'Administration runtime and material directories must be absolute.', + ); + const plan = guardedPlan(options.plan); + await prepareRuntime(options.runtimeBase, options.owner); + const base = resolve(options.runtimeBase); + const paths = Object.freeze({ + base, + data: join(base, 'data'), + config: join(base, 'config'), + scratch: join(base, 'scratch'), + home: join(base, 'home'), + defaultStorage: join(base, 'data', 'caddy'), + autosaveDirectory: join(base, 'config', 'caddy'), + }); + for (const directory of [ + paths.data, + paths.config, + paths.scratch, + paths.home, + paths.defaultStorage, + paths.autosaveDirectory, + ]) { + await prepareRuntime(directory, options.owner); + await assertProtected(directory, options.owner, true); + } + const material = await loadOrCreateAdminMaterial(options.materialDirectory, options.owner); + if (!material.ok) return material; + const { paths: pem, authorizedClientCertificateBase64, tls } = material.value; + const config = candidate( + canonicalBody({ + admin: { + disabled: true, + identity: { + identifiers: ['localhost'], + issuers: [{ module: 'internal', ca: 'cadder-admin' }], + }, + remote: { + listen: `127.0.0.1:${options.adminPort}`, + access_control: [{ public_keys: [authorizedClientCertificateBase64] }], + }, + }, + apps: { + pki: { + certificate_authorities: { + 'cadder-admin': { + install_trust: false, + root: { format: 'pem_file', certificate: pem.root.certificate }, + intermediate: { + format: 'pem_file', + certificate: pem.intermediate.certificate, + private_key: pem.intermediate.key, + }, + }, + local: { install_trust: false }, + }, + }, + ...routeApplications(plan), + }, + }), + ); + return { + ok: true, + value: Object.freeze({ + config, + paths, + environment: childEnvironment(paths, options.baseEnvironment ?? process.env), + client: Object.freeze({ + host: '127.0.0.1', + port: options.adminPort, + servername: 'localhost', + rejectUnauthorized: true, + ...tls, + }), + }), + }; + } catch (error) { + return { + ok: false, + error: { + kind: 'configuration', + code: error instanceof CaddyPreparationError ? error.code : 'caddy_admin_policy_failed', + message: 'Caddy administration policy or protected runtime was refused.', + guidance: null, + retryable: false, + requestId: null, + }, + }; + } +} diff --git a/src/caddy/certificates.ts b/src/caddy/certificates.ts new file mode 100644 index 0000000..78e9a56 --- /dev/null +++ b/src/caddy/certificates.ts @@ -0,0 +1,351 @@ +import 'reflect-metadata'; +import { webcrypto } from 'node:crypto'; +import { constants } from 'node:fs'; +import { open, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + AuthorityKeyIdentifierExtension, + BasicConstraintsExtension, + ExtendedKeyUsage, + ExtendedKeyUsageExtension, + KeyUsageFlags, + KeyUsagesExtension, + PemConverter, + SubjectKeyIdentifierExtension, + X509Certificate, + X509CertificateGenerator, +} from '@peculiar/x509'; +import type { PortResult, RuntimeOwner } from '../contracts/ports.ts'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, +} from '../platform/runtime-security.ts'; +import { errorCode, type ProtocolError } from '../protocol/errors.ts'; + +// SAFETY: Node 24 WebCrypto implements the standard Crypto API expected by x509; +// its declaration uses Node-specific BufferSource types instead of DOM ones. +const crypto = webcrypto as unknown as Crypto; +const curve = { name: 'ECDSA', namedCurve: 'P-256' }; +const signingAlgorithm = { name: 'ECDSA', hash: 'SHA-256' }; +const day = 24 * 60 * 60 * 1000; +const roles = ['root', 'intermediate', 'client'] as const; +type Role = (typeof roles)[number]; +type PemIdentity = Readonly<{ certificate: string; key: string }>; +type Bundle = Record; +const maxFileBytes = 16 * 1024; +const issuerRoles = { root: 'root', intermediate: 'root', client: 'intermediate' } as const; +const pathLengths = { root: 1, intermediate: 0, client: undefined }; +const caUsage = KeyUsageFlags.keyCertSign | KeyUsageFlags.cRLSign; +const filenames = { + root: { certificate: 'root.crt.pem', key: 'root.key.pem' }, + intermediate: { certificate: 'intermediate.crt.pem', key: 'intermediate.key.pem' }, + client: { certificate: 'client.crt.pem', key: 'client.key.pem' }, +} as const; + +export type AdminMaterial = Readonly<{ + paths: Readonly>; + certificates: Readonly>; + /** Caddy access_control.public_keys expects leaf certificate DER, not SPKI. */ + authorizedClientCertificateBase64: string; + /** Internal TLS adapter credentials; never log or include this object in diagnostics. */ + tls: Readonly<{ ca: string; cert: string; key: string }>; +}>; + +const names: Record = { + root: 'CN=Cadder Admin Root', + intermediate: 'CN=Cadder Admin Intermediate', + client: 'CN=Cadder Admin Client', +}; + +class MaterialError extends Error { + constructor(readonly code: string) { + super('Caddy administration material was refused.'); + } +} + +function pemData(pem: string, tag: string): ArrayBuffer { + // Accept only one complete PEM block, not parser-tolerated trailing material. + const blocks = PemConverter.decodeWithHeaders(pem); + if ( + blocks.length !== 1 || + blocks[0]!.type !== tag || + blocks[0]!.headers.length !== 0 || + PemConverter.encode(blocks[0]!.rawData, tag).replace(/\s/g, '') !== pem.replace(/\s/g, '') + ) + throw new MaterialError('caddy_material_invalid'); + return blocks[0]!.rawData; +} + +async function generateBundle(): Promise { + // Fixed validity policy: 365-day years, root 10 years, intermediate 5, + // client 1. Backdate five minutes for initial skew. Caddy owns repeated + // short-lived server issuance; this module never rotates expired material. + const now = Date.now(); + const notBefore = new Date(now - 5 * 60 * 1000); + const years = { root: 10, intermediate: 5, client: 1 }; + const keys = {} as Record; + const bundle = {} as Bundle; + for (const role of roles) { + keys[role] = await crypto.subtle.generateKey(curve, true, ['sign', 'verify']); + const issuer = issuerRoles[role]; + const ca = role !== 'client'; + const certificate = await X509CertificateGenerator.create( + { + subject: names[role], + issuer: names[issuer], + publicKey: keys[role].publicKey, + signingKey: keys[issuer].privateKey, + signingAlgorithm, + notBefore, + notAfter: new Date(now + years[role] * 365 * day), + extensions: [ + new BasicConstraintsExtension(ca, pathLengths[role], true), + new KeyUsagesExtension(ca ? caUsage : KeyUsageFlags.digitalSignature, true), + ...(ca ? [] : [new ExtendedKeyUsageExtension([ExtendedKeyUsage.clientAuth], true)]), + await SubjectKeyIdentifierExtension.create(keys[role].publicKey, false, crypto), + await AuthorityKeyIdentifierExtension.create(keys[issuer].publicKey, false, crypto), + ], + }, + crypto, + ); + bundle[role] = { + certificate: certificate.toString('pem'), + key: PemConverter.encode( + await crypto.subtle.exportKey('pkcs8', keys[role].privateKey), + 'PRIVATE KEY', + ), + }; + } + return bundle; +} + +async function validateBundle(bundle: Bundle): Promise> { + try { + const certificates = {} as Record; + const publicKeys: string[] = []; + const now = Date.now(); + for (const role of roles) { + const certificate = new X509Certificate(pemData(bundle[role].certificate, 'CERTIFICATE')); + certificates[role] = certificate; + const ca = role !== 'client'; + const basic = certificate.getExtension(BasicConstraintsExtension); + const usage = certificate.getExtension(KeyUsagesExtension); + const eku = certificate.getExtension(ExtendedKeyUsageExtension); + const extensionTypes = certificate.extensions.map((extension) => extension.type); + if ( + certificate.subject !== names[role] || + certificate.signatureAlgorithm.name !== 'ECDSA' || + certificate.signatureAlgorithm.hash.name !== 'SHA-256' || + !basic?.critical || + basic.ca !== ca || + basic.pathLength !== pathLengths[role] || + !usage?.critical || + usage.usages !== (ca ? caUsage : KeyUsageFlags.digitalSignature) || + (ca + ? eku !== null + : !eku || + !eku.critical || + eku.usages.length !== 1 || + eku.usages[0] !== ExtendedKeyUsage.clientAuth) || + new Set(extensionTypes).size !== extensionTypes.length || + certificate.extensions.some( + (extension) => + extension.critical && + !( + extension instanceof BasicConstraintsExtension || + extension instanceof KeyUsagesExtension || + extension instanceof ExtendedKeyUsageExtension + ), + ) + ) + throw new MaterialError('caddy_material_invalid'); + if ( + !Number.isFinite(certificate.notBefore.getTime()) || + !Number.isFinite(certificate.notAfter.getTime()) || + certificate.notBefore.getTime() > now || + certificate.notAfter.getTime() <= now + ) + throw new MaterialError('caddy_material_validity'); + const publicKey = await certificate.publicKey.export(curve, ['verify'], crypto); + publicKeys.push(Buffer.from(certificate.publicKey.rawData).toString('base64')); + const privateKey = await crypto.subtle.importKey( + 'pkcs8', + pemData(bundle[role].key, 'PRIVATE KEY'), + curve, + false, + ['sign'], + ); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const signature = await crypto.subtle.sign(signingAlgorithm, privateKey, challenge); + if (!(await crypto.subtle.verify(signingAlgorithm, publicKey, signature, challenge))) + throw new MaterialError('caddy_material_invalid'); + } + if (new Set(publicKeys).size !== roles.length) + throw new MaterialError('caddy_material_invalid'); + for (const role of roles) { + const certificate = certificates[role]; + const issuer = certificates[issuerRoles[role]]; + if ( + certificate.issuer !== issuer.subject || + certificate.notBefore < issuer.notBefore || + certificate.notAfter > issuer.notAfter || + !(await certificate.verify({ publicKey: issuer.publicKey, signatureOnly: true }, crypto)) + ) + throw new MaterialError('caddy_material_invalid'); + } + return certificates; + } catch (error) { + if (error instanceof MaterialError) throw error; + // Never return certificate/private-key parser or WebCrypto exception text. + throw new MaterialError('caddy_material_invalid'); + } +} + +async function readPem(path: string, owner: RuntimeOwner): Promise { + await assertProtected(path, owner); + const handle = await open( + path, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | constants.O_NONBLOCK, + ); + try { + const info = await handle.stat(); + if ( + !info.isFile() || + (process.platform !== 'win32' && (info.uid !== owner.uid || (info.mode & 0o777) !== 0o600)) + ) + throw new MaterialError('caddy_material_unsafe'); + if (info.size > maxFileBytes) throw new MaterialError('caddy_material_oversized'); + // One extra byte detects growth after stat. No unbounded readFile allocation. + const bytes = Buffer.alloc(maxFileBytes + 1); + let used = 0; + while (used < bytes.length) { + const { bytesRead } = await handle.read(bytes, used, bytes.length - used, null); + if (bytesRead === 0) break; + used += bytesRead; + } + if (used > maxFileBytes) throw new MaterialError('caddy_material_oversized'); + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, used)); + } catch { + throw new MaterialError('caddy_material_invalid'); + } + } finally { + await handle.close(); + } +} + +/** + * Caller must hold the runtime lifetime lock, and keep it while using material. + * Creates six fixed PEM files only when all six are absent. Existing identity + * is validated without writes, repair or rotation. Failure cleanup removes only + * exclusively created files; prepareRuntime's directory is left in place. + */ +export async function loadOrCreateAdminMaterial( + directory: string, + owner: RuntimeOwner, +): Promise> { + const created: string[] = []; + try { + await prepareRuntime(directory, owner); + const paths = {} as Record; + let present = 0; + for (const role of roles) { + paths[role] = { + certificate: join(directory, filenames[role].certificate), + key: join(directory, filenames[role].key), + }; + for (const path of Object.values(paths[role])) { + try { + await assertProtected(path, owner); + present += 1; + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + } + } + } + if (present !== 0 && present !== 6) throw new MaterialError('caddy_material_incomplete'); + let bundle: Bundle; + if (present === 0) { + try { + bundle = await generateBundle(); + } catch { + throw new MaterialError('caddy_material_generation_failed'); + } + } else { + bundle = {} as Bundle; + for (const role of roles) { + bundle[role] = { + certificate: await readPem(paths[role].certificate, owner), + key: await readPem(paths[role].key, owner), + }; + } + } + const certificates = await validateBundle(bundle); + if (present === 0) { + for (const role of roles) { + for (const field of ['certificate', 'key'] as const) { + const path = paths[role][field]; + if (!(await createProtectedFile(path, owner, bundle[role][field]))) + throw new MaterialError('caddy_material_conflict'); + created.push(path); + } + } + } + return { + ok: true, + value: { + paths, + certificates: { + root: bundle.root.certificate, + intermediate: bundle.intermediate.certificate, + client: bundle.client.certificate, + }, + authorizedClientCertificateBase64: certificates.client.toString('base64'), + tls: { + ca: bundle.root.certificate, + cert: `${bundle.client.certificate}\n${bundle.intermediate.certificate}`, + key: bundle.client.key, + }, + }, + }; + } catch (error) { + let cleanupFailed = false; + for (const path of created.reverse()) { + try { + await unlink(path); + } catch (cleanupError) { + if (errorCode(cleanupError) !== 'ENOENT') cleanupFailed = true; + } + } + let code = 'caddy_material_io'; + if (cleanupFailed) code = 'caddy_material_cleanup_failed'; + else if (error instanceof MaterialError) code = error.code; + else if (errorCode(error) === 'unsafe-runtime-permissions') code = 'caddy_material_unsafe'; + let kind: ProtocolError['kind'] = 'configuration'; + switch (code) { + case 'caddy_material_unsafe': + kind = 'accessDenied'; + break; + case 'caddy_material_conflict': + kind = 'conflict'; + break; + case 'caddy_material_io': + case 'caddy_material_cleanup_failed': + kind = 'storage'; + break; + } + return { + ok: false, + error: { + kind, + code, + message: 'Caddy administration material could not be created or validated.', + guidance: + 'Use a complete valid owner-protected bundle. I/O or cleanup failures may leave partial files; inspect the protected directory. Existing credentials are never repaired or rotated automatically.', + retryable: false, + requestId: null, + }, + }; + } +} diff --git a/src/caddy/composition.ts b/src/caddy/composition.ts new file mode 100644 index 0000000..860fc98 --- /dev/null +++ b/src/caddy/composition.ts @@ -0,0 +1,151 @@ +import type { CaddyConfig } from '../contracts/ports.ts'; +import { + canonicalizeDomain, + checkDomainOwnership, + compareText, + diagnostic, + maxCompositionDiagnostics, + readPreparedRoutes, + type CompositionDiagnostic, + type CompositionRegistration, + type CompositionResult, + type JsonObject, + type JsonValue, +} from './domains.ts'; + +export type PreparedCompositionInput = Readonly<{ + registration: CompositionRegistration; + config?: CaddyConfig; +}>; +export type RouteServer = Readonly<{ + listen: readonly string[]; + routes: readonly JsonObject[]; +}>; +/** Not a runnable CaddyConfig: secure admin/TLS assembly and apply belong to later work. */ +export type CaddyRoutePlan = Readonly<{ + kind: 'caddy-route-plan'; + servers: Readonly<{ cadder_http: RouteServer; cadder_https: RouteServer }>; + tlsSubjects: readonly string[]; +}>; + +/** Mutate only a private parsed tree, and only actual host matcher/subroute positions. */ +function filterRoute(route: JsonObject, hosts: ReadonlySet): JsonObject | null { + if (Array.isArray(route.match) && route.match.length > 0) { + const matchers: JsonObject[] = []; + for (const entry of route.match) { + const matcher = entry as JsonObject; + if (Array.isArray(matcher.host)) { + const retained = [ + ...new Set( + (matcher.host as string[]).map(canonicalizeDomain).filter((host) => hosts.has(host)), + ), + ]; + // An impossible matcher is removed as an OR alternative, never made hostless. + if (retained.length === 0) continue; + matcher.host = retained; + } + matchers.push(matcher); + } + if (matchers.length === 0) return null; + route.match = matchers; + } + if (Array.isArray(route.handle) && route.handle.length > 0) { + const handlers: JsonObject[] = []; + for (const entry of route.handle) { + const handler = entry as JsonObject; + if ( + handler.handler === 'subroute' && + Array.isArray(handler.routes) && + handler.routes.length > 0 + ) { + const children = filterRoutes(handler.routes as JsonObject[], hosts); + if (children.length === 0) continue; + handler.routes = children; + } + handlers.push(handler); + } + // An empty terminal route still stops the chain; keep its position and matchers. + if (handlers.length === 0 && route.terminal !== true) return null; + route.handle = handlers; + } + return route; +} + +function filterRoutes(routes: readonly JsonObject[], hosts: ReadonlySet): JsonObject[] { + const retained: JsonObject[] = []; + for (const route of routes) { + const filtered = filterRoute(route, hosts); + if (filtered !== null) retained.push(filtered); + } + return retained; +} + +function namespaceHttpIds(value: JsonValue): void { + if (Array.isArray(value)) { + for (const child of value) namespaceHttpIds(child); + } else if (value !== null && typeof value === 'object') { + if (typeof value['@id'] === 'string') value['@id'] = `http_${value['@id']}`; + for (const child of Object.values(value)) namespaceHttpIds(child); + } +} + +/** Stable registration-ID order, sorted server keys, original project route/handler order. */ +export function composeRoutePlan( + inputs: readonly PreparedCompositionInput[], +): CompositionResult { + const ownership = checkDomainOwnership(inputs.map((input) => input.registration)); + if (!ownership.ok) return ownership; + const diagnostics: CompositionDiagnostic[] = []; + const routes: JsonObject[] = []; + const httpRoutes: JsonObject[] = []; + const tlsSubjects = new Set(); + const ordered = [...inputs].sort((left, right) => + compareText(left.registration.registrationId, right.registration.registrationId), + ); + for (const input of ordered) { + const hosts = ownership.value.get(input.registration.registrationId)!; + if (hosts.length === 0) continue; + const parsed = readPreparedRoutes(input.config, [input.registration]); + if (!parsed.ok) { + diagnostics.push(...parsed.diagnostics); + continue; + } + try { + // The carrier is a string: JSON parsing already produced a private clone. + const retained = filterRoutes(parsed.value, new Set(hosts)); + for (const host of hosts) tlsSubjects.add(host); + if (retained.length === 0) continue; + const guard: JsonObject = { + match: [{ host: [...hosts] }], + handle: [{ handler: 'subroute', routes: retained }], + terminal: true, + }; + const httpGuard = structuredClone(guard); + namespaceHttpIds(httpGuard); + routes.push(guard); + httpRoutes.push(httpGuard); + } catch { + // Even complete bounded JSON may exceed the native clone/traversal capacity. + diagnostics.push( + diagnostic( + 'prepared-input-invalid', + 'Cannot safely clone and filter the complete adapted route tree.', + [input.registration], + ), + ); + } + } + if (diagnostics.length > 0) + return { ok: false, diagnostics: diagnostics.slice(0, maxCompositionDiagnostics) }; + return { + ok: true, + value: { + kind: 'caddy-route-plan', + servers: { + cadder_http: { listen: ['127.0.0.1:80', '[::1]:80'], routes: httpRoutes }, + cadder_https: { listen: ['127.0.0.1:443', '[::1]:443'], routes }, + }, + tlsSubjects: [...tlsSubjects].sort(compareText), + }, + }; +} diff --git a/src/caddy/configuration.ts b/src/caddy/configuration.ts new file mode 100644 index 0000000..feadb49 --- /dev/null +++ b/src/caddy/configuration.ts @@ -0,0 +1,152 @@ +import { constants } from 'node:fs'; +import { lstat, open, realpath } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { posix, win32 } from 'node:path'; +import { parse } from 'smol-toml'; +import { z } from 'zod'; +import { resolveInstallationRoot } from '../daemon/installation-root.ts'; +import { powershell } from '../platform/powershell.ts'; +import { absoluteCaddyPath } from './executable.ts'; +import { assertNotReparsePoint } from './executable.ts'; + +export type CaddySelection = Readonly<{ kind: 'command' | 'path'; value: string }>; +export type ConfigurationPaths = Readonly<{ portable: string; user: string; system: string }>; + +const selectors = z.strictObject({ + real_command: z.string().optional(), + real_path: z.string().optional(), +}); +const configuration = z.strictObject({ caddy: selectors.optional() }); + +/** Parse the released selectors only; source safety and executable checks are separate. */ +export function parseConfiguration(contents: string): CaddySelection | undefined { + const { caddy } = configuration.parse(parse(contents)); + if (!caddy) return undefined; + if (caddy.real_command !== undefined && caddy.real_path !== undefined) + throw new Error('caddy.real_command and caddy.real_path cannot both be configured.'); + if (caddy.real_command !== undefined) return { kind: 'command', value: caddy.real_command }; + if (caddy.real_path !== undefined) return { kind: 'path', value: caddy.real_path }; + return undefined; +} + +/** Only absence falls through. Broken links, unsafe files and invalid TOML fail closed. */ +export async function readConfiguration(path: string): Promise { + if (!absoluteCaddyPath(path)) throw new Error('Cadder configuration must use an absolute path.'); + try { + await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + if (process.platform === 'win32') await assertNotReparsePoint(path); + const canonical = await realpath(path); + const file = await open(canonical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + if (!(await file.stat()).isFile()) + throw new Error('Cadder configuration must be a regular file.'); + return parseConfiguration( + new TextDecoder('utf-8', { fatal: true }).decode(await file.readFile()), + ); + } finally { + await file.close(); + } +} + +export interface ConfigurationLocationInput { + platform: NodeJS.Platform; + installationRoot: string; + home: string; + xdgConfigHome?: string | undefined; + knownFolders?: Readonly<{ roamingAppData: string; programData: string }> | undefined; +} + +/** Pure OS location calculation; modeled locations are not native platform evidence. */ +export function configurationLocations(input: ConfigurationLocationInput): ConfigurationPaths { + const path = input.platform === 'win32' ? win32 : posix; + if ( + !absoluteCaddyPath(input.installationRoot, input.platform) || + !absoluteCaddyPath(input.home, input.platform) + ) + throw new Error('Configuration roots must be absolute.'); + const portable = path.join(input.installationRoot, 'cadder.toml'); + if (input.platform === 'win32') { + const folders = input.knownFolders; + if ( + !folders || + !absoluteCaddyPath(folders.roamingAppData, 'win32') || + !absoluteCaddyPath(folders.programData, 'win32') + ) + throw new Error('Windows configuration requires OS known folders.'); + return { + portable, + user: win32.join(folders.roamingAppData, 'Cadder', 'Cadder', 'config', 'cadder.toml'), + system: win32.join(folders.programData, 'Cadder', 'cadder.toml'), + }; + } + if (input.platform === 'darwin') + return { + portable, + user: posix.join( + input.home, + 'Library', + 'Application Support', + 'dev.Cadder.Cadder', + 'cadder.toml', + ), + system: '/Library/Application Support/Cadder/cadder.toml', + }; + return { + portable, + user: posix.join( + input.xdgConfigHome && posix.isAbsolute(input.xdgConfigHome) + ? input.xdgConfigHome + : posix.join(input.home, '.config'), + 'cadder', + 'cadder.toml', + ), + system: '/etc/cadder/cadder.toml', + }; +} + +/** Physical npm package / SEA directory, never cwd, argv wrappers or shared Node. */ +export async function defaultConfigurationPaths(): Promise { + const knownFolders = + process.platform === 'win32' + ? (JSON.parse( + await powershell(` + @{ roamingAppData = [Environment]::GetFolderPath('ApplicationData'); + programData = [Environment]::GetFolderPath('CommonApplicationData') } | ConvertTo-Json -Compress + `), + ) as { roamingAppData: string; programData: string }) + : undefined; + return configurationLocations({ + platform: process.platform, + installationRoot: resolveInstallationRoot(), + home: homedir(), + xdgConfigHome: process.env.XDG_CONFIG_HOME, + knownFolders, + }); +} + +export type SelectionSource = 'override' | 'portable' | 'user' | 'system' | 'path'; +export type SelectedConfiguration = Readonly<{ + selection: CaddySelection; + source: SelectionSource; +}>; + +/** Daemon-owned inputs only. No project, shim argument or environment selector is accepted. */ +export async function selectConfiguration( + paths: ConfigurationPaths, + explicitOverride?: string, +): Promise { + if (explicitOverride !== undefined) { + if (!absoluteCaddyPath(explicitOverride)) + throw new Error('Real-Caddy override must be absolute.'); + return { selection: { kind: 'path', value: explicitOverride }, source: 'override' }; + } + for (const source of ['portable', 'user', 'system'] as const) { + const selection = await readConfiguration(paths[source]); + if (selection) return { selection, source }; + } + return { selection: { kind: 'command', value: 'caddy' }, source: 'path' }; +} diff --git a/src/caddy/domains.ts b/src/caddy/domains.ts new file mode 100644 index 0000000..ce0daa0 --- /dev/null +++ b/src/caddy/domains.ts @@ -0,0 +1,237 @@ +import { canonicalizeDomain } from '../protocol/domain.ts'; +export { canonicalizeDomain } from '../protocol/domain.ts'; +import type { CaddyConfig } from '../contracts/ports.ts'; +import type { EntrypointRegistration } from '../protocol/dto.ts'; +import { assertCandidate } from './preparation.ts'; + +export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject; +export type JsonObject = { [key: string]: JsonValue }; +export type CompositionRegistration = Pick< + EntrypointRegistration, + 'registrationId' | 'activationState' | 'registeredDomains' | 'sourceConfigPath' +>; +export type CompositionDiagnostic = Readonly<{ + code: 'duplicate-registration' | 'domain-conflict' | 'prepared-input-invalid'; + message: string; + domainKey: string | null; + sourceConfigPaths: readonly string[]; +}>; +export type CompositionResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; diagnostics: readonly CompositionDiagnostic[] }>; +export type ExtractedDomain = Readonly<{ canonicalDomain: string; upstream: string | null }>; +export const maxCompositionDiagnostics = 64; +const maxDiagnosticText = 4096; + +export function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +export function isEnabled(state: CompositionRegistration['activationState']): boolean { + return state === 'registered' || state === 'activating' || state === 'active'; +} + +export function activeDomains(registration: CompositionRegistration): readonly string[] { + if (!isEnabled(registration.activationState)) return []; + return [ + ...new Set( + registration.registeredDomains + .filter((domain) => isEnabled(domain.activationState)) + .map((domain) => canonicalizeDomain(domain.name.canonical)), + ), + ].sort(compareText); +} + +/** Bound diagnostic payloads even when registration metadata is caller-controlled. */ +export function diagnostic( + code: CompositionDiagnostic['code'], + message: string, + registrations: readonly CompositionRegistration[], + domainKey: string | null = null, +): CompositionDiagnostic { + const paths = [...new Set(registrations.map((registration) => registration.sourceConfigPath.raw))] + .sort(compareText) + .slice(0, maxCompositionDiagnostics); + return { + code, + message: message.slice(0, maxDiagnosticText), + domainKey: domainKey?.slice(0, maxDiagnosticText) ?? null, + sourceConfigPaths: paths.map((path) => path.slice(0, maxDiagnosticText)), + }; +} + +/** Duplicate IDs are ambiguous even when inactive; domain ownership uses both states. */ +export function checkDomainOwnership( + registrations: readonly CompositionRegistration[], +): CompositionResult> { + const byId = new Map(); + for (const registration of registrations) { + const group = byId.get(registration.registrationId) ?? []; + group.push(registration); + byId.set(registration.registrationId, group); + } + const diagnostics: CompositionDiagnostic[] = []; + const active = new Map(); + const owners = new Map(); + for (const id of [...byId.keys()].sort(compareText)) { + const group = byId.get(id)!; + if (group.length > 1) { + diagnostics.push( + diagnostic('duplicate-registration', 'Registration ID occurs more than once.', group), + ); + continue; + } + const registration = group[0]!; + const hosts = activeDomains(registration); + active.set(id, hosts); + for (const host of hosts) { + const group = owners.get(host) ?? []; + group.push(registration); + owners.set(host, group); + } + } + for (const host of [...owners.keys()].sort(compareText)) { + const group = owners.get(host)!; + if (group.length > 1) + diagnostics.push( + diagnostic( + 'domain-conflict', + 'Domain is registered by multiple enabled entrypoints.', + group, + host, + ), + ); + } + return diagnostics.length > 0 + ? { ok: false, diagnostics: diagnostics.slice(0, maxCompositionDiagnostics) } + : { ok: true, value: active }; +} + +function isObject(value: JsonValue | undefined): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function object(value: JsonValue | undefined): JsonObject { + if (!isObject(value)) throw new Error('Expected object.'); + return value; +} + +function optionalObject(parent: JsonObject, key: string): JsonObject { + return parent[key] === undefined ? {} : object(parent[key]); +} + +function validateRoutes(value: JsonValue): JsonObject[] { + if (!Array.isArray(value)) throw new Error('Expected routes.'); + return value.map((entry) => { + const route = object(entry); + if (route.match !== undefined) { + if (!Array.isArray(route.match)) throw new Error('Expected matchers.'); + for (const entry of route.match) { + const matcher = object(entry); + if ( + matcher.host !== undefined && + (!Array.isArray(matcher.host) || matcher.host.some((host) => typeof host !== 'string')) + ) + throw new Error('Expected host strings.'); + } + } + if (route.handle !== undefined) { + if (!Array.isArray(route.handle)) throw new Error('Expected handlers.'); + for (const entry of route.handle) { + const handler = object(entry); + if (handler.handler === 'subroute' && handler.routes !== undefined) + validateRoutes(handler.routes); + } + } + return route; + }); +} + +/** Parse a complete integrity-checked carrier; never reinterpret opaque handler payloads. */ +export function readPreparedRoutes( + config: CaddyConfig | undefined, + registrations: readonly CompositionRegistration[] = [], +): CompositionResult { + try { + if (config === undefined) throw new Error('Missing prepared input.'); + assertCandidate(config); + const root = object(JSON.parse(config.adaptedConfig.body) as JsonValue); + const servers = optionalObject(optionalObject(optionalObject(root, 'apps'), 'http'), 'servers'); + const routes: JsonObject[] = []; + for (const name of Object.keys(servers).sort(compareText)) { + const server = object(servers[name]); + if (server.routes !== undefined) routes.push(...validateRoutes(server.routes)); + } + return { ok: true, value: routes }; + } catch { + return { + ok: false, + diagnostics: [ + diagnostic( + 'prepared-input-invalid', + 'Expected a complete, bounded, integrity-checked adapted HTTP route tree.', + registrations, + ), + ], + }; + } +} + +/** Visit only Caddy route positions; handler properties named host/routes remain opaque. */ +function nestedRoutes(route: JsonObject): JsonObject[] { + const routes: JsonObject[] = []; + for (const handler of (route.handle ?? []) as JsonObject[]) { + if (handler.handler === 'subroute') routes.push(...((handler.routes ?? []) as JsonObject[])); + } + return routes; +} + +function collectHosts(route: JsonObject, hosts: Set): void { + for (const matcher of (route.match ?? []) as JsonObject[]) { + for (const host of (matcher.host ?? []) as string[]) hosts.add(canonicalizeDomain(host)); + } + for (const child of nestedRoutes(route)) collectHosts(child, hosts); +} + +function firstUpstream(route: JsonObject): string | null { + for (const handler of (route.handle ?? []) as JsonObject[]) { + if (handler.handler === 'reverse_proxy' && Array.isArray(handler.upstreams)) { + for (const upstream of handler.upstreams) { + if (isObject(upstream) && typeof upstream.dial === 'string' && upstream.dial.trim() !== '') + return upstream.dial; + } + } + if (handler.handler === 'subroute') { + for (const child of (handler.routes ?? []) as JsonObject[]) { + const upstream = firstUpstream(child); + if (upstream !== null) return upstream; + } + } + } + return null; +} + +/** Retain the first upstream per top-level route and first occurrence of each domain. */ +export function extractRegisteredDomains( + config: CaddyConfig, +): CompositionResult { + const parsed = readPreparedRoutes(config); + if (!parsed.ok) return parsed; + const domains = new Map(); + for (const route of parsed.value) { + const hosts = new Set(); + collectHosts(route, hosts); + const upstream = firstUpstream(route); + for (const host of hosts) { + if (!domains.has(host)) domains.set(host, { canonicalDomain: host, upstream }); + } + } + return { + ok: true, + value: [...domains.values()].sort((left, right) => + compareText(left.canonicalDomain, right.canonicalDomain), + ), + }; +} diff --git a/src/caddy/executable.ts b/src/caddy/executable.ts new file mode 100644 index 0000000..92b4a95 --- /dev/null +++ b/src/caddy/executable.ts @@ -0,0 +1,71 @@ +import { constants, type BigIntStats } from 'node:fs'; +import { lstat, open, realpath, stat } from 'node:fs/promises'; +import { extname, posix, win32 } from 'node:path'; +import { powershell, psLiteral } from '../platform/powershell.ts'; + +export function fileIdentity(info: BigIntStats): string { + if (info.ino === 0n) throw new Error('Native executable file identity is unavailable.'); + return `${info.dev}:${info.ino}`; +} + +/** Match Rust absolute paths: Windows drive-rooted paths without a prefix are relative. */ +export function absoluteCaddyPath(value: string, platform = process.platform): boolean { + const path = platform === 'win32' ? win32 : posix; + return path.isAbsolute(value) && (platform !== 'win32' || path.parse(value).root.length > 1); +} + +/** Installed executables need not have runtime owner-only permissions or ACLs. */ +export async function nativeExecutable(path: string): Promise { + if (!absoluteCaddyPath(path) || path.includes('\0')) + throw new Error('Caddy executable must use an absolute path.'); + if (process.platform === 'win32') { + if (['.cmd', '.bat', '.ps1', '.js', '.cjs', '.mjs'].includes(extname(path).toLowerCase())) + throw new Error('Caddy executable must be native, not a command/script wrapper.'); + await assertNotReparsePoint(path); + } + const canonical = await realpath(path); + const file = await open(canonical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const info = await file.stat({ bigint: true }); + if (!info.isFile()) throw new Error('Caddy executable must be a regular file.'); + fileIdentity(info); + if (process.platform !== 'win32' && (info.mode & 0o111n) === 0n) + throw new Error('Caddy image is not executable.'); + const header = Buffer.alloc(4); + const { bytesRead } = await file.read(header, 0, 4, 0); + const native = + process.platform === 'win32' + ? header[0] === 0x4d && header[1] === 0x5a + : [ + '7f454c46', + 'feedface', + 'cefaedfe', + 'feedfacf', + 'cffaedfe', + 'cafebabe', + 'bebafeca', + 'cafebabf', + 'bfbafeca', + ].includes(header.toString('hex')); + if (bytesRead < 4 || !native) + throw new Error('Caddy executable must be a native image, not a script wrapper.'); + } finally { + await file.close(); + } + return canonical; +} + +export async function assertNotReparsePoint(path: string): Promise { + if ((await lstat(path)).isSymbolicLink()) + throw new Error('Caddy path must not be a Windows reparse point.'); + if (process.platform === 'win32') + await powershell(` + if (([IO.File]::GetAttributes(${psLiteral(path)}) -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Caddy path is a reparse point' } + `); +} + +/** Native dev/inode identity (bigint avoids Windows file-index precision loss). */ +export async function sameFile(left: string, right: string): Promise { + const [a, b] = await Promise.all([stat(left, { bigint: true }), stat(right, { bigint: true })]); + return fileIdentity(a) === fileIdentity(b); +} diff --git a/src/caddy/image.ts b/src/caddy/image.ts new file mode 100644 index 0000000..6ec30d7 --- /dev/null +++ b/src/caddy/image.ts @@ -0,0 +1,221 @@ +import { createHash } from 'node:crypto'; +import { constants, type BigIntStats } from 'node:fs'; +import { open, realpath, type FileHandle } from 'node:fs/promises'; +import { + runOwnedCommand, + type OwnedCommandOptions, + type OwnedCommandOutput, +} from '../platform/owned-command.ts'; +import { fileIdentity, nativeExecutable } from './executable.ts'; +import type { SelectionSource } from './configuration.ts'; + +export const minimumCaddyVersion = '2.11.4'; +export const compatibilityProbeRevision = 'cadder-v1-probe-1'; +export const requiredCaddyModules = Object.freeze([ + 'http', + 'http.encoders.gzip', + 'http.encoders.zstd', + 'http.handlers.encode', + 'http.handlers.file_server', + 'http.handlers.headers', + 'http.handlers.reverse_proxy', + 'http.handlers.rewrite', + 'http.handlers.static_response', + 'http.handlers.subroute', + 'http.matchers.header', + 'http.matchers.host', + 'http.matchers.method', + 'http.matchers.path', + 'http.matchers.query', + 'http.reverse_proxy.transport.http', + 'pki', + 'tls', + 'tls.issuance.internal', +]); +export type CompatibilityEvidence = Readonly<{ + version: string; + modules: readonly string[]; + probeRevision: string; +}>; + +export function parseVersion(output: Buffer): string { + const token = new TextDecoder('utf-8', { fatal: true }) + .decode(output) + .trim() + .split(/\s+/)[0] + ?.replace(/^v/, ''); + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/.exec( + token ?? '', + ); + if (!match) throw new Error('Cannot parse Caddy semantic version.'); + const major = BigInt(match[1]!); + const minor = BigInt(match[2]!); + const patch = BigInt(match[3]!); + // Baseline semver >=2.11.4, major<3: prerelease of the minimum is below it. + if ( + [major, minor, patch].some((part) => part > 0xffffffffffffffffn) || + major !== 2n || + minor < 11n || + (minor === 11n && (patch < 4n || (patch === 4n && match[4] !== undefined))) + ) + throw new Error(`Unsupported Caddy version; requires >=${minimumCaddyVersion}, major<3.`); + return token!; +} + +export function parseModules(output: Buffer): readonly string[] { + const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(output)); + if ( + !Array.isArray(value) || + value.some( + (item: unknown) => + !item || + typeof item !== 'object' || + typeof (item as { module_name?: unknown }).module_name !== 'string', + ) + ) + throw new Error('Invalid Caddy module inventory.'); + const modules = [ + ...new Set( + (value as { module_name: string }[]) + .map((item) => item.module_name) + .filter((name) => name.trim()), + ), + ].sort(); + const missing = requiredCaddyModules.filter((name) => !modules.includes(name)); + if (missing.length) throw new Error(`Caddy is missing required modules: ${missing.join(', ')}.`); + return Object.freeze(modules); +} + +function assertImage(info: BigIntStats): void { + if ( + !info.isFile() || + info.nlink === 0n || + (process.platform !== 'win32' && (info.mode & 0o111n) === 0n) + ) + throw new Error('Pinned Caddy image is not an executable regular file.'); +} +async function digest(file: FileHandle): Promise { + const before = await file.stat({ bigint: true }); + assertImage(before); + const hash = createHash('sha256'); + const buffer = Buffer.alloc(64 * 1024); + let position = 0; + while (BigInt(position) < before.size) { + const count = Number(before.size - BigInt(position)); + const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, count), position); + if (!bytesRead) throw new Error('Pinned Caddy image shrank while hashing.'); + hash.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + const after = await file.stat({ bigint: true }); + if ( + before.size !== after.size || + before.ctimeNs !== after.ctimeNs || + before.mtimeNs !== after.mtimeNs + ) + throw new Error('Pinned Caddy image changed while hashing.'); + return hash.digest('hex'); +} + +/** Open anchor + immutable native identity/content evidence; explicitly close after owned work. */ +export class PinnedCaddyImage { + private closed = false; + private compatibility: CompatibilityEvidence | undefined; + private active = 0; + private constructor( + readonly path: string, + readonly source: SelectionSource, + readonly identity: string, + readonly sha256: string, + private readonly anchor: FileHandle, + ) {} + + static async open(path: string, source: SelectionSource): Promise { + const canonical = await nativeExecutable(path); + const anchor = await open(canonical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const identity = fileIdentity(await anchor.stat({ bigint: true })); + const image = new PinnedCaddyImage(canonical, source, identity, await digest(anchor), anchor); + await image.verify(); + return image; + } catch (error) { + await anchor.close(); + throw error; + } + } + + get evidence(): CompatibilityEvidence { + if (!this.compatibility || this.closed) + throw new Error('Caddy compatibility evidence is unavailable.'); + return this.compatibility; + } + + async verify(): Promise { + if (this.closed) throw new Error('Pinned Caddy image is closed.'); + if ((await realpath(this.path)) !== this.path) + throw new Error('Pinned Caddy canonical path changed.'); + const current = await open(this.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const info = await current.stat({ bigint: true }); + assertImage(info); + if (fileIdentity(info) !== this.identity) + throw new Error('Pinned Caddy file identity changed.'); + if ((await digest(current)) !== this.sha256) + throw new Error('Pinned Caddy content digest changed.'); + } finally { + await current.close(); + } + } + + /** Stage 2 reuses this method; adapt/validate must specify 32 MiB per stream. */ + async run( + args: readonly string[], + options: Omit = {}, + ): Promise { + void this.evidence; + return await this.execute(args, options); + } + + private async execute( + args: readonly string[], + options: Omit, + ): Promise { + await this.verify(); + this.active++; + try { + return await runOwnedCommand(this.path, args, { + ...options, + beforeSpawn: () => this.verify(), + afterSpawn: () => this.verify(), + }); + } finally { + this.active--; + } + } + + async probe(options: Pick = {}): Promise { + if (this.compatibility) { + await this.verify(); + return; + } + const version = await this.execute(['version'], options); + if (version.exitCode !== 0) throw new Error('Caddy version probe failed.'); + const parsedVersion = parseVersion(version.stdout); + const modules = await this.execute(['list-modules', '--json'], options); + if (modules.exitCode !== 0) throw new Error('Caddy modules probe failed.'); + this.compatibility = Object.freeze({ + version: parsedVersion, + modules: parseModules(modules.stdout), + probeRevision: compatibilityProbeRevision, + }); + await this.verify(); + } + + async close(): Promise { + if (this.closed) return; + if (this.active) throw new Error('Cannot close pinned Caddy while an owned command is active.'); + this.closed = true; + await this.anchor.close(); + } +} diff --git a/src/caddy/preparation.ts b/src/caddy/preparation.ts new file mode 100644 index 0000000..dfa29d4 --- /dev/null +++ b/src/caddy/preparation.ts @@ -0,0 +1,128 @@ +import { createHash } from 'node:crypto'; +import type { CaddyConfig } from '../contracts/ports.ts'; +import type { ProtocolError } from '../protocol/errors.ts'; +import { + OwnedCommandError, + type OwnedCommandOptions, + type OwnedCommandOutput, +} from '../platform/owned-command.ts'; + +export const caddyConfigurationTimeoutMs = 30_000; +export const maxCaddyConfigurationBytes = 32 * 1024 * 1024; +export type PreparationOptions = Pick; + +export class CaddyPreparationError extends Error { + constructor( + readonly code: string, + message: string, + options?: ErrorOptions, + ) { + super(message.slice(0, 4096), options); + this.name = 'CaddyPreparationError'; + } +} + +export function configurationCommandOptions( + options: PreparationOptions, + signal?: AbortSignal, +): OwnedCommandOptions { + return { + ...options, + timeoutMs: Math.min( + options.timeoutMs ?? caddyConfigurationTimeoutMs, + caddyConfigurationTimeoutMs, + ), + maxStreamBytes: Math.min( + options.maxStreamBytes ?? maxCaddyConfigurationBytes, + maxCaddyConfigurationBytes, + ), + ...(signal === undefined ? {} : { signal }), + }; +} + +export function decodeOutput(output: OwnedCommandOutput, operation: 'adapt' | 'validate'): string { + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + let stdout: string; + let stderr: string; + try { + stdout = decoder.decode(output.stdout); + stderr = decoder.decode(output.stderr); + } catch (error) { + throw new CaddyPreparationError( + 'caddy_output_invalid', + 'Caddy returned invalid UTF-8 output.', + { cause: error }, + ); + } + if (output.exitCode !== 0) + throw new CaddyPreparationError( + `caddy_${operation}_failed`, + `Caddy ${operation} failed: ${stderr.trim().slice(0, 4000)}`, + ); + return stdout; +} + +export function configHash(body: string): string { + return createHash('sha256').update(body, 'utf8').digest('hex'); +} + +export function assertJsonBody(body: string, maxBytes = maxCaddyConfigurationBytes): void { + if (Buffer.byteLength(body, 'utf8') > maxBytes) + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Caddy JSON candidate exceeds its byte bound.', + ); + const bytes = Buffer.from(body, 'utf8'); + if (new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) !== body) + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Caddy JSON candidate has invalid encoding.', + ); + try { + JSON.parse(body); + } catch (error) { + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Caddy configuration candidate is not complete JSON.', + { cause: error }, + ); + } +} + +export function assertCandidate(config: CaddyConfig): void { + if ( + config?.adaptedConfig?.format !== 'json' || + typeof config.adaptedConfig.body !== 'string' || + typeof config.effectiveConfigHash !== 'string' + ) + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Expected a complete JSON Caddy candidate.', + ); + assertJsonBody(config.adaptedConfig.body); + if (config.effectiveConfigHash !== configHash(config.adaptedConfig.body)) + throw new CaddyPreparationError( + 'caddy_candidate_invalid', + 'Caddy candidate hash does not match its complete JSON body.', + ); +} + +/** Preserve typed bounded failures, never publish arbitrary resolver/project exception text. */ +export function preparationFailure(error: unknown, operation: 'adapt' | 'validate'): ProtocolError { + let code = `caddy_${operation}_failed`; + let message = `Cannot ${operation} the Caddy configuration.`; + let kind: ProtocolError['kind'] = 'caddyRuntime'; + if (error instanceof CaddyPreparationError) { + code = error.code; + message = error.message; + if (code === 'caddy_candidate_invalid') kind = 'configuration'; + } else if (error instanceof OwnedCommandError) { + code = `caddy_command_${error.code}`; + message = error.message; + if (error.code === 'timeout') kind = 'timeout'; + } else if (error instanceof AggregateError) { + code = 'caddy_cleanup_failed'; + message = 'Caddy command or staging cleanup failed.'; + } + return { kind, code, message, guidance: null, retryable: false, requestId: null }; +} diff --git a/src/caddy/resolver.ts b/src/caddy/resolver.ts new file mode 100644 index 0000000..c27e668 --- /dev/null +++ b/src/caddy/resolver.ts @@ -0,0 +1,229 @@ +import { lstat, readFile, realpath } from 'node:fs/promises'; +import { dirname, extname, join, parse, resolve, delimiter } from 'node:path'; +import { isSea } from 'node:sea'; +import { resolveInstallationRoot } from '../daemon/installation-root.ts'; +import { OwnedCommandError } from '../platform/owned-command.ts'; +import { + defaultConfigurationPaths, + selectConfiguration, + type ConfigurationPaths, + type SelectionSource, +} from './configuration.ts'; +import { absoluteCaddyPath, nativeExecutable, sameFile } from './executable.ts'; +import { PinnedCaddyImage } from './image.ts'; + +export type CaddyInstallation = Readonly<{ + root: string; + distribution: 'npm' | 'sea'; + executable?: string; +}>; +export interface CaddyResolverOptions { + explicitOverride?: string; + configurationPaths?: ConfigurationPaths; + installation?: CaddyInstallation; + /** Daemon startup environment snapshot, not project/shim selectors. */ + environment?: NodeJS.ProcessEnv; +} + +async function exists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +export async function installationEntries( + installation: CaddyInstallation, +): Promise { + if (!absoluteCaddyPath(installation.root)) + throw new Error('Cadder installation root must be absolute.'); + const root = await realpath(installation.root); + const entries: string[] = []; + const names = ['caddy', 'cadder', 'cadderd', 'cadder-caddy']; + if (installation.distribution === 'sea') { + if (!installation.executable || !absoluteCaddyPath(installation.executable)) + throw new Error('SEA installation requires its native executable.'); + entries.push(installation.executable); + for (const name of names) + entries.push(join(root, process.platform === 'win32' ? `${name}.exe` : name)); + } else { + let manifest: { name?: unknown; bin?: unknown }; + try { + manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as typeof manifest; + } catch (error) { + throw new Error('Cannot read the physical Cadder npm manifest.', { cause: error }); + } + if (manifest.name !== 'cadder') + throw new Error('Portable npm anchor is not the physical Cadder package.'); + const bin = manifest.bin; + if (typeof bin === 'string') entries.push(resolve(root, bin)); + else if (bin !== undefined) { + if (!bin || typeof bin !== 'object' || Array.isArray(bin)) + throw new Error('Invalid Cadder npm bin inventory.'); + for (const target of Object.values(bin)) { + if (typeof target !== 'string') throw new Error('Invalid Cadder npm bin target.'); + entries.push(resolve(root, target)); + } + } + // Local npm .bin and Windows/global npm wrappers sit outside the physical package. + let directory = root; + while (dirname(directory) !== directory) { + if (parse(directory).base === 'node_modules') { + const prefix = dirname(directory); + const wrappers = [join(directory, '.bin'), prefix, resolve(prefix, '..', 'bin')]; + for (const wrapper of wrappers) + for (const name of names) + for (const suffix of ['', '.cmd', '.ps1', '.exe']) + entries.push(join(wrapper, `${name}${suffix}`)); + break; + } + directory = dirname(directory); + } + } + return entries; +} + +async function rejectInstallationIdentity( + candidate: string, + entries: readonly string[], +): Promise { + for (const entry of entries) { + if ((await exists(entry)) && (await sameFile(candidate, entry))) + throw new Error('Selected Caddy aliases a Cadder distribution entry.'); + } +} + +function assertCommand(command: string): void { + if (!command || /[\s\\/\0:]/.test(command) || ['.', '..'].includes(command)) + throw new Error('Real-Caddy command must be a single program name without arguments.'); +} + +async function resolveNative( + path: string, + entries: readonly string[], + scoop: boolean, +): Promise { + let canonical = await nativeExecutable(path); + await rejectInstallationIdentity(canonical, entries); + if (scoop && process.platform === 'win32') { + const descriptor = canonical.slice(0, canonical.length - extname(canonical).length) + '.shim'; + if (await exists(descriptor)) { + const contents = await readFile(descriptor, 'utf8'); + const target = contents + .split(/\r?\n/) + .map((line) => /^\s*path\s*=\s*"([^"\0]+)"\s*$/.exec(line)?.[1]) + .find((value) => value !== undefined); + if (!target) throw new Error('Invalid native Scoop target descriptor.'); + canonical = await nativeExecutable( + absoluteCaddyPath(target) ? target : resolve(dirname(descriptor), target), + ); + await rejectInstallationIdentity(canonical, entries); + } + } + return canonical; +} + +export class RealCaddyResolver { + private readonly options: CaddyResolverOptions; + private readonly environment: NodeJS.ProcessEnv; + private pinned: Promise | undefined; + private readonly lifetime = new AbortController(); + private closed = false; + + constructor(options: CaddyResolverOptions = {}) { + this.options = { ...options }; + this.environment = { ...(options.environment ?? process.env) }; + } + + /** Evidence failures stay cached; caller cancellation stops only that caller's wait. */ + async pin(signal?: AbortSignal): Promise { + if (this.closed) throw new Error('Caddy resolver is closed.'); + if (signal?.aborted) throw new OwnedCommandError('abort', 'Owned command cancelled.'); + const pinned = (this.pinned ??= this.capture()); + if (!signal) return await pinned; + return await new Promise((resolve, reject) => { + const abort = () => reject(new OwnedCommandError('abort', 'Owned command cancelled.')); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + void pinned.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); + } + + private async capture(): Promise { + const sea = isSea(); + const installation = this.options.installation ?? { + root: resolveInstallationRoot(), + distribution: sea ? 'sea' : 'npm', + ...(sea ? { executable: process.execPath } : {}), + }; + const paths = this.options.configurationPaths ?? { + ...(await defaultConfigurationPaths()), + portable: join(await realpath(installation.root), 'cadder.toml'), + }; + const entries = await installationEntries(installation); + const selected = await selectConfiguration(paths, this.options.explicitOverride); + let path: string; + if (selected.selection.kind === 'path') + path = await resolveNative(selected.selection.value, entries, false); + else path = await this.onPath(selected.selection.value, entries); + const image = await PinnedCaddyImage.open(path, selected.source); + try { + await image.probe({ env: this.environment, signal: this.lifetime.signal }); + return image; + } catch (error) { + await image.close(); + throw error; + } + } + + private async onPath(command: string, entries: readonly string[]): Promise { + assertCommand(command); + const key = Object.keys(this.environment).find((name) => + process.platform === 'win32' ? name.toLowerCase() === 'path' : name === 'PATH', + ); + const value = key === undefined ? undefined : this.environment[key]; + if (value === undefined) throw new Error('PATH is not set.'); + for (const directory of pathDirectories(value)) { + if (!absoluteCaddyPath(directory)) continue; + const candidate = join( + directory, + process.platform === 'win32' && !extname(command) ? `${command}.exe` : command, + ); + if (!(await exists(candidate))) continue; + try { + return await resolveNative(candidate, entries, true); + } catch { + /* Unsafe PATH candidates cannot select an image; retain PATH order among safe files. */ + } + } + throw new Error('Trusted native Caddy command was not found on safe absolute PATH.'); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.lifetime.abort(); + if (this.pinned) { + const image = await this.pinned.catch(() => undefined); + await image?.close(); + } + } +} + +/** Windows split_paths semantics: quotes protect separators, not relative entries. */ +function pathDirectories(value: string): string[] { + if (process.platform !== 'win32') return value.split(delimiter); + const directories = ['']; + let quoted = false; + for (const character of value) { + if (character === '"') quoted = !quoted; + else if (character === ';' && !quoted) directories.push(''); + else directories[directories.length - 1] += character; + } + return directories; +} + +export type { SelectionSource }; diff --git a/src/caddy/runtime.ts b/src/caddy/runtime.ts new file mode 100644 index 0000000..1d30694 --- /dev/null +++ b/src/caddy/runtime.ts @@ -0,0 +1,364 @@ +import { randomUUID } from 'node:crypto'; +import { unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { CaddyConfig, PortResult, RuntimeOwner } from '../contracts/ports.ts'; +import type { + ConfigurationApplyOutcome, + ConfigurationRuntime, +} from '../daemon/configuration-transactions.ts'; +import { OwnedCommandError } from '../platform/owned-command.ts'; +import { + assertProtected, + assertRuntimeDescendant, + createProtectedFile, +} from '../platform/runtime-security.ts'; +import { CaddyAdminClient } from './admin-client.ts'; +import type { SecureAdminPolicy } from './admin-policy.ts'; +import type { RealCaddyResolver } from './resolver.ts'; +import { CaddyConfigValidator } from './validation.ts'; + +export type CaddyRuntimeOptions = Readonly<{ + startupMs?: number; + stopMs?: number; + pollMs?: number; + onOutput?: (stream: 'stdout' | 'stderr', bytes: Buffer) => void; +}>; +type Execution = { + abort: AbortController; + settled: Promise; + completed: boolean; + started: boolean; + clean: boolean; + staged: string; +}; +const rejected: ConfigurationApplyOutcome = Object.freeze({ status: 'definitely-rejected' }); +const ambiguous: ConfigurationApplyOutcome = Object.freeze({ status: 'ambiguous' }); +const applied: ConfigurationApplyOutcome = Object.freeze({ status: 'applied' }); + +function bound(value: number | undefined, fallback: number): number { + const result = value ?? fallback; + if (!Number.isInteger(result) || result < 1 || result > 30_000) + throw new Error('Invalid Caddy lifecycle bounds.'); + return result; +} +function unavailable(): PortResult { + return { + ok: false, + error: { + kind: 'caddyRuntime', + code: 'caddy_runtime_unavailable', + message: 'Cannot prove the owned Caddy runtime state.', + guidance: null, + retryable: false, + requestId: null, + }, + }; +} + +/** One caller-owned resolver, one prepared identity, one owned execution. No automatic restart. */ +export class CaddyRuntime implements ConfigurationRuntime { + readonly #policy: SecureAdminPolicy; + readonly #owner: RuntimeOwner; + readonly #admin: CaddyAdminClient; + readonly #validator: CaddyConfigValidator; + readonly #startupMs: number; + readonly #stopMs: number; + readonly #pollMs: number; + readonly #onOutput: CaddyRuntimeOptions['onOutput']; + #execution: Execution | undefined; + #uncertain = false; + #closed = false; + #tail: Promise = Promise.resolve(); + #operation: AbortController | undefined; + #closing: Promise | undefined; + + constructor( + policy: SecureAdminPolicy, + owner: RuntimeOwner, + private readonly resolver: RealCaddyResolver, + options: CaddyRuntimeOptions = {}, + ) { + // Copy all startup primitives; no project environment or mutable policy is retained. + this.#policy = Object.freeze({ + config: Object.freeze({ + adaptedConfig: Object.freeze({ ...policy.config.adaptedConfig }), + effectiveConfigHash: policy.config.effectiveConfigHash, + }), + paths: Object.freeze({ ...policy.paths }), + environment: Object.freeze({ ...policy.environment }), + client: Object.freeze({ ...policy.client }), + }); + this.#owner = Object.freeze({ ...owner }); + this.#startupMs = bound(options.startupMs, 30_000); + this.#stopMs = bound(options.stopMs, 5000); + this.#pollMs = bound(options.pollMs, 50); + this.#onOutput = options.onOutput; + this.#admin = new CaddyAdminClient(this.#policy); + this.#validator = new CaddyConfigValidator(resolver, { env: this.#policy.environment }); + } + + apply(config: CaddyConfig | null): Promise { + if (this.#closed) return Promise.resolve(rejected); + let candidate: CaddyConfig | null = null; + if (config !== null) { + try { + // Snapshot before entering the queue, and refuse noncanonical/security-changing input. + candidate = Object.freeze({ + adaptedConfig: Object.freeze({ ...config.adaptedConfig }), + effectiveConfigHash: config.effectiveConfigHash, + }); + if (!this.#admin.accepts(candidate)) return Promise.resolve(rejected); + } catch { + return Promise.resolve(rejected); + } + } + return this.#enqueue(async (signal) => { + if (candidate === null) return await this.#stop(signal); + if (this.#uncertain) return ambiguous; + if (this.#execution?.completed) { + if (!(await this.#release())) return ambiguous; + } + if (this.#execution) return await this.#admin.apply(candidate, signal); + return await this.#start(candidate, signal); + }, rejected); + } + + readActive(): Promise> { + return this.#enqueue(async (signal) => { + if (this.#uncertain || this.#execution?.completed) return unavailable(); + if (!this.#execution) return { ok: true, value: null }; + const observed = await this.#admin.readActive(signal); + // An exit during observation cannot be cached as successful active state. + return this.#execution.completed ? unavailable() : observed; + }, unavailable()); + } + + status(): 'running' | 'idle' | 'unhealthy' { + if (this.#uncertain || this.#execution?.completed) return 'unhealthy'; + return this.#execution?.started ? 'running' : 'idle'; + } + + /** Refuse admission immediately, cancel pending admin/start work, then drain native work/staging. */ + close(): Promise { + if (this.#closing) return this.#closing; + this.#closed = true; + this.#operation?.abort(); + if (this.#execution && !this.#execution.started) this.#execution.abort.abort(); + this.#closing = (async () => { + await this.#tail; + try { + const result = await this.#stop(new AbortController().signal); + if (result.status !== 'applied') + throw new Error('Owned Caddy teardown could not be proved.'); + } finally { + this.#admin.close(); + } + })(); + return this.#closing; + } + + #enqueue(operation: (signal: AbortSignal) => Promise, refused: T): Promise { + if (this.#closed) return Promise.resolve(refused); + const result = this.#tail.then(async () => { + if (this.#closed) return refused; + const abort = new AbortController(); + this.#operation = abort; + try { + return await operation(abort.signal); + } finally { + this.#operation = undefined; + } + }); + this.#tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async #roots(signal: AbortSignal): Promise { + const paths = this.#policy.paths; + for (const name of ['data', 'config', 'scratch', 'home'] as const) { + if (paths[name] !== join(paths.base, name)) throw new Error('Invalid runtime roots.'); + } + if ( + paths.defaultStorage !== join(paths.data, 'caddy') || + paths.autosaveDirectory !== join(paths.config, 'caddy') + ) + throw new Error('Invalid runtime roots.'); + for (const path of [paths.base, paths.data, paths.config, paths.scratch, paths.home]) { + if (signal.aborted) return; + await assertProtected(path, this.#owner, true); + } + if (signal.aborted) return; + await assertRuntimeDescendant(paths.data, paths.defaultStorage, this.#owner, true); + if (signal.aborted) return; + await assertRuntimeDescendant(paths.config, paths.autosaveDirectory, this.#owner, true); + } + + async #start(candidate: CaddyConfig, outer: AbortSignal): Promise { + const abort = new AbortController(); + const cancel = (): void => abort.abort(); + outer.addEventListener('abort', cancel, { once: true }); + if (outer.aborted) cancel(); + const timer = setTimeout(cancel, this.#startupMs); + let staged: string | undefined; + try { + await this.#roots(abort.signal); + if (abort.signal.aborted) return rejected; + // Persisted TLS identity authenticates a peer, not ownership of this execution. + // Refuse an existing endpoint rather than adopting or stopping its process. + if ((await this.#admin.readActive(abort.signal)).ok || abort.signal.aborted) return rejected; + const validation = await this.#validator.validate(candidate, abort.signal); + if (!validation.ok || abort.signal.aborted) return rejected; + const image = await this.resolver.pin(abort.signal); + if (abort.signal.aborted) return rejected; + const path = join(this.#policy.paths.scratch, `caddy-${randomUUID()}.json`); + if (!(await createProtectedFile(path, this.#owner, candidate.adaptedConfig.body))) + return rejected; + staged = path; + await assertProtected(path, this.#owner); + if (abort.signal.aborted) return rejected; + const execution: Execution = { + abort: new AbortController(), + completed: false, + started: false, + clean: false, + staged: path, + settled: Promise.resolve(), + }; + this.#execution = execution; + staged = undefined; // The execution now owns staging until positive settlement. + let notifyStarted!: () => void; + const ready = new Promise((resolve) => { + notifyStarted = resolve; + }); + execution.settled = image + .run(['run', '--config', path], { + longLived: true, + signal: execution.abort.signal, + env: this.#policy.environment, + cwd: this.#policy.paths.scratch, + ...(this.#onOutput ? { onOutput: this.#onOutput } : {}), + onStarted: () => { + execution.started = true; + notifyStarted(); + }, + }) + .then( + () => { + execution.clean = true; + }, + (error: unknown) => { + execution.clean = error instanceof OwnedCommandError && error.code === 'abort'; + }, + ) + .then(() => { + execution.completed = true; + }); + void execution.settled.then(cancel); // Early exit aborts a pending startup readback. + await this.#waitForStart(execution, ready, abort.signal); + while (execution.started && !abort.signal.aborted && !execution.completed) { + const observed = await this.#admin.readActive(abort.signal); + if (observed.ok) { + if ( + observed.value === candidate.effectiveConfigHash && + !execution.completed && + !abort.signal.aborted + ) + return applied; + break; // Authenticated mismatch is a failed startup, not a retry receipt. + } + await this.#pause(abort.signal); + } + execution.abort.abort(); + await execution.settled; + await this.#release(); + return ambiguous; + } catch { + if (this.#execution) { + this.#execution.abort.abort(); + await this.#execution.settled; + await this.#release(); + return ambiguous; + } + return rejected; + } finally { + clearTimeout(timer); + outer.removeEventListener('abort', cancel); + if (staged !== undefined) { + try { + await assertProtected(staged, this.#owner); + await unlink(staged); + } catch { + this.#uncertain = true; + } + } + } + } + + #waitForStart(execution: Execution, ready: Promise, signal: AbortSignal): Promise { + if (signal.aborted || execution.completed) return Promise.resolve(); + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish); + resolve(); + }; + signal.addEventListener('abort', finish, { once: true }); + void ready.then(finish); + void execution.settled.then(finish); + }); + } + + #pause(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timer); + signal.removeEventListener('abort', finish); + resolve(); + }; + const timer = setTimeout(finish, this.#pollMs); + signal.addEventListener('abort', finish, { once: true }); + }); + } + + async #stop(signal: AbortSignal): Promise { + const execution = this.#execution; + if (!execution) return this.#uncertain ? ambiguous : applied; + if (execution.started && !execution.completed && !signal.aborted) { + const grace = new AbortController(); + const cancel = (): void => grace.abort(); + signal.addEventListener('abort', cancel, { once: true }); + const timer = setTimeout(cancel, this.#stopMs); + try { + await this.#admin.stop(grace.signal); + while (!execution.completed && !grace.signal.aborted) await this.#pause(grace.signal); + } finally { + clearTimeout(timer); + signal.removeEventListener('abort', cancel); + } + } + execution.abort.abort(); + await execution.settled; + return (await this.#release()) ? applied : ambiguous; + } + + async #release(): Promise { + const execution = this.#execution; + if (!execution?.completed || !execution.clean) { + this.#uncertain = true; + return false; + } + try { + await assertProtected(execution.staged, this.#owner); + await unlink(execution.staged); + this.#execution = undefined; + this.#uncertain = false; + return true; + } catch { + this.#uncertain = true; + return false; + } + } +} diff --git a/src/caddy/validation.ts b/src/caddy/validation.ts new file mode 100644 index 0000000..dbc1f05 --- /dev/null +++ b/src/caddy/validation.ts @@ -0,0 +1,65 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { CaddyConfig, CaddyPort, CaddyValidation, PortResult } from '../contracts/ports.ts'; +import { RealCaddyResolver } from './resolver.ts'; +import { + CaddyPreparationError, + assertCandidate, + configurationCommandOptions, + decodeOutput, + preparationFailure, + type PreparationOptions, +} from './preparation.ts'; + +/** Implements the existing validation signature only; deliberately has no apply method. */ +export class CaddyConfigValidator implements Pick { + constructor( + private readonly resolver: RealCaddyResolver, + private readonly options: PreparationOptions = {}, + ) {} + + async validate(config: CaddyConfig, signal?: AbortSignal): Promise> { + let directory: string | undefined; + let failure: unknown; + let validated: CaddyValidation | undefined; + try { + assertCandidate(config); + const body = config.adaptedConfig.body; + const effectiveConfigHash = config.effectiveConfigHash; + const image = await this.resolver.pin(signal); + directory = await mkdtemp(join(tmpdir(), 'cadder-caddy-validation-')); + const path = join(directory, 'candidate.json'); + await writeFile(path, body, { flag: 'wx', mode: 0o600 }); + const output = await image.run(['validate', '--config', path], { + ...configurationCommandOptions(this.options, signal), + cwd: directory, + }); + decodeOutput(output, 'validate'); + validated = Object.freeze({ effectiveConfigHash, diagnostics: Object.freeze([]) }); + } catch (error) { + failure = error; + } + // run() has completed all owned child/pipe finalizers before owned staging is removed. + if (directory !== undefined) { + try { + await rm(directory, { recursive: true, maxRetries: 3, retryDelay: 100 }); + } catch (error) { + failure = new CaddyPreparationError( + 'caddy_cleanup_failed', + 'Caddy validation staging cleanup failed.', + { + cause: + failure === undefined + ? error + : new AggregateError([failure, error], 'Validation and staging cleanup failed.', { + cause: failure, + }), + }, + ); + } + } + if (validated !== undefined && failure === undefined) return { ok: true, value: validated }; + return { ok: false, error: preparationFailure(failure, 'validate') }; + } +} diff --git a/src/cli/output.ts b/src/cli/output.ts new file mode 100644 index 0000000..4dcadfd --- /dev/null +++ b/src/cli/output.ts @@ -0,0 +1,154 @@ +import type { EntrypointRegistration, StateSnapshot } from '../protocol/dto.ts'; +import type { RpcResult } from '../protocol/rpc.ts'; +import { terminalSafe } from '../storage/redaction.ts'; +import { localUpstreamPort } from '../client/selectors.ts'; +import type { PortOwner } from '../platform/inspection.ts'; + +export function table(headers: string[], rows: (string | number | null | undefined)[][]): string { + const values = [headers, ...rows].map((row) => + row.map((value) => terminalSafe(String(value ?? '-'))), + ); + const widths = headers.map((_, column) => + Math.max(...values.map((row) => row[column]?.length ?? 0)), + ); + return values + .map((row) => ' ' + row.map((value, index) => value.padEnd(widths[index]!)).join(' ') + ' ') + .join('\n'); +} +export function status(snapshot: StateSnapshot): string { + return `cadderd running\nCaddy ${snapshot.runtime.status.toLowerCase()}\nConfig ${snapshot.config.status.toLowerCase()}`; +} +export function projects(snapshot: StateSnapshot): string { + if (!snapshot.registrations.length) return 'No projects registered.'; + return table( + ['STATE', 'PROJECT', 'CADDYFILE'], + [...snapshot.registrations] + .sort((a, b) => a.sourceWorkingDirectory.raw.localeCompare(b.sourceWorkingDirectory.raw)) + .map((project) => [ + project.activationState, + project.sourceWorkingDirectory.raw, + project.sourceConfigPath.raw, + ]), + ); +} +export function effectiveState( + project: EntrypointRegistration, + domain: EntrypointRegistration['registeredDomains'][number], +): string { + if (project.activationState === 'faulted' || domain.activationState === 'faulted') + return 'faulted'; + if (project.activationState === 'inactive' || domain.activationState === 'inactive') + return 'inactive'; + return project.activationState === 'active' && domain.activationState === 'active' + ? 'active' + : 'pending'; +} +export function domains(snapshot: StateSnapshot): string { + const rows = routes(snapshot); + if (!rows.length) return 'No domains registered.'; + return table( + ['STATE', 'DOMAIN', 'UPSTREAM', 'PROJECT'], + rows.map(({ project, domain }) => [ + effectiveState(project, domain), + domain.name.raw, + displayUpstream(domain.upstream), + project.sourceWorkingDirectory.raw, + ]), + ); +} +export function routes(snapshot: StateSnapshot) { + return [...snapshot.registrations] + .sort((a, b) => a.sourceWorkingDirectory.raw.localeCompare(b.sourceWorkingDirectory.raw)) + .flatMap((project) => + [...project.registeredDomains] + .sort((a, b) => a.name.canonical.localeCompare(b.name.canonical)) + .map((domain) => ({ project, domain })), + ); +} +export function displayUpstream(upstream: string | undefined): string { + const port = localUpstreamPort(upstream); + return port === undefined ? (upstream ?? '-') : `:${port}`; +} +export function routeTable(rows: ReturnType): string { + return rows.length + ? table( + ['PROJECT STATE', 'DOMAIN STATE', 'DOMAIN', 'UPSTREAM', 'PROJECT', 'CADDYFILE'], + rows.map(({ project, domain }) => [ + project.activationState, + domain.activationState, + domain.name.raw, + displayUpstream(domain.upstream), + project.sourceWorkingDirectory.raw, + project.sourceConfigPath.raw, + ]), + ) + : 'No matching routes.'; +} +export function sockets(owners: PortOwner[]): string { + if (!owners.length) return 'No listening socket found.'; + return table( + ['PROTOCOL', 'ADDRESS', 'PID', 'PROCESS', 'EXECUTABLE'], + owners.map((owner) => [ + owner.protocol, + `${owner.address}:${owner.port}`, + owner.pid ?? 'unknown', + owner.processName ?? 'unknown', + owner.executable, + ]), + ); +} +export function diagnostics(snapshot: StateSnapshot): string { + const runtime = snapshot.runtime; + return [ + `Caddy ${runtime.status.toLowerCase()}`, + `Config ${snapshot.config.status.toLowerCase()}`, + ...[ + [runtime.processId, 'PID'], + [runtime.version, 'Version'], + [runtime.binaryPath, 'Binary'], + [runtime.adminEndpoint, 'Admin'], + ] + .filter(([value]) => value !== null) + .map(([value, label]) => `${String(label).padEnd(9)}${terminalSafe(String(value))}`), + '', + 'Runtime diagnostics', + runtime.diagnostics.length + ? table( + ['CODE', 'OPERATION', 'MESSAGE'], + runtime.diagnostics.map((entry) => [entry.code, entry.operation, entry.message]), + ) + : 'None.', + '', + 'Configuration diagnostics', + snapshot.config.diagnostics.length + ? table( + ['CODE', 'DOMAIN', 'CADDYFILES', 'MESSAGE'], + snapshot.config.diagnostics.map((entry) => [ + entry.code, + entry.domainKey, + entry.sourceConfigPaths.join(', ') || '-', + entry.message, + ]), + ) + : 'None.', + ].join('\n'); +} +export function logs(response: RpcResult<'query-logs-request'>): string { + return [ + `Status ${response.streamStatus.toLowerCase()}`, + `Channel ${terminalSafe(response.stream.channel)}`, + ...(response.stream.domainKey ? [`Domain ${terminalSafe(response.stream.domainKey)}`] : []), + '', + response.entries.length + ? table( + ['SEQ', 'TIME (UTC)', 'LEVEL', 'MESSAGE'], + response.entries.map((entry) => [ + entry.sequenceNumber, + entry.timestampUtc.replace(/\.000Z$/, '+00:00').replace(/Z$/, '+00:00'), + entry.severity, + entry.rawMessage, + ]), + ) + : 'No log entries.', + ].join('\n'); +} diff --git a/src/cli/run.ts b/src/cli/run.ts new file mode 100644 index 0000000..db93d9f --- /dev/null +++ b/src/cli/run.ts @@ -0,0 +1,224 @@ +import { Command, CommanderError, InvalidArgumentError } from 'commander'; +import { ClientService } from '../client/service.ts'; +import { domainForName, localUpstreamPort, projectForPath } from '../client/selectors.ts'; +import { inspectPort, terminatePortOwner } from '../platform/inspection.ts'; +import { CadderError, RpcError, errorCode } from '../protocol/errors.ts'; +import { VERSION } from '../protocol/version.ts'; +import { runtimeLogStream } from '../storage/schema.ts'; +import type { LogInput } from '../storage/schema.ts'; +import { terminalSafe } from '../storage/redaction.ts'; +import * as output from './output.ts'; + +function integer(raw: string, maximum: number, minimum = 0): number { + if ( + !/^\d+$/.test(raw) || + !Number.isSafeInteger(Number(raw)) || + Number(raw) > maximum || + Number(raw) < minimum + ) + throw new InvalidArgumentError(`Expected an integer from ${minimum} to ${maximum}.`); + return Number(raw); +} +export function exitCode(error: unknown): number { + if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : 2; + const code = errorCode(error); + if (code === 'daemon-unavailable' || code === 'daemon_unavailable') return 3; + if (code === 'daemon-start-failed') return 4; + if (code === 'target-not-found') return 5; + if (code === 'target-conflict') return 6; + if (code === 'unsupported-operation') return 8; + if (error instanceof RpcError) { + if (error.protocolError.kind === 'accessDenied') return 7; + if (['configuration', 'conflict', 'staleInstance'].includes(error.protocolError.kind)) return 6; + if (error.protocolError.kind === 'invalidInput') return 2; + } + if (code === 'EACCES' || code === 'EPERM' || code === 'unsafe-runtime-permissions') return 7; + if (code === 'invalid-input') return 2; + return 9; +} + +export async function runCli( + argv: readonly string[], + client = new ClientService(), + io = { + out: (text: string) => { + process.stdout.write(text); + }, + error: (text: string) => { + process.stderr.write(text); + }, + }, +): Promise { + const print = (text: string): void => io.out(text + '\n'); + const program = new Command('cadder') + .version(VERSION) + .description('Inspect and manage Cadder routes') + .exitOverride() + .configureOutput({ writeOut: io.out, writeErr: io.error }); + program.addHelpCommand('help [command]', 'Display help for a command.'); + function group(name: string, description: string): Command { + const command = program.command(name).description(description); + command.action(() => command.outputHelp({ error: true })); + return command; + } + program.action(() => program.outputHelp({ error: true })); + program + .command('status') + .description('Show cadderd, Caddy, and configuration state.') + .action(async () => print(output.status(await client.snapshot()))); + const daemon = group('daemon', 'Start, stop, or restart cadderd.'); + for (const action of ['start', 'stop', 'restart'] as const) + daemon.command(action).action(async () => { + await client[action](); + print(action === 'stop' ? 'cadderd stopped.' : 'cadderd running.'); + }); + const projects = group('projects', 'Inspect and manage registered projects.'); + projects.command('list').action(async () => print(output.projects(await client.snapshot()))); + const domains = group('domains', 'Inspect and manage registered domains.'); + domains.command('list').action(async () => print(output.domains(await client.snapshot()))); + for (const action of ['enable', 'disable'] as const) { + projects.command(`${action} `).action(async (path: string) => { + const project = await projectForPath(await client.snapshot(), path); + const result = await client.setProjectEnabled({ + registrationId: project.registrationId, + enabled: action === 'enable', + }); + if (!result.ok) throw new RpcError(result.error); + print(result.value.message); + }); + domains + .command(`${action} `) + .option('--caddyfile ') + .action(async (name: string, options: { caddyfile?: string }) => { + const target = await domainForName(await client.snapshot(), name, options.caddyfile); + const result = await client.setDomainEnabled({ + registrationId: target.project.registrationId, + domainKey: target.domain.name.canonical, + enabled: action === 'enable', + }); + if (!result.ok) throw new RpcError(result.error); + print(result.value.message); + }); + } + const port = group('port', 'Inspect a local port or terminate its expected owner.'); + port.command('inspect ').action(async (raw: string) => { + const value = integer(raw, 65535); + const owners = await inspectPort(value); + print(`Port ${value}\n\nSockets\n${output.sockets(owners)}\n\nCadder routes`); + try { + print( + output.routeTable( + output + .routes(await client.snapshot()) + .filter(({ domain }) => localUpstreamPort(domain.upstream) === value), + ), + ); + } catch (error) { + if (errorCode(error) !== 'daemon-unavailable') throw error; + print('Registration data unavailable.\n\nCadder: daemon unavailable'); + } + }); + port + .command('kill ') + .requiredOption('--pid ', 'Previously observed process ID') + .action(async (raw: string, options: { pid: string }) => { + const value = integer(raw, 65535); + const pid = integer(options.pid, 0xffffffff, 1); + await terminatePortOwner(value, pid); + print(`Termination signal sent to PID ${pid} for port ${value}.`); + }); + const caddyfile = group('caddyfile', 'Inspect a Caddyfile registration and its local upstreams.'); + caddyfile.command('inspect ').action(async (path: string) => { + const snapshot = await client.snapshot(); + print(`Caddyfile ${terminalSafe(path)}\n${output.status(snapshot)}`); + try { + const project = await projectForPath(snapshot, path); + print( + 'Registered yes\n\n' + + output.projects({ ...snapshot, registrations: [project] }) + + '\n\nDomains\n' + + output.domains({ ...snapshot, registrations: [project] }), + ); + for (const value of new Set( + project.registeredDomains + .map((domain) => localUpstreamPort(domain.upstream)) + .filter((value) => value !== undefined), + )) + print('\nLocal upstream sockets\n' + output.sockets(await inspectPort(value))); + } catch (error) { + if (errorCode(error) !== 'target-not-found') throw error; + print('Registered no'); + } + }); + domains + .command('inspect ') + .option('--caddyfile ') + .action(async (name: string, options: { caddyfile?: string }) => { + const snapshot = await client.snapshot(); + const selected = await domainForName(snapshot, name, options.caddyfile); + print( + `Domain ${terminalSafe(name)}\n${output.status(snapshot)}\n\n${output.routeTable([selected])}`, + ); + const value = localUpstreamPort(selected.domain.upstream); + if (value !== undefined) + print('\nLocal upstream sockets\n' + output.sockets(await inspectPort(value))); + }); + program + .command('diagnostics') + .description('Show explicit runtime and configuration diagnostics.') + .action(async () => print(output.diagnostics(await client.snapshot()))); + const logs = group('logs', 'Read bounded, redacted daemon logs.'); + for (const kind of ['runtime', 'project', 'domain'] as const) { + const command = logs + .command( + kind === 'runtime' ? 'runtime' : `${kind} <${kind === 'project' ? 'caddyfile' : 'domain'}>`, + ) + .option( + '--limit ', + 'Number of newest entries (1-200)', + (raw) => integer(raw, 200, 1), + 50, + ); + if (kind === 'domain') command.option('--caddyfile '); + command.action(async (...arguments_: unknown[]) => { + const options = arguments_[kind === 'runtime' ? 0 : 1] as { + limit: number; + caddyfile?: string; + }; + let stream: LogInput['stream'] = runtimeLogStream; + if (kind !== 'runtime') { + const snapshot = await client.snapshot(); + const selector = arguments_[0] as string; + stream = + kind === 'project' + ? (await projectForPath(snapshot, selector)).logStream + : (await domainForName(snapshot, selector, options.caddyfile)).domain.logStream; + } + const result = await client.queryLogs({ stream, limit: options.limit }); + if (!result.ok) throw new RpcError(result.error); + print(output.logs(result.value)); + }); + } + program + .command('tui') + .description('Open the full-screen operator.') + .option('--start-daemon') + .action(async (options: { startDaemon?: boolean }) => { + if (options.startDaemon) await client.start(); + if (!process.stdin.isTTY || !process.stdout.isTTY) + throw new CadderError( + 'unsupported-operation', + 'cadder tui requires an interactive terminal.', + ); + const { openTui } = await import('../tui/app.tsx'); + await openTui(client); + }); + try { + await program.parseAsync([...argv], { from: 'user' }); + return 0; + } catch (error) { + if (!(error instanceof CommanderError)) + io.error(terminalSafe(error instanceof Error ? error.message : 'Operation failed.') + '\n'); + return exitCode(error); + } +} diff --git a/src/client/connection.ts b/src/client/connection.ts new file mode 100644 index 0000000..f6e4223 --- /dev/null +++ b/src/client/connection.ts @@ -0,0 +1,88 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import type { RuntimePaths } from '../daemon/paths.ts'; +import { assertProtected, runtimeOwner } from '../platform/runtime-security.ts'; +import { JsonChannel } from '../protocol/channel.ts'; +import { authenticateClient } from '../protocol/authentication.ts'; +import { + requestSchema, + responseSchema, + UNCORRELATED_REQUEST_ID, + type RpcMethod, + type RpcParams, + type RpcResult, +} from '../protocol/rpc.ts'; +import { PROTOCOL_VERSION } from '../protocol/version.ts'; +import { CadderError, RpcError, errorCode } from '../protocol/errors.ts'; + +export async function rpc( + paths: RuntimePaths, + method: M, + ...args: Record extends RpcParams + ? [params?: NoInfer>] + : [params: NoInfer>] +): Promise> { + const requestId = randomUUID(); + const request = requestSchema.safeParse({ + protocolVersion: PROTOCOL_VERSION, + requestId, + method, + params: args[0] === undefined ? {} : args[0], + }); + if (!request.success) throw new CadderError('invalid-request', 'Invalid RPC request.'); + const owner = await runtimeOwner(process.getuid?.()); + let secret: Buffer; + try { + await assertProtected(paths.directory, owner, true); + await assertProtected(paths.secret, owner); + secret = await readFile(paths.secret); + } catch (error) { + if (errorCode(error) === 'ENOENT') throw unavailable(); + throw error; + } + if (secret.length !== 32) + throw new CadderError('invalid-runtime-secret', 'Invalid runtime secret.'); + const socket = createConnection(paths.endpoint); + const channel = new JsonChannel(socket); + try { + const session = await authenticateClient(channel, secret, paths.instance); + try { + session.send(request.data); + const mutation = [ + 'register-entrypoint-request', + 'unregister-entrypoint-request', + 'set-entrypoint-enabled-request', + 'set-domain-enabled-request', + ].includes(method); + const parsed = responseSchema(method).safeParse( + await session.receive(mutation ? 120000 : 15000), + ); + if ( + !parsed.success || + parsed.data.requestId !== requestId || + parsed.data.requestId === UNCORRELATED_REQUEST_ID + ) + throw new CadderError('invalid-response', 'Daemon response does not match this request.'); + const response = parsed.data; + if ('error' in response) throw new RpcError(response.error); + // The selected schema is exactly catalog[method].result. + return response.result as RpcResult; + } finally { + session.close(); + } + } catch (error) { + if (['ENOENT', 'ECONNREFUSED'].includes(errorCode(error) ?? '')) throw unavailable(); + throw error; + } finally { + secret.fill(0); + channel.close(); + } +} + +function unavailable(): CadderError { + return new CadderError( + 'daemon-unavailable', + 'cadderd is not running. Start it with cadder daemon start.', + ); +} diff --git a/src/client/launch.ts b/src/client/launch.ts new file mode 100644 index 0000000..fe6a14e --- /dev/null +++ b/src/client/launch.ts @@ -0,0 +1,43 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isSea } from 'node:sea'; + +/** Resolve only this installation's entry; never consult PATH for cadderd. */ +export async function daemonCommand(): Promise<{ file: string; args: string[] }> { + if (isSea()) { + const file = join( + dirname(process.execPath), + process.platform === 'win32' ? 'cadderd.exe' : 'cadderd', + ); + await access(file); + return { file, args: [] }; + } + const source = import.meta.url.endsWith('.ts'); + const path = fileURLToPath( + new URL(source ? '../entrypoints/cadderd.ts' : '../entrypoints/cadderd.js', import.meta.url), + ); + await access(path); + return { + file: process.execPath, + args: [...(source ? ['--import', import.meta.resolve('tsx')] : []), path], + }; +} + +export async function launchDaemon(args: readonly string[] = []): Promise { + const command = await daemonCommand(); + return new Promise((resolve, reject) => { + const child = spawn(command.file, [...command.args, ...args], { + detached: true, + windowsHide: true, + stdio: 'ignore', + shell: false, + }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(child); + }); + }); +} diff --git a/src/client/selectors.ts b/src/client/selectors.ts new file mode 100644 index 0000000..a85ec48 --- /dev/null +++ b/src/client/selectors.ts @@ -0,0 +1,61 @@ +import { realpath } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { isIP } from 'node:net'; +import { canonicalizeDomain } from '../protocol/domain.ts'; +import type { StateSnapshot, EntrypointRegistration } from '../protocol/dto.ts'; +import { CadderError } from '../protocol/errors.ts'; + +export async function projectForPath( + snapshot: StateSnapshot, + path: string, +): Promise { + const absolute = await realpath(resolve(path)).catch(() => resolve(path)); + const normalized = process.platform === 'win32' ? absolute.toLowerCase() : absolute; + const project = snapshot.registrations.find((registration) => { + const source = registration.sourceConfigPath.canonical ?? registration.sourceConfigPath.raw; + return (process.platform === 'win32' ? source.toLowerCase() : source) === normalized; + }); + if (!project) throw new CadderError('target-not-found', 'Caddyfile is not registered.'); + return project; +} + +export async function domainForName(snapshot: StateSnapshot, name: string, caddyfile?: string) { + const canonical = canonicalizeDomain(name); + const projects = caddyfile ? [await projectForPath(snapshot, caddyfile)] : snapshot.registrations; + const matches = projects.flatMap((project) => + project.registeredDomains + .filter((domain) => domain.name.canonical === canonical) + .map((domain) => ({ project, domain })), + ); + if (matches.length > 1) + throw new CadderError('target-conflict', 'Domain is ambiguous. Select its --caddyfile.'); + if (!matches[0]) throw new CadderError('target-not-found', 'Domain is not registered.'); + return matches[0]; +} + +export function localUpstreamPort(upstream: string | undefined): number | undefined { + if (!upstream) return; + const authority = upstream + .trim() + .replace(/^https?:\/\//, '') + .split('/')[0]!; + const separator = authority.lastIndexOf(':'); + const portText = separator < 0 ? authority : authority.slice(separator + 1); + if (!/^\d+$/.test(portText)) return; + const port = Number(portText); + if (!Number.isInteger(port) || port > 65535) return; + if (separator < 0) return port; + const host = authority.slice(0, separator).replace(/^\[|\]$/g, ''); + if (!host || host.toLowerCase() === 'localhost') return port; + if (isIP(host) === 4 && (host.startsWith('127.') || host === '0.0.0.0')) return port; + try { + if ( + isIP(host) === 6 && + !host.includes('%') && + ['[::1]', '[::]'].includes(new URL(`http://[${host}]`).hostname) + ) + return port; + } catch { + /* Unix sockets and remote/non-network upstreams have no local port owner. */ + } +} diff --git a/src/client/service.ts b/src/client/service.ts new file mode 100644 index 0000000..541514f --- /dev/null +++ b/src/client/service.ts @@ -0,0 +1,116 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import type { ClientServicePort, PortResult } from '../contracts/ports.ts'; +import type { RpcParams } from '../protocol/rpc.ts'; +import type { StateSnapshot } from '../protocol/dto.ts'; +import { CadderError, RpcError, errorCode } from '../protocol/errors.ts'; +import { resolvePaths, type RuntimePaths } from '../daemon/paths.ts'; +import { rpc } from './connection.ts'; +import { launchDaemon } from './launch.ts'; + +export class ClientService implements ClientServicePort { + constructor(readonly paths: RuntimePaths = resolvePaths()) {} + queryState() { + return this.#result(() => rpc(this.paths, 'query-state-request')); + } + queryLogs(params: RpcParams<'query-logs-request'>) { + return this.#result(() => rpc(this.paths, 'query-logs-request', params)); + } + setProjectEnabled(params: RpcParams<'set-entrypoint-enabled-request'>) { + return this.#result(() => rpc(this.paths, 'set-entrypoint-enabled-request', params)); + } + setDomainEnabled(params: RpcParams<'set-domain-enabled-request'>) { + return this.#result(() => rpc(this.paths, 'set-domain-enabled-request', params)); + } + shutdown() { + return this.#result(() => rpc(this.paths, 'shutdown-daemon-request')); + } + + async snapshot(): Promise { + const response = await rpc(this.paths, 'query-state-request'); + if (!response.accepted || !response.snapshot) + throw new CadderError('daemon-unavailable', 'Daemon state is unavailable.'); + return response.snapshot; + } + async start(args: readonly string[] = []): Promise { + try { + await this.snapshot(); + return; + } catch (error) { + if (errorCode(error) !== 'daemon-unavailable') throw error; + } + const child = await launchDaemon(args); + let ready = false; + let permissionsFailure: unknown; + try { + // Cold Windows ACL preparation can exceed thirty seconds in an isolated VM. + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + try { + await this.snapshot(); + ready = true; + return; + } catch (error) { + // New Windows paths exist briefly before their owner-only ACL is published. + if (errorCode(error) === 'unsafe-runtime-permissions') permissionsFailure = error; + else if (errorCode(error) !== 'daemon-unavailable') throw error; + } + await delay(100); + } + if (permissionsFailure) throw permissionsFailure; + throw new CadderError( + 'daemon-start-failed', + 'cadderd did not become ready. Run cadderd in the foreground for diagnostics.', + ); + } finally { + if (!ready && child.exitCode === null) child.kill(); + } + } + async stop(): Promise { + try { + await rpc(this.paths, 'shutdown-daemon-request'); + } catch (error) { + if (errorCode(error) === 'daemon-unavailable') return; + throw error; + } + const deadline = Date.now() + 15000; + let unavailable = 0; + while (Date.now() < deadline) { + try { + await this.snapshot(); + unavailable = 0; + } catch (error) { + if (errorCode(error) === 'daemon-unavailable') { + if (++unavailable === 3) return; + } else if (errorCode(error) === 'daemon_shutting_down') unavailable = 0; + else throw error; + } + await delay(100); + } + throw new CadderError( + 'daemon-stop-failed', + 'cadderd did not finish shutdown within its deadline.', + ); + } + async restart(): Promise { + await this.stop(); + await this.start(); + } + async #result(operation: () => Promise): Promise> { + try { + return { ok: true, value: await operation() }; + } catch (error) { + if (error instanceof RpcError) return { ok: false, error: error.protocolError }; + return { + ok: false, + error: { + kind: 'internal', + code: errorCode(error)?.replaceAll('-', '_') ?? 'client_failed', + message: error instanceof CadderError ? error.message : 'Cannot contact cadderd.', + guidance: null, + retryable: false, + requestId: null, + }, + }; + } + } +} diff --git a/src/contracts/ports.ts b/src/contracts/ports.ts new file mode 100644 index 0000000..19fd29c --- /dev/null +++ b/src/contracts/ports.ts @@ -0,0 +1,99 @@ +import type { ProtocolError } from '../protocol/errors.ts'; +import type { RpcParams, RpcResult } from '../protocol/rpc.ts'; + +/** The identity used to protect one installation's runtime boundary. */ +export type RuntimeOwner = Readonly<{ + id: string; + uid?: number; + elevated: boolean; +}>; + +/** + * A complete adapted Caddy configuration candidate. + * CADDY-005 bounds the JSON body to 32 MiB before this port is called. + */ +export type CaddyConfig = Readonly<{ + effectiveConfigHash: string; + adaptedConfig: Readonly<{ + format: 'json'; + body: string; + }>; +}>; + +export type CaddyDiagnostic = Readonly<{ + code: string; + message: string; + domainKey: string | null; +}>; + +export type CaddyValidation = Readonly<{ + effectiveConfigHash: string; + diagnostics: readonly CaddyDiagnostic[]; +}>; + +export type CaddyApplyReceipt = Readonly<{ + effectiveConfigHash: string; +}>; + +/** Stable project/domain intent; it contains no session, process, or live lease data. */ +export type DesiredState = Readonly<{ + projects: readonly Readonly<{ + projectKey: string; + sourceWorkingDirectory: string; + sourceConfigPath: string; + enabled: boolean; + domains: readonly Readonly<{ + canonicalDomain: string; + upstream: string | null; + enabled: boolean; + }>[]; + }>[]; +}>; + +export type ProtectedPathKind = 'directory' | 'file'; + +/** A discriminated result shared by every port without a runtime result engine. */ +export type PortResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; error: ProtocolError }>; + +export type QueryLogsResult = RpcResult<'query-logs-request'>; +export type QueryStateResult = RpcResult<'query-state-request'>; +export type ProjectActivationResult = RpcResult<'set-entrypoint-enabled-request'>; +export type DomainActivationResult = RpcResult<'set-domain-enabled-request'>; +export type ShutdownResult = RpcResult<'shutdown-daemon-request'>; + +/** Caddy configuration validation and application; process lifecycle is out of scope. */ +export interface CaddyPort { + validate(config: CaddyConfig): Promise>; + apply(config: CaddyConfig): Promise>; +} + +/** Current-owner lookup and fail-closed protection checks for runtime paths. */ +export interface PlatformPort { + currentOwner(explicitUid?: number): Promise>; + assertProtected( + path: string, + owner: RuntimeOwner, + kind: ProtectedPathKind, + ): Promise>; +} + +/** Durable stable intent only; live registration/session/process state is not persisted here. */ +export interface StoragePort { + loadDesiredState(): Promise>; + persistDesiredState(state: DesiredState): Promise>; +} + +/** The shared daemon-backed client surface used by CLI and TUI. */ +export interface ClientServicePort { + queryState(): Promise>; + queryLogs(params: RpcParams<'query-logs-request'>): Promise>; + setProjectEnabled( + params: RpcParams<'set-entrypoint-enabled-request'>, + ): Promise>; + setDomainEnabled( + params: RpcParams<'set-domain-enabled-request'>, + ): Promise>; + shutdown(): Promise>; +} diff --git a/src/daemon/application.ts b/src/daemon/application.ts new file mode 100644 index 0000000..e73960f --- /dev/null +++ b/src/daemon/application.ts @@ -0,0 +1,441 @@ +import { createHash } from 'node:crypto'; +import { realpath } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; +import type { CaddyConfig, DesiredState, PortResult } from '../contracts/ports.ts'; +import { canonicalizeDomain, extractRegisteredDomains, activeDomains } from '../caddy/domains.ts'; +import { + composeRoutePlan, + type CaddyRoutePlan, + type PreparedCompositionInput, +} from '../caddy/composition.ts'; +import type { CaddyAdaptInput } from '../caddy/adapter.ts'; +import type { EntrypointRegistration, StateSnapshot } from '../protocol/dto.ts'; +import { registrationSchema } from '../protocol/dto.ts'; +import { RpcError, type ProtocolError } from '../protocol/errors.ts'; +import type { RpcHandler, RpcRequest, HandlerResult } from '../protocol/rpc.ts'; +import { redact } from '../storage/redaction.ts'; +import { + ConfigurationTransactions, + type ConfigurationSnapshot, +} from './configuration-transactions.ts'; +import type { LogInput, StoredLog } from '../storage/schema.ts'; +import { runtimeLogStream } from '../storage/schema.ts'; + +type ApplicationOptions = { + transactions: ConfigurationTransactions; + adapt(input: CaddyAdaptInput): Promise>; + secure(plan: CaddyRoutePlan): Promise>; + queryLogs(stream: LogInput['stream'], limit: number): Promise; + log(entry: LogInput): Promise; + shutdown(): void; + runtime(): StateSnapshot['runtime']; + storage: StateSnapshot['storage']; +}; +function reject( + kind: 'invalidInput' | 'conflict' | 'staleInstance' | 'configuration', + message: string, +): never { + throw new RpcError({ + kind, + code: kind === 'staleInstance' ? 'stale_session' : 'operation_rejected', + message, + guidance: null, + retryable: false, + requestId: null, + }); +} +function projectKey(registration: EntrypointRegistration): string { + const path = registration.sourceConfigPath.canonical!; + return createHash('sha256') + .update(process.platform === 'win32' ? path.toLowerCase() : path) + .digest('hex') + .slice(0, 32); +} +function unwrap(result: PortResult): T { + if (!result.ok) throw new RpcError(result.error); + return result.value; +} + +/** Live leases stay in memory; only the existing queue publishes configuration changes. */ +export class DaemonApplication { + readonly #heartbeats = new Map(); + #closing = false; + #lastError: ProtocolError | null = null; + #attemptedAt: string | null = null; + #appliedAt: string | null = null; + constructor(private readonly options: ApplicationOptions) {} + + readonly handle: RpcHandler = async (request) => { + const mutation = [ + 'register-entrypoint-request', + 'unregister-entrypoint-request', + 'set-entrypoint-enabled-request', + 'set-domain-enabled-request', + ].includes(request.method); + if (mutation) this.#attemptedAt = new Date().toISOString(); + try { + const result = await this.#execute(request); + if (mutation) { + this.#lastError = null; + this.#appliedAt = new Date().toISOString(); + } + return result; + } catch (error) { + if (mutation && error instanceof RpcError) { + this.#lastError = { ...error.protocolError, message: redact(error.message) }; + await this.#log(this.#lastError.message); + } + throw error; + } + }; + + async #execute(request: RpcRequest): Promise { + if (this.#closing) + throw new RpcError({ + kind: 'shuttingDown', + code: 'daemon_shutting_down', + message: 'The daemon is shutting down.', + guidance: null, + retryable: true, + requestId: null, + }); + const basic = { accepted: true, message: 'Operation completed.' }; + switch (request.method) { + case 'query-state-request': + return { ...basic, snapshot: this.snapshot() }; + case 'query-logs-request': { + const stream = request.params.stream; + const limit = request.params.limit ?? 100; + if (limit < 1 || limit > 200) reject('invalidInput', 'Log limit must be from 1 to 200.'); + const canonical = this.#stream(stream); + const entries = await this.options.queryLogs(canonical, limit); + return { + ...basic, + stream: canonical, + entries, + streamStatus: entries.length ? 'active' : 'empty', + }; + } + case 'shutdown-daemon-request': + this.#closing = true; + // Let IPC flush the response before stopping admission and the owned runtime. + setTimeout(() => this.options.shutdown(), 30); + return basic; + case 'heartbeat-entrypoint-request': { + const registration = this.#owned( + request.params.registrationId, + request.params.shimSessionNonce, + ); + this.#heartbeats.set(registration.registrationId, Date.now()); + return basic; + } + case 'register-entrypoint-request': { + let id = ''; + const result = await this.options.transactions.submit(async (committed) => { + const supplied = registrationSchema.parse(request.params.registration); + const nonce = supplied.entrypointInstance.shimSessionNonce; + if ( + !nonce || + nonce.length > 256 || + supplied.ownerProcess.shimSessionNonce !== nonce || + supplied.registrationId !== supplied.entrypointInstance.instanceId + ) + reject('invalidInput', 'Registration requires one matching session nonce.'); + if ( + !isAbsolute(supplied.sourceWorkingDirectory.raw) || + !isAbsolute(supplied.sourceConfigPath.raw) + ) + reject('invalidInput', 'Registration source paths must be absolute.'); + const registration = { + ...supplied, + sourceWorkingDirectory: { + ...supplied.sourceWorkingDirectory, + canonical: await realpath(supplied.sourceWorkingDirectory.raw), + }, + sourceConfigPath: { + ...supplied.sourceConfigPath, + canonical: await realpath(supplied.sourceConfigPath.raw), + }, + }; + const key = projectKey(registration); + id = supplied.registrationId; + const previous = committed.registrations.find((entry) => projectKey(entry) === key); + if ( + (previous && + (previous.entrypointInstance.shimSessionNonce !== nonce || + previous.registrationId !== id)) || + committed.registrations.some( + (entry) => entry.registrationId === id && projectKey(entry) !== key, + ) + ) + reject('conflict', 'This project already belongs to a live shim session.'); + const adapted = unwrap(await this.options.adapt(registration)); + const domains = extractRegisteredDomains(adapted); + if (!domains.ok) + reject('configuration', 'Cannot read project domains from its adapted configuration.'); + const existing = committed.desiredState.projects.find( + (entry) => entry.projectKey === key, + ); + const desired = { + projectKey: key, + sourceWorkingDirectory: registration.sourceWorkingDirectory.canonical, + sourceConfigPath: registration.sourceConfigPath.canonical, + enabled: existing?.enabled ?? true, + domains: domains.value.map((domain) => ({ + ...domain, + enabled: + existing?.domains.find((entry) => entry.canonicalDomain === domain.canonicalDomain) + ?.enabled ?? true, + })), + }; + const now = new Date().toISOString(); + registration.registrationId = id; + registration.createdAtUtc = previous?.createdAtUtc ?? now; + registration.lastHeartbeatUtc = now; + registration.logStream = { + streamId: `entrypoint-${id}`, + domainKey: null, + channel: 'caddy', + }; + registration.registeredDomains = desired.domains.map((domain) => ({ + name: { raw: domain.canonicalDomain, canonical: domain.canonicalDomain }, + activationState: domain.enabled ? 'active' : 'inactive', + upstream: domain.upstream ?? undefined, + logStream: { + streamId: `domain-${domain.canonicalDomain}`, + domainKey: domain.canonicalDomain, + channel: 'caddy', + }, + })); + registration.activationState = desired.enabled ? 'active' : 'inactive'; + const desiredState = { + projects: [ + ...committed.desiredState.projects.filter((entry) => entry.projectKey !== key), + desired, + ], + }; + return this.#prepare( + [ + ...committed.registrations.filter((entry) => entry.registrationId !== id), + registration, + ], + desiredState, + new Map([[id, adapted]]), + ); + }); + unwrap(result); + this.#heartbeats.set(id, Date.now()); + await this.#log('Project registered.'); + return { ...basic, registrationId: id }; + } + case 'unregister-entrypoint-request': { + unwrap( + await this.options.transactions.submit(async (committed) => { + this.#owned(request.params.registrationId, request.params.shimSessionNonce, committed); + return this.#prepare( + committed.registrations.filter( + (entry) => entry.registrationId !== request.params.registrationId, + ), + committed.desiredState, + ); + }), + ); + this.#heartbeats.delete(request.params.registrationId); + await this.#log('Project unregistered.'); + return basic; + } + case 'set-entrypoint-enabled-request': + case 'set-domain-enabled-request': { + const params = request.params; + unwrap( + await this.options.transactions.submit(async (committed) => { + const registration = committed.registrations.find( + (entry) => entry.registrationId === params.registrationId, + ); + if (!registration) reject('invalidInput', 'Project was not found.'); + if ( + request.method === 'set-entrypoint-enabled-request' && + request.params.shimSessionNonce !== null + ) + this.#owned(params.registrationId, request.params.shimSessionNonce, committed); + const registrations = structuredClone( + committed.registrations, + ) as EntrypointRegistration[]; + const desiredState = structuredClone(committed.desiredState) as { + projects: DesiredState['projects'][number][]; + }; + const desired = desiredState.projects.find( + (entry) => entry.projectKey === projectKey(registration), + )!; + const target = registrations.find( + (entry) => entry.registrationId === params.registrationId, + )!; + if (request.method === 'set-entrypoint-enabled-request') { + desiredState.projects = desiredState.projects.map((entry) => + entry === desired ? { ...entry, enabled: request.params.enabled } : entry, + ); + target.activationState = request.params.enabled ? 'active' : 'inactive'; + } else { + const domain = canonicalizeDomain(request.params.domainKey); + if (!target.registeredDomains.some((entry) => entry.name.canonical === domain)) + reject('invalidInput', 'Domain was not found.'); + desiredState.projects = desiredState.projects.map((entry) => + entry === desired + ? { + ...entry, + domains: entry.domains.map((item) => + item.canonicalDomain === domain + ? { ...item, enabled: request.params.enabled } + : item, + ), + } + : entry, + ); + for (const entry of target.registeredDomains) + if (entry.name.canonical === domain) + entry.activationState = request.params.enabled ? 'active' : 'inactive'; + } + return this.#prepare(registrations, desiredState); + }), + ); + return basic; + } + } + } + + snapshot(): StateSnapshot { + const committed = this.options.transactions.readCommitted(); + const fence = this.options.transactions.readFence() ?? this.#lastError; + return { + capturedAtUtc: new Date().toISOString(), + registrations: committed.registrations.map((entry) => ({ + ...structuredClone(entry), + lastHeartbeatUtc: new Date( + this.#heartbeats.get(entry.registrationId) ?? Date.parse(entry.lastHeartbeatUtc), + ).toISOString(), + })), + runtime: this.options.runtime(), + config: { + status: fence ? 'failed' : committed.config ? 'applied' : 'idle', + lastAttemptedAtUtc: this.#attemptedAt, + lastSuccessfulReloadAtUtc: this.#appliedAt, + effectiveConfigHash: committed.config?.effectiveConfigHash ?? null, + diagnostics: fence + ? [{ code: fence.code, message: fence.message, domainKey: null, sourceConfigPaths: [] }] + : [], + }, + storage: structuredClone(this.options.storage), + }; + } + + async expire(cutoff = Date.now() - 15000): Promise { + const stale = this.options.transactions + .readCommitted() + .registrations.filter( + (entry) => + (this.#heartbeats.get(entry.registrationId) ?? Date.parse(entry.lastHeartbeatUtc)) < + cutoff, + ); + if (!stale.length || this.#closing) return; + unwrap( + await this.options.transactions.submit(async (committed) => + this.#prepare( + committed.registrations.filter( + (entry) => + (this.#heartbeats.get(entry.registrationId) ?? Date.parse(entry.lastHeartbeatUtc)) >= + cutoff, + ), + committed.desiredState, + ), + ), + ); + for (const entry of stale) + if ( + !this.options.transactions + .readCommitted() + .registrations.some((current) => current.registrationId === entry.registrationId) + ) + this.#heartbeats.delete(entry.registrationId); + } + close(): void { + this.#closing = true; + } + + #owned( + id: string, + nonce: string, + committed = this.options.transactions.readCommitted(), + ): EntrypointRegistration { + const registration = committed.registrations.find((entry) => entry.registrationId === id); + if (!registration || registration.entrypointInstance.shimSessionNonce !== nonce) + reject('staleInstance', 'This shim no longer owns the project session.'); + return registration; + } + async #prepare( + registrations: readonly EntrypointRegistration[], + desiredState: DesiredState, + prepared = new Map(), + ): Promise> { + const inputs: PreparedCompositionInput[] = []; + for (const registration of registrations) { + const config = activeDomains(registration).length + ? (prepared.get(registration.registrationId) ?? + unwrap(await this.options.adapt(registration))) + : undefined; + inputs.push({ registration, ...(config ? { config } : {}) }); + } + const plan = composeRoutePlan(inputs); + if (!plan.ok) reject('conflict', 'Project domains conflict or cannot be composed.'); + const config = plan.value.tlsSubjects.length + ? unwrap(await this.options.secure(plan.value)) + : null; + return { ok: true, value: { registrations, desiredState, config } }; + } + #stream(input: LogInput['stream']): LogInput['stream'] { + if ( + input.streamId === runtimeLogStream.streamId && + input.domainKey === null && + input.channel === runtimeLogStream.channel + ) + return runtimeLogStream; + for (const registration of this.options.transactions.readCommitted().registrations) { + for (const stream of [ + registration.logStream, + ...registration.registeredDomains.map((domain) => domain.logStream), + ]) { + if ( + stream.streamId === input.streamId && + stream.domainKey === input.domainKey && + stream.channel === input.channel + ) + return stream; + } + } + reject('invalidInput', 'Select one current project, domain or runtime log stream.'); + } + async #log(rawMessage: string): Promise { + try { + await this.options.log({ + timestampUtc: new Date().toISOString(), + severity: 'info', + stream: runtimeLogStream, + attributionKind: 'runtimeControl', + entryKind: 'lifecycle', + rawMessage, + domainKey: null, + sourceRegistrationId: null, + sourceInstanceId: null, + operation: null, + }); + } catch { + if (this.options.storage) + this.options.storage.diagnostics = [ + { + code: 'log_persistence_failed', + message: 'A diagnostic could not be retained; committed routes are unchanged.', + operation: null, + }, + ]; + } + } +} diff --git a/src/daemon/caddy-logs.ts b/src/daemon/caddy-logs.ts new file mode 100644 index 0000000..376a1d0 --- /dev/null +++ b/src/daemon/caddy-logs.ts @@ -0,0 +1,126 @@ +import { StringDecoder } from 'node:string_decoder'; +import type { EntrypointRegistration } from '../protocol/dto.ts'; +import { canonicalizeDomain } from '../protocol/domain.ts'; +import { runtimeLogStream, type LogInput } from '../storage/schema.ts'; + +/** Bounded line decoding; private-key continuation lines never become separate events. */ +export class CaddyLogs { + readonly #streams = { + stdout: { + decoder: new StringDecoder('utf8'), + line: '', + marker: '', + begin: false, + oversized: false, + privateKey: false, + }, + stderr: { + decoder: new StringDecoder('utf8'), + line: '', + marker: '', + begin: false, + oversized: false, + privateKey: false, + }, + }; + #pending = new Set>(); + constructor( + private readonly registrations: () => readonly EntrypointRegistration[], + private readonly append: (entries: LogInput[]) => Promise, + private readonly failed: () => void, + ) {} + accept = (channel: 'stdout' | 'stderr', bytes: Buffer): void => { + const stream = this.#streams[channel]; + for (const character of stream.decoder.write(bytes)) { + stream.marker = (stream.marker + character).slice(-11); + if (stream.marker.toUpperCase() === '-----BEGIN ') stream.begin = true; + if (stream.oversized && stream.begin) stream.privateKey = true; + if (character === '\n') { + if (!stream.oversized) this.#line(stream.line, stream); + stream.line = ''; + stream.marker = ''; + stream.begin = false; + stream.oversized = false; + } else if (!stream.oversized) { + if (stream.line.length < 8192) stream.line += character; + else { + stream.line = ''; + stream.oversized = true; + if (stream.begin) stream.privateKey = true; + this.failed(); + } + } + } + }; + async close(): Promise { + for (const stream of Object.values(this.#streams)) { + stream.line += stream.decoder.end(); + if (stream.line && !stream.oversized) this.#line(stream.line, stream); + stream.line = ''; + } + await Promise.all(this.#pending); + } + #line(line: string, stream: { privateKey: boolean }): void { + if (stream.privateKey) { + if (/-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----/i.test(line)) stream.privateKey = false; + return; + } + if ( + /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/i.test(line) && + !/-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----/i.test(line) + ) + stream.privateKey = true; + let message = line; + let host: string | undefined; + let level: LogInput['severity'] = 'info'; + try { + const parsed = JSON.parse(line) as { + msg?: unknown; + level?: unknown; + request?: { host?: unknown }; + identifier?: unknown; + }; + if (typeof parsed.msg === 'string') message = parsed.msg; + const candidate = parsed.request?.host ?? parsed.identifier; + if (typeof candidate === 'string') host = canonicalizeDomain(candidate.replace(/:\d+$/, '')); + if (['trace', 'debug', 'info', 'warn', 'error', 'fatal'].includes(String(parsed.level))) + level = parsed.level as LogInput['severity']; + } catch { + /* Plain upstream diagnostic lines use the same redaction boundary. */ + } + const event: LogInput = { + timestampUtc: new Date().toISOString(), + severity: level, + stream: runtimeLogStream, + attributionKind: 'runtime', + entryKind: 'normal', + rawMessage: message, + domainKey: null, + sourceRegistrationId: null, + sourceInstanceId: null, + operation: null, + }; + const entries = [event]; + for (const project of this.registrations()) { + const domain = project.registeredDomains.find((entry) => entry.name.canonical === host); + if (domain) + for (const logStream of [project.logStream, domain.logStream]) + entries.push({ + ...event, + stream: logStream, + attributionKind: logStream.domainKey ? 'domain' : 'entrypoint', + domainKey: logStream.domainKey, + sourceRegistrationId: project.registrationId, + sourceInstanceId: project.entrypointInstance.instanceId, + }); + } + if (this.#pending.size >= 64) { + this.failed(); + return; + } + const pending = this.append(entries) + .catch(this.failed) + .finally(() => this.#pending.delete(pending)); + this.#pending.add(pending); + } +} diff --git a/src/daemon/configuration-transactions.ts b/src/daemon/configuration-transactions.ts new file mode 100644 index 0000000..9fd995b --- /dev/null +++ b/src/daemon/configuration-transactions.ts @@ -0,0 +1,310 @@ +import { z } from 'zod'; +import { assertCandidate } from '../caddy/preparation.ts'; +import type { + CaddyConfig, + CaddyPort, + DesiredState, + PortResult, + StoragePort, +} from '../contracts/ports.ts'; +import { registrationSchema, type EntrypointRegistration } from '../protocol/dto.ts'; +import { RpcError, type ProtocolError } from '../protocol/errors.ts'; +import { desiredStateSchema } from '../storage/schema.ts'; + +export type ConfigurationSnapshot = Readonly<{ + config: CaddyConfig | null; + desiredState: DesiredState; + registrations: readonly EntrypointRegistration[]; +}>; + +/** Rejection guarantees no transition; throws and unrecognized results are uncertain. */ +export type ConfigurationApplyOutcome = Readonly<{ + status: 'applied' | 'definitely-rejected' | 'ambiguous'; +}>; + +/** Required owned-runtime backend; null means a real transition to idle, not a stub. */ +export interface ConfigurationRuntime { + apply(config: CaddyConfig | null): Promise; + /** + * Independent protected-runtime observation, never a cached apply receipt. + * Null is positively observed idle; undeterminable state must return ok:false, not null. + */ + readActive(): Promise>; +} + +export type PrepareConfiguration = ( + committed: ConfigurationSnapshot, +) => PortResult | Promise>; + +const applyOutcomeSchema = z.strictObject({ + status: z.enum(['applied', 'definitely-rejected', 'ambiguous']), +}); + +const configurationSnapshotSchema = z.strictObject({ + config: z + .strictObject({ + effectiveConfigHash: z.string(), + adaptedConfig: z.strictObject({ format: z.literal('json'), body: z.string() }), + }) + .nullable(), + desiredState: desiredStateSchema, + registrations: z.array(registrationSchema), +}); + +function freeze(value: T): T { + if (value !== null && typeof value === 'object') { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; +} + +function copySnapshot(value: ConfigurationSnapshot): ConfigurationSnapshot { + const copy = configurationSnapshotSchema.parse(structuredClone(value)); + if (copy.config !== null) assertCandidate(copy.config); + return freeze(copy); +} + +function diagnostic(kind: ProtocolError['kind'], code: string, message: string): ProtocolError { + return { kind, code, message, guidance: null, retryable: false, requestId: null }; +} + +function failure(kind: ProtocolError['kind'], code: string, message: string): PortResult { + return { ok: false, error: diagnostic(kind, code, message) }; +} + +/** One queue owns preparation through publication; initial state is already verified/durable. */ +export class ConfigurationTransactions { + #committed: ConfigurationSnapshot; + #fence: ProtocolError | null = null; + #closed = false; + #tail: Promise = Promise.resolve(); + readonly #validator: Pick; + readonly #storage: Pick; + readonly #runtime: ConfigurationRuntime; + + constructor( + initial: ConfigurationSnapshot, + validator: Pick, + storage: Pick, + runtime: ConfigurationRuntime, + ) { + try { + this.#committed = copySnapshot(initial); + } catch { + throw new RpcError( + diagnostic( + 'configuration', + 'config_candidate_invalid', + 'Invalid committed configuration snapshot.', + ), + ); + } + this.#validator = validator; + this.#storage = storage; + this.#runtime = runtime; + } + + readCommitted(): ConfigurationSnapshot { + return freeze(structuredClone(this.#committed)); + } + + readFence(): ProtocolError | null { + return this.#fence === null ? null : { ...this.#fence }; + } + + submit(prepare: PrepareConfiguration): Promise> { + return this.#enqueue(async () => { + if (this.#fence !== null) + return failure( + 'caddyRuntime', + 'config_mutations_fenced', + 'Runtime reconciliation is required before another change.', + ); + return this.#change(prepare); + }); + } + + /** Queued proof of last-known-good only; abandoned candidates are never committed. */ + reconcile(): Promise> { + return this.#enqueue(async () => { + // Prepare the isolated return value before any restoration side effects. + const success = { ok: true as const, value: this.readCommitted() }; + const observed = await this.#observe(); + if ( + (!observed.ok || observed.value !== this.#targetHash()) && + !(await this.#restoreCommitted()) + ) + return this.#fenced( + 'config_reconciliation_failed', + 'Cannot reconcile the last committed Caddy configuration.', + ); + this.#fence = null; + return success; + }); + } + + /** Stops admissions and waits for owned queue work, without claiming resource teardown. */ + async close(): Promise { + this.#closed = true; + await this.#tail; + } + + #enqueue( + operation: () => Promise>, + ): Promise> { + if (this.#closed) + return Promise.resolve( + failure( + 'shuttingDown', + 'config_transactions_closed', + 'Configuration transactions are closed.', + ), + ); + const result = this.#tail + .then(operation) + .catch(() => + this.#fenced('config_transaction_failed', 'Configuration transaction failed unexpectedly.'), + ); + this.#tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + #fenced(code: string, message: string): PortResult { + this.#fence = diagnostic('caddyRuntime', code, message); + return { ok: false, error: { ...this.#fence } }; + } + + async #change(prepare: PrepareConfiguration): Promise> { + let prepared: PortResult; + try { + prepared = await prepare(this.readCommitted()); + if (prepared.ok !== true) + return failure( + 'configuration', + 'config_prepare_failed', + 'Configuration preparation was rejected.', + ); + } catch (error) { + if (error instanceof RpcError) return { ok: false, error: error.protocolError }; + return failure( + 'configuration', + 'config_prepare_failed', + 'Cannot prepare the configuration change.', + ); + } + let candidate: ConfigurationSnapshot; + let success: PortResult; + try { + candidate = copySnapshot(prepared.value); + // Build the isolated result before side effects: publication itself cannot fail. + success = { ok: true, value: freeze(structuredClone(candidate)) }; + } catch { + return failure( + 'configuration', + 'config_candidate_invalid', + 'Invalid configuration transaction candidate.', + ); + } + if (candidate.config !== null) { + try { + const validation = await this.#validator.validate( + freeze(structuredClone(candidate.config)), + ); + if (validation.ok !== true) + return failure( + 'configuration', + 'config_validation_failed', + 'Caddy configuration validation failed.', + ); + if (validation.value.effectiveConfigHash !== candidate.config.effectiveConfigHash) + return failure( + 'configuration', + 'config_validation_mismatch', + 'Caddy validation did not identify the complete candidate.', + ); + } catch { + return failure( + 'configuration', + 'config_validation_failed', + 'Caddy configuration validation failed.', + ); + } + } + const outcome = await this.#apply(candidate.config); + if (outcome === 'definitely-rejected') + return failure( + 'caddyRuntime', + 'config_apply_rejected', + 'Caddy definitely rejected the configuration change.', + ); + if (outcome !== 'applied') + return this.#fenced('config_apply_uncertain', 'Caddy application outcome is uncertain.'); + const observed = await this.#observe(); + if (!observed.ok || observed.value !== (candidate.config?.effectiveConfigHash ?? null)) + return this.#fenced( + 'config_verification_failed', + 'Cannot verify the active Caddy configuration.', + ); + let persisted = false; + try { + const result = await this.#storage.persistDesiredState( + freeze(structuredClone(candidate.desiredState)), + ); + persisted = result.ok === true; + } catch { + // STO-002 dependency: a thrown/rejected persist leaves previous durable intent intact. + } + if (!persisted) { + if (!(await this.#restoreCommitted())) + this.#fenced( + 'config_rollback_failed', + 'Cannot restore and verify the last committed Caddy configuration.', + ); + return failure( + 'storage', + 'config_persist_failed', + 'Cannot persist the configuration change.', + ); + } + this.#committed = candidate; + return success; + } + + #targetHash(): string | null { + return this.#committed.config?.effectiveConfigHash ?? null; + } + + async #apply(config: CaddyConfig | null): Promise { + try { + const outcome = await this.#runtime.apply(freeze(structuredClone(config))); + return applyOutcomeSchema.parse(outcome).status; + } catch { + return 'ambiguous'; + } + } + + async #observe(): Promise> { + try { + const observed = await this.#runtime.readActive(); + if (observed.ok === true && (observed.value === null || typeof observed.value === 'string')) + return { ok: true, value: observed.value }; + } catch { + // Arbitrary backend exception text cannot cross the ProtocolError boundary. + } + return failure( + 'caddyRuntime', + 'config_verification_failed', + 'Cannot observe the active Caddy configuration.', + ); + } + + async #restoreCommitted(): Promise { + if ((await this.#apply(this.#committed.config)) !== 'applied') return false; + const observed = await this.#observe(); + return observed.ok && observed.value === this.#targetHash(); + } +} diff --git a/src/daemon/installation-root.ts b/src/daemon/installation-root.ts new file mode 100644 index 0000000..c64ef0e --- /dev/null +++ b/src/daemon/installation-root.ts @@ -0,0 +1,46 @@ +import { readFileSync, realpathSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { isSea } from 'node:sea'; +import { fileURLToPath } from 'node:url'; +import { CadderError, errorCode } from '../protocol/errors.ts'; + +export interface InstallationRootInput { + installationRoot?: string; + sea?: boolean; + executable?: string; + moduleUrl?: string; +} + +// Internal inputs let tests exercise distribution layouts without launch wrappers. +export function resolveInstallationRoot(input: InstallationRootInput = {}): string { + try { + if (input.installationRoot !== undefined) { + const root = realpathSync.native(resolve(input.installationRoot)); + if (!statSync(root).isDirectory()) throw new Error('Installation root is not a directory.'); + return root; + } + if (input.sea ?? isSea()) + return dirname(realpathSync.native(input.executable ?? process.execPath)); + let directory = dirname(realpathSync.native(fileURLToPath(input.moduleUrl ?? import.meta.url))); + for (;;) { + try { + const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as { + name?: string; + }; + if (manifest.name === 'cadder') return directory; + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + } + const parent = dirname(directory); + if (parent === directory) throw new Error('No cadder package anchor found.'); + directory = parent; + } + } catch (error) { + const failure = new CadderError( + 'installation-root-unavailable', + 'Cannot resolve the physical Cadder installation root; refusing a shared default runtime.', + ); + failure.cause = error; + throw failure; + } +} diff --git a/src/daemon/ipc-server.ts b/src/daemon/ipc-server.ts new file mode 100644 index 0000000..48387fd --- /dev/null +++ b/src/daemon/ipc-server.ts @@ -0,0 +1,146 @@ +import { createServer, type Socket } from 'node:net'; +import { chmod, lstat, unlink, writeFile } from 'node:fs/promises'; +import { once } from 'node:events'; +import { JsonChannel } from '../protocol/channel.ts'; +import { authenticateServer } from '../protocol/authentication.ts'; +import { dispatchRpc, errorResponseSchema, type RpcHandler } from '../protocol/rpc.ts'; +import { CadderError, errorCode } from '../protocol/errors.ts'; +import { PROTOCOL_VERSION, SECURITY_POLICY_VERSION, VERSION } from '../protocol/version.ts'; +import { + createProtectedFile, + protectCreated, + type RuntimeOwner, +} from '../platform/runtime-security.ts'; +import type { RuntimePaths } from './paths.ts'; + +export async function serveIpc( + paths: RuntimePaths, + owner: RuntimeOwner, + secret: Buffer, + handler: RpcHandler, + onDenied: () => void = () => {}, +): Promise<{ close(): Promise }> { + // Caller must hold the SQLite runtime lock before removing crash residue. + if (process.platform !== 'win32') { + try { + const info = await lstat(paths.endpoint); + if (!info.isSocket() || info.uid !== owner.uid) + throw new CadderError('unsafe-endpoint', 'Refusing to replace a non-owned socket path.'); + await unlink(paths.endpoint); + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + } + } + const sockets = new Set(); + let closing: Promise | undefined; + let discoveryOwned = false; + const server = createServer((socket) => { + if (closing || sockets.size >= 64) { + socket.destroy(); + return; + } + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + const channel = new JsonChannel(socket); + void (async () => { + const session = await authenticateServer(channel, secret, paths.instance); + try { + const request = await session.receive(); + if (closing) return; + const response = await dispatchRpc(request, handler); + try { + session.send(response); + } catch (error) { + if (errorCode(error) !== 'frame-too-large') throw error; + session.send( + errorResponseSchema.parse({ + protocolVersion: PROTOCOL_VERSION, + requestId: response.requestId, + error: { + kind: 'frame', + code: 'frame', + message: 'Daemon response exceeds the IPC frame size limit.', + guidance: null, + retryable: false, + requestId: response.requestId, + }, + }), + ); + } + await new Promise((resolve) => socket.end(resolve)); + } finally { + session.close(); + } + })().catch(() => { + onDenied(); + channel.close(); + }); + }); + try { + server.listen(paths.endpoint); + await once(server, 'listening'); + if (process.platform !== 'win32') { + await chmod(paths.endpoint, 0o600); + await protectCreated(paths.endpoint, owner); + } + await createProtectedFile(paths.discovery, owner); + // The lock owner may also replace validated, safe crash residue. + discoveryOwned = true; + await writeFile( + paths.discovery, + JSON.stringify({ + version: VERSION, + protocolVersion: PROTOCOL_VERSION, + securityPolicyVersion: SECURITY_POLICY_VERSION, + runtimeDir: paths.directory, + profile: paths.profile, + instance: paths.instance, + endpoint: paths.endpoint, + processId: process.pid, + owner: owner.id, + elevated: owner.elevated, + }), + ); + } catch (error) { + const failures = [error]; + try { + await close(); + } catch (cleanupError) { + failures.push(cleanupError); + } + if (failures.length > 1) + throw new AggregateError(failures, 'IPC startup and cleanup failed.', { cause: error }); + throw error; + } + function close(): Promise { + closing ??= closeListener(); + return closing; + } + async function closeListener(): Promise { + const failures: unknown[] = []; + for (const socket of sockets) { + try { + socket.destroy(); + } catch (error) { + failures.push(error); + } + } + try { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } catch (error) { + if (errorCode(error) !== 'ERR_SERVER_NOT_RUNNING') failures.push(error); + } + if (discoveryOwned) { + try { + await unlink(paths.discovery); + } catch (error) { + if (errorCode(error) !== 'ENOENT') failures.push(error); + } + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, 'IPC listener cleanup failed.'); + } + return { close }; +} diff --git a/src/daemon/local-runtime.ts b/src/daemon/local-runtime.ts new file mode 100644 index 0000000..55aee88 --- /dev/null +++ b/src/daemon/local-runtime.ts @@ -0,0 +1,77 @@ +import { resolvePaths, type RuntimePaths } from './paths.ts'; +import { acquireRuntimeLock } from './runtime-lock.ts'; +import { serveIpc } from './ipc-server.ts'; +import { prepareRuntime, runtimeOwner } from '../platform/runtime-security.ts'; +import type { RuntimeOwner } from '../contracts/ports.ts'; +import type { RpcHandler } from '../protocol/rpc.ts'; +import { CadderError } from '../protocol/errors.ts'; + +export interface LocalRuntime { + paths: RuntimePaths; + recovered: boolean; + stop(): Promise; +} + +export async function startLocalRuntime( + options: { + runtimeDir?: string; + profile?: string; + runtimeOwner?: number; + installationRoot?: string; + initialize?: (paths: RuntimePaths, owner: RuntimeOwner) => Promise<{ close(): Promise }>; + }, + handler: RpcHandler, + onDenied?: () => void, +): Promise { + if (process.getuid?.() === 0 && !options.runtimeDir) { + throw new CadderError( + 'runtime-owner-required', + 'Root must specify --runtime-owner and --runtime-dir.', + ); + } + const owner = await runtimeOwner(options.runtimeOwner); + const paths = resolvePaths(options); + await prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + let owned: { close(): Promise } | undefined; + try { + owned = await options.initialize?.(paths, owner); + const listener = await serveIpc(paths, owner, lock.secret, handler, onDenied); + let stopping: Promise | undefined; + return { + paths, + recovered: lock.recovered, + stop() { + stopping ??= (async () => { + // Keep the endpoint/exclusion until owned teardown is proved complete. + await owned?.close(); + const failures: unknown[] = []; + for (const finalize of [() => listener.close(), () => lock.release()]) { + try { + await finalize(); + } catch (error) { + failures.push(error); + } + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, 'Local runtime shutdown failed.'); + })(); + return stopping; + }, + }; + } catch (error) { + const failures = [error]; + try { + await owned?.close(); + await lock.release(); + } catch (cleanupError) { + failures.push(cleanupError); + } + if (failures.length > 1) + throw new AggregateError(failures, 'Local runtime startup and cleanup failed.', { + cause: error, + }); + throw error; + } +} diff --git a/src/daemon/paths.ts b/src/daemon/paths.ts new file mode 100644 index 0000000..3e26a5a --- /dev/null +++ b/src/daemon/paths.ts @@ -0,0 +1,82 @@ +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { CadderError } from '../protocol/errors.ts'; +import { resolveInstallationRoot } from './installation-root.ts'; + +export interface RuntimePaths { + directory: string; + profile: 'default' | 'dev'; + instance: string; + endpoint: string; + secret: string; + lock: string; + metadata: string; + discovery: string; + history: string; +} + +export function resolvePaths( + options: { runtimeDir?: string; profile?: string; installationRoot?: string } = {}, +): RuntimePaths { + const override = options.runtimeDir ?? process.env.CADDER_RUNTIME_DIR; + const value = ( + options.profile ?? + (override ? 'default' : process.env.CADDER_RUNTIME_PROFILE) ?? + 'default' + ) + .trim() + .toLowerCase(); + let profile: 'default' | 'dev'; + if (['', 'default', 'prod', 'production'].includes(value)) profile = 'default'; + else if (['dev', 'development'].includes(value)) profile = 'dev'; + else throw new CadderError('invalid-profile', 'Expected runtime profile default or dev.'); + let base: string; + if (process.platform === 'win32') + base = join( + process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), + 'Cadder', + 'Cadder', + 'run', + ); + else if (process.platform === 'darwin') + base = join(homedir(), 'Library', 'Application Support', 'dev.Cadder.Cadder', 'run'); + else if (process.env.XDG_RUNTIME_DIR) base = join(process.env.XDG_RUNTIME_DIR, 'cadder'); + else + base = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'cadder', 'run'); + let directory = join(resolve(override ?? base), 'v2'); + if (override === undefined) directory = join(directory, digest(resolveInstallationRoot(options))); + if (!override && profile === 'dev') { + const id = + process.env.CADDER_DEV_ID ?? + `workspace-${digest(resolve(process.env.CADDER_DEV_WORKSPACE ?? process.cwd()))}`; + if (!/^(?!\.{1,2}$)[a-zA-Z0-9._-]+$/.test(id)) + throw new CadderError('invalid-profile', 'Invalid CADDER_DEV_ID.'); + directory = join(directory, 'profiles', 'dev', id); + } + const instance = digest(directory); + const endpoint = + process.platform === 'win32' + ? `\\\\.\\pipe\\cadder-v2-${instance}` + : join(directory, 'cadder.sock'); + if (process.platform !== 'win32' && Buffer.byteLength(endpoint, 'utf8') > 103) + throw new CadderError( + 'runtime-endpoint-too-long', + 'Runtime socket path exceeds 103 UTF-8 bytes. Select a shorter CADDER_RUNTIME_DIR.', + ); + return { + directory, + profile, + instance, + endpoint, + secret: join(directory, 'ipc-secret'), + lock: join(directory, 'lock.sqlite3'), + metadata: join(directory, 'cadder.lock.json'), + discovery: join(directory, 'cadder-ipc.json'), + history: join(directory, 'runtime.sqlite3'), + }; +} + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 16); +} diff --git a/src/daemon/runtime-lock.ts b/src/daemon/runtime-lock.ts new file mode 100644 index 0000000..402ccdb --- /dev/null +++ b/src/daemon/runtime-lock.ts @@ -0,0 +1,153 @@ +import { randomBytes } from 'node:crypto'; +import { readFile, unlink, writeFile } from 'node:fs/promises'; +import { DatabaseSync } from 'node:sqlite'; +import type { RuntimePaths } from './paths.ts'; +import type { RuntimeOwner } from '../platform/runtime-security.ts'; +import { normalizeRuntimeCreationOwner } from '../platform/runtime-creation-owner.ts'; +import { + assertProtected, + assertRuntimeDescendant, + createProtectedFile, +} from '../platform/runtime-security.ts'; +import { CadderError, errorCode } from '../protocol/errors.ts'; +import { PROTOCOL_VERSION, SECURITY_POLICY_VERSION, VERSION } from '../protocol/version.ts'; + +export interface RuntimeLock { + recovered: boolean; + secret: Buffer; + release(): Promise; +} + +export async function acquireRuntimeLock( + paths: RuntimePaths, + owner: RuntimeOwner, +): Promise { + await assertProtected(paths.directory, owner, true); + await createProtectedFile(paths.lock, owner); + const journal = `${paths.lock}-journal`; + async function assertJournal(): Promise { + try { + await assertRuntimeDescendant(paths.directory, journal, owner); + } catch (error) { + // Only this known leaf may be absent; root/chain/inspection failures stay fatal. + if ( + errorCode(error) !== 'ENOENT' || + !(error && typeof error === 'object' && 'path' in error && error.path === journal) + ) + throw error; + } + } + await assertJournal(); + await normalizeRuntimeCreationOwner(owner); + const database = new DatabaseSync(paths.lock, { timeout: 0 }); + let transaction = false; + let metadataOwned = false; + let secretCreated = false; + let secret: Buffer | undefined; + + async function finalize(failedStart: boolean): Promise { + const failures: unknown[] = []; + for (const path of [ + ...(metadataOwned ? [paths.metadata] : []), + ...(failedStart && secretCreated ? [paths.secret] : []), + ]) { + try { + await unlink(path); + } catch (error) { + if (errorCode(error) !== 'ENOENT') failures.push(error); + } + } + try { + if (transaction) database.exec('ROLLBACK;'); + } catch (error) { + failures.push(error); + } + try { + database.close(); + } catch (error) { + failures.push(error); + } + secret?.fill(0); + return failures; + } + + try { + try { + database.exec('PRAGMA journal_mode=DELETE;'); + database.exec('BEGIN EXCLUSIVE;'); + transaction = true; + } catch (error) { + const sqliteCode = + error && + typeof error === 'object' && + 'errcode' in error && + typeof error.errcode === 'number' + ? error.errcode & 0xff + : undefined; + if (sqliteCode === 5 || sqliteCode === 6) { + throw new CadderError( + 'runtime-already-running', + 'Runtime is already locked. Use cadder daemon status or cadder daemon shutdown.', + ); + } + throw error; + } + // SQLite may have created the DELETE journal; exclusion remains held until release. + await assertJournal(); + let recovered = false; + try { + await readFile(paths.metadata); + recovered = true; + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + } + const generated = randomBytes(32); + try { + secretCreated = await createProtectedFile(paths.secret, owner, generated); + } finally { + generated.fill(0); + } + secret = await readFile(paths.secret); + if (secret.length !== 32) + throw new CadderError('invalid-runtime-secret', 'Runtime secret must contain 32 bytes.'); + await createProtectedFile(paths.metadata, owner); + // Safe stale metadata becomes ours only after lock acquisition and path validation. + metadataOwned = true; + await writeFile( + paths.metadata, + JSON.stringify({ + version: VERSION, + protocolVersion: PROTOCOL_VERSION, + securityPolicyVersion: SECURITY_POLICY_VERSION, + processId: process.pid, + acquiredAt: new Date().toISOString(), + owner: owner.id, + elevated: owner.elevated, + runtimeDir: paths.directory, + profile: paths.profile, + endpoint: paths.endpoint, + executable: process.execPath, + }), + ); + let releasing: Promise | undefined; + return { + recovered, + secret, + release() { + releasing ??= (async () => { + const failures = await finalize(false); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, 'Runtime lock cleanup failed.'); + })(); + return releasing; + }, + }; + } catch (error) { + const failures = await finalize(true); + if (failures.length === 0) throw error; + throw new AggregateError([error, ...failures], 'Runtime lock acquisition and cleanup failed.', { + cause: error, + }); + } +} diff --git a/src/daemon/start.ts b/src/daemon/start.ts new file mode 100644 index 0000000..ca3dc71 --- /dev/null +++ b/src/daemon/start.ts @@ -0,0 +1,200 @@ +import { createServer } from 'node:net'; +import { join } from 'node:path'; +import { CaddyConfigAdapter } from '../caddy/adapter.ts'; +import { + configForRoutePlan, + prepareSecureAdminPolicy, + type SecureAdminPolicy, +} from '../caddy/admin-policy.ts'; +import { RealCaddyResolver } from '../caddy/resolver.ts'; +import { CaddyRuntime } from '../caddy/runtime.ts'; +import { CaddyConfigValidator } from '../caddy/validation.ts'; +import { ApplicationStorage } from '../storage/storage.ts'; +import { runtimeLogStream } from '../storage/schema.ts'; +import { RpcError } from '../protocol/errors.ts'; +import { ConfigurationTransactions } from './configuration-transactions.ts'; +import { DaemonApplication } from './application.ts'; +import { CaddyLogs } from './caddy-logs.ts'; +import type { StateSnapshot } from '../protocol/dto.ts'; +import { startLocalRuntime, type LocalRuntime } from './local-runtime.ts'; + +/** Reserve a candidate address; Caddy startup still refuses conflicts instead of adoption. */ +async function adminPort(): Promise { + const server = createServer(); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close((error) => { + if (error || !address || typeof address === 'string') + reject(error ?? new Error('Cannot allocate administration address.')); + else resolve(address.port); + }); + }); + }); +} + +export async function startDaemon( + options: { + runtimeDir?: string; + runtimeOwner?: number; + installationRoot?: string; + realCaddy?: string; + } = {}, +): Promise { + let application: DaemonApplication; + let expiry: NodeJS.Timeout | undefined; + const runtime: LocalRuntime = await startLocalRuntime( + { + ...options, + initialize: async (paths, owner) => { + const storage = await ApplicationStorage.open(paths.directory, owner); + const resolver = new RealCaddyResolver({ + ...(options.realCaddy ? { explicitOverride: options.realCaddy } : {}), + }); + let caddy: CaddyRuntime | undefined; + let logs: CaddyLogs | undefined; + let transactions: ConfigurationTransactions | undefined; + let policy: SecureAdminPolicy | undefined; + let validator: CaddyConfigValidator | undefined; + try { + const desired = await storage.loadDesiredState(); + if (!desired.ok) throw new RpcError(desired.error); + const storageStatus: NonNullable = { + backend: 'sqlite', + path: paths.history, + schemaVersion: 1, + diagnostics: [], + }; + logs = new CaddyLogs( + () => application?.snapshot().registrations ?? [], + storage.append.bind(storage), + () => { + storageStatus.diagnostics = [ + { + code: 'log_ingestion_failed', + message: 'Some Caddy diagnostics could not be retained.', + operation: null, + }, + ]; + }, + ); + const adapter = new CaddyConfigAdapter(resolver); + transactions = new ConfigurationTransactions( + { config: null, registrations: [], desiredState: desired.value }, + { validate: (config) => validator!.validate(config) }, + storage, + { + apply: (config) => + caddy + ? caddy.apply(config) + : Promise.resolve({ + status: config === null ? 'applied' : 'definitely-rejected', + }), + readActive: () => + caddy ? caddy.readActive() : Promise.resolve({ ok: true, value: null }), + }, + ); + let binaryPath: string | null = null; + let version: string | null = null; + application = new DaemonApplication({ + transactions, + adapt: async (input) => { + const adapted = await adapter.adapt(input); + if (adapted.ok) { + const image = await resolver.pin(); + binaryPath = image.path; + version = image.evidence.version; + } + return adapted; + }, + secure: async (plan) => { + if (!policy) { + const secured = await prepareSecureAdminPolicy({ + runtimeBase: join(paths.directory, 'caddy'), + materialDirectory: join(paths.directory, 'admin'), + owner, + adminPort: await adminPort(), + plan, + }); + if (!secured.ok) return secured; + policy = secured.value; + caddy = new CaddyRuntime(policy, owner, resolver, { onOutput: logs!.accept }); + validator = new CaddyConfigValidator(resolver, { env: policy.environment }); + } + return { ok: true, value: configForRoutePlan(policy, plan) }; + }, + queryLogs: storage.queryLogs.bind(storage), + log: async (entry) => storage.append([entry]), + shutdown: () => { + void runtime.stop().catch(() => { + process.exitCode = 1; + }); + }, + runtime: () => ({ + status: caddy?.status() ?? 'idle', + binaryPath, + version, + processId: null, + adminEndpoint: policy ? `https://127.0.0.1:${policy.client.port}` : null, + diagnostics: [], + }), + storage: storageStatus, + }); + await storage.append([ + { + timestampUtc: new Date().toISOString(), + severity: 'info', + stream: runtimeLogStream, + attributionKind: 'runtimeControl', + entryKind: 'lifecycle', + rawMessage: 'Daemon started.', + domainKey: null, + sourceRegistrationId: null, + sourceInstanceId: null, + operation: null, + }, + ]); + let expiring = false; + expiry = setInterval(() => { + if (expiring) return; + expiring = true; + void application + .expire() + .catch(async () => { + // An expired route must not keep serving after a failed revocation. + await runtime.stop().catch(() => { + process.exitCode = 1; + }); + }) + .finally(() => { + expiring = false; + }); + }, 1000); + expiry.unref(); + return { + close: async () => { + clearInterval(expiry); + application.close(); + await transactions!.close(); + await caddy?.close(); + await logs!.close(); + await resolver.close(); + await storage.close(); + }, + }; + } catch (error) { + clearInterval(expiry); + await transactions?.close(); + await caddy?.close(); + await logs?.close(); + await resolver.close(); + await storage.close(); + throw error; + } + }, + }, + (request) => application.handle(request), + ); + return runtime; +} diff --git a/src/entrypoints/cadder.ts b/src/entrypoints/cadder.ts new file mode 100644 index 0000000..564af05 --- /dev/null +++ b/src/entrypoints/cadder.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +import { runCli } from '../cli/run.ts'; +process.exitCode = await runCli(process.argv.slice(2)); diff --git a/src/entrypoints/cadderd.ts b/src/entrypoints/cadderd.ts new file mode 100644 index 0000000..f624739 --- /dev/null +++ b/src/entrypoints/cadderd.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { Command, InvalidArgumentError } from 'commander'; +import { startDaemon } from '../daemon/start.ts'; +import { ClientService } from '../client/service.ts'; +import { resolvePaths } from '../daemon/paths.ts'; +import { VERSION } from '../protocol/version.ts'; +import { terminalSafe } from '../storage/redaction.ts'; + +const program = new Command('cadderd') + .version(VERSION) + .description('Own local IPC, project registrations, Caddy and diagnostic storage.') + .option('--real-caddy ', 'Absolute trusted upstream Caddy executable') + .option('--runtime-dir ', 'Explicit isolated runtime directory') + .option('--runtime-owner ', 'Explicit Unix runtime owner UID', (value) => { + if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) + throw new InvalidArgumentError('Expected a Unix UID.'); + return Number(value); + }) + .option('--background', 'Start or attach to the packaged daemon in the background'); +await program.parseAsync(); +const options = program.opts<{ + realCaddy?: string; + runtimeDir?: string; + runtimeOwner?: number; + background?: boolean; +}>(); +try { + if (options.background) { + const args = process.argv.slice(2).filter((argument) => argument !== '--background'); + await new ClientService(resolvePaths(options)).start(args); + } else { + const runtime = await startDaemon(options); + const stop = (): void => { + void runtime + .stop() + .then(() => { + process.off('SIGINT', stop); + process.off('SIGTERM', stop); + }) + .catch(() => { + process.stderr.write('Owned daemon shutdown failed.\n'); + process.exitCode = 1; + }); + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + } +} catch (error) { + process.stderr.write( + terminalSafe(error instanceof Error ? error.message : 'Daemon startup failed.') + '\n', + ); + process.exitCode = 1; +} diff --git a/src/entrypoints/caddy.ts b/src/entrypoints/caddy.ts new file mode 100644 index 0000000..71568c1 --- /dev/null +++ b/src/entrypoints/caddy.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import { runShim } from '../shim/run.ts'; +import { terminalSafe } from '../storage/redaction.ts'; +try { + process.exitCode = await runShim(process.argv.slice(2)); +} catch (error) { + process.stderr.write( + terminalSafe(error instanceof Error ? error.message : 'Caddy shim failed.') + '\n', + ); + process.exitCode = 1; +} diff --git a/src/platform/inspection.ts b/src/platform/inspection.ts new file mode 100644 index 0000000..7a5133f --- /dev/null +++ b/src/platform/inspection.ts @@ -0,0 +1,112 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { readlink } from 'node:fs/promises'; +import { powershell } from './powershell.ts'; +import { CadderError } from '../protocol/errors.ts'; + +const execute = promisify(execFile); +export type PortOwner = { + protocol: 'tcp' | 'udp'; + address: string; + port: number; + pid: number | null; + processName: string | null; + executable: string | null; +}; + +export async function inspectPort(port: number): Promise { + if (!Number.isInteger(port) || port < 0 || port > 65535) + throw new CadderError('invalid-input', 'Port must be from 0 to 65535.'); + if (process.platform === 'win32') { + return JSON.parse( + await powershell(` + $result = @(); + foreach ($kind in @('tcp', 'udp')) { + $sockets = if ($kind -eq 'tcp') { @(Get-NetTCPConnection -State Listen | Where-Object LocalPort -eq ${port}) } else { @(Get-NetUDPEndpoint | Where-Object LocalPort -eq ${port}) }; + foreach ($socket in $sockets) { + $ownerProcess = Get-Process -Id $socket.OwningProcess -ErrorAction SilentlyContinue; + $result += @{ protocol=$kind; address=[string]$socket.LocalAddress; port=[int]$socket.LocalPort; pid=[int]$socket.OwningProcess; processName=$ownerProcess.ProcessName; executable=$ownerProcess.Path }; + } + }; + ConvertTo-Json -InputObject @($result) -Compress + `), + ) as PortOwner[]; + } + if (process.platform === 'linux') { + const { stdout } = await execute('ss', ['-H', '-lntup', `sport = :${port}`], { + timeout: 5000, + maxBuffer: 1024 * 1024, + }); + const owners: PortOwner[] = []; + for (const line of stdout.trim().split('\n').filter(Boolean)) { + const fields = line.trim().split(/\s+/); + const protocol = fields[0]; + if (protocol !== 'tcp' && protocol !== 'udp') continue; + const address = fields[4]!.replace(/:\d+$/, ''); + const processes = [...line.matchAll(/\("([^"]*)",pid=(\d+)/g)]; + if (!processes.length) + owners.push({ protocol, address, port, pid: null, processName: null, executable: null }); + for (const match of processes) { + const pid = Number(match[2]); + owners.push({ + protocol, + address, + port, + pid, + processName: match[1] ?? null, + executable: await readlink(`/proc/${pid}/exe`).catch(() => null), + }); + } + } + return owners; + } + if (process.platform === 'darwin') { + let output: string; + try { + output = ( + await execute( + '/usr/sbin/lsof', + ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', `-iUDP:${port}`, '-FpcPn'], + { timeout: 5000, maxBuffer: 1024 * 1024 }, + ) + ).stdout; + } catch (error) { + const result = error as { code?: unknown; stdout?: string; stderr?: string }; + // macOS lsof returns 1 when only one requested protocol has matches. + if (result.code !== 1 || result.stderr?.trim()) throw error; + output = result.stdout ?? ''; + } + const owners: PortOwner[] = []; + let pid: number | null = null; + let name: string | null = null; + let protocol: 'tcp' | 'udp' = 'tcp'; + for (const line of output.split('\n')) { + if (line.startsWith('p')) pid = Number(line.slice(1)); + if (line.startsWith('c')) name = line.slice(1); + if (line.startsWith('P')) protocol = line.slice(1) === 'UDP' ? 'udp' : 'tcp'; + if (line.startsWith('n')) + owners.push({ + protocol, + address: line.slice(1).replace(/:\d+$/, ''), + port, + pid, + processName: name, + executable: null, + }); + } + return owners; + } + throw new CadderError( + 'unsupported-operation', + 'Socket inspection is unavailable on this platform.', + ); +} + +export async function terminatePortOwner(port: number, pid: number): Promise { + if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) + throw new CadderError('invalid-input', 'Expected a different positive process ID.'); + const owners = await inspectPort(port); + if (!owners.some((owner) => owner.pid === pid)) + throw new CadderError('target-conflict', 'The expected process no longer owns this port.'); + process.kill(pid, 'SIGTERM'); +} diff --git a/src/platform/owned-command.ts b/src/platform/owned-command.ts new file mode 100644 index 0000000..7056f9c --- /dev/null +++ b/src/platform/owned-command.ts @@ -0,0 +1,450 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { isAbsolute, join } from 'node:path'; + +export interface OwnedCommandOptions { + timeoutMs?: number; + maxStreamBytes?: number; + cwd?: string; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + /** Drain without retaining output or a lifetime deadline; requires an abort owner. */ + longLived?: boolean; + /** Verified native creation/resume, not application readiness. Callback failures stop owned work. */ + onStarted?: () => void; + /** Observes application bytes only, after native framing has been removed. */ + onOutput?: (stream: 'stdout' | 'stderr', bytes: Buffer) => void; + /** Verify immediately before actual native creation (after Windows wrapper startup). */ + beforeSpawn?: () => Promise; + /** Reverify the pinned image after creation; Windows does this while it is suspended. */ + afterSpawn?: () => Promise; +} +export type OwnedCommandOutput = Readonly<{ stdout: Buffer; stderr: Buffer; exitCode: number }>; + +export class OwnedCommandError extends Error { + constructor( + readonly code: 'spawn' | 'timeout' | 'overflow' | 'abort' | 'stream' | 'cleanup', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'OwnedCommandError'; + } +} + +// One non-inheritable Job per command. Only JSON data crosses the PowerShell boundary. +// The native image is suspended until Node has reverified it, and never runs outside the Job. +const windowsScript = ` +$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; +Add-Type -TypeDefinition @' +using System; +using System.Text; +using System.Runtime.InteropServices; +public static class CadderOwnedCommand { + [StructLayout(LayoutKind.Sequential)] struct BasicLimits { + public long ProcessTime, JobTime; public uint Flags; public UIntPtr Min, Max; + public uint Active; public UIntPtr Affinity; public uint Priority, Scheduling; + } + [StructLayout(LayoutKind.Sequential)] struct Counters { public ulong A,B,C,D,E,F; } + [StructLayout(LayoutKind.Sequential)] struct Accounting { + public long User, Kernel, PeriodUser, PeriodKernel; public uint Faults, Total, Active, Terminated; + } + [StructLayout(LayoutKind.Sequential)] struct Limits { + public BasicLimits Basic; public Counters Io; public UIntPtr ProcessMemory, JobMemory, PeakProcess, PeakJob; + } + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct Startup { + public uint Size; public string Reserved, Desktop, Title; public uint X,Y,XS,YS,XC,YC,Fill,Flags; + public ushort Show, ReservedSize; public IntPtr ReservedData, In, Out, Err; + } + [StructLayout(LayoutKind.Sequential)] struct StartupEx { public Startup Startup; public IntPtr Attributes; } + [StructLayout(LayoutKind.Sequential)] struct ProcessInfo { public IntPtr Process, Thread; public uint Pid, Tid; } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int kind, IntPtr data, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, UIntPtr attribute, IntPtr value, UIntPtr size, IntPtr previous, IntPtr returned); + [DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int kind, out Accounting data, uint size, IntPtr returned); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CreatePipe(out IntPtr read, out IntPtr write, IntPtr attributes, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateFile(string path, uint access, uint share, IntPtr attributes, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool CreateProcess(string image, StringBuilder command, IntPtr pa, IntPtr ta, bool inherit, uint flags, IntPtr environment, string cwd, ref StartupEx startup, out ProcessInfo info); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle, uint timeout); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process, out uint code); + static void Check(bool ok) { if (!ok) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); } + static void StopJob(IntPtr job, uint code) { + Check(TerminateJobObject(job, code)); + var deadline = System.Diagnostics.Stopwatch.StartNew(); + while (true) { + Accounting data; + Check(QueryInformationJobObject(job, 1, out data, (uint)Marshal.SizeOf(typeof(Accounting)), IntPtr.Zero)); + if (data.Active == 0) return; + if (deadline.ElapsedMilliseconds >= 4000) throw new TimeoutException("Owned Job did not terminate within four seconds."); + System.Threading.Thread.Sleep(10); + } + } + static string Quote(string value) { + StringBuilder b = new StringBuilder(); b.Append('\\"'); int slashes = 0; + foreach (char c in value) { + if (c == '\\\\') { slashes++; continue; } + if (c == '\\"') { b.Append('\\\\', slashes * 2 + 1); b.Append(c); } + else { b.Append('\\\\', slashes); b.Append(c); } + slashes = 0; + } + b.Append('\\\\', slashes * 2); b.Append('\\"'); return b.ToString(); + } + public static int Run(string image, string[] args, string cwd, long maxStreamBytes) { + IntPtr job = IntPtr.Zero, input = new IntPtr(-1), attributes = IntPtr.Zero, jobValue = IntPtr.Zero; + ProcessInfo p = new ProcessInfo(); bool attributeReady = false; + IntPtr outRead = IntPtr.Zero, outWrite = IntPtr.Zero, errRead = IntPtr.Zero, errWrite = IntPtr.Zero; + System.IO.FileStream outPipe = null, errPipe = null; + System.Threading.Tasks.Task outPump = null, errPump = null; + int overflow = 0; + try { + job = CreateJobObject(IntPtr.Zero, null); Check(job != IntPtr.Zero); + Limits limits = new Limits(); limits.Basic.Flags = 0x2000; + int size = Marshal.SizeOf(limits); IntPtr data = Marshal.AllocHGlobal(size); + try { Marshal.StructureToPtr(limits, data, false); Check(SetInformationJobObject(job, 9, data, (uint)size)); } + finally { Marshal.FreeHGlobal(data); } + input = CreateFile("NUL", 0x80000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero); + Check(input != new IntPtr(-1)); Check(SetHandleInformation(input, 1, 1)); + StartupEx s = new StartupEx(); s.Startup.Size = (uint)Marshal.SizeOf(s); s.Startup.Flags = 0x100; + Check(CreatePipe(out outRead, out outWrite, IntPtr.Zero, 0)); + Check(CreatePipe(out errRead, out errWrite, IntPtr.Zero, 0)); + Check(SetHandleInformation(outWrite, 1, 1)); Check(SetHandleInformation(errWrite, 1, 1)); + s.Startup.In = input; s.Startup.Out = outWrite; s.Startup.Err = errWrite; + IntPtr attributeSize = IntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeSize); + attributes = Marshal.AllocHGlobal(attributeSize); + Check(InitializeProcThreadAttributeList(attributes, 1, 0, ref attributeSize)); attributeReady = true; + jobValue = Marshal.AllocHGlobal(IntPtr.Size); Marshal.WriteIntPtr(jobValue, job); + Check(UpdateProcThreadAttribute(attributes, 0, new UIntPtr(0x2000d), jobValue, new UIntPtr((uint)IntPtr.Size), IntPtr.Zero, IntPtr.Zero)); + s.Attributes = attributes; + StringBuilder command = new StringBuilder(Quote(image)); + foreach (string arg in args) command.Append(" ").Append(Quote(arg)); + var output = Console.OpenStandardOutput(); var control = Console.OpenStandardInput(); + output.WriteByte(1); output.Flush(); + if (control.ReadByte() != 1) throw new Exception("Missing pre-creation image verification."); + Check(CreateProcess(image, command, IntPtr.Zero, IntPtr.Zero, true, 0x08080004, IntPtr.Zero, String.IsNullOrEmpty(cwd) ? null : cwd, ref s, out p)); + Check(CloseHandle(outWrite)); outWrite = IntPtr.Zero; + Check(CloseHandle(errWrite)); errWrite = IntPtr.Zero; + output.WriteByte(2); output.Flush(); + if (control.ReadByte() != 2) throw new Exception("Missing post-creation image verification."); + Check(ResumeThread(p.Thread) != 0xffffffff); + output.WriteByte(3); output.Flush(); + outPipe = new System.IO.FileStream(new Microsoft.Win32.SafeHandles.SafeFileHandle(outRead, true), System.IO.FileAccess.Read); outRead = IntPtr.Zero; + errPipe = new System.IO.FileStream(new Microsoft.Win32.SafeHandles.SafeFileHandle(errRead, true), System.IO.FileAccess.Read); errRead = IntPtr.Zero; + outPump = System.Threading.Tasks.Task.Factory.StartNew(() => { + byte[] buffer = new byte[81920]; long forwarded = 0; int count; + while ((count = outPipe.Read(buffer, 0, buffer.Length)) != 0) { + if (maxStreamBytes >= 0 && count > maxStreamBytes - forwarded) { + System.Threading.Interlocked.Exchange(ref overflow, 1); + throw new System.IO.IOException("Owned stdout payload exceeded its limit."); + } + output.Write(buffer, 0, count); + if (maxStreamBytes >= 0) forwarded += count; + } + }); + errPump = System.Threading.Tasks.Task.Factory.StartNew(() => errPipe.CopyTo(Console.OpenStandardError())); + var stop = System.Threading.Tasks.Task.Factory.StartNew(() => control.ReadByte()); + while (WaitForSingleObject(p.Process, 20) != 0) { + if (outPump.IsFaulted || errPump.IsFaulted) throw new Exception("Owned output forwarding failed."); + if (stop.IsCompleted) { StopJob(job, 1); break; } + } + uint code; Check(GetExitCodeProcess(p.Process, out code)); + StopJob(job, code); + System.Threading.Tasks.Task.WaitAll(outPump, errPump); + byte[] result = BitConverter.GetBytes(code); output.Write(result, 0, result.Length); output.Flush(); + return 0; + } catch { + if (job != IntPtr.Zero) StopJob(job, 1); + // Job settlement precedes pipe settlement: descendants may hold the write ends. + if (outPump != null) { try { outPump.Wait(); } catch (AggregateException) {} } + if (errPump != null) { try { errPump.Wait(); } catch (AggregateException) {} } + return overflow != 0 ? 254 : 252; + } finally { + if (p.Process != IntPtr.Zero) { + TerminateJobObject(job, 1); + WaitForSingleObject(p.Process, 5000); CloseHandle(p.Process); + } + if (outPipe != null) outPipe.Dispose(); if (errPipe != null) errPipe.Dispose(); + if (outRead != IntPtr.Zero) CloseHandle(outRead); if (outWrite != IntPtr.Zero) CloseHandle(outWrite); + if (errRead != IntPtr.Zero) CloseHandle(errRead); if (errWrite != IntPtr.Zero) CloseHandle(errWrite); + if (p.Thread != IntPtr.Zero) CloseHandle(p.Thread); + if (input != new IntPtr(-1)) CloseHandle(input); + if (job != IntPtr.Zero) CloseHandle(job); + if (attributeReady) DeleteProcThreadAttributeList(attributes); + if (attributes != IntPtr.Zero) Marshal.FreeHGlobal(attributes); + if (jobValue != IntPtr.Zero) Marshal.FreeHGlobal(jobValue); + } + } +} +'@; +try { + $data = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:CADDER_OWNED_COMMAND_DATA)) | ConvertFrom-Json; + [Environment]::SetEnvironmentVariable('CADDER_OWNED_COMMAND_DATA', $null); + exit [CadderOwnedCommand]::Run($data.image, [string[]]$data.args, $data.cwd, [long]$data.maxStreamBytes); +} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 253; } +`; + +/** Direct argv, separately bounded binary streams, and cleanup limited to this owned group/Job. */ +export async function runOwnedCommand( + image: string, + args: readonly string[], + options: OwnedCommandOptions = {}, +): Promise { + if (!isAbsolute(image) || image.includes('\0') || args.some((arg) => arg.includes('\0'))) + throw new OwnedCommandError( + 'spawn', + 'Owned command requires an absolute image and NUL-free argv.', + ); + if (options.longLived && !options.signal) + throw new RangeError('Long-lived execution requires an abort owner.'); + const timeoutMs = options.timeoutMs ?? 30_000; + const limit = options.maxStreamBytes ?? 1024 * 1024; + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs <= 0 || + !Number.isSafeInteger(limit) || + limit < 0 + ) + throw new RangeError('Command deadline and stream limit must be bounded nonnegative integers.'); + if (options.signal?.aborted) throw new OwnedCommandError('abort', 'Owned command cancelled.'); + const windows = process.platform === 'win32'; + if (!windows) await options.beforeSpawn?.(); + if (options.signal?.aborted) throw new OwnedCommandError('abort', 'Owned command cancelled.'); + const env = { ...(options.env ?? process.env) }; + let executable = image; + let argv = [...args]; + if (windows) { + executable = join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + argv = [ + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(windowsScript, 'utf16le').toString('base64'), + ]; + env.CADDER_OWNED_COMMAND_DATA = Buffer.from( + JSON.stringify({ + image, + args, + cwd: options.cwd ?? null, + maxStreamBytes: options.longLived ? -1 : limit, + }), + ).toString('base64'); + for (const key of Object.keys(env)) + if (['psmodulepath', 'lib', 'libpath', 'include'].includes(key.toLowerCase())) + delete env[key]; + env.PSModulePath = join(executable, '..', 'Modules'); + } + return await new Promise((resolve, reject) => { + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(executable, argv, { + shell: false, + detached: !windows, + windowsHide: true, + env, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + stdio: 'pipe', + }); + } catch (error) { + reject(new OwnedCommandError('spawn', 'Cannot create owned command.', { cause: error })); + return; + } + const chunks: Buffer[][] = [[], []]; + const counts = [0, 0]; + let stdoutTail = Buffer.alloc(0); + let failure: unknown; + let phase = windows ? 0 : 3; + let settled = false; + let cleanupTimer: ReturnType | undefined; + let pendingVerification = Promise.resolve(); + const terminate = () => { + try { + if (!windows && child.pid !== undefined) process.kill(-child.pid, 'SIGKILL'); + else if (phase >= 2) child.stdin.end(Buffer.from([3])); + else child.stdin.end(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH' && failure === undefined) + failure = error; + } + }; + const finish = (code: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(cleanupTimer); + options.signal?.removeEventListener('abort', abort); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + if (windows && code === 253) + failure = new OwnedCommandError('cleanup', 'Owned Job settlement could not be proved.'); + if (windows && code === 254 && failure === undefined) + failure = new OwnedCommandError( + 'overflow', + 'Owned command output exceeded its per-stream limit.', + ); + if (failure !== undefined) reject(failure); + else if (phase !== 3 || code === null || (windows && (code !== 0 || stdoutTail.length !== 4))) + reject( + new OwnedCommandError('spawn', 'Owned command did not complete native execution.', { + cause: new Error(Buffer.concat(chunks[1]!).subarray(0, 4096).toString('utf8')), + }), + ); + else + resolve({ + stdout: Buffer.concat(chunks[0]!), + stderr: Buffer.concat(chunks[1]!), + exitCode: windows ? stdoutTail.readUInt32LE(0) : code, + }); + }; + const fail = (error: unknown) => { + if (failure !== undefined || settled) return; + failure = error; + terminate(); + clearTimeout(cleanupTimer); + cleanupTimer = setTimeout(() => { + failure = new AggregateError( + [failure, new OwnedCommandError('cleanup', 'Owned command cleanup exceeded 5 seconds.')], + 'Owned command and cleanup failed.', + { cause: failure }, + ); + child.kill('SIGKILL'); + finish(null); + }, 5000); + }; + const verify = async (before = false) => { + try { + if (before) await options.beforeSpawn?.(); + else await options.afterSpawn?.(); + if (failure === undefined && !settled) { + if (windows && before) child.stdin.write(Buffer.from([1])); + else if (windows) child.stdin.write(Buffer.from([2])); + else { + child.stdin.end(); + if (child.exitCode === null && child.signalCode === null) options.onStarted?.(); + } + } + } catch (error) { + fail(error); + } + }; + const abort = () => fail(new OwnedCommandError('abort', 'Owned command cancelled.')); + const timer = options.longLived + ? undefined + : setTimeout( + () => fail(new OwnedCommandError('timeout', 'Owned command exceeded its deadline.')), + timeoutMs, + ); + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) abort(); + child.once('error', (error) => + fail(new OwnedCommandError('spawn', 'Cannot start owned command.', { cause: error })), + ); + child.once('spawn', () => { + if (!windows) pendingVerification = verify(); + }); + child.once('exit', (code) => { + // Kill residual Unix group members even when the leader exited successfully. + if (!windows) terminate(); + if (settled || failure !== undefined) return; + clearTimeout(timer); + cleanupTimer = setTimeout(() => { + failure = new OwnedCommandError( + 'stream', + 'Owned command streams did not settle after exit.', + ); + finish(code); + }, 5000); + }); + child.once('close', (code) => { + void pendingVerification.then(async () => { + if (!windows && child.pid !== undefined) { + const deadline = performance.now() + 4000; + while (!settled) { + try { + process.kill(-child.pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') break; + failure = new OwnedCommandError('cleanup', 'Cannot observe owned group settlement.', { + cause: error, + }); + break; + } + if (performance.now() >= deadline) { + failure = new OwnedCommandError( + 'cleanup', + 'Owned group settlement exceeded its deadline.', + ); + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + finish(code); + }); + }); + child.stdin.on('error', (error) => + fail(new OwnedCommandError('stream', 'Owned command stdin failed.', { cause: error })), + ); + for (const [index, stream] of [child.stdout, child.stderr].entries()) { + stream.on('error', (error) => + fail(new OwnedCommandError('stream', 'Owned command output failed.', { cause: error })), + ); + stream.on('data', (bytes: Buffer) => { + if (settled || failure !== undefined) return; + while (windows && index === 0 && phase < 3 && bytes.length > 0) { + if (bytes[0] !== phase + 1) { + fail(new OwnedCommandError('spawn', 'Invalid owned command creation handshake.')); + return; + } + phase++; + bytes = bytes.subarray(1); + if (phase < 3) pendingVerification = verify(phase === 1); + else { + try { + if (child.exitCode === null && child.signalCode === null) options.onStarted?.(); + } catch (error) { + fail(error); + return; + } + } + } + if (windows && index === 0 && phase === 3) { + // Only wrapper exit 0 authorizes decoding this fixed-size completion trailer. + const combined = Buffer.concat([stdoutTail, bytes]); + const payloadBytes = Math.max(0, combined.length - 4); + bytes = combined.subarray(0, payloadBytes); + stdoutTail = Buffer.from(combined.subarray(payloadBytes)); + } + try { + if (bytes.length) options.onOutput?.(index === 0 ? 'stdout' : 'stderr', bytes); + } catch (error) { + fail(error); + return; + } + if (options.longLived) return; + counts[index]! += bytes.length; + if (counts[index]! > limit) { + fail( + new OwnedCommandError( + 'overflow', + 'Owned command output exceeded its per-stream limit.', + ), + ); + return; + } + chunks[index]!.push(bytes); + }); + } + }); +} diff --git a/src/platform/powershell.ts b/src/platform/powershell.ts new file mode 100644 index 0000000..675a3b6 --- /dev/null +++ b/src/platform/powershell.ts @@ -0,0 +1,39 @@ +import { execFile } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + +const execute = promisify(execFile); + +export function psLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export async function powershell(script: string): Promise { + const encoded = Buffer.from(`$ErrorActionPreference = 'Stop'; ${script}`, 'utf16le').toString( + 'base64', + ); + const executable = join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + // Add-Type must not inherit unrelated native compiler search paths. + const excluded = new Set(['psmodulepath', 'lib', 'libpath', 'include']); + const environment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !excluded.has(key.toLowerCase())), + ); + environment.PSModulePath = join(dirname(executable), 'Modules'); + const result = await execute( + executable, + ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], + { + windowsHide: true, + env: environment, + timeout: 20000, + maxBuffer: 1024 * 1024, + }, + ); + return result.stdout.trim(); +} diff --git a/src/platform/runtime-creation-owner.ts b/src/platform/runtime-creation-owner.ts new file mode 100644 index 0000000..6719b79 --- /dev/null +++ b/src/platform/runtime-creation-owner.ts @@ -0,0 +1,106 @@ +import type { RuntimeOwner } from './runtime-security.ts'; +import { powershell, psLiteral } from './powershell.ts'; +import { CadderError } from '../protocol/errors.ts'; + +/** Set only the Windows primary token's default creation owner, never existing file ACLs. */ +export async function normalizeRuntimeCreationOwner(owner: RuntimeOwner): Promise { + if (process.platform !== 'win32') return; + try { + const proof: unknown = JSON.parse( + await powershell(` +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +public static class CadderRuntimeCreationOwner { + [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, int pid); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("advapi32.dll", SetLastError=true)] static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token); + [DllImport("advapi32.dll", SetLastError=true)] static extern bool GetTokenInformation(IntPtr token, int kind, IntPtr data, int length, out int required); + [DllImport("advapi32.dll", SetLastError=true)] static extern bool SetTokenInformation(IntPtr token, int kind, IntPtr data, int length); + public sealed class Result { public int ProcessId; public string User; public string Before; public string After; public bool Changed; } + static IntPtr Information(IntPtr token, int kind) { + int length; + bool queried = GetTokenInformation(token, kind, IntPtr.Zero, 0, out length); + int error = Marshal.GetLastWin32Error(); + if (queried || error != 122 || length < IntPtr.Size || length > 65536) throw new Win32Exception(error); + IntPtr data = Marshal.AllocHGlobal(length); + try { + if (!GetTokenInformation(token, kind, data, length, out length)) throw new Win32Exception(Marshal.GetLastWin32Error()); + return data; + } catch { Marshal.FreeHGlobal(data); throw; } + } + static string Sid(IntPtr token, int kind) { + IntPtr data = Information(token, kind); + try { return new SecurityIdentifier(Marshal.ReadIntPtr(data)).Value; } + finally { Marshal.FreeHGlobal(data); } + } + static void CloseToken(ref IntPtr token) { + if (!CloseHandle(token)) throw new Win32Exception(Marshal.GetLastWin32Error()); + token = IntPtr.Zero; + } + public static Result Normalize(int pid, string expected) { + IntPtr process = IntPtr.Zero, token = IntPtr.Zero, user = IntPtr.Zero, owner = IntPtr.Zero; + try { + // PROCESS_QUERY_LIMITED_INFORMATION; target the Node process, not this helper. + process = OpenProcess(0x1000, false, pid); + if (process == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (!OpenProcessToken(process, 0x0008, out token)) throw new Win32Exception(Marshal.GetLastWin32Error()); + string actual = Sid(token, 1); + if (actual != expected) throw new InvalidOperationException("Target token user mismatch."); + string before = Sid(token, 4); + bool changed = before != actual; + if (changed) { + CloseToken(ref token); + // TOKEN_QUERY | TOKEN_ADJUST_DEFAULT, only when TokenOwner differs. + if (!OpenProcessToken(process, 0x0008 | 0x0080, out token)) throw new Win32Exception(Marshal.GetLastWin32Error()); + user = Information(token, 1); + if (new SecurityIdentifier(Marshal.ReadIntPtr(user)).Value != expected) throw new InvalidOperationException("Adjustment token user mismatch."); + owner = Marshal.AllocHGlobal(IntPtr.Size); + Marshal.WriteIntPtr(owner, Marshal.ReadIntPtr(user)); + if (!SetTokenInformation(token, 4, owner, IntPtr.Size)) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + // Independently reopen the process primary token for readback, with query only. + CloseToken(ref token); + if (!OpenProcessToken(process, 0x0008, out token)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (Sid(token, 1) != expected) throw new InvalidOperationException("Readback token user mismatch."); + string after = Sid(token, 4); + if (after != actual) throw new InvalidOperationException("Default owner readback mismatch."); + return new Result { ProcessId=pid, User=actual, Before=before, After=after, Changed=changed }; + } finally { + if (owner != IntPtr.Zero) Marshal.FreeHGlobal(owner); + if (user != IntPtr.Zero) Marshal.FreeHGlobal(user); + if (token != IntPtr.Zero) CloseHandle(token); + if (process != IntPtr.Zero) CloseHandle(process); + } + } +} +'@; +[CadderRuntimeCreationOwner]::Normalize(${process.pid}, ${psLiteral(owner.id)}) | ConvertTo-Json -Compress +`), + ); + if ( + !proof || + typeof proof !== 'object' || + !('ProcessId' in proof) || + proof.ProcessId !== process.pid || + !('User' in proof) || + proof.User !== owner.id || + !('Before' in proof) || + typeof proof.Before !== 'string' || + !/^S-1-\d+(?:-\d+)+$/.test(proof.Before) || + !('After' in proof) || + proof.After !== owner.id || + !('Changed' in proof) || + typeof proof.Changed !== 'boolean' || + proof.Changed !== (proof.Before !== proof.User) + ) + throw new Error('Invalid native creation owner proof.'); + } catch { + throw new CadderError( + 'unsafe-runtime-permissions', + 'Cannot verify the current process default creation owner.', + ); + } +} diff --git a/src/platform/runtime-security.ts b/src/platform/runtime-security.ts new file mode 100644 index 0000000..c45c0ec --- /dev/null +++ b/src/platform/runtime-security.ts @@ -0,0 +1,278 @@ +import { constants } from 'node:fs'; +import { chown, lstat, mkdir, open, realpath, unlink } from 'node:fs/promises'; +import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path'; +import { CadderError, errorCode } from '../protocol/errors.ts'; +import { powershell, psLiteral } from './powershell.ts'; +import type { RuntimeOwner } from '../contracts/ports.ts'; + +export type { RuntimeOwner } from '../contracts/ports.ts'; + +export async function runtimeOwner(explicitUid?: number): Promise { + if (process.platform === 'win32') { + return JSON.parse( + await powershell(` + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent(); + $principal = New-Object System.Security.Principal.WindowsPrincipal($identity); + @{ id = $identity.User.Value; elevated = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } | ConvertTo-Json -Compress + `), + ) as RuntimeOwner; + } + const uid = process.getuid!(); + if (uid === 0 && explicitUid === undefined) + throw new CadderError( + 'runtime-owner-required', + 'Root must specify --runtime-owner and --runtime-dir.', + ); + if ( + explicitUid !== undefined && + (!Number.isSafeInteger(explicitUid) || explicitUid < 0 || (uid !== 0 && explicitUid !== uid)) + ) { + throw new CadderError( + 'invalid-runtime-owner', + 'Runtime owner must be the current user unless the daemon runs as root.', + ); + } + const ownerUid = explicitUid ?? uid; + return { id: `uid:${ownerUid}`, uid: ownerUid, elevated: uid === 0 }; +} + +function runtimeLeaf(path: string): string { + const rootLength = parse(path).root.length; + let end = path.length; + while (end > rootLength && (path[end - 1] === sep || (sep === '\\' && path[end - 1] === '/'))) + end -= 1; + return path.slice(0, end); +} + +async function assertRuntimeAncestors(path: string): Promise { + // Preserve lexical components: resolve(path) could erase a linked prefix before '..'. + const leaf = runtimeLeaf(path); + const root = parse(leaf).root; + const absolute = isAbsolute(leaf) + ? leaf + : `${root ? resolve(root) : process.cwd()}${sep}${leaf.slice(root.length)}`; + const parent = dirname(absolute); + let ancestor = parse(parent).root; + const components = parent.slice(ancestor.length).split(sep === '\\' ? /[\\/]+/ : /\/+/); + for (const component of ['', ...components]) { + if (component === '..') ancestor = dirname(ancestor); + else if (component && component !== '.') ancestor = join(ancestor, component); + let info; + try { + info = await lstat(ancestor); + } catch (error) { + // A later '..' can re-enter an existing linked prefix; do not stop at missing parents. + if (errorCode(error) === 'ENOENT') continue; + throw error; + } + if (!info.isSymbolicLink()) continue; + // Only these standard Darwin top-level OS aliases are permitted, never + // arbitrary root-owned or same-named nested links. Parent ACLs are unchanged. + if ( + process.platform === 'darwin' && + info.uid === 0 && + ['/tmp', '/var', '/etc'].includes(ancestor) + ) { + const target = `/private${ancestor}`; + try { + if ((await realpath(ancestor)) === target) { + const physical = await lstat(target); + if (physical.isDirectory() && !physical.isSymbolicLink()) { + // Inspection follows only this verified OS alias; caller paths stay unchanged. + ancestor = target; + continue; + } + } + } catch { + // An unverified OS alias is unsafe, including missing or inaccessible targets. + } + } + throw new CadderError( + 'unsafe-runtime-permissions', + 'Runtime ancestors must not be links or junctions.', + ); + } +} + +export async function prepareRuntime(directory: string, owner: RuntimeOwner): Promise { + await assertRuntimeAncestors(directory); + try { + await lstat(runtimeLeaf(directory)); + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + try { + const parent = dirname(directory); + const created = await mkdir(parent, { mode: 0o700, recursive: true }); + if (created && process.getuid?.() === 0) { + // Only newly created ancestors may be assigned to the explicit runtime owner. + let path = created; + await protectCreated(path, owner, true); + for (const component of relative(created, parent).split(sep).filter(Boolean)) { + path = join(path, component); + await protectCreated(path, owner, true); + } + } + await mkdir(directory, { mode: 0o700 }); + await protectCreated(directory, owner, true); + } catch (error) { + if (errorCode(error) !== 'EEXIST') throw error; + } + } + await assertProtected(directory, owner, true); +} + +export async function protectCreated( + path: string, + owner: RuntimeOwner, + directory = false, +): Promise { + if (process.platform === 'win32') { + const type = directory ? 'DirectorySecurity' : 'FileSecurity'; + const inheritance = directory ? "'ContainerInherit,ObjectInherit'" : "'None'"; + await powershell(` + $sid = New-Object System.Security.Principal.SecurityIdentifier(${psLiteral(owner.id)}); + $actualOwner = (Get-Acl -LiteralPath ${psLiteral(path)}).GetOwner([System.Security.Principal.SecurityIdentifier]).Value; + $acl = New-Object System.Security.AccessControl.${type}; + # An already-owned new path needs WRITE_DAC, not a redundant WRITE_OWNER request. + if ($actualOwner -ne $sid.Value) { $acl.SetOwner($sid) }; + $acl.SetAccessRuleProtection($true, $false); + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', ${inheritance}, 'None', 'Allow'); + $acl.AddAccessRule($rule); + [System.IO.${directory ? 'Directory' : 'File'}]::SetAccessControl(${psLiteral(path)}, $acl); + `); + } else if (process.getuid!() === 0 && owner.uid !== undefined) { + await chown(path, owner.uid, owner.uid); + } +} + +export async function assertProtected( + path: string, + owner: RuntimeOwner, + directory = false, +): Promise { + await assertRuntimeAncestors(path); + await assertRuntimePath(path, owner, directory, true); +} + +/** Read-only native storage check beneath a strictly protected product directory. */ +export async function assertRuntimeDescendant( + root: string, + path: string, + owner: RuntimeOwner, + directory = false, +): Promise { + const separators = sep === '\\' ? /[\\/]+/ : /\/+/; + // Reject lexical aliases before normalization could erase a linked component. + if ( + !isAbsolute(root) || + !isAbsolute(path) || + (!directory && path !== runtimeLeaf(path)) || + [root, path].some((value) => + value + .slice(parse(value).root.length) + .split(separators) + .some((part) => part === '.' || part === '..'), + ) + ) + throw new CadderError( + 'unsafe-runtime-permissions', + 'Expected an unaliased runtime descendant.', + ); + const base = runtimeLeaf(root); + const leaf = runtimeLeaf(path); + const inside = relative(base, leaf); + if (!inside || isAbsolute(inside) || inside.split(separators)[0] === '..') + throw new CadderError( + 'unsafe-runtime-permissions', + 'Runtime descendant must be inside its root.', + ); + await assertProtected(base, owner, true); + const components = inside.split(separators); + let current = base; + for (let index = 0; index < components.length; index++) { + current = join(current, components[index]!); + await assertRuntimePath(current, owner, index < components.length - 1 || directory, false); + } +} + +async function assertRuntimePath( + path: string, + owner: RuntimeOwner, + directory: boolean, + strict: boolean, +): Promise { + // A final separator can make lstat follow a directory link instead of inspecting it. + const info = await lstat(runtimeLeaf(path)); + if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) { + throw new CadderError( + 'unsafe-runtime-permissions', + 'Runtime paths must be real directories or regular files, not links.', + ); + } + if (process.platform === 'win32') { + await powershell(` + $acl = Get-Acl -LiteralPath ${psLiteral(path)}; + if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne ${psLiteral(owner.id)}${strict ? ' -or !$acl.AreAccessRulesProtected' : ''}) { throw 'Unsafe runtime owner or ACL inheritance' }; + $ownerAllowed = $false; + foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { + if ($rule.IdentityReference.Value -ne ${psLiteral(owner.id)} -or $rule.AccessControlType -ne 'Allow') { throw 'Runtime ACL permits a different account' }; + if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl${strict ? '' : ' -and ($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -eq 0'}) { $ownerAllowed = $true }; + }; + if (!$ownerAllowed) { throw 'Runtime owner lacks full access' }; + `).catch(() => { + throw new CadderError('unsafe-runtime-permissions', 'Unsafe runtime ACL; refusing to start.'); + }); + } else if (info.uid !== owner.uid || (info.mode & 0o777) !== (directory ? 0o700 : 0o600)) { + throw new CadderError( + 'unsafe-runtime-permissions', + 'Runtime owner and permissions must be 0700 for directories and 0600 for files.', + ); + } +} + +export async function createProtectedFile( + path: string, + owner: RuntimeOwner, + data: string | Uint8Array = '', +): Promise { + await assertRuntimeAncestors(path); + let handle; + try { + handle = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + } catch (error) { + if (errorCode(error) !== 'EEXIST') throw error; + await assertProtected(path, owner); + return false; + } + const failures: unknown[] = []; + try { + await protectCreated(path, owner); + await handle.writeFile(data); + await handle.sync(); + } catch (error) { + failures.push(error); + } + try { + await handle.close(); + } catch (error) { + failures.push(error); + } + if (failures.length === 0) { + try { + await assertProtected(path, owner); + } catch (error) { + failures.push(error); + } + } + if (failures.length === 0) return true; + // Only the successful exclusive open above grants ownership of this new path. + try { + await unlink(path); + } catch (error) { + if (errorCode(error) !== 'ENOENT') failures.push(error); + } + if (failures.length === 1) throw failures[0]; + throw new AggregateError(failures, 'Protected file creation and cleanup failed.', { + cause: failures[0], + }); +} diff --git a/src/protocol/authentication.ts b/src/protocol/authentication.ts new file mode 100644 index 0000000..17ca4a7 --- /dev/null +++ b/src/protocol/authentication.ts @@ -0,0 +1,122 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { z } from 'zod'; +import { CadderError } from './errors.ts'; +import { JsonChannel } from './channel.ts'; +import { PROTOCOL_VERSION, SECURITY_POLICY_VERSION } from './version.ts'; + +const nonce = z.string().regex(/^[a-f0-9]{64}$/); +const hello = z.object({ + type: z.literal('hello'), + nonce, + protocolVersion: z.literal(PROTOCOL_VERSION), + policyVersion: z.literal(SECURITY_POLICY_VERSION), +}); +const challenge = z.object({ type: z.literal('challenge'), nonce, proof: nonce }); +const proof = z.object({ type: z.literal('proof'), proof: nonce }); +const ready = z.object({ type: z.literal('ready'), proof: nonce }); +const message = z.object({ + sequence: z.number().int().nonnegative(), + payload: z.string(), + mac: nonce, +}); + +function mac(secret: Buffer, value: string): string { + return createHmac('sha256', secret).update(value).digest('hex'); +} + +function verify(secret: Buffer, value: string, received: string): void { + if (!timingSafeEqual(Buffer.from(mac(secret, value), 'hex'), Buffer.from(received, 'hex'))) { + throw new CadderError('authentication-failed', 'IPC authentication failed.'); + } +} + +function transcript(instance: string, clientNonce: string, serverNonce: string): string { + return JSON.stringify([ + 'cadder-ipc', + PROTOCOL_VERSION, + SECURITY_POLICY_VERSION, + instance, + clientNonce, + serverNonce, + ]); +} + +export async function authenticateServer( + channel: JsonChannel, + secret: Buffer, + instance: string, +): Promise { + const client = hello.parse(await channel.next()); + const serverNonce = randomBytes(32).toString('hex'); + const context = transcript(instance, client.nonce, serverNonce); + channel.send({ type: 'challenge', nonce: serverNonce, proof: mac(secret, `server:${context}`) }); + const response = proof.parse(await channel.next()); + verify(secret, `client:${context}`, response.proof); + channel.send({ type: 'ready', proof: mac(secret, `ready:${context}`) }); + return new AuthenticatedSession( + channel, + Buffer.from(mac(secret, `session:${context}`), 'hex'), + 'server', + ); +} + +export async function authenticateClient( + channel: JsonChannel, + secret: Buffer, + instance: string, +): Promise { + const clientNonce = randomBytes(32).toString('hex'); + channel.send({ + type: 'hello', + nonce: clientNonce, + protocolVersion: PROTOCOL_VERSION, + policyVersion: SECURITY_POLICY_VERSION, + }); + const server = challenge.parse(await channel.next()); + const context = transcript(instance, clientNonce, server.nonce); + verify(secret, `server:${context}`, server.proof); + channel.send({ type: 'proof', proof: mac(secret, `client:${context}`) }); + const response = ready.parse(await channel.next()); + verify(secret, `ready:${context}`, response.proof); + return new AuthenticatedSession( + channel, + Buffer.from(mac(secret, `session:${context}`), 'hex'), + 'client', + ); +} + +export class AuthenticatedSession { + private sent = 0; + private received = 0; + constructor( + private channel: JsonChannel, + private key: Buffer, + private role: 'client' | 'server', + ) {} + + send(value: unknown): void { + const payload = JSON.stringify(value); + const sequence = this.sent; + this.channel.send({ + sequence, + payload, + mac: mac(this.key, JSON.stringify([this.role, sequence, payload])), + }); + this.sent++; + } + + async receive(timeout = 15000): Promise { + const frame = message.parse(await this.channel.next(timeout)); + if (frame.sequence !== this.received) + throw new CadderError('replayed-frame', 'Out-of-order or replayed IPC frame.'); + const peer = this.role === 'client' ? 'server' : 'client'; + verify(this.key, JSON.stringify([peer, frame.sequence, frame.payload]), frame.mac); + this.received++; + return JSON.parse(frame.payload); + } + + close(): void { + this.key.fill(0); + this.channel.close(); + } +} diff --git a/src/protocol/channel.ts b/src/protocol/channel.ts new file mode 100644 index 0000000..796dbaa --- /dev/null +++ b/src/protocol/channel.ts @@ -0,0 +1,88 @@ +import type { Socket } from 'node:net'; +import { CadderError } from './errors.ts'; +import { AUTH_TIMEOUT_MS, MAX_FRAME_BYTES } from './version.ts'; + +export class JsonChannel { + private buffer: Buffer = Buffer.alloc(0); + private queue: unknown[] = []; + private failure: Error | undefined; + private pending: { resolve(value: unknown): void; reject(error: Error): void } | undefined; + + constructor(public readonly socket: Socket) { + socket.on('data', (data: Buffer) => this.consume(data)); + socket.on('error', (error) => this.fail(error)); + socket.on('close', () => + this.fail(new CadderError('connection-closed', 'Daemon connection closed.')), + ); + } + + send(value: unknown): void { + const frame = Buffer.from(`${JSON.stringify(value)}\n`); + if (frame.length > MAX_FRAME_BYTES) + throw new CadderError('frame-too-large', 'IPC frame exceeds the size limit.'); + if (this.failure) throw this.failure; + this.socket.write(frame); + } + + async next(timeout = AUTH_TIMEOUT_MS): Promise { + if (this.queue.length) return this.queue.shift(); + if (this.failure) throw this.failure; + if (this.pending) throw new CadderError('concurrent-read', 'Only one IPC read can be pending.'); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const error = new CadderError('ipc-timeout', 'Daemon did not respond before the deadline.'); + this.fail(error); + this.socket.destroy(); + }, timeout); + this.pending = { + resolve(value) { + clearTimeout(timer); + resolve(value); + }, + reject(error) { + clearTimeout(timer); + reject(error); + }, + }; + }); + } + + close(): void { + this.socket.destroy(); + } + + private consume(data: Buffer): void { + this.buffer = Buffer.concat([this.buffer, data]); + let newline: number; + while ((newline = this.buffer.indexOf(10)) !== -1) { + if (newline > MAX_FRAME_BYTES || this.queue.length >= 8) return this.rejectFrame(); + let frame: unknown; + try { + frame = JSON.parse(this.buffer.subarray(0, newline).toString('utf8')); + } catch { + return this.rejectFrame(); + } + this.buffer = this.buffer.subarray(newline + 1); + if (this.pending) { + const pending = this.pending; + this.pending = undefined; + pending.resolve(frame); + } else this.queue.push(frame); + } + if (this.buffer.length > MAX_FRAME_BYTES) this.rejectFrame(); + } + + private rejectFrame(): void { + this.fail(new CadderError('invalid-frame', 'Invalid or oversized IPC frame.')); + this.socket.destroy(); + } + + private fail(error: Error): void { + this.failure ??= error; + if (this.pending) { + const pending = this.pending; + this.pending = undefined; + pending.reject(error); + } + } +} diff --git a/src/protocol/domain.ts b/src/protocol/domain.ts new file mode 100644 index 0000000..e3952af --- /dev/null +++ b/src/protocol/domain.ts @@ -0,0 +1,17 @@ +import { domainToASCII } from 'node:url'; + +/** Match literal-domain IDNA without invoking URL decoding or host parsing. */ +export function canonicalizeDomain(raw: string): string { + const domain = raw.trim().replace(/\.+$/, ''); + const fallback = domain.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); + if ( + /[\s%/:\\@#?]/u.test(domain) || + domain.includes('[') || + domain.includes(']') || + [...domain].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) + ) + return fallback; + const suffix = '.cadder-idna'; + const mapped = domainToASCII(domain + suffix); + return mapped.endsWith(suffix) ? mapped.slice(0, -suffix.length) : fallback; +} diff --git a/src/protocol/dto.ts b/src/protocol/dto.ts new file mode 100644 index 0000000..635ea75 --- /dev/null +++ b/src/protocol/dto.ts @@ -0,0 +1,125 @@ +import { z } from 'zod'; + +export const safeUnsignedIntegerSchema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); +const u32 = safeUnsignedIntegerSchema.max(0xffffffff); +const timestamp = z.string().datetime({ offset: true }); +const nullableString = z.string().nullable(); +const activation = z.enum(['unknown', 'registered', 'activating', 'active', 'inactive', 'faulted']); + +export const logStreamSchema = z.strictObject({ + streamId: z.string(), + domainKey: nullableString, + channel: z.string(), +}); +const sourcePathSchema = z.strictObject({ raw: z.string(), canonical: nullableString }); +export const registrationSchema = z.strictObject({ + registrationId: z.string(), + entrypointInstance: z.strictObject({ + instanceId: z.string(), + startedAtUtc: timestamp, + shimSessionNonce: z.string(), + }), + sourceWorkingDirectory: sourcePathSchema, + sourceConfigPath: sourcePathSchema, + registeredDomains: z.array( + z.strictObject({ + name: z.strictObject({ raw: z.string(), canonical: z.string() }), + activationState: activation, + upstream: nullableString.transform((value) => value ?? undefined).optional(), + logStream: logStreamSchema, + }), + ), + activationState: activation, + ownerProcess: z.strictObject({ + processId: u32, + processStartTimeUtc: timestamp, + shimSessionNonce: z.string(), + executablePath: nullableString, + }), + logStream: logStreamSchema, + shimRun: z + .strictObject({ + adapter: nullableString, + rawArguments: z.array(z.string()), + commandLine: z.string(), + }) + .nullable(), + createdAtUtc: timestamp, + lastHeartbeatUtc: timestamp, +}); +// Serde Option inputs accept omission; wire output without skip_serializing_if emits null. +export const logStreamInputSchema = logStreamSchema.extend({ + domainKey: nullableString.default(null), +}); +const sourcePathInputSchema = sourcePathSchema.extend({ canonical: nullableString.default(null) }); +export const registrationInputSchema = registrationSchema.extend({ + sourceWorkingDirectory: sourcePathInputSchema, + sourceConfigPath: sourcePathInputSchema, + registeredDomains: z.array( + registrationSchema.shape.registeredDomains.element.extend({ logStream: logStreamInputSchema }), + ), + ownerProcess: registrationSchema.shape.ownerProcess.extend({ + executablePath: nullableString.default(null), + }), + logStream: logStreamInputSchema, + shimRun: registrationSchema.shape.shimRun + .unwrap() + .extend({ adapter: nullableString.default(null) }) + .nullable() + .default(null), +}); + +const runtimeDiagnosticSchema = z.strictObject({ + code: z.string(), + message: z.string(), + operation: nullableString, +}); +export const snapshotSchema = z.strictObject({ + capturedAtUtc: timestamp, + registrations: z.array(registrationSchema), + runtime: z.strictObject({ + status: z.enum(['unknown', 'notResolved', 'resolved', 'running', 'unhealthy', 'idle']), + binaryPath: nullableString, + version: nullableString, + processId: u32.nullable(), + adminEndpoint: nullableString, + diagnostics: z.array(runtimeDiagnosticSchema), + }), + config: z.strictObject({ + status: z.enum(['unknown', 'notApplied', 'applied', 'failed', 'idle']), + lastAttemptedAtUtc: timestamp.nullable(), + lastSuccessfulReloadAtUtc: timestamp.nullable(), + effectiveConfigHash: nullableString, + diagnostics: z.array( + z.strictObject({ + code: z.string(), + message: z.string(), + domainKey: nullableString, + sourceConfigPaths: z.array(z.string()), + }), + ), + }), + storage: z + .strictObject({ + backend: z.string(), + path: nullableString, + schemaVersion: u32, + diagnostics: z.array(runtimeDiagnosticSchema), + }) + .nullable(), +}); +export const logEntrySchema = z.strictObject({ + sequenceNumber: safeUnsignedIntegerSchema, + timestampUtc: timestamp, + severity: z.enum(['unknown', 'trace', 'debug', 'info', 'warn', 'error', 'fatal']), + stream: logStreamSchema, + attributionKind: z.enum(['unknown', 'runtime', 'runtimeControl', 'entrypoint', 'domain']), + entryKind: z.enum(['normal', 'lifecycle', 'ingestionOverflow', 'retentionGap']), + rawMessage: z.string(), + domainKey: nullableString, + sourceRegistrationId: nullableString, + sourceInstanceId: nullableString, + operation: nullableString, +}); +export type StateSnapshot = z.infer; +export type EntrypointRegistration = z.infer; diff --git a/src/protocol/errors.ts b/src/protocol/errors.ts new file mode 100644 index 0000000..fb20ad0 --- /dev/null +++ b/src/protocol/errors.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +export const requestIdSchema = z.string().uuid(); +export const protocolErrorSchema = z.strictObject({ + kind: z.enum([ + 'incompatibleProtocolVersion', + 'unsupportedOperation', + 'payloadDecodeFailed', + 'accessDenied', + 'invalidInput', + 'conflict', + 'configuration', + 'caddyRuntime', + 'storage', + 'busy', + 'frame', + 'timeout', + 'protocolViolation', + 'shuttingDown', + 'staleInstance', + 'internal', + ]), + code: z + .string() + .max(64) + .regex(/^[a-z](?:[a-z0-9_]*[a-z0-9])?$/), + message: z.string(), + guidance: z.string().nullable(), + retryable: z.boolean(), + requestId: requestIdSchema.nullable(), + deniedOperation: z + .string() + .nullable() + .transform((value) => value ?? undefined) + .optional(), +}); +export type ProtocolError = z.infer; + +export class CadderError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'CadderError'; + } +} + +export class RpcError extends CadderError { + readonly protocolError: ProtocolError; + + constructor(error: z.input) { + const parsed = protocolErrorSchema.parse(error); + super(parsed.code, parsed.message); + this.protocolError = parsed; + } +} + +export function toWireError(error: unknown, requestId: string): ProtocolError { + if (error instanceof RpcError) { + const parsed = protocolErrorSchema.safeParse({ ...error.protocolError, requestId }); + if (parsed.success) return parsed.data; + } + return { + kind: 'internal', + code: 'internal', + message: 'Daemon request failed. See diagnostics.', + guidance: null, + retryable: false, + requestId, + }; +} + +export function errorCode(error: unknown): string | undefined { + if (error && typeof error === 'object' && 'code' in error) { + return String(error.code); + } + return undefined; +} diff --git a/src/protocol/rpc.ts b/src/protocol/rpc.ts new file mode 100644 index 0000000..96f7ad0 --- /dev/null +++ b/src/protocol/rpc.ts @@ -0,0 +1,175 @@ +import { z } from 'zod'; +import { + logEntrySchema, + logStreamInputSchema, + logStreamSchema, + registrationInputSchema, + safeUnsignedIntegerSchema, + snapshotSchema, +} from './dto.ts'; +import { protocolErrorSchema, requestIdSchema, toWireError } from './errors.ts'; +import { PROTOCOL_VERSION } from './version.ts'; + +const basicResultSchema = z.strictObject({ + requestId: requestIdSchema, + accepted: z.boolean(), + message: z.string(), +}); +const ownerParamsSchema = z.strictObject({ + registrationId: z.string(), + shimSessionNonce: z.string(), +}); +const emptyParamsSchema = z.strictObject({}); +export const catalog = { + 'register-entrypoint-request': { + params: z.strictObject({ registration: registrationInputSchema }), + result: basicResultSchema.extend({ registrationId: z.string().nullable() }), + }, + 'unregister-entrypoint-request': { params: ownerParamsSchema, result: basicResultSchema }, + 'heartbeat-entrypoint-request': { params: ownerParamsSchema, result: basicResultSchema }, + 'query-state-request': { + params: emptyParamsSchema, + result: basicResultSchema.extend({ snapshot: snapshotSchema.nullable() }), + }, + 'set-entrypoint-enabled-request': { + params: z.strictObject({ + registrationId: z.string(), + shimSessionNonce: z.string().nullable().default(null), + enabled: z.boolean(), + }), + result: basicResultSchema, + }, + 'set-domain-enabled-request': { + params: z.strictObject({ + registrationId: z.string(), + domainKey: z.string(), + enabled: z.boolean(), + }), + result: basicResultSchema, + }, + 'query-logs-request': { + params: z.strictObject({ + stream: logStreamInputSchema, + limit: safeUnsignedIntegerSchema.nullable().default(null), + }), + result: basicResultSchema.extend({ + stream: logStreamSchema, + streamStatus: z.enum(['unknown', 'empty', 'active', 'stale', 'removed', 'readError']), + entries: z.array(logEntrySchema).max(200), + }), + }, + 'shutdown-daemon-request': { params: emptyParamsSchema, result: basicResultSchema }, +} as const; +export type RpcMethod = keyof typeof catalog; +export type RpcParams = z.input<(typeof catalog)[M]['params']>; +export type RpcResult = z.infer<(typeof catalog)[M]['result']>; +export type RpcRequest = { + [M in RpcMethod]: { + protocolVersion: typeof PROTOCOL_VERSION; + requestId: string; + method: M; + params: z.output<(typeof catalog)[M]['params']>; + }; +}[RpcMethod]; +export type HandlerResult = { + [M in RpcMethod]: Omit, 'requestId'> & { + requestId?: string; + }; +}[RpcMethod]; +export type RpcHandler = (request: RpcRequest) => Promise; + +function methodRequestSchema(method: M, params: S) { + return z.strictObject({ + protocolVersion: z.literal(PROTOCOL_VERSION), + requestId: requestIdSchema, + method: z.literal(method), + params, + }); +} +export const requestSchema = z.discriminatedUnion('method', [ + methodRequestSchema('register-entrypoint-request', catalog['register-entrypoint-request'].params), + methodRequestSchema( + 'unregister-entrypoint-request', + catalog['unregister-entrypoint-request'].params, + ), + methodRequestSchema( + 'heartbeat-entrypoint-request', + catalog['heartbeat-entrypoint-request'].params, + ), + methodRequestSchema('query-state-request', catalog['query-state-request'].params), + methodRequestSchema( + 'set-entrypoint-enabled-request', + catalog['set-entrypoint-enabled-request'].params, + ), + methodRequestSchema('set-domain-enabled-request', catalog['set-domain-enabled-request'].params), + methodRequestSchema('query-logs-request', catalog['query-logs-request'].params), + methodRequestSchema('shutdown-daemon-request', catalog['shutdown-daemon-request'].params), +]); +const responseHeader = { protocolVersion: z.literal(PROTOCOL_VERSION), requestId: requestIdSchema }; +export const errorResponseSchema = z + .strictObject({ ...responseHeader, error: protocolErrorSchema }) + .refine( + (response) => response.error.requestId === response.requestId, + 'Error request ID must match its envelope.', + ); + +export function responseSchema(method: M) { + const success = z + .strictObject({ ...responseHeader, result: catalog[method].result }) + .refine( + (response) => response.result.requestId === response.requestId, + 'Result request ID must match its envelope.', + ); + return z.union([success, errorResponseSchema]); +} + +export type RpcResponse = + | { protocolVersion: typeof PROTOCOL_VERSION; requestId: string; result: RpcResult } + | z.infer; + +export const UNCORRELATED_REQUEST_ID = '00000000-0000-4000-8000-000000000000'; + +// Authentication and framing are enforced before this boundary. +export async function dispatchRpc(input: unknown, handler: RpcHandler): Promise { + const parsed = requestSchema.safeParse(input); + if (!parsed.success) { + const envelope = z.object({ method: z.string().optional() }).safeParse(input); + let requestId = UNCORRELATED_REQUEST_ID; + // Correlate only validated IDs; never reflect unbounded peer diagnostic text. + if (input && typeof input === 'object' && 'requestId' in input) { + const id = requestIdSchema.safeParse(input.requestId); + if (id.success) requestId = id.data; + } + const unknownMethod = + envelope.success && + envelope.data.method !== undefined && + !Object.hasOwn(catalog, envelope.data.method); + return errorResponseSchema.parse({ + protocolVersion: PROTOCOL_VERSION, + requestId, + error: { + kind: unknownMethod ? 'unsupportedOperation' : 'payloadDecodeFailed', + code: unknownMethod ? 'unsupported_operation' : 'invalid_payload', + message: unknownMethod ? 'Unsupported RPC operation.' : 'Invalid RPC request.', + guidance: null, + retryable: false, + requestId, + }, + }); + } + const request: RpcRequest = parsed.data; + try { + const result = await handler(request); + return responseSchema(request.method).parse({ + protocolVersion: PROTOCOL_VERSION, + requestId: request.requestId, + result: { ...result, requestId: request.requestId }, + }); + } catch (error) { + return errorResponseSchema.parse({ + protocolVersion: PROTOCOL_VERSION, + requestId: request.requestId, + error: toWireError(error, request.requestId), + }); + } +} diff --git a/src/protocol/version.ts b/src/protocol/version.ts new file mode 100644 index 0000000..c775499 --- /dev/null +++ b/src/protocol/version.ts @@ -0,0 +1,5 @@ +export const VERSION = '2.0.0-rc.1'; +export const PROTOCOL_VERSION = 3; +export const SECURITY_POLICY_VERSION = 2; +export const MAX_FRAME_BYTES = 1024 * 1024; +export const AUTH_TIMEOUT_MS = 5000; diff --git a/src/shim/run.ts b/src/shim/run.ts new file mode 100644 index 0000000..e99393e --- /dev/null +++ b/src/shim/run.ts @@ -0,0 +1,210 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { ClientService } from '../client/service.ts'; +import { rpc } from '../client/connection.ts'; +import { RealCaddyResolver } from '../caddy/resolver.ts'; +import { VERSION } from '../protocol/version.ts'; +import { CadderError, errorCode } from '../protocol/errors.ts'; +import type { EntrypointRegistration } from '../protocol/dto.ts'; + +const inspection = new Set([ + 'adapt', + 'build-info', + 'environ', + 'help', + 'list-modules', + 'validate', + 'version', +]); +const passthrough = new Set(['completion', 'file-server', 'fmt', 'manpage', 'reverse-proxy']); +export function shimPolicy( + argv: readonly string[], +): 'managed' | 'inspection' | 'passthrough' | 'unsupported' { + const raw = argv[0] ?? 'help'; + const command = ['--help', '-h'].includes(raw) + ? 'help' + : ['--version', '-v'].includes(raw) + ? 'version' + : raw; + return command === 'run' + ? 'managed' + : inspection.has(command) + ? 'inspection' + : passthrough.has(command) + ? 'passthrough' + : 'unsupported'; +} + +export function buildRegistration( + argv: readonly string[], + cwd = process.cwd(), +): EntrypointRegistration { + let config = resolve(cwd, 'Caddyfile'); + let adapter: string | null = null; + for (let index = 1; index < argv.length; index++) { + if (['--config', '-c', '--adapter', '-a'].includes(argv[index]!)) { + const flag = argv[index]!; + const value = argv[++index]; + if (!value) throw new CadderError('invalid-input', `${flag} requires a value.`); + if (flag === '--config' || flag === '-c') config = resolve(cwd, value); + else adapter = value; + } + } + const nonce = randomUUID(); + const instanceId = randomUUID(); + const now = new Date().toISOString(); + return { + registrationId: instanceId, + entrypointInstance: { instanceId, startedAtUtc: now, shimSessionNonce: nonce }, + sourceWorkingDirectory: { raw: resolve(cwd), canonical: null }, + sourceConfigPath: { raw: config, canonical: null }, + registeredDomains: [], + activationState: 'active', + ownerProcess: { + processId: process.pid, + processStartTimeUtc: new Date(Date.now() - process.uptime() * 1000).toISOString(), + shimSessionNonce: nonce, + executablePath: process.execPath, + }, + logStream: { streamId: `entrypoint-${instanceId}`, domainKey: null, channel: 'caddy' }, + shimRun: { adapter, rawArguments: [...argv], commandLine: ['caddy', ...argv].join(' ') }, + createdAtUtc: now, + lastHeartbeatUtc: now, + }; +} + +export async function managedRun( + argv: readonly string[], + client = new ClientService(), + signal?: AbortSignal, +): Promise { + const abort = new AbortController(); + const stop = (): void => abort.abort(); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + signal?.addEventListener('abort', stop, { once: true }); + if (signal?.aborted) stop(); + const registration = buildRegistration(argv); + const nonce = registration.entrypointInstance.shimSessionNonce; + let id: string | null = null; + let cleanupFailure: unknown; + try { + if (abort.signal.aborted) return; + await client.start(); + if (abort.signal.aborted) return; + const register = async (): Promise => { + const reply = await rpc(client.paths, 'register-entrypoint-request', { registration }); + if (!reply.accepted || !reply.registrationId) + throw new CadderError('registration-rejected', reply.message); + return reply.registrationId; + }; + id = await register(); + let disconnectedAt: number | undefined; + while (!abort.signal.aborted) { + try { + await delay(5000, undefined, { signal: abort.signal }); + } catch { + break; + } + try { + const reply = await rpc(client.paths, 'heartbeat-entrypoint-request', { + registrationId: id, + shimSessionNonce: nonce, + }); + if (!reply.accepted) throw new CadderError('stale_session', reply.message); + disconnectedAt = undefined; + } catch (error) { + if (errorCode(error) === 'stale_session') { + id = await register(); + disconnectedAt = undefined; + } else if ( + ['daemon-unavailable', 'daemon_shutting_down'].includes(errorCode(error) ?? '') + ) { + disconnectedAt ??= Date.now(); + if (Date.now() - disconnectedAt >= 15000) + throw new CadderError( + 'daemon-unavailable', + 'Lost cadderd. Restart the daemon and run caddy run again.', + ); + } else throw error; + } + } + } finally { + process.off('SIGINT', stop); + process.off('SIGTERM', stop); + signal?.removeEventListener('abort', stop); + if (id) { + try { + await rpc(client.paths, 'unregister-entrypoint-request', { + registrationId: id, + shimSessionNonce: nonce, + }); + } catch (error) { + if ( + !['daemon-unavailable', 'daemon_shutting_down', 'stale_session'].includes( + errorCode(error) ?? '', + ) + ) + cleanupFailure = error; + } + } + } + if (cleanupFailure) throw cleanupFailure; +} + +export async function runShim(argv: readonly string[]): Promise { + if (argv.includes('--cadder-shim-info')) { + process.stdout.write( + JSON.stringify({ role: 'caddy-shim', version: VERSION, executable: process.execPath }) + '\n', + ); + return 0; + } + const policy = shimPolicy(argv); + if (policy === 'unsupported') + throw new CadderError( + 'unsupported-operation', + `Cadder shim does not support caddy ${argv[0] ?? ''}.`, + ); + if (policy === 'managed') { + await managedRun(argv); + return 0; + } + const resolver = new RealCaddyResolver(); + try { + const image = await resolver.pin(); + await image.verify(); + if (policy === 'inspection') + process.stderr.write('Output is real-Caddy inspection, not Cadder runtime state.\n'); + return await new Promise((resolveExit, reject) => { + const child = spawn(image.path, [...argv], { + stdio: 'inherit', + shell: false, + windowsHide: true, + }); + const interrupt = (): void => { + child.kill('SIGINT'); + }; + const terminate = (): void => { + child.kill('SIGTERM'); + }; + process.once('SIGINT', interrupt); + process.once('SIGTERM', terminate); + const cleanup = (): void => { + process.off('SIGINT', interrupt); + process.off('SIGTERM', terminate); + }; + child.once('error', (error) => { + cleanup(); + reject(error); + }); + child.once('exit', (code, signal) => { + cleanup(); + resolveExit(code ?? (signal === 'SIGINT' ? 130 : 1)); + }); + }); + } finally { + await resolver.close(); + } +} diff --git a/src/storage/database.ts b/src/storage/database.ts new file mode 100644 index 0000000..11912b8 --- /dev/null +++ b/src/storage/database.ts @@ -0,0 +1,120 @@ +import { DatabaseSync } from 'node:sqlite'; +import type { DesiredState } from '../contracts/ports.ts'; +import { logStreamSchema } from '../protocol/dto.ts'; +import { CadderError } from '../protocol/errors.ts'; +import { desiredStateSchema, logInputSchema, type LogInput, type StoredLog } from './schema.ts'; +import { redact } from './redaction.ts'; + +const tables = [ + 'CREATE TABLE desired (id INTEGER PRIMARY KEY CHECK (id = 1), body TEXT NOT NULL) STRICT', + 'CREATE TABLE logs (sequence INTEGER PRIMARY KEY AUTOINCREMENT, stream TEXT NOT NULL, body TEXT NOT NULL) STRICT', + 'CREATE INDEX logs_stream ON logs (stream, sequence)', +]; +function storageError(): CadderError { + return new CadderError('storage-failed', 'Application storage is unavailable or incompatible.'); +} +function streamKey(stream: unknown): string { + const value = logStreamSchema.parse(stream); + return JSON.stringify([value.streamId, value.domainKey, value.channel]); +} + +/** Only the storage worker opens this connection in production. */ +export class ApplicationDatabase { + readonly #database: DatabaseSync; + constructor(path: string) { + this.#database = new DatabaseSync(path, { timeout: 1000 }); + try { + const db = this.#database; + const integrity = db.prepare('PRAGMA quick_check').all(); + if (integrity.length !== 1 || integrity[0]?.quick_check !== 'ok') throw storageError(); + const version = db.prepare('PRAGMA user_version').get()?.user_version; + const objects = db + .prepare("SELECT sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY name") + .all(); + if (version === 0 && objects.length === 0) { + this.#transaction(() => { + for (const sql of tables) db.exec(sql); + db.prepare('INSERT INTO desired VALUES (1, ?)').run(JSON.stringify({ projects: [] })); + db.exec('PRAGMA user_version = 1'); + }); + } else { + if ( + version !== 1 || + objects.length !== tables.length || + objects.some((object) => !tables.includes(String(object.sql))) + ) + throw storageError(); + } + this.loadDesiredState(); + } catch (error) { + this.#database.close(); + throw error instanceof CadderError ? error : storageError(); + } + } + + loadDesiredState(): DesiredState { + const rows = this.#database + .prepare('SELECT body FROM desired WHERE length(CAST(body AS BLOB)) <= 1048576') + .all(); + if (rows.length !== 1 || typeof rows[0]?.body !== 'string') throw storageError(); + return desiredStateSchema.parse(JSON.parse(rows[0].body)); + } + + persistDesiredState(state: DesiredState): void { + const body = JSON.stringify(desiredStateSchema.parse(state)); + if (Buffer.byteLength(body) > 1024 * 1024) throw storageError(); + this.#transaction(() => + this.#database.prepare('UPDATE desired SET body = ? WHERE id = 1').run(body), + ); + } + + append(entries: readonly LogInput[]): void { + if (entries.length > 200) throw storageError(); + const values = entries.map((entry) => { + const parsed = logInputSchema.parse(entry); + return { ...parsed, rawMessage: redact(parsed.rawMessage).slice(0, 4096) }; + }); + this.#transaction(() => { + const insert = this.#database.prepare('INSERT INTO logs (stream, body) VALUES (?, ?)'); + for (const entry of values) insert.run(streamKey(entry.stream), JSON.stringify(entry)); + for (const key of new Set(values.map((entry) => streamKey(entry.stream)))) { + this.#database + .prepare( + 'DELETE FROM logs WHERE stream = ? AND sequence NOT IN (SELECT sequence FROM logs WHERE stream = ? ORDER BY sequence DESC LIMIT 1000)', + ) + .run(key, key); + } + this.#database.exec( + 'DELETE FROM logs WHERE sequence NOT IN (SELECT sequence FROM logs ORDER BY sequence DESC LIMIT 5000)', + ); + }); + } + + queryLogs(stream: unknown, limit: number): StoredLog[] { + if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw storageError(); + const rows = this.#database + .prepare( + 'SELECT sequence, body FROM (SELECT sequence, body FROM logs WHERE stream = ? ORDER BY sequence DESC LIMIT ?) ORDER BY sequence', + ) + .all(streamKey(stream), limit); + return rows.map((row) => ({ + ...logInputSchema.parse(JSON.parse(String(row.body))), + sequenceNumber: Number(row.sequence), + })); + } + + close(): void { + this.#database.close(); + } + + #transaction(operation: () => unknown): void { + this.#database.exec('BEGIN IMMEDIATE'); + try { + operation(); + this.#database.exec('COMMIT'); + } catch (error) { + this.#database.exec('ROLLBACK'); + throw error; + } + } +} diff --git a/src/storage/redaction.ts b/src/storage/redaction.ts new file mode 100644 index 0000000..f5cfb40 --- /dev/null +++ b/src/storage/redaction.ts @@ -0,0 +1,28 @@ +const marker = '[redacted]'; + +/** Run before a message crosses either persistence or presentation boundaries. */ +export function redact(message: string): string { + return message + .replace( + /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----|$)/gi, + marker, + ) + .replace( + /\b(authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^\r\n]*/gi, + '$1: ' + marker, + ) + .replace(/\bbearer\s+[^\s,;"']+/gi, 'Bearer ' + marker) + .replace( + /(["']?(?:access[_-]?token|refresh[_-]?token|api[_-]?token|token|password|passwd|secret|api[_-]?key)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s,;&}\]]+\]?)/gi, + '$1' + marker, + ); +} + +export function terminalSafe(message: string): string { + // Strip complete ANSI sequences before removing the remaining C0/C1 controls. + /* eslint-disable no-control-regex -- Terminal output must recognize and remove control bytes. */ + return message + .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\|$)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[\s\S]?/g, '') + .replace(/[\x00-\x1f\x7f-\x9f]/g, ''); +} +/* eslint-enable no-control-regex */ diff --git a/src/storage/schema.ts b/src/storage/schema.ts new file mode 100644 index 0000000..6fe7c33 --- /dev/null +++ b/src/storage/schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { logEntrySchema } from '../protocol/dto.ts'; + +export const desiredStateSchema = z.strictObject({ + projects: z.array( + z.strictObject({ + projectKey: z.string(), + sourceWorkingDirectory: z.string(), + sourceConfigPath: z.string(), + enabled: z.boolean(), + domains: z.array( + z.strictObject({ + canonicalDomain: z.string(), + upstream: z.string().nullable(), + enabled: z.boolean(), + }), + ), + }), + ), +}); +export type StoredLog = z.infer; +export const logInputSchema = logEntrySchema.omit({ sequenceNumber: true }); +export type LogInput = z.input; + +export const runtimeLogStream = Object.freeze({ + streamId: 'runtime-control', + domainKey: null, + channel: 'control', +}); diff --git a/src/storage/storage.ts b/src/storage/storage.ts new file mode 100644 index 0000000..1e13c1b --- /dev/null +++ b/src/storage/storage.ts @@ -0,0 +1,154 @@ +import { Worker } from 'node:worker_threads'; +import { getAsset, isSea } from 'node:sea'; +import { join } from 'node:path'; +import type { DesiredState, PortResult, RuntimeOwner, StoragePort } from '../contracts/ports.ts'; +import { + assertProtected, + assertRuntimeDescendant, + createProtectedFile, +} from '../platform/runtime-security.ts'; +import { errorCode, RpcError } from '../protocol/errors.ts'; +import type { LogInput, StoredLog } from './schema.ts'; +import { desiredStateSchema } from './schema.ts'; + +function failure(): PortResult { + return { + ok: false, + error: { + kind: 'storage', + code: 'storage_unavailable', + message: 'Application storage is unavailable.', + guidance: null, + retryable: false, + requestId: null, + }, + }; +} +type Reply = { id: number; ok: boolean; value?: unknown }; + +export class ApplicationStorage implements StoragePort { + #nextId = 1; + #pending = new Map< + number, + { resolve(value: unknown): void; reject(error: unknown): void; timer: NodeJS.Timeout } + >(); + #closed = false; + #closing: Promise | undefined; + readonly #exit: Promise; + private constructor(private readonly worker: Worker) { + this.#exit = new Promise((resolve) => worker.once('exit', resolve)); + worker.on('message', (reply: Reply) => { + const pending = this.#pending.get(reply.id); + if (!pending) return; + this.#pending.delete(reply.id); + clearTimeout(pending.timer); + if (reply.ok) pending.resolve(reply.value); + else pending.reject(this.#error()); + }); + worker.on('error', () => this.#fail()); + worker.on('exit', () => this.#fail()); + } + + static async open(directory: string, owner: RuntimeOwner): Promise { + await assertProtected(directory, owner, true); + const path = join(directory, 'runtime.sqlite3'); + await createProtectedFile(path, owner); + for (const suffix of ['-journal', '-wal', '-shm']) { + try { + await assertRuntimeDescendant(directory, path + suffix, owner); + if (suffix !== '-journal') throw new Error('Unsupported application journal mode.'); + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error; + } + } + const url = isSea() + ? new URL( + 'data:text/javascript,' + encodeURIComponent(getAsset('cadder-storage-worker', 'utf8')), + ) + : new URL(import.meta.url.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url); + const worker = new Worker(url, { workerData: { path } }); + const storage = new ApplicationStorage(worker); + try { + await storage.#wait(0); + await assertProtected(path, owner); + return storage; + } catch (error) { + await worker.terminate(); + throw error; + } + } + + async loadDesiredState(): Promise> { + try { + return { ok: true, value: desiredStateSchema.parse(await this.#job('load')) }; + } catch { + return failure(); + } + } + async persistDesiredState(state: DesiredState): Promise> { + try { + await this.#job('persist', desiredStateSchema.parse(state)); + return { ok: true, value: undefined }; + } catch { + return failure(); + } + } + async append(entries: readonly LogInput[]): Promise { + await this.#job('append', entries); + } + async queryLogs(stream: LogInput['stream'], limit: number): Promise { + return (await this.#job('logs', stream, limit)) as StoredLog[]; + } + close(): Promise { + this.#closing ??= (async () => { + if (this.#closed) { + await this.#exit; + return; + } + const closing = this.#job('close'); + this.#closed = true; + try { + await closing; + await this.#exit; + } catch (error) { + await this.worker.terminate(); + throw error; + } + })(); + return this.#closing; + } + #job(method: string, value?: unknown, limit?: number): Promise { + if (this.#closed || this.#pending.size >= 64) return Promise.reject(this.#error()); + const id = this.#nextId++; + const result = this.#wait(id); + try { + this.worker.postMessage({ id, method, value, limit }); + } catch { + this.#fail(); + void this.worker.terminate(); + } + return result; + } + #wait(id: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#fail(); + void this.worker.terminate(); + }, 5000); + this.#pending.set(id, { resolve, reject, timer }); + }); + } + #error(): RpcError { + const result = failure(); + if (result.ok) throw new Error('Unreachable.'); + return new RpcError(result.error); + } + #fail(): void { + this.#closed = true; + for (const pending of this.#pending.values()) { + clearTimeout(pending.timer); + pending.reject(this.#error()); + } + this.#pending.clear(); + } +} diff --git a/src/storage/worker.ts b/src/storage/worker.ts new file mode 100644 index 0000000..8fd0374 --- /dev/null +++ b/src/storage/worker.ts @@ -0,0 +1,35 @@ +import { parentPort, workerData } from 'node:worker_threads'; +import { ApplicationDatabase } from './database.ts'; +import type { DesiredState } from '../contracts/ports.ts'; +import type { LogInput } from './schema.ts'; + +const database = new ApplicationDatabase((workerData as { path: string }).path); +parentPort!.postMessage({ id: 0, ok: true }); +parentPort!.on('message', (job: { id: number; method: string; value: unknown; limit?: number }) => { + try { + let value: unknown; + switch (job.method) { + case 'load': + value = database.loadDesiredState(); + break; + case 'persist': + database.persistDesiredState(job.value as DesiredState); + break; + case 'append': + database.append(job.value as LogInput[]); + break; + case 'logs': + value = database.queryLogs(job.value, job.limit!); + break; + case 'close': + database.close(); + break; + default: + throw new Error('Unknown storage operation.'); + } + parentPort!.postMessage({ id: job.id, ok: true, value }); + if (job.method === 'close') parentPort!.close(); + } catch { + parentPort!.postMessage({ id: job.id, ok: false }); + } +}); diff --git a/src/tui/app.tsx b/src/tui/app.tsx new file mode 100644 index 0000000..c6215de --- /dev/null +++ b/src/tui/app.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Text, render, useApp, useInput, useWindowSize } from 'ink'; +import { basename } from 'node:path'; +import { ClientService } from '../client/service.ts'; +import type { StateSnapshot } from '../protocol/dto.ts'; +import { RpcError, errorCode } from '../protocol/errors.ts'; +import { terminalSafe } from '../storage/redaction.ts'; +import { VERSION } from '../protocol/version.ts'; + +export function RoutesApp({ client }: { client: ClientService }) { + const { exit } = useApp(); + const { rows: height } = useWindowSize(); + const [snapshot, setSnapshot] = useState(null); + const [offline, setOffline] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const busy = useRef(false); + const alive = useRef(true); + const [confirm, setConfirm] = useState(false); + const [selected, setSelected] = useState(0); + const refresh = useCallback(async () => { + try { + const state = await client.snapshot(); + if (alive.current) { + setSnapshot(state); + setOffline(false); + } + } catch (failure) { + if (alive.current) { + setOffline(errorCode(failure) === 'daemon-unavailable'); + setSnapshot(null); + if (errorCode(failure) !== 'daemon-unavailable') + setError( + 'Cannot read daemon state. Open cadder diagnostics after restoring the connection.', + ); + } + } + }, [client]); + useEffect(() => { + alive.current = true; + let refreshing = false; + const poll = (): void => { + if (!refreshing && !busy.current) { + refreshing = true; + void refresh().finally(() => { + refreshing = false; + }); + } + }; + poll(); + const timer = setInterval(poll, 1000); + return () => { + alive.current = false; + clearInterval(timer); + }; + }, [refresh]); + const rows = (snapshot?.registrations ?? []).flatMap((project) => [ + { + key: project.registrationId, + project, + domain: null, + label: basename(project.sourceWorkingDirectory.raw) || project.sourceWorkingDirectory.raw, + enabled: project.activationState === 'active', + }, + ...project.registeredDomains.map((domain, index) => ({ + key: `${project.registrationId}:${domain.name.canonical}`, + project, + domain, + label: `${index === project.registeredDomains.length - 1 ? '\u2514\u2500' : '\u251c\u2500'} ${domain.name.raw}${domain.upstream ? ' ' + domain.upstream : ''}`, + enabled: domain.activationState === 'active', + })), + ]); + const current = Math.min(selected, Math.max(0, rows.length - 1)); + const visible = Math.max(1, height - 7); + const offset = Math.max(0, current - visible + 1); + async function act(operation: () => Promise): Promise { + if (busy.current) return; + busy.current = true; + setPending(true); + setError(null); + try { + await operation(); + await refresh(); + } catch (failure) { + if (alive.current) + setError( + terminalSafe(failure instanceof Error ? failure.message : 'Action failed. Try again.'), + ); + } finally { + busy.current = false; + if (alive.current) setPending(false); + } + } + useInput((input, key) => { + if (input === 'q' || key.escape) { + if (confirm) setConfirm(false); + else exit(); + return; + } + if (busy.current) return; + if (confirm) { + if (input === 'y') { + setConfirm(false); + void act(() => client.stop()); + } else if (input === 'n') setConfirm(false); + return; + } + if (key.upArrow) setSelected(Math.max(0, current - 1)); + if (key.downArrow) setSelected(Math.max(0, Math.min(rows.length - 1, current + 1))); + if (input === 'r') { + void act(() => client.restart()); + return; + } + if (input === 's' && snapshot) { + setConfirm(true); + return; + } + if ((key.return || input === ' ') && offline) { + void act(() => client.start()); + return; + } + if ((key.return || input === ' ') && snapshot && rows[current]) { + const row = rows[current]; + void act(async () => { + const result = row.domain + ? await client.setDomainEnabled({ + registrationId: row.project.registrationId, + domainKey: row.domain.name.canonical, + enabled: !row.enabled, + }) + : await client.setProjectEnabled({ + registrationId: row.project.registrationId, + enabled: !row.enabled, + }); + if (!result.ok) throw new RpcError(result.error); + }); + } + }); + return ( + + + Cadder {VERSION} + + cadderd {snapshot ? 'online' : offline ? 'offline' : 'unavailable'} Caddy{' '} + {snapshot?.runtime.status ?? '-'} + + + + {offline ? ( + Press Enter to start cadderd. + ) : !snapshot ? ( + Connecting... + ) : rows.length === 0 ? ( + No projects registered. Run caddy run in your project. + ) : ( + rows.slice(offset, offset + visible).map((row, index) => ( + + {offset + index === current ? '> ' : ' '} + {row.enabled ? '\u25cf' : '\u25cb'} {terminalSafe(row.label)} + + )) + )} + + {confirm ? ( + Stop cadderd and its Caddy process? y / n + ) : pending ? ( + Working... + ) : error ? ( + + {error} + + ) : ( + + )} + + Enter / Space toggle s stop{' '} + r restart q quit + + + ); +} + +export async function openTui(client: ClientService): Promise { + let instance: ReturnType | undefined; + const stop = (): void => instance?.unmount(); + try { + instance = render(, { + exitOnCtrlC: true, + patchConsole: false, + alternateScreen: true, + }); + process.once('SIGTERM', stop); + await instance.waitUntilExit(); + } finally { + process.off('SIGTERM', stop); + instance?.unmount(); + } +} diff --git a/test/application-storage.test.ts b/test/application-storage.test.ts new file mode 100644 index 0000000..0cd703e --- /dev/null +++ b/test/application-storage.test.ts @@ -0,0 +1,185 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, expect, it } from 'vitest'; +import { ApplicationDatabase } from '../src/storage/database.ts'; +import { ApplicationStorage } from '../src/storage/storage.ts'; +import { runtimeLogStream, type LogInput } from '../src/storage/schema.ts'; +import { redact, terminalSafe } from '../src/storage/redaction.ts'; +import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.ts'; +import { normalizeRuntimeCreationOwner } from '../src/platform/runtime-creation-owner.ts'; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); +async function root(): Promise { + const path = await mkdtemp(join(tmpdir(), 'cadder-storage-')); + roots.push(path); + return path; +} +function log(rawMessage: string, streamId = 'runtime-control'): LogInput { + return { + timestampUtc: new Date().toISOString(), + severity: 'info', + stream: { ...runtimeLogStream, streamId }, + attributionKind: 'runtimeControl', + entryKind: 'normal', + rawMessage, + domainKey: null, + sourceRegistrationId: null, + sourceInstanceId: null, + operation: null, + }; +} + +it('persists only validated intent, closes and reopens without a live lease', async () => { + const path = join(await root(), 'state.sqlite3'); + let db = new ApplicationDatabase(path); + expect(db.loadDesiredState()).toEqual({ projects: [] }); + const desired = { + projects: [ + { + projectKey: 'project', + sourceWorkingDirectory: '/project', + sourceConfigPath: '/project/Caddyfile', + enabled: false, + domains: [{ canonicalDomain: 'app.localhost', upstream: null, enabled: false }], + }, + ], + }; + db.persistDesiredState(desired); + db.close(); + db = new ApplicationDatabase(path); + try { + expect(db.loadDesiredState()).toEqual(desired); + const invalid = { projects: [{ ...desired.projects[0]!, lease: 'forbidden' }] }; + expect(() => db.persistDesiredState(invalid)).toThrow(); + expect(db.loadDesiredState()).toEqual(desired); + } finally { + db.close(); + } +}); + +it('rejects partial, newer, foreign and corrupt databases without changing their bytes', async () => { + for (const sql of [ + 'CREATE TABLE unrelated (value TEXT)', + 'PRAGMA user_version = 2', + 'CREATE TABLE logs (body TEXT)', + ]) { + const path = join(await root(), 'invalid.sqlite3'); + const seed = new DatabaseSync(path); + seed.exec(sql); + seed.close(); + const before = await readFile(path); + expect(() => new ApplicationDatabase(path)).toThrow(); + expect(await readFile(path)).toEqual(before); + } + const path = join(await root(), 'corrupt.sqlite3'); + await writeFile(path, 'not a database'); + expect(() => new ApplicationDatabase(path)).toThrow(); + expect(await readFile(path, 'utf8')).toBe('not a database'); +}); + +it('rolls back a failed persistence transaction instead of accepting lost state', async () => { + const path = join(await root(), 'failure.sqlite3'); + const db = new ApplicationDatabase(path); + const contender = new DatabaseSync(path); + try { + contender.exec('BEGIN EXCLUSIVE'); + expect(() => db.persistDesiredState({ projects: [] })).toThrow(); + contender.exec('ROLLBACK'); + expect(db.loadDesiredState()).toEqual({ projects: [] }); + } finally { + contender.close(); + db.close(); + } +}); + +it('redacts before retaining logs and returns newest bounded rows in ascending stream order', async () => { + const path = join(await root(), 'logs.sqlite3'); + const db = new ApplicationDatabase(path); + try { + for (let batch = 0; batch < 6; batch++) + db.append(Array.from({ length: 200 }, (_, i) => log(`row=${batch * 200 + i} token=hidden`))); + const rows = db.queryLogs(runtimeLogStream, 200); + expect(rows).toHaveLength(200); + expect(rows[0]?.rawMessage).toBe('row=1000 token=[redacted]'); + expect(rows.at(-1)?.sequenceNumber).toBe(1200); + db.append([log('independent', 'other')]); + expect(db.queryLogs(runtimeLogStream, 1)[0]?.sequenceNumber).toBe(1200); + expect(() => db.queryLogs(runtimeLogStream, 201)).toThrow(); + expect(() => db.append(Array.from({ length: 201 }, () => log('too many')))).toThrow(); + const inspect = new DatabaseSync(path); + try { + expect( + inspect + .prepare('SELECT count(*) AS total FROM logs WHERE stream = ?') + .get(JSON.stringify(['runtime-control', null, 'control']))?.total, + ).toBe(1000); + expect( + inspect.prepare("SELECT count(*) AS total FROM logs WHERE body LIKE '%hidden%'").get() + ?.total, + ).toBe(0); + for (let stream = 0; stream < 6; stream++) + for (let batch = 0; batch < 5; batch++) + db.append(Array.from({ length: 200 }, () => log('bounded', String(stream)))); + expect(inspect.prepare('SELECT count(*) AS total FROM logs').get()?.total).toBe(5000); + } finally { + inspect.close(); + } + } finally { + db.close(); + } +}); + +it('uses a real protected worker connection, drains writes and rejects work after close', async () => { + const directory = join(await root(), 'runtime'); + const owner = await runtimeOwner(); + await normalizeRuntimeCreationOwner(owner); + await prepareRuntime(directory, owner); + let storage = await ApplicationStorage.open(directory, owner); + expect(await storage.loadDesiredState()).toEqual({ ok: true, value: { projects: [] } }); + expect(await storage.persistDesiredState({ projects: [] })).toEqual({ + ok: true, + value: undefined, + }); + await storage.append([log('password=secret-value')]); + expect((await storage.queryLogs(runtimeLogStream, 10))[0]?.rawMessage).toBe( + 'password=[redacted]', + ); + await storage.close(); + await storage.close(); + expect((await storage.loadDesiredState()).ok).toBe(false); + await expect(storage.append([log('late')])).rejects.toThrow(); + storage = await ApplicationStorage.open(directory, owner); + try { + expect(await storage.queryLogs(runtimeLogStream, 1)).toHaveLength(1); + } finally { + await storage.close(); + } +}, 30000); + +it('removes complete credentials in supported forms and keeps terminal controls inert', () => { + const messages = [ + 'Authorization: Bearer hidden other words', + 'cookie: session=hidden', + 'Bearer hidden', + 'token=hidden', + 'access-token="hidden with spaces"', + 'api_token=hidden', + '{"password":"hidden with \\"quotes\\""}', + "'secret': 'hidden with spaces'", + '/?token=hidden&safe=ok', + '-----BEGIN RSA PRIVATE KEY-----\nhidden\n-----END RSA PRIVATE KEY-----', + '-----BEGIN PRIVATE KEY-----\nhidden', + ]; + for (const input of messages) { + const output = redact(input); + expect(output, input).not.toContain('hidden'); + expect(output).toContain('[redacted]'); + expect(redact(output)).toBe(output); + } + expect(terminalSafe('ok\x1b[31mred\x1b[0m\x00\x9b\r\n')).toBe('okred'); +}); diff --git a/test/authentication.test.ts b/test/authentication.test.ts new file mode 100644 index 0000000..db96371 --- /dev/null +++ b/test/authentication.test.ts @@ -0,0 +1,201 @@ +import { createServer, createConnection } from 'node:net'; +import { createHmac, randomBytes } from 'node:crypto'; +import { once } from 'node:events'; +import { expect, it } from 'vitest'; +import { JsonChannel } from '../src/protocol/channel.ts'; +import { authenticateClient, authenticateServer } from '../src/protocol/authentication.ts'; +import { fakeStateResult } from './fixtures/rpc-data.ts'; +import { MAX_FRAME_BYTES } from '../src/protocol/version.ts'; + +const request = { + protocolVersion: 3, + requestId: '00000000-0000-4000-8000-000000000001', + method: 'query-state-request', + params: {}, +}; + +async function channels() { + const server = createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing fixture port'); + const connected = once(server, 'connection'); + const socket = createConnection(address.port, '127.0.0.1'); + const [accepted] = await connected; + const client = new JsonChannel(socket); + const daemon = new JsonChannel(accepted); + return { + client, + daemon, + close() { + client.close(); + daemon.close(); + server.close(); + }, + }; +} + +it('authenticates both directions without sending the secret, then protects RPC frames', async () => { + const pair = await channels(); + const secret = randomBytes(32); + try { + const [client, daemon] = await Promise.all([ + authenticateClient(pair.client, secret, 'fixture'), + authenticateServer(pair.daemon, secret, 'fixture'), + ]); + client.send(request); + expect(await daemon.receive()).toEqual(request); + const response = { + protocolVersion: 3, + requestId: request.requestId, + result: { ...fakeStateResult(), requestId: request.requestId }, + }; + daemon.send(response); + expect(await client.receive()).toEqual(response); + client.close(); + daemon.close(); + } finally { + pair.close(); + } +}); + +it.each(['client', 'server'] as const)( + 'keeps the %s send sequence when an oversized authenticated frame is rejected', + async (role) => { + const pair = await channels(); + const secret = randomBytes(32); + try { + const [client, daemon] = await Promise.all([ + authenticateClient(pair.client, secret, 'fixture'), + authenticateServer(pair.daemon, secret, 'fixture'), + ]); + const sender = role === 'client' ? client : daemon; + const receiver = role === 'client' ? daemon : client; + expect(() => sender.send({ message: 'x'.repeat(MAX_FRAME_BYTES + 1) })).toThrow( + 'IPC frame exceeds the size limit.', + ); + sender.send(request); + // Real authenticated receives enforce sequence 0 for fallback and 1 for the next send. + expect(await receiver.receive()).toEqual(request); + expect(() => sender.send({ message: 'x'.repeat(MAX_FRAME_BYTES + 1) })).toThrow( + 'IPC frame exceeds the size limit.', + ); + sender.send(request); + expect(await receiver.receive()).toEqual(request); + } finally { + pair.close(); + } + }, +); + +it('rejects a fake endpoint before sending proof or RPC', async () => { + const pair = await channels(); + try { + const result = authenticateClient(pair.client, randomBytes(32), 'fixture'); + await pair.daemon.next(); + pair.daemon.send({ type: 'challenge', nonce: 'a'.repeat(64), proof: '0'.repeat(64) }); + await expect(result).rejects.toThrow('authentication failed'); + } finally { + pair.close(); + } +}); + +it('rejects an RPC before the handshake', async () => { + const pair = await channels(); + try { + const result = authenticateServer(pair.daemon, randomBytes(32), 'fixture'); + pair.client.send({ ...request, method: 'shutdown-daemon-request' }); + await expect(result).rejects.toThrow(); + } finally { + pair.close(); + } +}); + +it('rejects a wrong client secret and direction-reflected proofs', async () => { + const pair = await channels(); + try { + const result = authenticateServer(pair.daemon, randomBytes(32), 'fixture'); + pair.client.send({ + type: 'hello', + nonce: 'b'.repeat(64), + protocolVersion: 3, + policyVersion: 2, + }); + const challenge = (await pair.client.next()) as { proof: string }; + pair.client.send({ type: 'proof', proof: challenge.proof }); + await expect(result).rejects.toThrow('authentication failed'); + } finally { + pair.close(); + } +}); + +it('rejects replayed proofs even if the client challenge is reused', async () => { + const secret = randomBytes(32); + const clientNonce = 'b'.repeat(64); + const first = await channels(); + let oldProof: string; + try { + const authenticated = authenticateServer(first.daemon, secret, 'fixture'); + first.client.send({ type: 'hello', nonce: clientNonce, protocolVersion: 3, policyVersion: 2 }); + const challenge = (await first.client.next()) as { nonce: string }; + const context = JSON.stringify(['cadder-ipc', 3, 2, 'fixture', clientNonce, challenge.nonce]); + oldProof = createHmac('sha256', secret).update(`client:${context}`).digest('hex'); + first.client.send({ type: 'proof', proof: oldProof }); + await first.client.next(); + await authenticated; + } finally { + first.close(); + } + const second = await channels(); + try { + const authenticated = authenticateServer(second.daemon, secret, 'fixture'); + second.client.send({ type: 'hello', nonce: clientNonce, protocolVersion: 3, policyVersion: 2 }); + await second.client.next(); + second.client.send({ type: 'proof', proof: oldProof }); + await expect(authenticated).rejects.toThrow('authentication failed'); + } finally { + second.close(); + } +}); + +it('rejects replayed authenticated RPC frames', async () => { + const pair = await channels(); + const secret = randomBytes(32); + try { + const [client, daemon] = await Promise.all([ + authenticateClient(pair.client, secret, 'fixture'), + authenticateServer(pair.daemon, secret, 'fixture'), + ]); + let captured: Buffer = Buffer.alloc(0); + pair.daemon.socket.on('data', (data: Buffer) => { + captured = data; + }); + client.send(request); + await daemon.receive(); + pair.client.socket.write(captured); + await expect(daemon.receive()).rejects.toThrow('replayed IPC frame'); + } finally { + pair.close(); + } +}); + +it('rejects modified RPC payloads', async () => { + const pair = await channels(); + const secret = randomBytes(32); + try { + const [client, daemon] = await Promise.all([ + authenticateClient(pair.client, secret, 'fixture'), + authenticateServer(pair.daemon, secret, 'fixture'), + ]); + pair.client.send({ + sequence: 0, + payload: JSON.stringify({ ...request, method: 'shutdown-daemon-request' }), + mac: '0'.repeat(64), + }); + await expect(daemon.receive()).rejects.toThrow('authentication failed'); + client.close(); + } finally { + pair.close(); + } +}); diff --git a/test/caddy-admin-client.test.ts b/test/caddy-admin-client.test.ts new file mode 100644 index 0000000..8d60753 --- /dev/null +++ b/test/caddy-admin-client.test.ts @@ -0,0 +1,685 @@ +import 'reflect-metadata'; +import { webcrypto } from 'node:crypto'; +import { createServer, request } from 'node:https'; +import { createServer as createTcpServer, type Server } from 'node:net'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Duplex } from 'node:stream'; +import { + BasicConstraintsExtension, + ExtendedKeyUsage, + ExtendedKeyUsageExtension, + KeyUsageFlags, + KeyUsagesExtension, + PemConverter, + SubjectAlternativeNameExtension, + X509CertificateGenerator, +} from '@peculiar/x509'; +import { afterEach, beforeAll, expect, it, vi } from 'vitest'; +import { CaddyAdminClient } from '../src/caddy/admin-client.ts'; +import { normalizeCaddyConfig, type SecureAdminPolicy } from '../src/caddy/admin-policy.ts'; +import type { CaddyConfig } from '../src/contracts/ports.ts'; +import { ConfigurationTransactions } from '../src/daemon/configuration-transactions.ts'; +import { configHash } from '../src/caddy/preparation.ts'; + +vi.mock('node:https', async (original) => { + const actual = await original(); + return { ...actual, request: vi.fn(actual.request) }; +}); + +const crypto = webcrypto as unknown as Crypto; +const algorithm = { name: 'ECDSA', namedCurve: 'P-256' }; +const signingAlgorithm = { name: 'ECDSA', hash: 'SHA-256' }; +type Identity = { cert: string; key: string; keys: CryptoKeyPair }; +type Handler = (req: IncomingMessage, res: ServerResponse) => void; +let root: Identity; +let foreignRoot: Identity; +let client: Identity; +let foreignClient: Identity; +let serverIdentity: Identity; +let wrongName: Identity; +let expired: Identity; +const servers: Server[] = []; +const sockets = new Set(); +const clients: CaddyAdminClient[] = []; +const timers = new Set>(); + +async function issue( + role: 'root' | 'client' | 'server', + issuer?: Identity, + name = 'localhost', + old = false, +): Promise { + const keys = await crypto.subtle.generateKey(algorithm, true, ['sign', 'verify']); + const certificate = await X509CertificateGenerator.create( + { + subject: `CN=${role === 'root' ? 'Fixture Root' : name}`, + issuer: 'CN=Fixture Root', + publicKey: keys.publicKey, + signingKey: issuer?.keys.privateKey ?? keys.privateKey, + signingAlgorithm, + notBefore: new Date(Date.now() - 60_000), + notAfter: new Date(Date.now() + (old ? -1_000 : 3_600_000)), + extensions: [ + new BasicConstraintsExtension(role === 'root', role === 'root' ? 1 : undefined, true), + new KeyUsagesExtension( + role === 'root' ? KeyUsageFlags.keyCertSign : KeyUsageFlags.digitalSignature, + true, + ), + ...(role === 'root' + ? [] + : [ + new ExtendedKeyUsageExtension( + [role === 'client' ? ExtendedKeyUsage.clientAuth : ExtendedKeyUsage.serverAuth], + true, + ), + ]), + ...(role === 'server' + ? [new SubjectAlternativeNameExtension([{ type: 'dns', value: name }])] + : []), + ], + }, + crypto, + ); + return { + cert: certificate.toString('pem'), + key: PemConverter.encode( + await crypto.subtle.exportKey('pkcs8', keys.privateKey), + 'PRIVATE KEY', + ), + keys, + }; +} + +beforeAll(async () => { + root = await issue('root'); + foreignRoot = await issue('root'); + client = await issue('client', root); + foreignClient = await issue('client', foreignRoot); + serverIdentity = await issue('server', root); + wrongName = await issue('server', root, 'other.localhost'); + expired = await issue('server', root, 'localhost', true); +}); +afterEach(async () => { + vi.mocked(request).mockClear(); + for (const client of clients.splice(0)) client.close(); + for (const timer of timers) clearInterval(timer); + timers.clear(); + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await Promise.all( + servers + .splice(0) + .map( + (server) => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + ), + ); +}); + +function candidate(tag = 'initial'): CaddyConfig { + return normalizeCaddyConfig( + JSON.stringify({ + admin: { + disabled: true, + remote: { listen: 'fixture', access_control: [{ public_keys: ['authorized'] }] }, + }, + apps: { + pki: { certificate_authorities: { local: { install_trust: false } } }, + http: { opaque: [tag, { z: 1, a: 2 }] }, + }, + }), + ); +} +async function fixture( + handler: Handler, + identity = serverIdentity, +): Promise> { + const server = createServer( + { + key: identity.key, + cert: identity.cert, + ca: root.cert, + requestCert: true, + rejectUnauthorized: true, + }, + handler, + ); + servers.push(server); + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (typeof address !== 'object' || address === null) throw new Error('Fixture address missing'); + return { + config: candidate(), + client: { + host: '127.0.0.1', + port: address.port, + servername: 'localhost', + rejectUnauthorized: true, + ca: root.cert, + cert: client.cert, + key: client.key, + }, + }; +} +function makeClient( + policy: Pick, + options: ConstructorParameters[1] = {}, +): CaddyAdminClient { + const value = new CaddyAdminClient(policy, options); + clients.push(value); + return value; +} +async function unavailable(value: CaddyAdminClient): Promise { + const result = await value.readActive(); + expect(result).toMatchObject({ + ok: false, + error: { code: 'caddy_admin_readback_failed', retryable: false }, + }); + expect(JSON.stringify(result)).not.toMatch( + /PRIVATE KEY|CERTIFICATE|SECRET|localhost|127\.0\.0\.1/, + ); +} + +it('uses authorized HTTPS, POST /load complete canonical JSON and independent GET /config/', async () => { + let observedBody = ''; + let active = candidate('actually changed'); + const calls: string[] = []; + const policy = await fixture((req, res) => { + calls.push(`${req.method} ${req.url}`); + if (req.method === 'POST') { + expect(req.headers['content-type']).toBe('application/json'); + req.setEncoding('utf8'); + req.on('data', (chunk: string) => { + observedBody += chunk; + }); + req.on('end', () => res.end()); + } else res.end(` \n${active.adaptedConfig.body}\n`); + }); + const value = makeClient(policy); + expect(await value.apply(policy.config)).toEqual({ status: 'applied' }); + expect(observedBody).toBe(policy.config.adaptedConfig.body); + expect(await value.readActive()).toEqual({ ok: true, value: active.effectiveConfigHash }); + active = candidate('second independent change'); + expect(await value.readActive()).toEqual({ ok: true, value: active.effectiveConfigHash }); + expect(calls).toEqual(['POST /load', 'GET /config/', 'GET /config/']); +}); + +it('verified POST /stop is bounded, cancellation-aware and only a receipt', async () => { + const calls: string[] = []; + const policy = await fixture((req, res) => { + calls.push(`${req.method} ${req.url}`); + res.end(); + }); + const value = makeClient(policy); + expect(await value.stop()).toEqual({ status: 'applied' }); + expect(calls).toEqual(['POST /stop']); + expect(await value.stop(AbortSignal.abort())).toEqual({ status: 'ambiguous' }); + expect(calls).toHaveLength(1); + const stalled = makeClient(await fixture(() => undefined), { timeoutMs: 50 }); + expect(await stalled.stop()).toEqual({ status: 'ambiguous' }); + const abort = new AbortController(); + const pending = stalled.readActive(abort.signal); + abort.abort(); + expect((await pending).ok).toBe(false); +}); + +it('stop retains normal HTTPS authentication denial', async () => { + let requests = 0; + const policy = await fixture((_req, res) => { + requests++; + res.end(); + }, wrongName); + expect(await makeClient(policy).stop()).toEqual({ status: 'ambiguous' }); + expect(requests).toBe(0); +}); + +it.each(['root', 'name', 'client', 'expired'] as const)( + 'denies wrong %s via actual TLS without diagnostics leakage', + async (kind) => { + let requests = 0; + let identity = serverIdentity; + if (kind === 'name') identity = wrongName; + if (kind === 'expired') identity = expired; + const policy = await fixture((_req, res) => { + requests++; + res.end('{}'); + }, identity); + const tls = { ...policy.client }; + if (kind === 'root') tls.ca = foreignRoot.cert; + if (kind === 'client') { + tls.cert = foreignClient.cert; + tls.key = foreignClient.key; + } + const value = makeClient({ ...policy, client: tls }); + expect(await value.apply(policy.config)).toEqual({ status: 'ambiguous' }); + await unavailable(value); + expect(requests).toBe(0); + }, +); + +it('refuses noncanonical, invalid, mismatched and changed security candidates before network', async () => { + let requests = 0; + const policy = await fixture((_req, res) => { + requests++; + res.end(); + }); + const value = makeClient(policy); + const changed = JSON.parse(policy.config.adaptedConfig.body) as { + admin: { disabled: boolean }; + apps: { pki: unknown }; + }; + const bad: unknown[] = [ + null, + {}, + { ...policy.config, effectiveConfigHash: 'incorrect' }, + normalizeCaddyConfig('{}'), + ]; + const spaced = ` ${policy.config.adaptedConfig.body}`; + const normalized = normalizeCaddyConfig(spaced); + // Matching raw hash is still not a canonical candidate. + bad.push({ + ...normalized, + adaptedConfig: { format: 'json', body: spaced }, + effectiveConfigHash: configHash(spaced), + }); + changed.admin.disabled = false; + bad.push(normalizeCaddyConfig(JSON.stringify(changed))); + changed.admin.disabled = true; + changed.apps.pki = { certificate_authorities: { local: { install_trust: true } } }; + bad.push(normalizeCaddyConfig(JSON.stringify(changed))); + changed.apps.pki = { + certificate_authorities: { 'cadder-admin': { root: { certificate: 'foreign CA' } } }, + }; + bad.push(normalizeCaddyConfig(JSON.stringify(changed))); + const foreignAuthorization = JSON.parse(policy.config.adaptedConfig.body) as { + admin: { remote: { access_control: unknown } }; + }; + foreignAuthorization.admin.remote.access_control = [{ public_keys: ['foreign client'] }]; + bad.push(normalizeCaddyConfig(JSON.stringify(foreignAuthorization))); + for (const config of bad) + expect(await value.apply(config as CaddyConfig)).toEqual({ status: 'definitely-rejected' }); + expect(requests).toBe(0); +}); + +it('explicitly refuses idle without sending an HTTPS request', async () => { + const policy = await fixture((_req, res) => res.end()); + const value = makeClient(policy); + expect(await value.apply(null as never)).toEqual({ status: 'definitely-rejected' }); + expect(vi.mocked(request)).not.toHaveBeenCalled(); +}); + +it('isolates the explicit credentials and baseline from caller mutation', async () => { + const policy = await fixture((_req, res) => res.end(candidate().adaptedConfig.body)); + const mutable = { config: policy.config, client: { ...policy.client } }; + const value = makeClient(mutable); + mutable.client.ca = 'SECRET bad root'; + mutable.client.port = 2019; + mutable.config = normalizeCaddyConfig('{}'); + expect(await value.apply(policy.config)).toEqual({ status: 'applied' }); + expect(await value.readActive()).toEqual({ ok: true, value: policy.config.effectiveConfigHash }); +}); + +it.each([200, 204, 299, 400, 301, 307, 401, 403, 404, 500, 503])( + 'classifies completed HTTP %i without redirects/retries', + async (status) => { + let calls = 0; + const policy = await fixture((_req, res) => { + calls++; + res.writeHead(status, { Location: 'http://127.0.0.1:2019/', 'Content-Type': 'text/plain' }); + res.end('SECRET Caddy error /private/path'); + }); + const value = makeClient(policy); + let expected = 'ambiguous'; + if (status >= 200 && status < 300) expected = 'applied'; + if (status === 400) expected = 'definitely-rejected'; + expect(await value.apply(policy.config)).toEqual({ status: expected }); + expect(calls).toBe(1); + }, +); + +it.each([204, 301, 400, 403, 500])( + 'readback HTTP %i is unavailable, not idle or a receipt', + async (status) => { + const policy = await fixture((_req, res) => { + res.writeHead(status); + res.end('{}'); + }); + await unavailable(makeClient(policy)); + }, +); + +it.each(['truncated', 'array', 'null', 'text', 'utf8', 'empty', 'bom'])( + 'refuses invalid readback %s, never substitutes cached state', + async (kind) => { + const bodies: Record = { + truncated: '{', + array: '[]', + null: 'null', + text: 'SECRET exception', + utf8: Buffer.from([0xc3, 0x28]), + empty: '', + bom: '\ufeff{}', + }; + const policy = await fixture((_req, res) => res.end(bodies[kind])); + await unavailable(makeClient(policy)); + }, +); + +it.each([ + 'declared', + 'chunked', + 'headers', + 'encoding', + 'framing', + 'invalid-header', + 'invalid-utf8', +])('bounds and validates %s replies for both operations', async (kind) => { + const policy = await fixture((_req, res) => { + if (kind === 'declared') { + res.writeHead(200, { 'Content-Length': 1024 }); + res.flushHeaders(); + } else if (kind === 'chunked') { + res.write('{}'); + res.end('x'.repeat(40)); + } else if (kind === 'headers') { + res.setHeader('X-Big', 'x'.repeat(1024)); + res.end('{}'); + } else if (kind === 'encoding') { + res.setHeader('Content-Encoding', 'gzip'); + res.end('{}'); + } else if (kind === 'invalid-utf8') res.end(Buffer.from([0xff])); + else if (kind === 'invalid-header') { + res.socket!.end('HTTP/1.1 200 OK\r\nContent-Length: nope\r\n\r\n{}'); + } else { + res.writeHead(400, { 'Content-Length': 10 }); + res.write('{}'); + res.socket!.end(); + } + }); + const value = makeClient(policy, { maxResponseBytes: 32, maxHeaderBytes: 512, timeoutMs: 1000 }); + expect(await value.apply(policy.config)).toEqual({ status: 'ambiguous' }); + await unavailable(value); +}); + +it('uses an absolute body deadline despite slow continuous chunks and never retries a POST', async () => { + let calls = 0; + const policy = await fixture((_req, res) => { + calls++; + res.writeHead(200); + res.write('{'); + const timer = setInterval(() => res.write(' '), 10); + timers.add(timer); + res.on('close', () => { + clearInterval(timer); + timers.delete(timer); + }); + }); + const value = makeClient(policy, { timeoutMs: 120 }); + const start = performance.now(); + expect(await value.apply(policy.config)).toEqual({ status: 'ambiguous' }); + expect(performance.now() - start).toBeLessThan(1000); + expect(calls).toBe(1); +}); + +it('absolute deadline aborts a stalled TLS handshake and destroys the owned socket', async () => { + let clientHelloBytes = 0; + const server = createTcpServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.on('data', (bytes: Buffer) => { + clientHelloBytes += bytes.length; + }); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing fixture port'); + const value = makeClient( + { + config: candidate(), + client: { + host: '127.0.0.1', + port: address.port, + servername: 'localhost', + rejectUnauthorized: true, + ca: root.cert, + cert: client.cert, + key: client.key, + }, + }, + { timeoutMs: 100 }, + ); + expect(await value.apply(candidate())).toEqual({ status: 'ambiguous' }); + expect(clientHelloBytes).toBeGreaterThan(0); + expect(vi.mocked(request)).toHaveBeenCalledTimes(1); + // Owned request destruction closes the peer; no fixture teardown needed to trigger it. + await vi.waitFor(() => expect(sockets.size).toBe(0)); +}); + +it('accepts the exact byte bound and UTF-8 characters split across chunks', async () => { + const body = '{"emoji":"😀"}'; + const bytes = Buffer.from(body); + const policy = await fixture((_req, res) => { + res.write(bytes.subarray(0, 11)); + setImmediate(() => res.end(bytes.subarray(11))); + }); + const value = makeClient(policy, { maxResponseBytes: bytes.length }); + expect(await value.readActive()).toEqual({ + ok: true, + value: normalizeCaddyConfig(body).effectiveConfigHash, + }); +}); + +it('close aborts in-flight work, settles once and blocks new admissions', async () => { + let entered!: () => void; + const entry = new Promise((resolve) => { + entered = resolve; + }); + let calls = 0; + const policy = await fixture((_req, res) => { + calls++; + res.writeHead(200); + res.write(' '); + entered(); + }); + const value = makeClient(policy); + const pending = value.apply(policy.config); + await entry; + value.close(); + value.close(); + expect(await pending).toEqual({ status: 'ambiguous' }); + expect(await value.apply(policy.config)).toEqual({ status: 'definitely-rejected' }); + await unavailable(value); + expect(calls).toBe(1); +}); + +it.each([ + 'port', + 'host', + 'servername', + 'verify', + 'ca', + 'cert', + 'key', + 'config', + 'missing-envelope', + 'timeout', + 'response-bound', + 'header-bound', +] as const)('rejects invalid constructor %s with bounded secret-free errors', async (kind) => { + const policy = await fixture((_req, res) => res.end('{}')); + const mutable = { config: policy.config, client: { ...policy.client } }; + const options: { timeoutMs?: number; maxResponseBytes?: number; maxHeaderBytes?: number } = {}; + if (kind === 'port') mutable.client.port = 1.5; + if (kind === 'host') Object.assign(mutable.client, { host: 'SECRET.example' }); + if (kind === 'servername') Object.assign(mutable.client, { servername: 'SECRET.example' }); + if (kind === 'verify') Object.assign(mutable.client, { rejectUnauthorized: false }); + if (kind === 'ca' || kind === 'cert' || kind === 'key') mutable.client[kind] = ''; + if (kind === 'config') mutable.config = { ...policy.config, effectiveConfigHash: 'SECRET' }; + if (kind === 'missing-envelope') mutable.config = normalizeCaddyConfig('{}'); + if (kind === 'timeout') options.timeoutMs = 0; + if (kind === 'response-bound') options.maxResponseBytes = 32 * 1024 * 1024 + 1; + if (kind === 'header-bound') options.maxHeaderBytes = Infinity; + expect(() => new CaddyAdminClient(mutable, options)).toThrow( + 'Invalid prepared administration policy or client bounds.', + ); + expect(vi.mocked(request)).not.toHaveBeenCalled(); +}); + +it('unexpected HTTPS throws are ambiguous, sanitized and never retried', async () => { + const policy = await fixture((_req, res) => res.end('{}')); + const value = makeClient(policy); + vi.mocked(request).mockImplementationOnce(() => { + throw new Error('SECRET /private/key.pem'); + }); + expect(await value.apply(policy.config)).toEqual({ status: 'ambiguous' }); + expect(vi.mocked(request)).toHaveBeenCalledTimes(1); + vi.mocked(request).mockImplementationOnce(() => { + throw new Error('SECRET /private/key.pem'); + }); + await unavailable(value); + expect(vi.mocked(request)).toHaveBeenCalledTimes(2); +}); + +it.each(['disconnect', 'no-headers'])('handles %s without proving no transition', async (kind) => { + const policy = await fixture((req) => { + if (kind === 'disconnect') req.socket.destroy(); + }); + const value = makeClient(policy, { timeoutMs: 100 }); + expect(await value.apply(policy.config)).toEqual({ status: 'ambiguous' }); + await unavailable(value); +}); + +it('normalizes actual readback with lexical numeric keys, arrays and opaque data intact', async () => { + const actual = + '{"z":[3,1,{"2":"b","10":"a"}],"admin":{"unknown":true},"a":{"unknown_module":{"payload":[null,false]}}}'; + const policy = await fixture((_req, res) => res.end(actual)); + const value = makeClient(policy); + expect(await value.readActive()).toEqual({ + ok: true, + value: normalizeCaddyConfig(actual).effectiveConfigHash, + }); + expect(normalizeCaddyConfig(actual).adaptedConfig.body).toContain('"10":"a","2":"b"'); +}); + +it('preserves late POST uncertainty even if a delayed response attempts to succeed', async () => { + let reply!: ServerResponse; + let entered!: () => void; + const entry = new Promise((resolve) => { + entered = resolve; + }); + let calls = 0; + const policy = await fixture((_req, res) => { + calls++; + reply = res; + entered(); + }); + const value = makeClient(policy, { timeoutMs: 100 }); + const operation = value.apply(policy.config); + await entry; + expect(await operation).toEqual({ status: 'ambiguous' }); + reply.end(); + expect(await operation).toEqual({ status: 'ambiguous' }); + expect(calls).toBe(1); +}); + +it('queue restores and independently verifies the committed configuration after persist failure', async () => { + let active = candidate(); + const applied: string[] = []; + const policy = await fixture((req, res) => { + if (req.method !== 'POST') { + res.end(active.adaptedConfig.body); + return; + } + let text = ''; + req.setEncoding('utf8'); + req.on('data', (chunk: string) => { + text += chunk; + }); + req.on('end', () => { + active = normalizeCaddyConfig(text); + applied.push(active.effectiveConfigHash); + res.end(); + }); + }); + const value = makeClient(policy); + const initial = { config: policy.config, desiredState: { projects: [] }, registrations: [] }; + const next = candidate('new'); + const queue = new ConfigurationTransactions( + initial, + { + validate: async (config) => ({ + ok: true, + value: { effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }, + }), + }, + { + persistDesiredState: async () => ({ + ok: false, + error: { + kind: 'storage', + code: 'failed', + message: 'failed', + guidance: null, + retryable: false, + requestId: null, + }, + }), + }, + { + apply: async (config) => (config === null ? { status: 'ambiguous' } : value.apply(config)), + readActive: () => value.readActive(), + }, + ); + expect( + await queue.submit(() => ({ ok: true, value: { ...initial, config: next } })), + ).toMatchObject({ ok: false, error: { code: 'config_persist_failed' } }); + expect(applied).toEqual([next.effectiveConfigHash, policy.config.effectiveConfigHash]); + expect(queue.readFence()).toBeNull(); + expect(queue.readCommitted()).toEqual(initial); + await queue.close(); +}); + +it('fences queue publication on independently changed readback', async () => { + const policy = await fixture((_req, res) => + res.end(candidate('unexpected native difference').adaptedConfig.body), + ); + const value = makeClient(policy); + let persisted = 0; + const initial = { config: policy.config, desiredState: { projects: [] }, registrations: [] }; + const queue = new ConfigurationTransactions( + initial, + { + validate: async (config) => ({ + ok: true, + value: { effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }, + }), + }, + { + persistDesiredState: async () => { + persisted++; + return { ok: true, value: undefined }; + }, + }, + { + apply: async (config) => (config === null ? { status: 'ambiguous' } : value.apply(config)), + readActive: () => value.readActive(), + }, + ); + expect( + await queue.submit(() => ({ ok: true, value: { ...initial, config: candidate('submitted') } })), + ).toMatchObject({ ok: false, error: { code: 'config_verification_failed' } }); + expect(queue.readFence()?.code).toBe('config_verification_failed'); + expect(persisted).toBe(0); + expect(queue.readCommitted()).toEqual(initial); + await queue.close(); +}); diff --git a/test/caddy-admin-policy.test.ts b/test/caddy-admin-policy.test.ts new file mode 100644 index 0000000..e9997c5 --- /dev/null +++ b/test/caddy-admin-policy.test.ts @@ -0,0 +1,773 @@ +import { X509Certificate } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + normalizeCaddyConfig, + configForRoutePlan, + prepareSecureAdminPolicy, + type SecureAdminPolicy, + type SecureAdminPolicyOptions, +} from '../src/caddy/admin-policy.ts'; +import { loadOrCreateAdminMaterial, type AdminMaterial } from '../src/caddy/certificates.ts'; +import { composeRoutePlan, type CaddyRoutePlan } from '../src/caddy/composition.ts'; +import type { CompositionRegistration, JsonObject } from '../src/caddy/domains.ts'; +import { + assertCandidate, + configHash, + maxCaddyConfigurationBytes, +} from '../src/caddy/preparation.ts'; +import * as security from '../src/platform/runtime-security.ts'; +import { powershell } from '../src/platform/powershell.ts'; +import { protocolErrorSchema } from '../src/protocol/errors.ts'; + +vi.mock('../src/platform/runtime-security.ts', async (original) => { + const actual = await original(); + return { + ...actual, + prepareRuntime: vi.fn(actual.prepareRuntime), + assertProtected: vi.fn(actual.assertProtected), + }; +}); +vi.mock('../src/caddy/certificates.ts', async (original) => { + const actual = await original(); + return { ...actual, loadOrCreateAdminMaterial: vi.fn(actual.loadOrCreateAdminMaterial) }; +}); +vi.mock('node:fs/promises', async (original) => { + const actual = await original(); + return { ...actual, lstat: vi.fn(actual.lstat) }; +}); +vi.mock('../src/platform/powershell.ts', async (original) => { + const actual = await original(); + return { ...actual, powershell: vi.fn(actual.powershell) }; +}); +const actualSecurity = await vi.importActual( + '../src/platform/runtime-security.ts', +); +const actualMaterial = await vi.importActual( + '../src/caddy/certificates.ts', +); +const actualFs = await vi.importActual('node:fs/promises'); +const actualPowerShell = await vi.importActual( + '../src/platform/powershell.ts', +); +const nativePlatform = process.platform; +const uidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid'); +const owner = { id: 'uid:42', uid: 42, elevated: false }; +const base = join(tmpdir(), 'cadder-policy-modeled'); +const materialDirectory = join(tmpdir(), 'cadder-policy-material-modeled'); +const mockMaterial: AdminMaterial = { + paths: { + root: { + certificate: join(materialDirectory, 'root.crt.pem'), + key: join(materialDirectory, 'root.key.pem'), + }, + intermediate: { + certificate: join(materialDirectory, 'intermediate.crt.pem'), + key: join(materialDirectory, 'intermediate.key.pem'), + }, + client: { + certificate: join(materialDirectory, 'client.crt.pem'), + key: join(materialDirectory, 'client.key.pem'), + }, + }, + certificates: { + root: 'ROOT PUBLIC PEM', + intermediate: 'INTERMEDIATE PUBLIC PEM', + client: 'CLIENT PUBLIC PEM', + }, + authorizedClientCertificateBase64: 'CLIENT_LEAF_DER', + tls: { ca: 'ROOT PUBLIC PEM', cert: 'CLIENT CHAIN PUBLIC PEM', key: 'SECRET CLIENT KEY' }, +}; +let cleanup: string | undefined; +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + Object.defineProperty(process, 'getuid', { configurable: true, value: () => 42 }); + vi.mocked(security.prepareRuntime).mockResolvedValue(); + vi.mocked(security.assertProtected).mockResolvedValue(); + vi.mocked(loadOrCreateAdminMaterial).mockResolvedValue({ ok: true, value: mockMaterial }); + vi.mocked(powershell).mockResolvedValue(''); + vi.mocked(fs.lstat).mockImplementation(async () => stat()); +}); +afterEach(async () => { + vi.restoreAllMocks(); + if (uidDescriptor) Object.defineProperty(process, 'getuid', uidDescriptor); + else Reflect.deleteProperty(process, 'getuid'); + if (cleanup) { + await actualFs.rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; + } +}); +function stat( + changes: Partial<{ uid: number; mode: number; link: boolean; directory: boolean }> = {}, +): Awaited> { + return { + uid: changes.uid ?? 42, + mode: changes.mode ?? 0o700, + isSymbolicLink: () => changes.link ?? false, + isDirectory: () => changes.directory ?? true, + isFile: () => false, + } as Awaited>; +} +function registration(id: string, hosts = ['app.localhost']): CompositionRegistration { + return { + registrationId: id, + activationState: 'active', + sourceConfigPath: { raw: `${id}/Caddyfile`, canonical: null }, + registeredDomains: hosts.map((host) => ({ + name: { raw: host, canonical: host }, + activationState: 'active', + logStream: { streamId: id, domainKey: host, channel: 'domain' }, + })), + }; +} +function routePlan(projectExtras: JsonObject = {}): CaddyRoutePlan { + const body = JSON.stringify({ + ...projectExtras, + apps: { + ...(projectExtras.apps as JsonObject), + http: { + servers: { + untrusted: { + listen: [':9999'], + routes: [ + { + '@id': 'opaque', + match: [{ host: ['app.localhost', 'other.localhost'] }], + handle: [ + { + handler: 'static_response', + body: 'original', + opaque: { + host: 'opaque', + admin: { disabled: false }, + routes: [2, 1], + __proto__: null, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }); + const result = composeRoutePlan([ + { registration: registration('b', ['other.localhost']), config: normalizeCaddyConfig(body) }, + { registration: registration('a'), config: normalizeCaddyConfig(body) }, + ]); + if (!result.ok) throw new Error('fixture composition failed'); + return result.value; +} +function options(overrides: Partial = {}): SecureAdminPolicyOptions { + return { + runtimeBase: base, + materialDirectory, + owner, + adminPort: 32451, + plan: overrides.plan ?? routePlan(), + baseEnvironment: { PATH: '/safe/bin' }, + ...overrides, + }; +} +async function policy( + overrides: Partial = {}, +): Promise { + const result = await prepareSecureAdminPolicy(options(overrides)); + if (!result.ok) throw new Error(`Policy failed: ${result.error.code}`); + return result.value; +} +async function refused(overrides: Partial, code?: string): Promise { + const result = await prepareSecureAdminPolicy(options(overrides)); + expect(result).toMatchObject({ + ok: false, + error: { retryable: false, ...(code ? { code } : {}) }, + }); + if (result.ok) throw new Error('unexpected success'); + expect(protocolErrorSchema.safeParse(result.error).success).toBe(true); + expect(result.error.message.length).toBeLessThan(256); + expect(JSON.stringify(result)).not.toMatch(/SECRET|PRIVATE KEY|attacker|parser/); +} + +it('changes guarded routes while reusing the verified identity and immutable base policy', async () => { + const prepared = await policy(); + const before = prepared.config.adaptedConfig.body; + const empty = composeRoutePlan([]); + if (!empty.ok) throw new Error('Empty route plan failed.'); + const next = configForRoutePlan(prepared, empty.value); + const previous = JSON.parse(before); + const updated = JSON.parse(next.adaptedConfig.body); + expect(updated.admin).toEqual(previous.admin); + expect(updated.apps.pki).toEqual(previous.apps.pki); + expect(updated.apps.tls.certificates.automate).toEqual([]); + expect(updated.apps.http.servers.cadder_http.routes).toEqual([]); + expect(prepared.config.adaptedConfig.body).toBe(before); + expect(next.effectiveConfigHash).not.toBe(prepared.config.effectiveConfigHash); + expect(vi.mocked(loadOrCreateAdminMaterial)).toHaveBeenCalledOnce(); +}); + +it('assembles native mutual-TLS policy, imported CA paths and real internal web issuance', async () => { + const value = await policy(); + const config = JSON.parse(value.config.adaptedConfig.body); + expect(config.admin).toEqual({ + disabled: true, + identity: { identifiers: ['localhost'], issuers: [{ module: 'internal', ca: 'cadder-admin' }] }, + remote: { listen: '127.0.0.1:32451', access_control: [{ public_keys: ['CLIENT_LEAF_DER'] }] }, + }); + expect(config.apps.pki.certificate_authorities).toEqual({ + 'cadder-admin': { + install_trust: false, + root: { format: 'pem_file', certificate: mockMaterial.paths.root.certificate }, + intermediate: { + format: 'pem_file', + certificate: mockMaterial.paths.intermediate.certificate, + private_key: mockMaterial.paths.intermediate.key, + }, + }, + local: { install_trust: false }, + }); + expect(config.apps.tls).toEqual({ + automation: { + policies: [ + { + subjects: ['app.localhost', 'other.localhost'], + issuers: [{ module: 'internal', ca: 'local' }], + }, + ], + }, + certificates: { automate: ['app.localhost', 'other.localhost'] }, + }); + expect(config.apps.http.servers.cadder_https.tls_connection_policies).toEqual([{}]); + expect(config.apps.http.servers.cadder_http.tls_connection_policies).toBeUndefined(); + expect(config.apps.http.servers.cadder_https.automatic_https).toEqual({ disable: true }); + expect(value.config.adaptedConfig.body).not.toMatch( + /SECRET|PUBLIC PEM|root.key.pem|client.key.pem|acme/, + ); + expect(value.client).toEqual({ + host: '127.0.0.1', + port: 32451, + servername: 'localhost', + rejectUnauthorized: true, + ...mockMaterial.tls, + }); + expect(loadOrCreateAdminMaterial).toHaveBeenCalledWith(materialDirectory, owner); + assertCandidate(value.config); + expect(normalizeCaddyConfig(value.config.adaptedConfig.body)).toEqual(value.config); + expect(Object.isFrozen(value)).toBe(true); + expect(Object.isFrozen(value.client)).toBe(true); + expect(Object.isFrozen(value.config.adaptedConfig)).toBe(true); +}); + +it('preserves listeners, outer guards, original nested opaque handlers, ordering and input immutability', async () => { + const plan = routePlan(); + const before = structuredClone(plan); + const value = await policy({ plan }); + const servers = JSON.parse(value.config.adaptedConfig.body).apps.http.servers; + for (const name of ['cadder_http', 'cadder_https'] as const) { + expect(servers[name].listen).toEqual(before.servers[name].listen); + expect(servers[name].routes).toEqual(before.servers[name].routes); + expect( + servers[name].routes.map((r: { match: { host: string[] }[] }) => r.match[0]!.host), + ).toEqual([['app.localhost'], ['other.localhost']]); + } + expect(plan).toEqual(before); + expect(Object.isFrozen(plan)).toBe(false); +}); + +it('does not import project admin, remote, PKI, storage or server settings', async () => { + const plan = routePlan({ + admin: { + disabled: false, + remote: { listen: ':2021' }, + identity: { issuers: [{ module: 'acme' }] }, + }, + storage: { module: 'attacker' }, + apps: { + pki: { certificate_authorities: { evil: { install_trust: true } } }, + tls: { automation: { policies: [{ issuers: [{ module: 'acme' }] }] } }, + }, + }); + const config = JSON.parse((await policy({ plan })).config.adaptedConfig.body); + expect(Object.keys(config).sort()).toEqual(['admin', 'apps']); + expect(Object.keys(config.apps).sort()).toEqual(['http', 'pki', 'tls']); + expect(Object.keys(config.apps.pki.certificate_authorities).sort()).toEqual([ + 'cadder-admin', + 'local', + ]); + expect(config.apps.http.servers.untrusted).toBeUndefined(); + expect(config.admin.remote.listen).toBe('127.0.0.1:32451'); +}); + +it('preserves a composed wildcard guard and subject without introducing a new domain policy', async () => { + const config = normalizeCaddyConfig( + JSON.stringify({ + apps: { + http: { + servers: { + project: { + routes: [ + { + match: [{ host: ['*.localhost'] }], + handle: [{ handler: 'static_response', body: 'wildcard' }], + }, + ], + }, + }, + }, + }, + }), + ); + const composed = composeRoutePlan([ + { registration: registration('wildcard', ['*.localhost']), config }, + ]); + if (!composed.ok) throw new Error('wildcard composition failed'); + const body = JSON.parse((await policy({ plan: composed.value })).config.adaptedConfig.body); + expect(body.apps.tls.automation.policies[0].subjects).toEqual(['*.localhost']); + for (const name of ['cadder_http', 'cadder_https'] as const) { + expect(body.apps.http.servers[name].routes).toEqual(composed.value.servers[name].routes); + } +}); + +it('keeps an empty plan valid without catch-all certificate automation', async () => { + const result = composeRoutePlan([]); + if (!result.ok) throw new Error('empty composition failed'); + const config = JSON.parse((await policy({ plan: result.value })).config.adaptedConfig.body); + expect(config.apps.tls).toEqual({ automation: { policies: [] }, certificates: { automate: [] } }); + expect(config.apps.http.servers.cadder_https.routes).toEqual([]); +}); + +it('retains TLS subjects when all project routes prune without requiring a matching guard', async () => { + const config = normalizeCaddyConfig( + JSON.stringify({ + apps: { + http: { + servers: { + project: { + routes: [ + { + match: [{ host: ['disabled.localhost'] }], + handle: [{ handler: 'static_response', body: 'disabled-only' }], + }, + ], + }, + }, + }, + }, + }), + ); + const composed = composeRoutePlan([{ registration: registration('pruned'), config }]); + if (!composed.ok) throw new Error('pruned-route composition failed'); + expect(composed.value.tlsSubjects).toEqual(['app.localhost']); + const body = JSON.parse((await policy({ plan: composed.value })).config.adaptedConfig.body); + expect(body.apps.tls.automation.policies[0].subjects).toEqual(['app.localhost']); + expect(body.apps.tls.certificates.automate).toEqual(['app.localhost']); + for (const name of ['cadder_http', 'cadder_https'] as const) { + expect(composed.value.servers[name].routes).toEqual([]); + expect(body.apps.http.servers[name].routes).toEqual([]); + } +}); + +it.each(['linux', 'darwin', 'win32'] as const)( + 'isolates Caddy default storage, autosave, home and scratch on %s, including mixed-case env keys', + async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); + const ambient = { ...process.env }; + const baseEnvironment = Object.freeze({ + PATH: 'preserved', + unrelated: 'yes', + XDG_DATA_HOME: '/outside', + xdg_data_home: '/outside2', + Xdg_Config_Home: '/outside', + HOME: '/outside', + home: '/outside', + UserProfile: '/outside', + HomeDrive: 'X:', + HomePath: '/outside', + AppData: '/outside', + LOCALAPPDATA: '/outside', + Temp: '/outside', + TMP: '/outside', + TmpDir: '/outside', + XDG_CACHE_HOME: '/outside', + XDG_STATE_HOME: '/outside', + XDG_RUNTIME_DIR: '/outside', + caddy_admin: ':2019', + }); + const snapshot = { ...baseEnvironment }; + const value = await policy({ baseEnvironment }); + const env = value.environment; + expect(env.PATH).toBe('preserved'); + expect(env.unrelated).toBe('yes'); + expect(env.XDG_DATA_HOME).toBe(value.paths.data); + expect(env.XDG_CONFIG_HOME).toBe(value.paths.config); + expect(join(env.XDG_DATA_HOME!, 'caddy')).toBe(value.paths.defaultStorage); + expect(join(env.XDG_CONFIG_HOME!, 'caddy')).toBe(value.paths.autosaveDirectory); + for (const key of ['TEMP', 'TMP', 'TMPDIR', 'XDG_RUNTIME_DIR']) + expect(env[key]).toBe(value.paths.scratch); + expect(env.HOME).toBe(value.paths.home); + expect(env.XDG_CACHE_HOME).toBe(value.paths.data); + expect(env.XDG_STATE_HOME).toBe(value.paths.data); + expect(Object.values(env)).not.toContain('/outside'); + expect(Object.keys(env).filter((key) => key.toUpperCase() === 'XDG_DATA_HOME')).toEqual([ + 'XDG_DATA_HOME', + ]); + expect(env.caddy_admin).toBeUndefined(); + expect(env.CADDY_ADMIN).toBeUndefined(); + if (platform === 'win32') { + expect(env.USERPROFILE).toBe(value.paths.home); + expect(env.HOMEDRIVE! + env.HOMEPATH!).toBe(value.paths.home); + expect(env.APPDATA).toBe(value.paths.config); + expect(env.LOCALAPPDATA).toBe(value.paths.data); + } else { + for (const key of ['USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA']) + expect(env[key]).toBeUndefined(); + } + expect(baseEnvironment).toEqual(snapshot); + expect({ ...process.env }).toEqual(ambient); + expect(Object.isFrozen(env)).toBe(true); + expect(security.prepareRuntime).toHaveBeenCalledTimes(7); + for (const directory of [ + value.paths.data, + value.paths.config, + value.paths.scratch, + value.paths.home, + value.paths.defaultStorage, + value.paths.autosaveDirectory, + ]) { + expect(security.prepareRuntime).toHaveBeenCalledWith(directory, owner); + expect(security.assertProtected).toHaveBeenCalledWith(directory, owner, true); + } + }, +); + +it('defaults to a copy of ambient env without changing it', async () => { + const before = { ...process.env }; + const input = options(); + const withoutEnvironment = { ...input }; + delete withoutEnvironment.baseEnvironment; + const result = await prepareSecureAdminPolicy(withoutEnvironment); + expect(result.ok).toBe(true); + expect({ ...process.env }).toEqual(before); +}); + +it.each([0, -1, 65536, 1.1, NaN, Infinity, 80, 443, '32451', undefined, null])( + 'refuses invalid/reserved admin port %s before runtime writes', + async (adminPort) => { + await refused({ adminPort: adminPort as number }, 'caddy_admin_policy_invalid'); + expect(security.prepareRuntime).not.toHaveBeenCalled(); + expect(loadOrCreateAdminMaterial).not.toHaveBeenCalled(); + }, +); +it.each([1, 2021, 65535])('uses only the exact explicit valid port %s', async (adminPort) => { + const value = await policy({ adminPort }); + expect(JSON.parse(value.config.adaptedConfig.body).admin.remote.listen).toBe( + `127.0.0.1:${adminPort}`, + ); +}); +it.each(['runtimeBase', 'materialDirectory'] as const)('requires an absolute %s', async (key) => { + await refused({ [key]: 'relative' }, 'caddy_admin_policy_invalid'); + expect(security.prepareRuntime).not.toHaveBeenCalled(); +}); + +const outer = 'servers.cadder_http.routes.0'; +it.each([ + ['kind', 'kind', 'config'], + ['project admin', 'admin', { remote: { listen: ':2021' } }], + ['PKI', 'apps', { pki: {} }], + ['third server', 'servers.evil', {}], + ['widened HTTP', 'servers.cadder_http.listen', [':80']], + ['widened HTTPS', 'servers.cadder_https.listen', ['localhost:443']], + ['extra listener policy', 'servers.cadder_https.listener_wrappers', []], + ['missing routes', 'servers.cadder_http.routes', undefined], + ['non-array routes', 'servers.cadder_http.routes', {}], + ['malformed server', 'servers.cadder_http', null], + ['non-array subjects', 'tlsSubjects', null], + ['unsorted subjects', 'tlsSubjects', ['other.localhost', 'app.localhost']], + ['duplicate subjects', 'tlsSubjects', ['app.localhost', 'app.localhost']], + ['empty subject', 'tlsSubjects', ['']], + ['non-string subject', 'tlsSubjects', [42]], + ['noncanonical subject', 'tlsSubjects', ['APP.localhost']], + ['missing guard', `${outer}.match`, []], + ['hostless guard', `${outer}.match.0.host`, []], + ['unguarded route', `${outer}.terminal`, false], + ['guard matcher widening', `${outer}.match.0.path`, ['*']], + ['extra outer handler', `${outer}.handle.1`, { handler: 'static_response' }], + ['not a subroute', `${outer}.handle.0.handler`, 'file_server'], + ['invalid child routes', `${outer}.handle.0.routes`, [42]], + ['guard outside subjects', `${outer}.match.0.host`, ['evil.localhost']], + ['duplicate guard host', `${outer}.match.0.host`, ['app.localhost', 'app.localhost']], + ['mismatched guard order', `${outer}.match.0.host`, ['other.localhost']], +] as const)('refuses malformed/widened plan: %s', async (_name, path, value) => { + const plan = structuredClone(routePlan()); + const keys = path.split('.'); + let target: unknown = plan; + for (const key of keys.slice(0, -1)) target = (target as Record)[key]; + if (value === undefined) Reflect.deleteProperty(target as object, keys.at(-1)!); + else + Object.defineProperty(target, keys.at(-1)!, { + enumerable: true, + configurable: true, + writable: true, + value, + }); + await refused({ plan }, 'caddy_candidate_invalid'); + expect(security.prepareRuntime).not.toHaveBeenCalled(); +}); + +it.each(['owner', 'mode', 'link', 'ancestor-link', 'not-directory', 'ACL'] as const)( + 'uses existing helpers to deny unsafe runtime %s without repairing it', + async (failure) => { + vi.mocked(security.prepareRuntime).mockImplementation(actualSecurity.prepareRuntime); + if (failure === 'ACL') { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.mocked(powershell).mockRejectedValue(new Error('SECRET attacker ACL details')); + } else { + vi.mocked(fs.lstat).mockImplementation(async (path) => { + const target = String(path) === base || failure === 'ancestor-link'; + return target + ? stat({ + uid: failure === 'owner' ? 99 : 42, + mode: failure === 'mode' ? 0o755 : 0o700, + link: failure === 'link' || failure === 'ancestor-link', + directory: failure !== 'not-directory', + }) + : stat(); + }); + } + await refused({}, 'caddy_admin_policy_failed'); + expect(loadOrCreateAdminMaterial).not.toHaveBeenCalled(); + }, +); +it('refuses unsafe existing child paths and material failures without replacement or diagnostic leakage', async () => { + vi.mocked(security.assertProtected).mockRejectedValue(new Error('SECRET path parser details')); + await refused({}, 'caddy_admin_policy_failed'); + expect(loadOrCreateAdminMaterial).not.toHaveBeenCalled(); + vi.mocked(security.assertProtected).mockResolvedValue(); + vi.mocked(loadOrCreateAdminMaterial).mockResolvedValue({ + ok: false, + error: { + kind: 'configuration', + code: 'caddy_material_invalid', + message: 'Caddy administration material was refused.', + guidance: null, + retryable: false, + requestId: null, + }, + }); + await refused({}, 'caddy_material_invalid'); +}); + +it('prepares and re-verifies an actual owned runtime, with authorized client LEAF DER', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(nativePlatform); + if (uidDescriptor) Object.defineProperty(process, 'getuid', uidDescriptor); + else Reflect.deleteProperty(process, 'getuid'); + vi.mocked(fs.lstat).mockImplementation(actualFs.lstat); + vi.mocked(powershell).mockImplementation(actualPowerShell.powershell); + vi.mocked(security.prepareRuntime).mockImplementation(actualSecurity.prepareRuntime); + vi.mocked(security.assertProtected).mockImplementation(actualSecurity.assertProtected); + vi.mocked(loadOrCreateAdminMaterial).mockImplementation(actualMaterial.loadOrCreateAdminMaterial); + const nativeOwner = await security.runtimeOwner( + nativePlatform === 'win32' ? undefined : process.getuid!(), + ); + cleanup = await actualFs.mkdtemp(join(tmpdir(), 'cadder-policy-owned-')); + const input = options({ + runtimeBase: join(cleanup, 'runtime'), + materialDirectory: join(cleanup, 'material'), + owner: nativeOwner, + }); + const first = await prepareSecureAdminPolicy(input); + if (!first.ok) throw new Error(`Native protected preparation failed: ${first.error.code}`); + for (const directory of Object.values(first.value.paths)) + await actualSecurity.assertProtected(directory, nativeOwner, true); + const second = await prepareSecureAdminPolicy(input); + expect(second).toEqual(first); + const config = JSON.parse(first.value.config.adaptedConfig.body); + const der = Buffer.from(config.admin.remote.access_control[0].public_keys[0], 'base64'); + const leaf = new X509Certificate(der); + const clientPem = await actualFs.readFile( + join(input.materialDirectory, 'client.crt.pem'), + 'utf8', + ); + expect(leaf.raw).toEqual(new X509Certificate(clientPem).raw); + expect(leaf.ca).toBe(false); + expect(leaf.raw).not.toEqual(new X509Certificate(first.value.client.ca).raw); + expect(first.value.config.adaptedConfig.body).not.toContain('PRIVATE KEY'); +}, 60_000); + +describe('independent complete-body normalization', () => { + it('orders all object keys deterministically, preserving arrays, primitives and opaque __proto__ data', () => { + const left = + '{"z":[3,1,{"b":false,"a":null}],"2":"two","10":"ten","__proto__":{"polluted":true},"toJSON":"opaque","a":{"z":"\\u00e9","a":-0}}'; + const right = + '{"a":{"a":0,"z":"é"},"toJSON":"opaque","__proto__":{"polluted":true},"10":"ten","2":"two","z":[3,1,{"a":null,"b":false}]}'; + const first = normalizeCaddyConfig(left); + expect(first).toEqual(normalizeCaddyConfig(right)); + const canonical = + '{"10":"ten","2":"two","__proto__":{"polluted":true},"a":{"a":0,"z":"é"},"toJSON":"opaque","z":[3,1,{"a":null,"b":false}]}'; + expect(first.adaptedConfig.body).toBe(canonical); + expect(first.effectiveConfigHash).toBe(configHash(canonical)); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + expect(normalizeCaddyConfig('{"a":[1,2]}').effectiveConfigHash).not.toBe( + normalizeCaddyConfig('{"a":[2,1]}').effectiveConfigHash, + ); + expect(normalizeCaddyConfig('{"a":{}}')).not.toEqual( + normalizeCaddyConfig('{"a":{"default":false}}'), + ); + }); + it.each([ + '', + '{', + '{} {}', + '[]', + 'null', + '42', + '"string"', + '{"x":NaN}', + '{"x":Infinity}', + '{"x":"\ud800"}', + '{"x":1e400}', + ])('refuses incomplete, non-object or invalid JSON body %j with bounded diagnostics', (body) => { + expect(() => normalizeCaddyConfig(body)).toThrow('Expected a complete, bounded JSON object'); + }); + it('refuses object carriers without invoking toJSON or getters', async () => { + const toJSON = vi.fn(() => { + throw new Error('SECRET'); + }); + expect(() => normalizeCaddyConfig({ toJSON } as never)).toThrow(); + expect(toJSON).not.toHaveBeenCalled(); + const plan = routePlan(); + Object.defineProperty(plan, 'toJSON', { enumerable: true, value: toJSON }); + await refused({ plan }, 'caddy_candidate_invalid'); + expect(toJSON).not.toHaveBeenCalled(); + const getter = vi.fn(() => { + throw new Error('SECRET'); + }); + Object.defineProperty(plan, 'kind', { enumerable: true, get: getter }); + await refused({ plan }, 'caddy_candidate_invalid'); + expect(getter).not.toHaveBeenCalled(); + }); + it.each([ + 'cycle', + 'proxy', + 'native-failure', + 'prototype', + 'symbol', + 'nonenumerable', + 'undefined', + 'function', + 'bigint', + 'nan', + 'sparse', + 'array-property', + 'array-getter', + 'deep', + 'oversize-string', + 'oversize-key', + 'aggregate-bytes', + 'many-values', + ] as const)('refuses unsafe/oversized plan carrier: %s', async (fault) => { + const plan = routePlan(); + const route = plan.servers.cadder_http.routes[0]!; + let payload: unknown; + switch (fault) { + case 'cycle': + payload = plan; + break; + case 'proxy': + payload = new Proxy( + {}, + { + ownKeys: () => { + throw new Error('SECRET'); + }, + }, + ); + break; + case 'native-failure': + vi.spyOn(Object, 'getOwnPropertyDescriptors').mockImplementationOnce(() => { + throw new Error('SECRET'); + }); + payload = {}; + break; + case 'prototype': + payload = new Date(); + break; + case 'symbol': + payload = { [Symbol('secret')]: 1 }; + break; + case 'nonenumerable': + payload = Object.defineProperty({}, 'secret', { value: 1 }); + break; + case 'undefined': + payload = undefined; + break; + case 'function': + payload = () => 1; + break; + case 'bigint': + payload = 1n; + break; + case 'nan': + payload = NaN; + break; + case 'sparse': + payload = new Array(2); + break; + case 'array-property': + payload = Object.assign([1], { extra: 2 }); + break; + case 'array-getter': + payload = Object.defineProperty([1], '0', { + get: () => { + throw new Error('SECRET'); + }, + }); + break; + case 'deep': + payload = {}; + for (let i = 0; i < 150; i++) payload = { child: payload }; + break; + case 'oversize-string': + payload = 'x'.repeat(maxCaddyConfigurationBytes + 1); + break; + case 'oversize-key': + payload = { ['x'.repeat(maxCaddyConfigurationBytes + 1)]: 1 }; + break; + case 'aggregate-bytes': + payload = { + a: 'x'.repeat(maxCaddyConfigurationBytes / 2), + b: 'x'.repeat(maxCaddyConfigurationBytes / 2), + }; + break; + case 'many-values': + payload = new Array(1_000_001).fill(null); + break; + } + Object.defineProperty(route, 'payload', { enumerable: true, value: payload }); + await refused({ plan }, 'caddy_candidate_invalid'); + }); + it('bounds full bodies, canonical expansion, depth and total visited values', () => { + expect(() => + normalizeCaddyConfig('{"s":"' + 'x'.repeat(maxCaddyConfigurationBytes) + '"}'), + ).toThrow(); + const repeated = 'é'.repeat(Math.floor(maxCaddyConfigurationBytes / 2)); + expect(() => normalizeCaddyConfig(JSON.stringify({ s: repeated }))).toThrow(); + // Compact number spellings fit the input bound; canonical spelling exceeds it. + const expanding = + '{"a":[' + '1e9,'.repeat(900_000) + '0],"s":"' + 'x'.repeat(25_000_000) + '"}'; + expect(Buffer.byteLength(expanding)).toBeLessThan(maxCaddyConfigurationBytes); + expect(() => normalizeCaddyConfig(expanding)).toThrow(); + expect(() => + normalizeCaddyConfig( + '{"s":"' + '\u0001'.repeat(Math.floor(maxCaddyConfigurationBytes / 6)) + '"}', + ), + ).toThrow(); + expect(() => + normalizeCaddyConfig('{"s":' + '['.repeat(150) + '0' + ']'.repeat(150) + '}'), + ).toThrow(); + expect(() => normalizeCaddyConfig('{"s":[' + '0,'.repeat(1_000_000) + '0]}')).toThrow(); + }); +}); diff --git a/test/caddy-certificates.test.ts b/test/caddy-certificates.test.ts new file mode 100644 index 0000000..cda9d25 --- /dev/null +++ b/test/caddy-certificates.test.ts @@ -0,0 +1,671 @@ +import 'reflect-metadata'; +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { createPrivateKey, webcrypto, X509Certificate as NativeCertificate } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { + BasicConstraintsExtension, + ExtendedKeyUsage, + ExtendedKeyUsageExtension, + Extension, + KeyUsageFlags, + KeyUsagesExtension, + PemConverter, + X509Certificate, + X509CertificateGenerator, + type X509CertificateCreateParams, +} from '@peculiar/x509'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { loadOrCreateAdminMaterial, type AdminMaterial } from '../src/caddy/certificates.ts'; +import { assertProtected, runtimeOwner } from '../src/platform/runtime-security.ts'; +import { powershell } from '../src/platform/powershell.ts'; +import { protocolErrorSchema } from '../src/protocol/errors.ts'; + +vi.mock('node:fs/promises', async (original) => { + const actual = await original(); + return { + ...actual, + lstat: vi.fn(actual.lstat), + open: vi.fn(actual.open), + unlink: vi.fn(actual.unlink), + }; +}); +vi.mock('../src/platform/powershell.ts', async (original) => { + const actual = await original(); + return { ...actual, powershell: vi.fn(actual.powershell) }; +}); +const actualFs = await vi.importActual('node:fs/promises'); +const actualPowerShell = await vi.importActual( + '../src/platform/powershell.ts', +); +const owner = { id: 'uid:42', uid: 42, elevated: false }; +const directory = join(tmpdir(), 'cadder-mocked-admin'); +const roles = ['root', 'intermediate', 'client'] as const; +type Role = (typeof roles)[number]; +type CertificateChanges = Partial>; +type File = { bytes: Buffer; uid: number; mode: number; linked?: boolean }; +const files = new Map(); +const handles = new Map>(); +const uidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid'); +const day = 24 * 60 * 60 * 1000; +const nativeDirectories: string[] = []; +let writeFailure: 'writeFile' | 'sync' | 'close' | undefined; +let failurePath: string | undefined; + +function missing() { + return Object.assign(new Error('missing'), { code: 'ENOENT' }); +} +function diskFailure() { + return Object.assign(new Error('SECRET private parser payload'), { code: 'ENOSPC' }); +} +function fileHandle(path: string) { + let offset = 0; + const phases = { + writeFile: vi.fn(async (data: string) => { + files.get(path)!.bytes = Buffer.from(data); + if (writeFailure === 'writeFile' && failurePath === path) throw diskFailure(); + }), + sync: vi.fn(async () => { + if (writeFailure === 'sync' && failurePath === path) throw diskFailure(); + }), + close: vi.fn(async () => { + if (writeFailure === 'close' && failurePath === path) throw diskFailure(); + }), + stat: vi.fn(async () => ({ + isFile: (): boolean => true, + uid: files.get(path)!.uid, + mode: files.get(path)!.mode, + size: files.get(path)!.bytes.length, + })), + read: vi.fn(async (buffer: Buffer, target: number, length: number) => { + const source = files.get(path)!.bytes; + const bytesRead = Math.min(length, source.length - offset); + source.copy(buffer, target, offset, offset + bytesRead); + offset += bytesRead; + return { bytesRead, buffer }; + }), + }; + return phases; +} + +beforeEach(() => { + files.clear(); + handles.clear(); + writeFailure = undefined; + failurePath = undefined; + vi.clearAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + Object.defineProperty(process, 'getuid', { configurable: true, value: () => 42 }); + vi.mocked(powershell).mockResolvedValue(''); + vi.mocked(fs.lstat).mockImplementation(async (path) => { + const file = files.get(String(path)); + if (!file && String(path).endsWith('.pem')) throw missing(); + return { + isSymbolicLink: () => file?.linked ?? false, + isFile: () => !!file, + isDirectory: () => !file, + uid: file?.uid ?? 42, + mode: file?.mode ?? 0o700, + } as never; + }); + vi.mocked(fs.open).mockImplementation(async (path, flags) => { + const name = String(path); + if (Number(flags) & constants.O_EXCL) { + if (files.has(name)) throw Object.assign(new Error('exists'), { code: 'EEXIST' }); + files.set(name, { bytes: Buffer.alloc(0), uid: 42, mode: 0o600 }); + } else if (!files.has(name)) throw missing(); + const handle = fileHandle(name); + handles.set(name, handle); + return handle as never; + }); + vi.mocked(fs.unlink).mockImplementation(async (path) => { + if (!files.delete(String(path))) throw missing(); + }); +}); +afterEach(async () => { + vi.restoreAllMocks(); + if (uidDescriptor) Object.defineProperty(process, 'getuid', uidDescriptor); + else Reflect.deleteProperty(process, 'getuid'); + await Promise.all( + nativeDirectories.splice(0).map((path) => actualFs.rm(path, { recursive: true, force: true })), + ); +}); + +async function material(): Promise { + const result = await loadOrCreateAdminMaterial(directory, owner); + if (!result.ok) throw new Error(`Material setup failed: ${result.error.code}`); + return result.value; +} +function text(path: string): string { + return files.get(path)!.bytes.toString('utf8'); +} +function replace(path: string, value: string | Buffer) { + files.get(path)!.bytes = Buffer.from(value); +} +async function expectFailure(code: string) { + const result = await loadOrCreateAdminMaterial(directory, owner); + expect(result).toMatchObject({ ok: false, error: { code, retryable: false } }); + if (result.ok) throw new Error('unexpected material success'); + expect(protocolErrorSchema.safeParse(result.error).success).toBe(true); + expect(JSON.stringify(result)).not.toMatch(/SECRET|PRIVATE KEY|parser/); + return result.error; +} +async function reissue(value: AdminMaterial, role: Role, changes: CertificateChanges) { + const certificate = new X509Certificate(value.certificates[role]); + const issuer = role === 'client' ? 'intermediate' : 'root'; + const signingKey = await crypto.subtle.importKey( + 'pkcs8', + PemConverter.decodeFirst(text(value.paths[issuer].key)), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'], + ); + const updated = await X509CertificateGenerator.create( + { + subject: certificate.subject, + issuer: certificate.issuer, + publicKey: certificate.publicKey, + signingKey, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + notBefore: certificate.notBefore, + notAfter: certificate.notAfter, + extensions: certificate.extensions, + ...changes, + }, + crypto, + ); + replace(value.paths[role].certificate, updated.toString('pem')); +} + +it('generates independent P-256 CAs/client, fixed PEM names, key matches and verified chain', async () => { + const value = await material(); + const publicKeys = roles.map((role) => + new NativeCertificate(value.certificates[role]).publicKey + .export({ format: 'der', type: 'spki' }) + .toString('base64'), + ); + expect(new Set(publicKeys).size).toBe(3); + for (const role of roles) { + const parsed = new X509Certificate(value.certificates[role]); + const native = new NativeCertificate(value.certificates[role]); + const issuer = new NativeCertificate( + value.certificates[role === 'client' ? 'intermediate' : 'root'], + ); + expect(native.verify(issuer.publicKey)).toBe(true); + expect(native.checkPrivateKey(createPrivateKey(text(value.paths[role].key)))).toBe(true); + expect(native.publicKey.asymmetricKeyDetails?.namedCurve).toBe('prime256v1'); + expect(parsed.getExtension(BasicConstraintsExtension)).toMatchObject({ + ca: role !== 'client', + critical: true, + pathLength: role === 'root' ? 1 : role === 'intermediate' ? 0 : undefined, + }); + expect(parsed.getExtension(KeyUsagesExtension)).toMatchObject({ + usages: + role === 'client' + ? KeyUsageFlags.digitalSignature + : KeyUsageFlags.keyCertSign | KeyUsageFlags.cRLSign, + critical: true, + }); + expect(parsed.getExtension(ExtendedKeyUsageExtension)?.usages).toEqual( + role === 'client' ? [ExtendedKeyUsage.clientAuth] : undefined, + ); + expect(parsed.notBefore.getTime()).toBeLessThanOrEqual(Date.now()); + expect(Math.round((parsed.notAfter.getTime() - Date.now()) / day)).toBe( + (role === 'root' ? 10 : role === 'intermediate' ? 5 : 1) * 365, + ); + expect(value.paths[role]).toEqual({ + certificate: join(directory, `${role}.crt.pem`), + key: join(directory, `${role}.key.pem`), + }); + } + expect(value.authorizedClientCertificateBase64).toBe( + new X509Certificate(value.certificates.client).toString('base64'), + ); + expect(value.tls).toEqual({ + ca: value.certificates.root, + cert: `${value.certificates.client}\n${value.certificates.intermediate}`, + key: text(value.paths.client.key), + }); + expect(files.size).toBe(6); +}); + +it('reuses existing complete identity without writes or regeneration', async () => { + const first = await material(); + const before = [...files].map(([path, file]) => [path, file.bytes.toString('base64')]); + const generate = vi.spyOn(X509CertificateGenerator, 'create'); + const reused = await material(); + expect(reused).toEqual(first); + expect(generate).not.toHaveBeenCalled(); + expect([...files].map(([path, file]) => [path, file.bytes.toString('base64')])).toEqual(before); + for (const handle of handles.values()) { + expect(handle.writeFile).not.toHaveBeenCalled(); + expect(handle.close).toHaveBeenCalledOnce(); + } +}); + +it('uses the available no-follow flag when opening existing PEM files', async () => { + await material(); + vi.mocked(fs.open).mockClear(); + await material(); + expect(fs.open).toHaveBeenCalledTimes(6); + for (const [, flags] of vi.mocked(fs.open).mock.calls) { + expect(flags).toBe(constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | constants.O_NONBLOCK); + expect(Number(flags) & constants.O_EXCL).toBe(0); + } +}); + +it('redacts generation exceptions without creating files', async () => { + vi.spyOn(X509CertificateGenerator, 'create').mockRejectedValueOnce(diskFailure()); + await expectFailure('caddy_material_generation_failed'); + expect(files.size).toBe(0); + expect(fs.open).not.toHaveBeenCalled(); +}); + +it('validates generated material before persisting, including independent keys', async () => { + const keys = await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + vi.spyOn(webcrypto.subtle, 'generateKey').mockResolvedValue(keys); + await expectFailure('caddy_material_invalid'); + expect(files.size).toBe(0); + expect(fs.open).not.toHaveBeenCalled(); +}); + +it.each(roles)('fails closed on expired %s without rotation', async (role) => { + const value = await material(); + const expiry = new X509Certificate(value.certificates[role]).notAfter.getTime(); + vi.spyOn(Date, 'now').mockReturnValue(expiry); + await expectFailure('caddy_material_validity'); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(files.size).toBe(6); +}); +it.each(roles)('rejects not-yet-valid %s', async (role) => { + const value = await material(); + await reissue(value, role, { notBefore: new Date(Date.now() + day) }); + await expectFailure('caddy_material_validity'); +}); + +it.each( + roles.flatMap((role) => (['certificate', 'key'] as const).map((field) => ({ role, field }))), +)('refuses malformed $role $field and leaves it unchanged', async ({ role, field }) => { + const value = await material(); + replace(value.paths[role][field], 'PRIVATE KEY SECRET parser payload'); + const before = text(value.paths[role][field]); + await expectFailure('caddy_material_invalid'); + expect(text(value.paths[role][field])).toBe(before); + expect(fs.unlink).not.toHaveBeenCalled(); +}); +it.each(['certificate', 'key'] as const)( + 'rejects oversized %s before allocating/reading it', + async (field) => { + const value = await material(); + replace(value.paths.root[field], Buffer.alloc(16 * 1024 + 1)); + await expectFailure('caddy_material_oversized'); + expect(handles.get(value.paths.root[field])!.read).not.toHaveBeenCalled(); + }, +); +it('bounds a growing file even if stat reported a small size', async () => { + const value = await material(); + replace(value.paths.root.certificate, Buffer.alloc(32 * 1024)); + const open = vi.mocked(fs.open).getMockImplementation()!; + vi.mocked(fs.open).mockImplementation(async (path, flags, mode) => { + const handle = await open(path, flags, mode); + if (path === value.paths.root.certificate) + handles + .get(String(path))! + .stat.mockResolvedValue({ isFile: () => true, uid: 42, mode: 0o600, size: 1 }); + return handle; + }); + await expectFailure('caddy_material_oversized'); + expect(handles.get(value.paths.root.certificate)!.read.mock.calls[0]![2]).toBe(16 * 1024 + 1); +}); +it('rejects invalid UTF-8, extra PEM blocks and empty files', async () => { + const value = await material(); + for (const bad of [ + Buffer.from([0xff]), + '', + `${value.certificates.root}\n${value.certificates.root}`, + ]) { + replace(value.paths.root.certificate, bad); + await expectFailure('caddy_material_invalid'); + } +}); +it.each(['certificate', 'key'] as const)('rejects PEM headers on %s', async (field) => { + const value = await material(); + const path = value.paths.root[field]; + const pem = PemConverter.encode( + PemConverter.decodeFirst(text(path)), + field === 'certificate' ? 'CERTIFICATE' : 'PRIVATE KEY', + ).replace(/^(-----BEGIN [^-]+-----)/, '$1\nProc-Type: 4,ENCRYPTED\n'); + expect(PemConverter.decodeWithHeaders(pem)[0]!.headers).toEqual([ + { key: 'Proc-Type', value: '4,ENCRYPTED' }, + ]); + replace(path, pem); + await expectFailure('caddy_material_invalid'); + expect(text(path)).toBe(pem); + expect(fs.unlink).not.toHaveBeenCalled(); +}); + +it.each(['certificate', 'key'] as const)( + 'redacts valid-PEM malformed DER %s parser exceptions', + async (field) => { + const value = await material(); + replace( + value.paths.root[field], + PemConverter.encode( + Buffer.from('SECRET parser details'), + field === 'certificate' ? 'CERTIFICATE' : 'PRIVATE KEY', + ), + ); + await expectFailure('caddy_material_invalid'); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); +it('refuses a correctly signed/matched client with a wrong EC curve', async () => { + const value = await material(); + const keys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, [ + 'sign', + 'verify', + ]); + await reissue(value, 'client', { publicKey: keys.publicKey }); + replace( + value.paths.client.key, + PemConverter.encode(await crypto.subtle.exportKey('pkcs8', keys.privateKey), 'PRIVATE KEY'), + ); + await expectFailure('caddy_material_invalid'); +}); + +it.each(roles)('refuses mismatched %s key', async (role) => { + const value = await material(); + replace(value.paths[role].key, text(value.paths[role === 'root' ? 'client' : 'root'].key)); + await expectFailure('caddy_material_invalid'); +}); +it.each([1, 3, 5])( + 'refuses a partial %i-file bundle without touching existing files', + async (count) => { + await material(); + for (const path of [...files.keys()].slice(count)) files.delete(path); + const before = [...files.keys()]; + await expectFailure('caddy_material_incomplete'); + expect([...files.keys()]).toEqual(before); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); + +it.each([ + 'ca', + 'path', + 'usage', + 'eku', + 'duplicate', + 'critical', + 'subject', + 'issuer', + 'signature', + 'containment', +] as const)('rejects invalid chain/profile: %s', async (fault) => { + const value = await material(); + const role = + fault === 'ca' || fault === 'path' || fault === 'usage' || fault === 'eku' + ? 'intermediate' + : 'client'; + const certificate = new X509Certificate(value.certificates[role]); + let extensions = certificate.extensions; + if (fault === 'ca' || fault === 'path') + extensions = extensions + .filter((extension) => !(extension instanceof BasicConstraintsExtension)) + .concat( + new BasicConstraintsExtension(fault !== 'ca', fault === 'path' ? 1 : undefined, true), + ); + if (fault === 'usage') + extensions = extensions + .filter((extension) => !(extension instanceof KeyUsagesExtension)) + .concat(new KeyUsagesExtension(KeyUsageFlags.digitalSignature, true)); + if (fault === 'eku') + extensions = [ + ...extensions, + new ExtendedKeyUsageExtension([ExtendedKeyUsage.clientAuth], true), + ]; + if (fault === 'duplicate') + extensions = [...extensions, new BasicConstraintsExtension(false, undefined, true)]; + if (fault === 'critical') + extensions = [ + ...extensions, + new Extension('1.2.3.4', true, new BasicConstraintsExtension(false).value), + ]; + const changes: CertificateChanges = { extensions }; + if (fault === 'subject') changes.subject = 'CN=Another client'; + if (fault === 'issuer') changes.issuer = 'CN=Another issuer'; + if (fault === 'containment') changes.notAfter = new Date(Date.now() + 6 * 365 * day); + if (fault === 'signature') + changes.signingKey = await crypto.subtle.importKey( + 'pkcs8', + PemConverter.decodeFirst(text(value.paths.root.key)), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'], + ); + await reissue(value, role, changes); + await expectFailure('caddy_material_invalid'); +}); +it.each(roles)( + 'verifies the %s certificate signature rather than trusting issuer names', + async (role) => { + const value = await material(); + const wrongSigner = role === 'client' ? 'root' : 'client'; + const signingKey = await crypto.subtle.importKey( + 'pkcs8', + PemConverter.decodeFirst(text(value.paths[wrongSigner].key)), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'], + ); + await reissue(value, role, { signingKey }); + await expectFailure('caddy_material_invalid'); + }, +); + +it.each([ + 'missing-usage', + 'ca', + 'missing-eku', + 'server-eku', + 'noncritical-basic', + 'noncritical-usage', + 'noncritical-eku', + 'wrong-signature-algorithm', +] as const)('rejects invalid authorized client: %s', async (fault) => { + const value = await material(); + let extensions = new X509Certificate(value.certificates.client).extensions; + if (fault === 'missing-usage' || fault === 'noncritical-usage') + extensions = extensions.filter((extension) => !(extension instanceof KeyUsagesExtension)); + if (fault === 'noncritical-usage') + extensions.push(new KeyUsagesExtension(KeyUsageFlags.digitalSignature, false)); + if (fault === 'ca' || fault === 'noncritical-basic') + extensions = extensions.filter( + (extension) => !(extension instanceof BasicConstraintsExtension), + ); + if (fault === 'ca') extensions.push(new BasicConstraintsExtension(true, 0, true)); + if (fault === 'noncritical-basic') extensions.push(new BasicConstraintsExtension(false)); + if (fault === 'missing-eku' || fault === 'server-eku' || fault === 'noncritical-eku') + extensions = extensions.filter( + (extension) => !(extension instanceof ExtendedKeyUsageExtension), + ); + if (fault === 'server-eku') + extensions.push(new ExtendedKeyUsageExtension([ExtendedKeyUsage.serverAuth])); + if (fault === 'noncritical-eku') + extensions.push(new ExtendedKeyUsageExtension([ExtendedKeyUsage.clientAuth], false)); + await reissue(value, 'client', { + extensions, + ...(fault === 'wrong-signature-algorithm' + ? { signingAlgorithm: { name: 'ECDSA', hash: 'SHA-384' } } + : {}), + }); + await expectFailure('caddy_material_invalid'); +}); + +it.each(['link', 'owner', 'mode'] as const)( + 'denies unsafe existing %s without writes, repairs or cleanup', + async (fault) => { + const value = await material(); + const file = files.get(value.paths.root.certificate)!; + if (fault === 'link') file.linked = true; + if (fault === 'owner') file.uid = 43; + if (fault === 'mode') file.mode = 0o644; + vi.mocked(fs.open).mockClear(); + expect((await expectFailure('caddy_material_unsafe')).kind).toBe('accessDenied'); + expect(fs.open).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); +it('rejects linked ancestors before looking for or creating files', async () => { + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path, options) => { + if (String(path) === dirname(directory)) + return { isSymbolicLink: () => true, uid: 42 } as never; + return inspect(path, options); + }); + await expectFailure('caddy_material_unsafe'); + expect(fs.open).not.toHaveBeenCalled(); + expect(files.size).toBe(0); +}); +it('denies Windows ACL failures without repairing them', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.mocked(powershell).mockRejectedValueOnce(new Error('unsafe ACL SECRET')); + await expectFailure('caddy_material_unsafe'); + expect(fs.open).not.toHaveBeenCalled(); + expect(powershell).toHaveBeenCalledOnce(); +}); + +it.each(['writeFile', 'sync', 'close'] as const)( + 'cleans only exclusively created paths after %s failure', + async (phase) => { + const unrelated = join(directory, 'unrelated.pem'); + files.set(unrelated, { bytes: Buffer.from('unowned'), uid: 43, mode: 0o644 }); + writeFailure = phase; + failurePath = join(directory, 'intermediate.key.pem'); + await expectFailure('caddy_material_io'); + expect([...files.keys()]).toEqual([unrelated]); + expect(fs.unlink).not.toHaveBeenCalledWith(unrelated); + expect(handles.get(failurePath)!.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledTimes(4); + }, +); +it('never removes a pre-existing concurrent file after an exclusive-create conflict', async () => { + const conflict = join(directory, 'intermediate.key.pem'); + const open = vi.mocked(fs.open).getMockImplementation()!; + vi.mocked(fs.open).mockImplementation(async (path, flags, mode) => { + if (path === conflict) + files.set(conflict, { bytes: Buffer.from('pre-existing'), uid: 42, mode: 0o600 }); + return open(path, flags, mode); + }); + await expectFailure('caddy_material_conflict'); + expect([...files.keys()]).toEqual([conflict]); + expect(text(conflict)).toBe('pre-existing'); + expect(fs.unlink).not.toHaveBeenCalledWith(conflict); +}); +it('reports cleanup failure while attempting every owned path and preserving unowned data', async () => { + writeFailure = 'sync'; + failurePath = join(directory, 'intermediate.key.pem'); + const unlink = vi.mocked(fs.unlink).getMockImplementation()!; + vi.mocked(fs.unlink).mockImplementation(async (path) => { + if (path === join(directory, 'root.key.pem')) throw diskFailure(); + return unlink(path); + }); + await expectFailure('caddy_material_cleanup_failed'); + expect([...files.keys()]).toEqual([join(directory, 'root.key.pem')]); + expect(fs.unlink).toHaveBeenCalledWith(join(directory, 'root.crt.pem')); +}); +it('tolerates an already-removed owned file while cleaning remaining owned paths', async () => { + writeFailure = 'sync'; + failurePath = join(directory, 'intermediate.key.pem'); + const unlink = vi.mocked(fs.unlink).getMockImplementation()!; + vi.mocked(fs.unlink).mockImplementation(async (path) => { + if (path === join(directory, 'root.key.pem')) files.delete(String(path)); + return unlink(path); + }); + await expectFailure('caddy_material_io'); + expect(files.size).toBe(0); + expect(fs.unlink).toHaveBeenCalledTimes(4); +}); + +it('does not claim ownership after failed open or helper cleanup failure', async () => { + vi.mocked(fs.open).mockRejectedValueOnce(diskFailure()); + await expectFailure('caddy_material_io'); + expect(fs.unlink).not.toHaveBeenCalled(); + writeFailure = 'writeFile'; + failurePath = join(directory, 'root.crt.pem'); + vi.mocked(fs.unlink).mockRejectedValueOnce(diskFailure()); + // The helper's aggregate error does not expose which cleanup phase failed. + // Do not invent a precise classification or claim its residual file as ours. + const error = await expectFailure('caddy_material_io'); + expect(error.guidance).toContain('may leave partial files'); + expect(fs.unlink).toHaveBeenCalledTimes(1); + expect(files.size).toBe(1); +}); +it.each(['read', 'close', 'stat'] as const)( + 'returns redacted I/O failures on existing-file %s', + async (phase) => { + const value = await material(); + const open = vi.mocked(fs.open).getMockImplementation()!; + vi.mocked(fs.open).mockImplementation(async (path, flags, mode) => { + const handle = await open(path, flags, mode); + if (path === value.paths.root.certificate) + handles.get(String(path))![phase].mockRejectedValueOnce(diskFailure()); + return handle; + }); + await expectFailure('caddy_material_io'); + expect(handles.get(value.paths.root.certificate)!.close).toHaveBeenCalledOnce(); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); +it('validates the opened object rather than trusting pre-open path checks', async () => { + const value = await material(); + const open = vi.mocked(fs.open).getMockImplementation()!; + vi.mocked(fs.open).mockImplementation(async (path, flags, mode) => { + const handle = await open(path, flags, mode); + if (path === value.paths.root.certificate) + handles + .get(String(path))! + .stat.mockResolvedValue({ isFile: () => false, uid: 42, mode: 0o600, size: 1 }); + return handle; + }); + await expectFailure('caddy_material_unsafe'); + expect(handles.get(value.paths.root.certificate)!.read).not.toHaveBeenCalled(); +}); + +it( + 'creates, reuses and checks all six permissions in actual owned temporary runtime data', + { timeout: 60_000 }, + async () => { + vi.restoreAllMocks(); + if (uidDescriptor) Object.defineProperty(process, 'getuid', uidDescriptor); + else Reflect.deleteProperty(process, 'getuid'); + vi.mocked(fs.lstat).mockImplementation(actualFs.lstat); + vi.mocked(fs.open).mockImplementation(actualFs.open); + vi.mocked(fs.unlink).mockImplementation(actualFs.unlink); + vi.mocked(powershell).mockImplementation(actualPowerShell.powershell); + const root = await actualFs.mkdtemp(join(tmpdir(), 'cadder-admin-material-')); + nativeDirectories.push(root); + const nativeOwner = await runtimeOwner( + process.platform !== 'win32' && process.getuid?.() === 0 ? 0 : undefined, + ); + const target = join(root, 'material'); + const first = await loadOrCreateAdminMaterial(target, nativeOwner); + expect(first.ok).toBe(true); + if (!first.ok) throw new Error(`Native creation refused: ${first.error.code}`); + await assertProtected(target, nativeOwner, true); + for (const paths of Object.values(first.value.paths)) + for (const path of Object.values(paths)) await assertProtected(path, nativeOwner); + const reused = await loadOrCreateAdminMaterial(target, nativeOwner); + expect(reused).toEqual(first); + if (process.platform !== 'win32') { + await actualFs.chmod(first.value.paths.client.key, 0o644); + const unsafe = await loadOrCreateAdminMaterial(target, nativeOwner); + expect(unsafe).toMatchObject({ ok: false, error: { kind: 'accessDenied' } }); + expect((await actualFs.lstat(first.value.paths.client.key)).mode & 0o777).toBe(0o644); + } + }, +); diff --git a/test/caddy-composition.test.ts b/test/caddy-composition.test.ts new file mode 100644 index 0000000..387d3e1 --- /dev/null +++ b/test/caddy-composition.test.ts @@ -0,0 +1,569 @@ +import { expect, it } from 'vitest'; +import { composeRoutePlan, type CaddyRoutePlan } from '../src/caddy/composition.ts'; +import type { CaddyConfig } from '../src/contracts/ports.ts'; +import { + activeDomains, + canonicalizeDomain, + checkDomainOwnership, + extractRegisteredDomains, + maxCompositionDiagnostics, + readPreparedRoutes, + type CompositionRegistration, + type JsonObject, +} from '../src/caddy/domains.ts'; +import { configHash, maxCaddyConfigurationBytes } from '../src/caddy/preparation.ts'; + +function candidate(value: unknown): CaddyConfig { + return bodyCandidate(JSON.stringify(value)); +} +function bodyCandidate(body: string): CaddyConfig { + return { adaptedConfig: { format: 'json', body }, effectiveConfigHash: configHash(body) }; +} +function configWithRoutes(routes: unknown[]): CaddyConfig { + return candidate({ apps: { http: { servers: { project: { routes } } } } }); +} +function registration( + id: string, + hosts: string[] = ['app.localhost'], + state: CompositionRegistration['activationState'] = 'active', +): CompositionRegistration { + return { + registrationId: id, + activationState: state, + sourceConfigPath: { raw: `${id}/Caddyfile`, canonical: null }, + registeredDomains: hosts.map((host) => ({ + name: { raw: host, canonical: host }, + activationState: 'active', + logStream: { streamId: id, domainKey: host, channel: 'domain' }, + })), + }; +} +const states = ['unknown', 'registered', 'activating', 'active', 'inactive', 'faulted'] as const; + +it.each([ + [' APP.Localhost... ', 'app.localhost'], + ['BÜCHER.localhost', 'xn--bcher-kva.localhost'], + ['XN--BCHER-KVA.localhost.', 'xn--bcher-kva.localhost'], + ['faß.localhost', 'xn--fa-hia.localhost'], + ['APP。LOCALHOST', 'app.localhost'], + ['APP.LOCALHOST', 'app.localhost'], + ['*.LOCALHOST', '*.localhost'], + ['_SERVICE.LOCALHOST', '_service.localhost'], + ['foo/bar', 'foo/bar'], + ['FOO%2eBAR', 'foo%2ebar'], + ['0x7f.1', '0x7f.1'], + ['127.1', '127.1'], + ['0177.0.0.1', '0177.0.0.1'], + ['127.0.0.1', '127.0.0.1'], + ['FOO:80', 'foo:80'], + ['FOO\\BAR', 'foo\\bar'], + ['FOO@BAR', 'foo@bar'], + ['FOO#BAR', 'foo#bar'], + ['FOO?BAR', 'foo?bar'], + ['[ABCD::1]', '[abcd::1]'], + ['Ä.BAD HOST', 'Ä.bad host'], + ['Ä.\u0000BAD', 'Ä.\u0000bad'], + ['Ä.\u0080BAD', 'Ä.\u0080bad'], + ['Ä.xn--', 'Ä.xn--'], + ['', ''], +])('canonicalizes literal domains without URL host reinterpretation: %s', (raw, expected) => { + expect(canonicalizeDomain(raw)).toBe(expected); +}); + +it('respects all registration/domain states and deduplicates canonical spellings', () => { + for (const projectState of states) { + for (const domainState of states) { + const r = registration('a', ['APP.localhost.', 'app.localhost'], projectState); + r.registeredDomains.forEach((domain) => { + domain.activationState = domainState; + }); + const enabled = ['registered', 'activating', 'active']; + expect(activeDomains(r)).toEqual( + enabled.includes(projectState) && enabled.includes(domainState) ? ['app.localhost'] : [], + ); + } + } +}); + +it('rejects conflicting enabled owners deterministically, not duplicates within one owner', () => { + const a = registration('a', ['BÜCHER.localhost.', 'xn--bcher-kva.localhost']); + const b = registration('b', ['XN--BCHER-KVA.localhost']); + const expected = { + ok: false, + diagnostics: [ + { + code: 'domain-conflict', + message: 'Domain is registered by multiple enabled entrypoints.', + domainKey: 'xn--bcher-kva.localhost', + sourceConfigPaths: ['a/Caddyfile', 'b/Caddyfile'], + }, + ], + }; + expect(checkDomainOwnership([a, b])).toEqual(expected); + expect(checkDomainOwnership([b, a])).toEqual(expected); + expect(checkDomainOwnership([a])).toMatchObject({ ok: true }); + b.activationState = 'inactive'; + expect(checkDomainOwnership([a, b])).toMatchObject({ ok: true }); + b.activationState = 'active'; + b.registeredDomains[0]!.activationState = 'faulted'; + expect(checkDomainOwnership([a, b])).toMatchObject({ ok: true }); +}); + +it('rejects duplicate registration input even when inactive and bounds diagnostics', () => { + const a = registration('a', [], 'inactive'); + expect(checkDomainOwnership([a, a])).toMatchObject({ + ok: false, + diagnostics: [{ code: 'duplicate-registration', sourceConfigPaths: ['a/Caddyfile'] }], + }); + const rows = Array.from({ length: 100 }, (_, i) => registration(String(i), ['x'.repeat(5000)])); + rows.forEach((r) => { + r.sourceConfigPath.raw = r.registrationId + 'x'.repeat(5000); + }); + const result = checkDomainOwnership([...rows, ...rows]); + expect(result.ok).toBe(false); + if (result.ok) throw new Error('Expected diagnostics.'); + expect(result.diagnostics).toHaveLength(maxCompositionDiagnostics); + expect(result.diagnostics.every((d) => d.sourceConfigPaths.every((p) => p.length <= 4096))).toBe( + true, + ); + const conflicts = checkDomainOwnership(rows); + if (conflicts.ok) throw new Error('Expected conflict.'); + expect(conflicts.diagnostics[0]!.domainKey).toHaveLength(4096); + expect(conflicts.diagnostics[0]!.sourceConfigPaths).toHaveLength(maxCompositionDiagnostics); +}); + +it('does not extract proxy metadata from opaque non-route handler payloads', () => { + const routes = [ + { + match: [{ host: ['app.localhost'] }], + handle: [ + { + handler: 'plugin', + payload: { + handler: 'reverse_proxy', + upstreams: [{ dial: 'opaque:8080' }], + host: ['opaque.localhost'], + }, + }, + ], + }, + ]; + const config = configWithRoutes(routes); + expect(extractRegisteredDomains(config)).toEqual({ + ok: true, + value: [{ canonicalDomain: 'app.localhost', upstream: null }], + }); + expect(projectRoutes(plan([{ registration: registration('a'), config }]))).toEqual(routes); +}); + +it('extracts sorted matcher domains/first upstream only from HTTP route positions', () => { + const input = candidate({ + admin: { host: ['admin.localhost'] }, + apps: { + tls: { host: ['tls.localhost'] }, + http: { + servers: { + z: { + routes: [ + { + match: [{ host: ['APP.localhost.'] }], + handle: [{ handler: 'reverse_proxy', upstreams: [{ dial: 'later:80' }] }], + }, + ], + }, + a: { + routes: [ + { + handle: [ + { + handler: 'subroute', + routes: [ + { + match: [{ host: ['BÜCHER.localhost', 'App.localhost'] }], + handle: [ + { + handler: 'reverse_proxy', + host: ['opaque.localhost'], + routes: [{ match: [{ host: ['opaque-route.localhost'] }] }], + upstreams: [ + null, + { dial: ' ' }, + { dial: 1 }, + { dial: '127.0.0.1:8080' }, + ], + }, + ], + }, + ], + }, + ], + }, + { match: [{ host: ['z.localhost'] }], handle: [] }, + ], + }, + }, + }, + }, + }); + expect(extractRegisteredDomains(input)).toEqual({ + ok: true, + value: [ + { canonicalDomain: 'app.localhost', upstream: '127.0.0.1:8080' }, + { canonicalDomain: 'xn--bcher-kva.localhost', upstream: '127.0.0.1:8080' }, + { canonicalDomain: 'z.localhost', upstream: null }, + ], + }); + expect(extractRegisteredDomains(candidate({}))).toEqual({ ok: true, value: [] }); + expect(extractRegisteredDomains(bodyCandidate('{'))).toMatchObject({ ok: false }); +}); + +it.each([ + undefined, + { adaptedConfig: { format: 'json', body: '{}' } }, + { adaptedConfig: { format: 'caddyfile', body: '{}' }, effectiveConfigHash: configHash('{}') }, + { adaptedConfig: { format: 'json', body: 1 }, effectiveConfigHash: 'x' }, + { ...candidate({}), effectiveConfigHash: 'wrong' }, + bodyCandidate('{"apps":'), + bodyCandidate('"\ud800"'), + bodyCandidate(' '.repeat(maxCaddyConfigurationBytes + 1)), + candidate(null), + candidate([]), + candidate({ apps: [] }), + candidate({ apps: { http: [] } }), + candidate({ apps: { http: { servers: [] } } }), + candidate({ apps: { http: { servers: { a: null } } } }), + candidate({ apps: { http: { servers: { a: { routes: {} } } } } }), + configWithRoutes([null]), + configWithRoutes([{ match: {} }]), + configWithRoutes([{ match: [null] }]), + configWithRoutes([{ match: [{ host: 'a' }] }]), + configWithRoutes([{ match: [{ host: [1] }] }]), + configWithRoutes([{ handle: {} }]), + configWithRoutes([{ handle: [null] }]), + configWithRoutes([{ handle: [{ handler: 'subroute', routes: {} }] }]), +])('rejects missing/malformed/hash-mismatched/overflow prepared carriers %#', (input) => { + expect(readPreparedRoutes(input as CaddyConfig | undefined, [registration('a')])).toEqual({ + ok: false, + diagnostics: [ + { + code: 'prepared-input-invalid', + domainKey: null, + message: 'Expected a complete, bounded, integrity-checked adapted HTTP route tree.', + sourceConfigPaths: ['a/Caddyfile'], + }, + ], + }); +}); + +function plan(inputs: Parameters[0]): CaddyRoutePlan { + const result = composeRoutePlan(inputs); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + return result.value; +} +function projectRoutes(value: CaddyRoutePlan, projectIndex = 0): JsonObject[] { + const guard = value.servers.cadder_https.routes[projectIndex]!; + return (guard.handle as JsonObject[])[0]!.routes as JsonObject[]; +} +function response(body: string, hosts?: string[]): JsonObject { + return { + ...(hosts === undefined ? {} : { match: [{ host: hosts }] }), + handle: [{ handler: 'static_response', body }], + }; +} + +it('composes multiple projects deterministically behind active guards with exact owned listeners', () => { + const a = { + registration: registration('a', ['BÜCHER.localhost.', 'app.localhost']), + config: configWithRoutes([response('a1', ['APP.LOCALHOST.']), response('a2')]), + }; + const z = { + registration: registration('z', ['z.localhost']), + config: configWithRoutes([response('z', ['z.localhost'])]), + }; + const value = plan([z, a]); + expect(plan([a, z])).toEqual(value); + expect(value.kind).toBe('caddy-route-plan'); + expect(value.servers.cadder_http.listen).toEqual(['127.0.0.1:80', '[::1]:80']); + expect(value.servers.cadder_https.listen).toEqual(['127.0.0.1:443', '[::1]:443']); + expect(value.tlsSubjects).toEqual(['app.localhost', 'xn--bcher-kva.localhost', 'z.localhost']); + expect(value.servers.cadder_https.routes.map((r) => r.match)).toEqual([ + [{ host: ['app.localhost', 'xn--bcher-kva.localhost'] }], + [{ host: ['z.localhost'] }], + ]); + expect(projectRoutes(value)).toEqual([response('a1', ['app.localhost']), response('a2')]); + expect(projectRoutes(value, 1)).toEqual([response('z', ['z.localhost'])]); + expect(value.servers.cadder_https.routes.every((route) => route.terminal === true)).toBe(true); +}); + +it('composition honors every activation combination and ignores unselected missing carriers', () => { + for (const projectState of states) { + for (const domainState of states) { + const r = registration('a', ['app.localhost'], projectState); + r.registeredDomains[0]!.activationState = domainState; + const enabled = + ['registered', 'activating', 'active'].includes(projectState) && + ['registered', 'activating', 'active'].includes(domainState); + const value = plan([ + { + registration: r, + ...(enabled ? { config: configWithRoutes([response('active', ['app.localhost'])]) } : {}), + }, + ]); + expect(value.servers.cadder_https.routes).toHaveLength(enabled ? 1 : 0); + expect(value.tlsSubjects).toEqual(enabled ? ['app.localhost'] : []); + } + } + expect(plan([{ registration: registration('empty', []) }]).servers.cadder_https.routes).toEqual( + [], + ); + expect(plan([]).tlsSubjects).toEqual([]); +}); + +it('prunes disabled-only nested branches/OR alternatives, preserves nested and separate hostless siblings', () => { + const r = registration('a', ['app.localhost', 'disabled.localhost']); + r.registeredDomains[1]!.activationState = 'inactive'; + const opaque = { + handler: 'plugin', + host: ['disabled.localhost', 42], + routes: [{ match: [{ host: ['disabled.localhost'] }] }], + nested: { host: { arbitrary: true } }, + dial: 'opaque', + }; + const nested = { + handle: [ + { + handler: 'subroute', + routes: [ + response('mixed', ['APP.localhost.', 'disabled.localhost']), + response('disabled', ['disabled.localhost']), + { + handle: [ + { handler: 'subroute', routes: [response('deep disabled', ['disabled.localhost'])] }, + ], + }, + response('nested hostless'), + { + match: [ + { host: ['disabled.localhost'], path: ['/disabled/*'] }, + { host: ['app.localhost'], path: ['/active/*'] }, + { path: ['/shared/*'] }, + ], + handle: [opaque], + }, + ], + }, + ], + }; + const value = plan([ + { + registration: r, + config: configWithRoutes([ + nested, + response('top disabled', ['disabled.localhost']), + response('separate hostless'), + { match: [{ host: [] }], handle: [opaque] }, + ]), + }, + ]); + expect(projectRoutes(value)).toEqual([ + { + handle: [ + { + handler: 'subroute', + routes: [ + response('mixed', ['app.localhost']), + response('nested hostless'), + { + match: [{ host: ['app.localhost'], path: ['/active/*'] }, { path: ['/shared/*'] }], + handle: [opaque], + }, + ], + }, + ], + }, + response('separate hostless'), + ]); + expect(value.servers.cadder_https.routes[0]!.match).toEqual([{ host: ['app.localhost'] }]); + expect(value.tlsSubjects).toEqual(['app.localhost']); +}); + +it('retains terminal chain position after disabled subroutes are pruned', () => { + for (const match of [undefined, [{ host: ['app.localhost'] }]]) { + const terminalRoute = { + ...(match === undefined ? {} : { match }), + handle: [{ handler: 'subroute', routes: [response('disabled', ['disabled.localhost'])] }], + terminal: true, + }; + const later = response('later', ['app.localhost']); + const config = configWithRoutes([terminalRoute, later]); + const before = config.adaptedConfig.body; + const value = plan([{ registration: registration('a'), config }]); + expect(projectRoutes(value)).toEqual([{ ...terminalRoute, handle: [] }, later]); + expect(value.servers.cadder_https.routes[0]!.match).toEqual([{ host: ['app.localhost'] }]); + expect(config.adaptedConfig.body).toBe(before); + } +}); + +it('retains complete hostless handler payloads, multiple handlers and empty/optional subroutes', () => { + const routes = [ + { match: [], handle: [{ handler: 'subroute', routes: [] }, { handler: 'subroute' }] }, + { + handle: [ + { handler: 'subroute', routes: [response('disabled', ['disabled.localhost'])] }, + { handler: 'headers', response: { set: { Host: ['unchanged'] } } }, + ], + }, + { arbitrary: null, host: 'opaque top-level', payload: [true, 123, 'keep'], handle: [] }, + ]; + const value = plan([{ registration: registration('a'), config: configWithRoutes(routes) }]); + expect(projectRoutes(value)).toEqual([ + routes[0], + { handle: [{ handler: 'headers', response: { set: { Host: ['unchanged'] } } }] }, + routes[2], + ]); +}); + +it('ignores root admin/TLS/server settings, preserves JSON and namespaces only HTTP ID copies', () => { + const routes = [ + { + '@id': 'route', + match: [{ host: ['app.localhost'] }], + handle: [ + { + '@id': 'proxy', + handler: 'reverse_proxy', + upstreams: [{ dial: '127.0.0.1:8080' }], + host: ['opaque.localhost'], + tls: { arbitrary: true }, + opaque: [ + { '@id': 'payload', host: ['not-a-domain'], number: 42, flag: false, null: null }, + ], + '@other': 'unchanged', + }, + ], + terminal: false, + }, + ]; + const config = candidate({ + admin: { listen: '0.0.0.0:2019' }, + apps: { + tls: { + automation: { policies: [{ subjects: ['evil.example'], issuers: [{ module: 'acme' }] }] }, + }, + http: { + servers: { + project: { listen: [':8080'], tls_connection_policies: [{ injected: true }], routes }, + }, + }, + }, + }); + const input = { registration: registration('a'), config }; + const before = structuredClone(input); + const value = plan([input]); + const snapshot = structuredClone(value); + expect(input).toEqual(before); + expect(Object.keys(value).sort()).toEqual(['kind', 'servers', 'tlsSubjects']); + expect(Object.keys(value.servers.cadder_https).sort()).toEqual(['listen', 'routes']); + expect(projectRoutes(value)).toEqual(routes); + const httpGuard = value.servers.cadder_http.routes[0]!; + const httpRoute = ((httpGuard.handle as JsonObject[])[0]!.routes as JsonObject[])[0]!; + expect(httpRoute['@id']).toBe('http_route'); + const proxy = (httpRoute.handle as JsonObject[])[0]!; + expect(proxy['@id']).toBe('http_proxy'); + expect((proxy.opaque as JsonObject[])[0]!['@id']).toBe('http_payload'); + httpRoute['@id'] = 'changed copy'; + expect(projectRoutes(value)[0]!['@id']).toBe('route'); + expect(plan([input])).toEqual(snapshot); + expect(input).toEqual(before); + expect(composeRoutePlan([input, { registration: registration('b'), config }]).ok).toBe(false); + expect(snapshot).toEqual(plan([input])); +}); + +it('preserves route order across deterministically sorted source servers', () => { + const config = candidate({ + apps: { + http: { + servers: { + z: { routes: [response('z1'), response('z2')] }, + a: { routes: [response('a1'), response('a2')] }, + missing: {}, + }, + }, + }, + }); + expect(projectRoutes(plan([{ registration: registration('a'), config }]))).toEqual([ + response('a1'), + response('a2'), + response('z1'), + response('z2'), + ]); +}); + +it('returns no plan for conflict/duplicate input and never fabricates routes', () => { + const a = { registration: registration('a'), config: configWithRoutes([response('a')]) }; + const b = { registration: registration('b'), config: configWithRoutes([response('b')]) }; + const conflict = composeRoutePlan([a, b]); + expect(conflict).toMatchObject({ ok: false, diagnostics: [{ code: 'domain-conflict' }] }); + expect(conflict).not.toHaveProperty('value'); + expect(composeRoutePlan([a, a])).toMatchObject({ + ok: false, + diagnostics: [{ code: 'duplicate-registration' }], + }); + expect( + plan([{ registration: registration('a'), config: candidate({}) }]).servers.cadder_https.routes, + ).toEqual([]); +}); + +it('fails the whole plan for each invalid selected carrier and sorts/bounds failures', () => { + const invalid = [ + undefined, + bodyCandidate('{'), + candidate([]), + { ...candidate({}), effectiveConfigHash: 'incorrect' }, + bodyCandidate(' '.repeat(maxCaddyConfigurationBytes + 1)), + configWithRoutes([{ handle: [{ handler: 'subroute', routes: [null] }] }]), + ]; + for (const config of invalid) { + const bad = { registration: registration('a'), ...(config === undefined ? {} : { config }) }; + const good = { + registration: registration('z', ['z.localhost']), + config: configWithRoutes([response('good')]), + }; + const before = structuredClone([good, bad]); + const result = composeRoutePlan([good, bad]); + expect(result).toMatchObject({ ok: false, diagnostics: [{ code: 'prepared-input-invalid' }] }); + expect(result).not.toHaveProperty('value'); + expect([good, bad]).toEqual(before); + } + const inputs = Array.from({ length: 100 }, (_, i) => ({ + registration: registration(String(i), [`${i}.localhost`]), + })); + const result = composeRoutePlan(inputs); + expect(result).toEqual(composeRoutePlan([...inputs].reverse())); + if (result.ok) throw new Error('Expected failure.'); + expect(result.diagnostics).toHaveLength(maxCompositionDiagnostics); +}); + +it('bounds clone/traversal capacity failures without returning a partial plan or mutating inputs', () => { + const deep = '{"next":'.repeat(50_000) + 'null' + '}'.repeat(50_000); + const config = bodyCandidate( + configWithRoutes([ + { handle: [{ handler: 'opaque', payload: 'DEEP' }] }, + ]).adaptedConfig.body.replace('"DEEP"', deep), + ); + const input = { registration: registration('a'), config }; + const before = { ...config, adaptedConfig: { ...config.adaptedConfig } }; + expect(readPreparedRoutes(config).ok).toBe(true); + expect(composeRoutePlan([input])).toEqual({ + ok: false, + diagnostics: [ + { + code: 'prepared-input-invalid', + domainKey: null, + sourceConfigPaths: ['a/Caddyfile'], + message: 'Cannot safely clone and filter the complete adapted route tree.', + }, + ], + }); + expect(config).toEqual(before); +}); diff --git a/test/caddy-configuration.test.ts b/test/caddy-configuration.test.ts new file mode 100644 index 0000000..442225a --- /dev/null +++ b/test/caddy-configuration.test.ts @@ -0,0 +1,157 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + configurationLocations, + defaultConfigurationPaths, + parseConfiguration, + readConfiguration, + selectConfiguration, + type ConfigurationPaths, +} from '../src/caddy/configuration.ts'; +import { configurationCases, caddyResolutionPrecedence } from './compatibility/catalog.ts'; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); +async function paths(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'cadder-caddy-config-')); + directories.push(directory); + return Object.fromEntries( + ['portable', 'user', 'system'].map((source) => [source, join(directory, `${source}.toml`)]), + ) as unknown as ConfigurationPaths; +} + +describe('released configuration contract', () => { + it.each(configurationCases)('$result', ({ toml, result }) => { + if (result.startsWith('reject:')) expect(() => parseConfiguration(toml)).toThrow(); + else { + const selected = parseConfiguration(toml)!; + expect(`${selected.kind}:${selected.value}`).toBe(result); + } + }); + it.each(['', '[caddy]', '# empty'])('defaults/empty selection: %j', (toml) => { + expect(parseConfiguration(toml)).toBeUndefined(); + }); + it.each(['caddy = 1', '[caddy]\nreal_path = 42', 'unknown = true', '[caddy]\nreal_command = []'])( + 'strict types and unknown top-level fields: %j', + (toml) => expect(() => parseConfiguration(toml)).toThrow(), + ); + it('does not reinterpret an empty configured string as an absent selector', () => { + expect(parseConfiguration('[caddy]\nreal_command = ""')).toEqual({ + kind: 'command', + value: '', + }); + }); + it('preserves all five source priorities', async () => { + expect(caddyResolutionPrecedence).toHaveLength(5); + const input = await paths(); + for (const source of ['portable', 'user', 'system'] as const) + await writeFile(input[source], `[caddy]\nreal_command = "${source}"`); + expect((await selectConfiguration(input, process.execPath)).source).toBe('override'); + for (const source of ['portable', 'user', 'system'] as const) { + expect(await selectConfiguration(input)).toEqual({ + selection: { kind: 'command', value: source }, + source, + }); + await writeFile(input[source], '[caddy]'); + } + expect((await selectConfiguration(input)).source).toBe('path'); + }); + it.each(['[caddy', '[caddy]\nunknown = true', '[caddy]\nreal_path = "x"\nreal_command = "x"'])( + 'invalid higher priority never falls back: %j', + async (toml) => { + const input = await paths(); + await writeFile(input.user, toml); + await writeFile(input.system, '[caddy]\nreal_command = "caddy"'); + await expect(selectConfiguration(input)).rejects.toThrow(); + }, + ); + it('rejects relative override without inspecting lower configuration', async () => { + await expect(selectConfiguration(await paths(), 'caddy')).rejects.toThrow('absolute'); + }); + it('invalid UTF-8 configuration fails rather than replacing bytes', async () => { + const input = await paths(); + await writeFile(input.portable, Buffer.from([0xff])); + await expect(selectConfiguration(input)).rejects.toThrow(); + }); + it('missing files fall through but directories and relative sources fail', async () => { + const input = await paths(); + expect((await selectConfiguration(input)).source).toBe('path'); + await mkdir(input.portable); + await expect(selectConfiguration(input)).rejects.toThrow('regular file'); + await expect(readConfiguration('cadder.toml')).rejects.toThrow('absolute'); + }); +}); + +describe('OS-modeled locations (not native filesystem proof)', () => { + it('Linux preserves absolute XDG and default locations, never relative XDG', () => { + const input = { + platform: 'linux' as const, + installationRoot: '/opt/cadder', + home: '/home/user', + }; + expect(configurationLocations(input)).toEqual({ + portable: '/opt/cadder/cadder.toml', + user: '/home/user/.config/cadder/cadder.toml', + system: '/etc/cadder/cadder.toml', + }); + expect(configurationLocations({ ...input, xdgConfigHome: '/config' }).user).toBe( + '/config/cadder/cadder.toml', + ); + expect(configurationLocations({ ...input, xdgConfigHome: 'project' }).user).toBe( + '/home/user/.config/cadder/cadder.toml', + ); + }); + it('macOS preserves the released ProjectDirs spelling', () => { + expect( + configurationLocations({ + platform: 'darwin', + installationRoot: '/opt/cadder', + home: '/Users/test', + }).user, + ).toBe('/Users/test/Library/Application Support/dev.Cadder.Cadder/cadder.toml'); + }); + it('Windows requires known folders rather than ambient APPDATA/PROGRAMDATA', () => { + const input = { + platform: 'win32' as const, + installationRoot: 'C:\\apps\\cadder', + home: 'C:\\Users\\test', + }; + expect(() => configurationLocations(input)).toThrow('known folders'); + expect( + configurationLocations({ + ...input, + knownFolders: { roamingAppData: 'C:\\roaming', programData: 'C:\\policy' }, + }), + ).toEqual({ + portable: 'C:\\apps\\cadder\\cadder.toml', + user: 'C:\\roaming\\Cadder\\Cadder\\config\\cadder.toml', + system: 'C:\\policy\\Cadder\\cadder.toml', + }); + }); + it('relative installation/home anchors fail', () => { + expect(() => + configurationLocations({ platform: 'linux', installationRoot: '.', home: '/home/test' }), + ).toThrow('absolute'); + }); +}); + +it.skipIf(process.platform !== 'win32')( + 'native Windows defaults ignore spoofed PROGRAMDATA', + async () => { + const before = await defaultConfigurationPaths(); + const previous = process.env.PROGRAMDATA; + try { + process.env.PROGRAMDATA = 'C:\\untrusted-policy'; + expect((await defaultConfigurationPaths()).system).toBe(before.system); + } finally { + if (previous === undefined) delete process.env.PROGRAMDATA; + else process.env.PROGRAMDATA = previous; + } + }, +); diff --git a/test/caddy-log-ingestion.test.ts b/test/caddy-log-ingestion.test.ts new file mode 100644 index 0000000..f39e73f --- /dev/null +++ b/test/caddy-log-ingestion.test.ts @@ -0,0 +1,88 @@ +import { expect, it, vi } from 'vitest'; +import { CaddyLogs } from '../src/daemon/caddy-logs.ts'; +import { ApplicationDatabase } from '../src/storage/database.ts'; +import { runtimeLogStream, type LogInput } from '../src/storage/schema.ts'; +import { fakeRegistration } from './fixtures/rpc-data.ts'; + +it('decodes bounded native output, attributes actual domains and redacts before SQLite', async () => { + const database = new ApplicationDatabase(':memory:'); + const project = fakeRegistration(); + project.logStream = { streamId: 'entrypoint-fixture', domainKey: null, channel: 'caddy' }; + project.registeredDomains[0]!.logStream = { + streamId: 'domain-example.localhost', + domainKey: 'example.localhost', + channel: 'caddy', + }; + const failed = vi.fn(); + const logs = new CaddyLogs( + () => [project], + async (entries) => database.append(entries), + failed, + ); + const line = Buffer.from( + JSON.stringify({ + level: 'warn', + msg: 'Zażółć password=hidden', + request: { host: 'EXAMPLE.localhost:443' }, + }) + '\n', + ); + logs.accept('stderr', line.subarray(0, 29)); + logs.accept('stderr', line.subarray(29)); + logs.accept( + 'stdout', + Buffer.from('-----BEGIN PRIVATE KEY-----\nhidden\n-----END PRIVATE KEY-----\nplain tail'), + ); + await logs.close(); + try { + const attributed = database.queryLogs(project.registeredDomains[0]!.logStream, 10); + expect(attributed).toHaveLength(1); + expect(attributed[0]).toMatchObject({ + rawMessage: 'Zażółć password=[redacted]', + attributionKind: 'domain', + sourceRegistrationId: project.registrationId, + severity: 'warn', + }); + expect(database.queryLogs(project.logStream, 10)[0]?.attributionKind).toBe('entrypoint'); + const runtime = database.queryLogs(runtimeLogStream, 10); + expect(runtime.some((entry) => entry.rawMessage === 'plain tail')).toBe(true); + expect(JSON.stringify(runtime)).not.toContain('hidden'); + expect(failed).not.toHaveBeenCalled(); + } finally { + database.close(); + } +}); + +it('bounds incomplete lines and pending writes, and does not leak a key after overflow', async () => { + const entries: LogInput[] = []; + const settle: (() => void)[] = []; + const failed = vi.fn(); + const logs = new CaddyLogs( + () => [], + (batch) => { + entries.push(...batch); + return new Promise((resolve) => settle.push(resolve)); + }, + failed, + ); + logs.accept( + 'stderr', + Buffer.from( + 'x'.repeat(9000) + '-----begin private key-----\nhidden\n-----end private key-----\n', + ), + ); + logs.accept('stdout', Buffer.from('row\n'.repeat(70))); + expect(entries).toHaveLength(64); + expect(JSON.stringify(entries)).not.toContain('hidden'); + expect(failed).toHaveBeenCalled(); + for (const finish of settle) finish(); + await logs.close(); + const failing = new CaddyLogs( + () => [], + async () => { + throw new Error('Disk full'); + }, + failed, + ); + failing.accept('stderr', Buffer.from('null\n')); + await failing.close(); +}); diff --git a/test/caddy-owned-process.test.ts b/test/caddy-owned-process.test.ts new file mode 100644 index 0000000..fc81242 --- /dev/null +++ b/test/caddy-owned-process.test.ts @@ -0,0 +1,494 @@ +import { execFile, spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, normalize } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { promisify } from 'node:util'; +import { build } from 'esbuild'; +import { expect, it, vi } from 'vitest'; +import { runOwnedCommand } from '../src/platform/owned-command.ts'; + +vi.mock('node:child_process', async (original) => { + const actual = await original(); + return { ...actual, spawn: vi.fn(actual.spawn) }; +}); +const execute = promisify(execFile); + +it('owned command preserves native argv and binary streams', async () => { + const args = ['space value', 'quote"value', 'trailing\\', '', 'line\nvalue']; + const observed: Record<'stdout' | 'stderr', Buffer[]> = { stdout: [], stderr: [] }; + const output = await runOwnedCommand( + process.execPath, + [ + '-e', + 'process.stdout.write(JSON.stringify(process.argv.slice(1))); process.stderr.write(Buffer.from([0,255,128]));', + '--', + ...args, + ], + { onOutput: (stream, bytes) => observed[stream].push(Buffer.from(bytes)) }, + ); + expect(output.exitCode).toBe(0); + expect(JSON.parse(output.stdout.toString())).toEqual(args); + expect(output.stderr).toEqual(Buffer.from([0, 255, 128])); + expect(Buffer.concat(observed.stdout)).toEqual(output.stdout); + expect(Buffer.concat(observed.stderr)).toEqual(output.stderr); +}); + +it('verified startup notification precedes immediate binary output without leaking handshake bytes', async () => { + const events: string[] = []; + const output = await runOwnedCommand( + process.execPath, + [ + '-e', + 'process.stdout.write(Buffer.from([1,2,3,0,255]));process.stderr.write(Buffer.from([3,2,1,128]));setTimeout(()=>{},100);', + ], + { + beforeSpawn: async () => { + await delay(20); + events.push('before'); + }, + afterSpawn: async () => { + await delay(20); + events.push('after'); + }, + onStarted: () => { + events.push('started'); + }, + }, + ); + expect(events).toEqual(['before', 'after', 'started']); + expect(output.stdout).toEqual(Buffer.from([1, 2, 3, 0, 255])); + expect(output.stderr).toEqual(Buffer.from([3, 2, 1, 128])); +}); + +it('startup notification failure or cancellation settles the same owned boundary', async () => { + await expect( + runOwnedCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + onStarted: () => { + throw new Error('fixture startup callback failed'); + }, + }), + ).rejects.toThrow('fixture startup callback failed'); + const abort = new AbortController(); + await expect( + runOwnedCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + signal: abort.signal, + onStarted: () => abort.abort(), + }), + ).rejects.toMatchObject({ code: 'abort' }); +}); + +it.each([0, 1, 252, 253, 254])( + 'preserves native exit code %s without reserving application codes', + async (code) => { + const output = await runOwnedCommand(process.execPath, [ + '-e', + `process.stdout.write(Buffer.from([1,2,3,252,253,0]));process.exit(${code})`, + ]); + expect(output.exitCode).toBe(code); + expect(output.stdout).toEqual(Buffer.from([1, 2, 3, 252, 253, 0])); + }, +); + +it.skipIf(process.platform !== 'win32')( + 'preserves native unsigned Windows exit codes', + async () => { + const output = await runOwnedCommand(process.execPath, ['-e', 'process.exit(2147483648)']); + expect(output.exitCode).toBe(2147483648); + }, +); + +it.skipIf(process.platform !== 'win32')( + 'completion framing supports fragmented/coalesced binary payload at exact and zero limits', + async () => { + for (const payload of [Buffer.alloc(0), Buffer.from([1, 2, 3, 253, 252, 0, 1, 2])]) { + const trailer = Buffer.alloc(4); + trailer.writeUInt32LE(253); + const bytes = Buffer.concat([Buffer.from([3]), payload, trailer]); + // Separate handshake messages; split the completion code across events and coalesce payload/control bytes. + fakeWrapper( + [ + bytes.subarray(0, 2), + bytes.subarray(2, bytes.length - 2), + bytes.subarray(bytes.length - 2), + ], + 0, + ); + const output = await runOwnedCommand(process.execPath, [], { + maxStreamBytes: payload.length, + }); + expect(output.stdout).toEqual(payload); + expect(output.exitCode).toBe(253); + } + }, +); + +it.skipIf(process.platform !== 'win32').each(['missing', 'partial', 'fault'] as const)( + 'never accepts %s wrapper finalization', + async (kind) => { + let bytes = Buffer.from([3, 1, 2, 3]); + if (kind === 'missing') bytes = Buffer.from([3]); + else if (kind === 'fault') bytes = Buffer.from([3, 0, 0, 0, 0]); + fakeWrapper([bytes], kind === 'fault' ? 252 : 0); + await expect(runOwnedCommand(process.execPath, [])).rejects.toMatchObject({ code: 'spawn' }); + }, +); + +function fakeWrapper(chunks: readonly Buffer[], code: number): void { + vi.mocked(spawn).mockImplementationOnce(() => { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null as number | null, + signalCode: null, + kill: () => true, + }); + child.stdin.on('data', (bytes: Buffer) => { + if (bytes[0] === 1) child.stdout.write(Buffer.from([2])); + if (bytes[0] === 2) { + for (const chunk of chunks) child.stdout.write(chunk); + child.stdout.end(); + child.stderr.end(); + queueMicrotask(() => { + child.exitCode = code; + child.emit('exit', code); + child.emit('close', code); + }); + } + }); + queueMicrotask(() => { + child.emit('spawn'); + child.stdout.write(Buffer.from([1])); + }); + return child as unknown as ReturnType; + }); +} + +it('owned command retains stream boundary, cwd, stdin EOF and nonzero native exit', async () => { + const output = await runOwnedCommand( + process.execPath, + [ + '-e', + 'process.stdin.on("end",()=>{process.stdout.write(Buffer.alloc(16,1));process.stderr.write(Buffer.alloc(16,2));process.exitCode=7});process.stdin.resume()', + ], + { maxStreamBytes: 16, cwd: tmpdir() }, + ); + expect(output.exitCode).toBe(7); + expect(output.stdout).toEqual(Buffer.alloc(16, 1)); + expect(output.stderr).toEqual(Buffer.alloc(16, 2)); + const cwd = await runOwnedCommand( + process.execPath, + ['-e', 'process.stdout.write(process.cwd())'], + { cwd: tmpdir() }, + ); + expect(normalize(await realpath(cwd.stdout.toString())).toLowerCase()).toBe( + normalize(await realpath(tmpdir())).toLowerCase(), + ); +}); + +it.each([0, 16])( + 'native stdout respects exact %s-byte payload bounds including its binary suffix', + async (limit) => { + const output = await runOwnedCommand( + process.execPath, + [ + '-e', + `process.stdout.write(Buffer.alloc(${limit},253));process.stderr.write(Buffer.alloc(${limit},254));`, + ], + { maxStreamBytes: limit }, + ); + expect(output.stdout).toEqual(Buffer.alloc(limit, 253)); + expect(output.stderr).toEqual(Buffer.alloc(limit, 254)); + expect(output.exitCode).toBe(0); + }, +); + +it.each([0, 16])( + 'hanging stdout max+1 rejects at %s-byte bounds without waiting for command deadline', + async (limit) => { + await expect( + runOwnedCommand( + process.execPath, + ['-e', `process.stdout.write(Buffer.alloc(${limit}+1));setInterval(()=>{},1000)`], + { maxStreamBytes: limit, timeoutMs: 10_000 }, + ), + ).rejects.toMatchObject({ code: 'overflow' }); + }, +); + +it.skipIf(process.platform !== 'win32').each([254, 253])( + 'wrapper status %s distinguishes proved overflow from cleanup failure', + async (status) => { + fakeWrapper([Buffer.from([3, 0, 0, 0, 0])], status); + await expect(runOwnedCommand(process.execPath, [])).rejects.toMatchObject({ + code: status === 254 ? 'overflow' : 'cleanup', + }); + }, +); + +it('default metadata output bound rejects more than 1 MiB', async () => { + await expect( + runOwnedCommand(process.execPath, [ + '-e', + 'process.stdout.write(Buffer.alloc(1024*1024+1));setInterval(()=>{},1000)', + ]), + ).rejects.toMatchObject({ code: 'overflow' }); +}); + +it('long-lived execution drains beyond command limits until owned cancellation settles', async () => { + const abort = new AbortController(); + const command = runOwnedCommand( + process.execPath, + [ + '-e', + 'setInterval(()=>{process.stdout.write(Buffer.alloc(65536));process.stderr.write(Buffer.alloc(65536))},10)', + ], + { longLived: true, signal: abort.signal, timeoutMs: 1, maxStreamBytes: 1 }, + ); + setTimeout(() => abort.abort(), 2500); + await expect(command).rejects.toMatchObject({ code: 'abort' }); + await expect(runOwnedCommand(process.execPath, [], { longLived: true })).rejects.toThrow( + RangeError, + ); +}); + +it('pre-aborted and invalid command options create no child', async () => { + await expect(runOwnedCommand('relative-caddy', [])).rejects.toMatchObject({ code: 'spawn' }); + await expect(runOwnedCommand(process.execPath, ['bad\0argument'])).rejects.toMatchObject({ + code: 'spawn', + }); + await expect( + runOwnedCommand(process.execPath, [], { signal: AbortSignal.abort() }), + ).rejects.toMatchObject({ code: 'abort' }); + await expect(runOwnedCommand(process.execPath, [], { timeoutMs: 0 })).rejects.toThrow(RangeError); + await expect(runOwnedCommand(process.execPath, [], { maxStreamBytes: -1 })).rejects.toThrow( + RangeError, + ); +}); + +it('post-creation verification cannot be lost when a fast child exits', async () => { + let started = false; + await expect( + runOwnedCommand(process.execPath, ['-e', 'process.exit(0)'], { + afterSpawn: async () => { + await delay(200); + throw new Error('delayed pin mismatch'); + }, + onStarted: () => { + started = true; + }, + }), + ).rejects.toThrow('delayed pin mismatch'); + expect(started).toBe(false); +}); + +it('owned command fails native creation without usable partial output', async () => { + await expect( + runOwnedCommand(join(tmpdir(), 'missing-caddy-native-image'), []), + ).rejects.toMatchObject({ code: 'spawn' }); +}); + +it.each(['stdout', 'stderr'])('owned command rejects %s overflow immediately', async (stream) => { + await expect( + runOwnedCommand( + process.execPath, + ['-e', `process.${stream}.write(Buffer.alloc(2048)); setInterval(()=>{},1000)`], + { maxStreamBytes: 1024 }, + ), + ).rejects.toMatchObject({ code: 'overflow' }); +}); + +it.each(['timeout', 'abort'] as const)( + 'owned %s settles descendants, not an unrelated process', + async (mode) => { + const directory = await mkdtemp(join(tmpdir(), 'cadder-owned-command-')); + const marker = join(directory, 'descendant.pid'); + const unrelated = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + stdio: 'ignore', + }); + let descendant: number | undefined; + const abort = new AbortController(); + const timer = mode === 'abort' ? setTimeout(() => abort.abort(), 4000) : undefined; + try { + const source = `const {spawn}=require('node:child_process'); const {writeFileSync}=require('node:fs'); const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform==='win32',stdio:['ignore','inherit','inherit']}); writeFileSync(process.argv[1],String(child.pid)); setInterval(()=>{},1000);`; + await expect( + runOwnedCommand(process.execPath, ['-e', source, marker], { + timeoutMs: 4000, + ...(mode === 'abort' ? { longLived: true, signal: abort.signal } : {}), + }), + ).rejects.toMatchObject({ code: mode }); + descendant = Number(await readFile(marker, 'utf8')); + expect(alive(descendant)).toBe(false); + expect(alive(unrelated.pid!)).toBe(true); + } finally { + clearTimeout(timer); + if (descendant && alive(descendant)) process.kill(descendant, 'SIGKILL'); + unrelated.kill('SIGKILL'); + await new Promise((resolve) => { + if (unrelated.exitCode !== null) resolve(); + else unrelated.once('exit', () => resolve()); + }); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it('owned command cancellation and failed post-creation pin check settle resources', async () => { + const abort = new AbortController(); + const command = runOwnedCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + signal: abort.signal, + }); + setTimeout(() => abort.abort(), 2000); + await expect(command).rejects.toMatchObject({ code: 'abort' }); + await expect( + runOwnedCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + afterSpawn: async () => { + throw new Error('post-spawn pin mismatch'); + }, + }), + ).rejects.toThrow('pin mismatch'); +}); + +it('owned Job/group closes inherited pipes after the leader exits and cleans the orphan', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cadder-owned-orphan-')); + const fixture = join(directory, 'orphan.cjs'); + const marker = join(directory, 'grandchild.pid'); + let grandchild: number | undefined; + try { + await build({ + entryPoints: ['test/fixtures/caddy-orphan.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: fixture, + }); + const output = await runOwnedCommand(process.execPath, [fixture, 'parent', marker], { + timeoutMs: 5000, + }); + grandchild = Number(await readFile(marker, 'utf8')); + expect(output.exitCode).toBe(0); + expect(output.stdout.toString()).toBe('inherited stdout\n'); + expect(output.stderr.toString()).toBe('inherited stderr\n'); + expect(alive(grandchild)).toBe(false); + } finally { + if (grandchild && alive(grandchild)) process.kill(grandchild, 'SIGKILL'); + await rm(directory, { recursive: true, force: true }); + } +}); +it.skipIf(process.platform === 'win32')( + 'escaped Unix pipe holders produce a bounded stream-settle error, not an operation timeout', + async () => { + const directory = await mkdtemp(join(tmpdir(), 'cadder-owned-escape-')); + const marker = join(directory, 'escaped.pid'); + let escaped: number | undefined; + try { + const source = `const {spawn}=require('node:child_process'); const {writeFileSync}=require('node:fs'); const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['ignore','inherit','inherit']});writeFileSync(process.argv[1],String(child.pid));child.unref();process.exit(0);`; + await expect( + runOwnedCommand(process.execPath, ['-e', source, marker], { timeoutMs: 10000 }), + ).rejects.toMatchObject({ code: 'stream' }); + escaped = Number(await readFile(marker, 'utf8')); + expect(alive(escaped)).toBe(true); + } finally { + // A fixture-known escaped PID is not a production process-enumeration permission. + if (escaped === undefined) { + try { + escaped = Number(await readFile(marker, 'utf8')); + } catch { + /* No native child was created. */ + } + } + if (escaped && alive(escaped)) process.kill(escaped, 'SIGKILL'); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** A capability reproducer, not acceptance of taskkill as an owned-tree implementation. */ +it.skipIf(process.platform !== 'win32')( + 'native Windows taskkill cannot clean an exited parent with inherited pipes', + async () => { + const directory = await mkdtemp(join(tmpdir(), 'cadder-caddy-orphan-')); + const fixture = join(directory, 'orphan.cjs'); + const marker = join(directory, 'grandchild.pid'); + let grandchild: number | undefined; + let child: ReturnType | undefined; + try { + await build({ + entryPoints: ['test/fixtures/caddy-orphan.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: fixture, + }); + child = spawn(process.execPath, [fixture, 'parent', marker], { + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdoutClosed = false; + let stderrClosed = false; + child.stdout!.on('close', () => { + stdoutClosed = true; + }); + child.stderr!.on('close', () => { + stderrClosed = true; + }); + child.stdout!.resume(); + let diagnostic = ''; + child.stderr!.on('data', (bytes: Buffer) => { + diagnostic += bytes.toString(); + }); + const leader = child.pid!; + await new Promise((resolve, reject) => { + child!.once('error', reject); + child!.once('exit', (code) => + code === 0 ? resolve() : reject(new Error(`Leader exited ${code}`)), + ); + }); + for (let attempt = 0; attempt < 100; attempt++) { + try { + grandchild = Number(await readFile(marker, 'utf8')); + break; + } catch { + await delay(20); + } + } + expect(grandchild, diagnostic).toBeDefined(); + expect(alive(grandchild!)).toBe(true); + const outcome = await execute( + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), + ['/PID', String(leader), '/T', '/F'], + { timeout: 5000, maxBuffer: 1024 * 1024 }, + ).then( + () => 'unexpected success', + (error: unknown) => String(error), + ); + await delay(100); + expect(outcome).not.toBe('unexpected success'); + expect(alive(grandchild!)).toBe(true); + expect(stdoutClosed).toBe(false); + expect(stderrClosed).toBe(false); + console.info( + `Owned-tree blocker: leader=${leader} exited; taskkill /T /F failed; orphan=${grandchild} alive and both inherited pipes open. ${outcome}`, + ); + } finally { + // Test owns this exact fixture PID; product code may not substitute this for tree tracking. + if (grandchild !== undefined && alive(grandchild)) process.kill(grandchild, 'SIGKILL'); + child?.stdout?.destroy(); + child?.stderr?.destroy(); + if (child?.exitCode === null) child.kill('SIGKILL'); + await rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/test/caddy-preparation.test.ts b/test/caddy-preparation.test.ts new file mode 100644 index 0000000..f68453a --- /dev/null +++ b/test/caddy-preparation.test.ts @@ -0,0 +1,528 @@ +import { + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; +import { dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { join, normalize } from 'node:path'; +import { build } from 'esbuild'; +import { afterEach, expect, it, vi } from 'vitest'; +import { CaddyConfigAdapter, type CaddyAdaptInput } from '../src/caddy/adapter.ts'; +import { CaddyConfigValidator } from '../src/caddy/validation.ts'; +import { + configHash, + maxCaddyConfigurationBytes, + caddyConfigurationTimeoutMs, + configurationCommandOptions, + preparationFailure, +} from '../src/caddy/preparation.ts'; +import { RealCaddyResolver } from '../src/caddy/resolver.ts'; +import { requiredCaddyModules } from '../src/caddy/image.ts'; +import type { CaddyConfig, CaddyPort } from '../src/contracts/ports.ts'; +import { caddyfileExamples } from './compatibility/catalog.ts'; + +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + for (const close of cleanups.splice(0).reverse()) await close(); +}); +const candidate = (body = '{}'): CaddyConfig => ({ + effectiveConfigHash: configHash(body), + adaptedConfig: { format: 'json', body }, +}); +async function setup() { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'cadder-preparation-test-'))); + cleanups.push(() => rm(directory, { recursive: true, force: true })); + const root = join(directory, 'node_modules', 'cadder'); + await mkdir(root, { recursive: true }); + await writeFile( + join(root, 'package.json'), + JSON.stringify({ name: 'cadder', bin: { caddy: 'dist/caddy.js' } }), + ); + const imagePath = join(directory, process.platform === 'win32' ? 'real-caddy.exe' : 'real-caddy'); + await copyFile(process.execPath, imagePath); + const preload = join(directory, 'preload.cjs'); + await build({ + entryPoints: ['test/fixtures/caddy-preparation-preload.ts'], + bundle: true, + format: 'cjs', + platform: 'node', + outfile: preload, + }); + const env = { ...process.env, NODE_OPTIONS: `--require=${JSON.stringify(preload)}` }; + await writeFile(join(directory, 'Caddyfile'), caddyfileExamples[0]!.source); + const log = join(directory, 'commands.jsonl'); + const settings = { modules: requiredCaddyModules, log }; + async function behavior(value: object) { + await writeFile(`${imagePath}.json`, JSON.stringify({ ...settings, ...value })); + } + await behavior({}); + const resolver = new RealCaddyResolver({ + explicitOverride: imagePath, + environment: env, + installation: { root, distribution: 'npm' }, + configurationPaths: { + portable: join(root, 'cadder.toml'), + user: join(directory, 'user.toml'), + system: join(directory, 'system.toml'), + }, + }); + cleanups.push(() => resolver.close()); + const input: CaddyAdaptInput = { + sourceWorkingDirectory: { raw: directory, canonical: null }, + sourceConfigPath: { raw: 'Caddyfile', canonical: null }, + shimRun: null, + }; + async function records() { + return (await readFile(log, 'utf8')) + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { + command: string; + args: string[]; + cwd: string; + image: string; + path: string; + body?: string; + sourceBody?: string; + pid: number; + }, + ); + } + return { + directory, + imagePath, + env, + input, + resolver, + behavior, + records, + adapter: new CaddyConfigAdapter(resolver, { env }), + validator: new CaddyConfigValidator(resolver, { env }), + }; +} + +it('adapts with raw relative path/source cwd and retained caddyfile default into complete JSON/hash', async () => { + const s = await setup(); + const result = await s.adapter.adapt(s.input); + expect(result).toEqual({ ok: true, value: candidate('{"apps":{"http":{"servers":{}}}}\n') }); + const [record] = await s.records(); + expect(record!.args).toEqual(['adapt', '--config', 'Caddyfile', '--adapter', 'caddyfile']); + expect(normalize(record!.cwd)).toBe(normalize(s.directory)); + expect(normalize(record!.image)).toBe(normalize(s.imagePath)); + expect(record!.sourceBody).toBe(caddyfileExamples[0]!.source); +}); + +it('prefers canonical config/cwd and preserves adapter metadata as direct argv', async () => { + const s = await setup(); + const cwd = join(s.directory, 'source project'); + await mkdir(cwd); + const path = join(cwd, 'config space;literal.json'); + await writeFile(path, '{}'); + const input = { + ...s.input, + sourceWorkingDirectory: { raw: 'unusable-relative', canonical: cwd }, + sourceConfigPath: { raw: 'wrong', canonical: path }, + shimRun: { + adapter: 'custom adapter', + rawArguments: ['--real-caddy', 'wrong'], + commandLine: 'ignored', + }, + }; + expect((await s.adapter.adapt(input)).ok).toBe(true); + const [record] = await s.records(); + expect(record!.args).toEqual(['adapt', '--config', path, '--adapter', 'custom adapter']); + expect(normalize(record!.cwd)).toBe(normalize(cwd)); +}); + +it.each(['malformed', 'invalid-utf8', 'invalid-stderr', 'exit'])( + 'adapt refuses %s output without a partial candidate', + async (mode) => { + const s = await setup(); + await s.behavior({ adapt: { mode, body: 'x'.repeat(20000) } }); + const result = await s.adapter.adapt(s.input); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message.length).toBeLessThanOrEqual(4096); + expect(result).not.toHaveProperty('value'); + }, +); + +it('rejects unsafe source metadata before spawning', async () => { + const s = await setup(); + for (const input of [ + { ...s.input, sourceWorkingDirectory: { raw: '.', canonical: null } }, + { ...s.input, sourceConfigPath: { raw: '', canonical: null } }, + { ...s.input, sourceConfigPath: { raw: 'good', canonical: 'relative' } }, + { ...s.input, sourceConfigPath: { raw: 'bad\0path', canonical: null } }, + ]) + expect((await s.adapter.adapt(input)).ok).toBe(false); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); +}); + +it('validates the complete JSON carrier through the existing validate-only port and removes staging', async () => { + const s = await setup(); + const validator: Pick = s.validator; + const config = candidate('{"apps":{}}\n'); + expect(await validator.validate(config)).toEqual({ + ok: true, + value: { effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }, + }); + expect(validator).not.toHaveProperty('apply'); + const [record] = await s.records(); + expect(record!.args).toEqual(['validate', '--config', record!.path]); + expect(record!.body).toBe(config.adaptedConfig.body); + expect(record!.path).not.toContain(process.cwd()); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +it('validation snapshots the original complete body/hash across asynchronous pinning', async () => { + const s = await setup(); + const config = { + effectiveConfigHash: configHash('{}'), + adaptedConfig: { format: 'json' as const, body: '{}' }, + }; + const command = s.validator.validate(config); + config.adaptedConfig.body = '{"changed":true}'; + config.effectiveConfigHash = configHash(config.adaptedConfig.body); + expect(await command).toEqual({ + ok: true, + value: { effectiveConfigHash: configHash('{}'), diagnostics: [] }, + }); + const [record] = await s.records(); + expect(record!.body).toBe('{}'); +}); + +it.each(['invalid-utf8', 'invalid-stderr', 'exit'])( + 'validation refuses %s and removes owned staging', + async (mode) => { + const s = await setup(); + await s.behavior({ validate: { mode, body: 'rejected '.repeat(5000) } }); + const result = await s.validator.validate(candidate()); + expect(result.ok).toBe(false); + expect(result).not.toHaveProperty('value'); + if (!result.ok) expect(result.error.message.length).toBeLessThanOrEqual(4096); + const [record] = await s.records(); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); + }, +); + +it.each([ + ['adapt', 'stdout', false], + ['adapt', 'stderr', false], + ['validate', 'stdout', false], + ['validate', 'stderr', false], + ['adapt', 'stdout', true], + ['adapt', 'stderr', true], + ['validate', 'stdout', true], + ['validate', 'stderr', true], +] as const)( + '%s %s enforces exact 32-MiB stream bound (overflow=%s)', + async (operation, stream, overflow) => { + const s = await setup(); + await s.behavior({ + [operation]: { mode: stream, bytes: maxCaddyConfigurationBytes + Number(overflow) }, + }); + const result = + operation === 'adapt' + ? await s.adapter.adapt(s.input) + : await s.validator.validate(candidate()); + expect(result.ok).toBe(!overflow); + if (overflow) + expect(result).toMatchObject({ ok: false, error: { code: 'caddy_command_overflow' } }); + if (operation === 'validate') { + const [record] = await s.records(); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); + } + }, + 30000, +); + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} +async function until(action: () => Promise): Promise { + const deadline = Date.now() + 8000; + for (;;) { + try { + return await action(); + } catch (error) { + if (Date.now() >= deadline) throw error; + await delay(20); + } + } +} + +it.each(['adapt', 'validate'] as const)( + '%s timeout cleans inherited-pipe descendants and unrelated child survives', + async (operation) => { + const s = await setup(); + await s.resolver.pin(); + const marker = join(s.directory, 'descendant.pid'); + await s.behavior({ [operation]: { mode: 'tree', marker } }); + const unrelated = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + stdio: 'ignore', + }); + let descendant: number | undefined; + try { + const result = + operation === 'adapt' + ? await new CaddyConfigAdapter(s.resolver, { env: s.env, timeoutMs: 5000 }).adapt(s.input) + : await new CaddyConfigValidator(s.resolver, { env: s.env, timeoutMs: 5000 }).validate( + candidate(), + ); + descendant = Number(await readFile(marker, 'utf8')); + expect(result).toMatchObject({ ok: false, error: { code: 'caddy_command_timeout' } }); + expect(alive(descendant)).toBe(false); + expect(alive(unrelated.pid!)).toBe(true); + if (operation === 'validate') { + const [record] = await s.records(); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); + } + } finally { + if (descendant && alive(descendant)) process.kill(descendant, 'SIGKILL'); + unrelated.kill('SIGKILL'); + await new Promise((resolve) => { + if (unrelated.exitCode !== null) resolve(); + else unrelated.once('exit', () => resolve()); + }); + } + }, + 30000, +); + +it.each(['adapt', 'validate'] as const)( + '%s abort kills ready descendants and closes inherited pipes', + async (operation) => { + const s = await setup(); + await s.resolver.pin(); + const marker = join(s.directory, 'descendant.pid'); + await s.behavior({ [operation]: { mode: 'tree', marker } }); + const controller = new AbortController(); + const command = + operation === 'adapt' + ? s.adapter.adapt(s.input, controller.signal) + : s.validator.validate(candidate(), controller.signal); + const descendant = await until(async () => Number(await readFile(marker, 'utf8'))); + expect(alive(descendant)).toBe(true); + controller.abort(); + expect(await command).toMatchObject({ ok: false, error: { code: 'caddy_command_abort' } }); + expect(alive(descendant)).toBe(false); + if (operation === 'validate') { + const [record] = await s.records(); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); + } + }, + 30000, +); + +it('exited validation parent leaves no orphan, inherited pipes or staging', async () => { + const s = await setup(); + const marker = join(s.directory, 'orphan.pid'); + await s.behavior({ validate: { mode: 'orphan', marker } }); + expect((await s.validator.validate(candidate())).ok).toBe(true); + expect(alive(Number(await readFile(marker, 'utf8')))).toBe(false); + const [record] = await s.records(); + await expect(readFile(record!.path)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +it('cancellation before initial pin creates no native command or staging', async () => { + const s = await setup(); + const signal = AbortSignal.abort(); + expect(await s.adapter.adapt(s.input, signal)).toMatchObject({ + ok: false, + error: { code: 'caddy_command_abort' }, + }); + expect(await s.validator.validate(candidate(), signal)).toMatchObject({ + ok: false, + error: { code: 'caddy_command_abort' }, + }); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); +}); + +it.each(['adapt', 'validate'] as const)( + '%s native spawn failure refuses partial results and removes staging', + async (operation) => { + const s = await setup(); + const image = await s.resolver.pin(); + const original = image.verify.bind(image); + let count = 0; + const before = (await readdir(tmpdir())).filter((name) => + name.startsWith('cadder-caddy-validation-'), + ); + const spy = vi.spyOn(image, 'verify').mockImplementation(async () => { + count++; + await original(); + if (count === 2) await rename(s.imagePath, join(s.directory, 'moved-before-create')); + }); + try { + const result = + operation === 'adapt' + ? await s.adapter.adapt(s.input) + : await s.validator.validate(candidate()); + expect(result).toMatchObject({ ok: false, error: { code: 'caddy_command_spawn' } }); + expect( + (await readdir(tmpdir())).filter((name) => name.startsWith('cadder-caddy-validation-')), + ).toEqual(before); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + spy.mockRestore(); + } + }, +); + +it('preparation limits cannot be raised above the retained command policy', () => { + expect( + configurationCommandOptions({ timeoutMs: 60000, maxStreamBytes: 64 * 1024 * 1024 }), + ).toMatchObject({ timeoutMs: 30000, maxStreamBytes: 32 * 1024 * 1024 }); + expect( + preparationFailure(new AggregateError([], 'unbounded private internals'), 'validate'), + ).toMatchObject({ + code: 'caddy_cleanup_failed', + message: 'Caddy command or staging cleanup failed.', + }); +}); + +it('pin mutation prevents adaptation/validation without reselection or leftover staging', async () => { + const s = await setup(); + await s.resolver.pin(); + await writeFile(s.imagePath, Buffer.from('mutated image')); + expect((await s.adapter.adapt(s.input)).ok).toBe(false); + const before = (await readdir(tmpdir())).filter((name) => + name.startsWith('cadder-caddy-validation-'), + ); + expect((await s.validator.validate(candidate())).ok).toBe(false); + expect( + (await readdir(tmpdir())).filter((name) => name.startsWith('cadder-caddy-validation-')), + ).toEqual(before); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); +}); + +it.skipIf(process.platform !== 'win32')( + 'actual suspended native creation mismatch rejects validation and cleans staging before execution', + async () => { + const s = await setup(); + const image = await s.resolver.pin(); + const original = image.verify.bind(image); + let count = 0; + let replaced = false; + const replacement = join(s.directory, 'replacement.exe'); + await copyFile(process.execPath, replacement); + const before = (await readdir(tmpdir())).filter((name) => + name.startsWith('cadder-caddy-validation-'), + ); + const spy = vi.spyOn(image, 'verify').mockImplementation(async () => { + count++; + if (count === 3) { + await rename(s.imagePath, join(s.directory, 'old.exe')); + await rename(replacement, s.imagePath); + replaced = true; + } + await original(); + }); + try { + expect((await s.validator.validate(candidate())).ok).toBe(false); + expect(count).toBe(3); + expect(replaced).toBe(true); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect( + (await readdir(tmpdir())).filter((name) => name.startsWith('cadder-caddy-validation-')), + ).toEqual(before); + } finally { + spy.mockRestore(); + } + }, +); + +it.skipIf(process.platform !== 'win32')( + 'native staging cleanup failure cannot return validated success', + async () => { + const s = await setup(); + await s.resolver.pin(); + const release = join(s.directory, 'release'); + await s.behavior({ validate: { mode: 'wait', release } }); + const command = s.validator.validate(candidate()); + const [record] = await until(() => s.records()); + cleanups.push(() => rm(dirname(record!.path), { recursive: true, force: true })); + const script = `$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue';$stream=[IO.File]::Open($env:CADDER_TEST_LOCK_FILE,'Open','Read','None');[Console]::Out.WriteLine('locked');[Console]::Out.Flush();[Console]::In.ReadLine() | Out-Null;$stream.Dispose()`; + const locker = spawn( + join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ), + [ + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(script, 'utf16le').toString('base64'), + ], + { env: { ...process.env, CADDER_TEST_LOCK_FILE: record!.path }, stdio: 'pipe' }, + ); + try { + await new Promise((resolve, reject) => { + locker.once('error', reject); + locker.stdout.once('data', () => resolve()); + }); + await writeFile(release, 'go'); + expect(await command).toMatchObject({ ok: false, error: { code: 'caddy_cleanup_failed' } }); + } finally { + locker.stdin.end(); + await new Promise((resolve) => { + if (locker.exitCode !== null) resolve(); + else locker.once('exit', () => resolve()); + }); + } + expect(await readFile(record!.path, 'utf8')).toBe('{}'); + }, + 30000, +); + +it('invalid JSON/hash/encoding/oversize carriers never reach Caddy', async () => { + const s = await setup(); + for (const config of [ + candidate('{'), + { ...candidate(), effectiveConfigHash: 'wrong' }, + candidate('"\ud800"'), + candidate(' '.repeat(maxCaddyConfigurationBytes + 1)), + { + ...candidate(), + adaptedConfig: { format: 'caddyfile', body: '{}' }, + } as unknown as CaddyConfig, + ]) + expect(await s.validator.validate(config)).toMatchObject({ + ok: false, + error: { code: 'caddy_candidate_invalid' }, + }); + await expect(readFile(join(s.directory, 'commands.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect(caddyConfigurationTimeoutMs).toBe(30000); + expect(maxCaddyConfigurationBytes).toBe(32 * 1024 * 1024); +}); diff --git a/test/caddy-resolution.test.ts b/test/caddy-resolution.test.ts new file mode 100644 index 0000000..4875ffd --- /dev/null +++ b/test/caddy-resolution.test.ts @@ -0,0 +1,532 @@ +import { + copyFile, + link, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { build } from 'esbuild'; +import { afterEach, expect, it } from 'vitest'; +import { RealCaddyResolver, installationEntries } from '../src/caddy/resolver.ts'; +import { + absoluteCaddyPath, + fileIdentity, + nativeExecutable, + sameFile, +} from '../src/caddy/executable.ts'; +import { + PinnedCaddyImage, + parseModules, + parseVersion, + requiredCaddyModules, + compatibilityProbeRevision, +} from '../src/caddy/image.ts'; +import { configurationLocations } from '../src/caddy/configuration.ts'; +import { runOwnedCommand } from '../src/platform/owned-command.ts'; +import { resolveInstallationRoot } from '../src/daemon/installation-root.ts'; + +const cleanup: (() => Promise)[] = []; +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close(); +}); +const nativeName = (name: string) => (process.platform === 'win32' ? `${name}.exe` : name); +async function setup() { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'cadder-caddy-resolution-'))); + cleanup.push(() => rm(directory, { recursive: true, force: true })); + const root = join(directory, 'project', 'node_modules', 'cadder'); + await mkdir(join(root, 'dist'), { recursive: true }); + await writeFile( + join(root, 'package.json'), + JSON.stringify({ + name: 'cadder', + bin: { caddy: 'dist/caddy.js', cadder: 'dist/cadder.js', cadderd: 'dist/cadderd.js' }, + }), + ); + const preload = join(directory, 'fixture.cjs'); + await build({ + entryPoints: ['test/fixtures/caddy-preload.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: preload, + }); + const env = { + ...process.env, + NODE_OPTIONS: `--require=${JSON.stringify(preload)}`, + PATH: directory, + }; + const paths = configurationLocations({ + platform: process.platform, + installationRoot: root, + home: directory, + ...(process.platform === 'win32' + ? { knownFolders: { roamingAppData: directory, programData: directory } } + : {}), + }); + // Source files intentionally distinct even when OS modeled roots share this temp directory. + const configurationPaths = { + ...paths, + user: join(directory, 'user.toml'), + system: join(directory, 'system.toml'), + }; + async function fake(name = 'caddy', behavior: object = {}) { + const path = join(directory, nativeName(name)); + await copyFile(process.execPath, path); + await writeFile(`${path}.json`, JSON.stringify({ modules: requiredCaddyModules, ...behavior })); + return path; + } + function resolver(extra: ConstructorParameters[0] = {}) { + const value = new RealCaddyResolver({ + environment: env, + installation: { root, distribution: 'npm' }, + configurationPaths, + ...extra, + }); + cleanup.push(() => value.close()); + return value; + } + return { directory, root, env, configurationPaths, fake, resolver }; +} + +it.each([ + ['C:\\tools\\caddy.exe', true], + ['\\\\server\\share\\caddy.exe', true], + ['//server/share/caddy.exe', true], + ['\\caddy.exe', false], + ['/caddy.exe', false], + ['C:caddy.exe', false], + ['caddy.exe', false], +] as const)('modeled Windows qualification %j is %s', (path, expected) => { + expect(absoluteCaddyPath(path, 'win32')).toBe(expected); +}); + +it.skipIf(process.platform !== 'win32')( + 'native Windows refuses root-relative executable overrides', + async () => { + const s = await setup(); + await expect(s.resolver({ explicitOverride: '\\caddy.exe' }).pin()).rejects.toThrow('absolute'); + await expect(nativeExecutable('\\caddy.exe')).rejects.toThrow('absolute'); + }, +); + +it('native physical identity detects hardlinks, distinguishes copies and never conflates shared Node', async () => { + const s = await setup(); + const first = await s.fake(); + const alias = join(s.directory, nativeName('alias')); + await link(first, alias); + const second = await s.fake('second'); + expect(fileIdentity(await stat(first, { bigint: true }))).not.toMatch(/:0$/); + expect(await sameFile(first, alias)).toBe(true); + expect(await sameFile(first, second)).toBe(false); + expect(await sameFile(first, process.execPath)).toBe(false); +}); + +it('pins compatibility/image evidence and closes explicitly', async () => { + const s = await setup(); + const path = await s.fake(); + const resolver = s.resolver(); + const image = await resolver.pin(); + expect(image.path).toBe(await realpath(path)); + expect(image.source).toBe('path'); + expect(image.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(image.evidence).toEqual({ + version: '2.11.4', + modules: [...requiredCaddyModules].sort(), + probeRevision: compatibilityProbeRevision, + }); + expect(await resolver.pin()).toBe(image); + await resolver.close(); + await expect(image.verify()).rejects.toThrow('closed'); + await expect(resolver.pin()).rejects.toThrow('closed'); +}); + +it.each(['override', 'portable', 'user', 'system'] as const)( + 'resolves precedence source %s', + async (source) => { + const s = await setup(); + const selected = await s.fake('selected'); + await s.fake(); + const lower = '[caddy]\nreal_path = "relative-must-not-win"'; + for (const kind of ['portable', 'user', 'system'] as const) + await writeFile(s.configurationPaths[kind], lower); + if (source !== 'override') { + for (const higher of ['portable', 'user', 'system'] as const) { + if (higher === source) break; + await writeFile(s.configurationPaths[higher], '[caddy]'); + } + await writeFile( + s.configurationPaths[source], + `[caddy]\nreal_path = ${JSON.stringify(selected)}`, + ); + } + const image = await s + .resolver(source === 'override' ? { explicitOverride: selected } : {}) + .pin(); + expect(image.source).toBe(source); + expect(image.path).toBe(await realpath(selected)); + }, +); + +it('portable single command preserves PATH order and ignores relative entries/project/legacy selectors', async () => { + const s = await setup(); + const chosen = await s.fake('caddy-real'); + await s.fake(); + await writeFile(s.configurationPaths.portable, '[caddy]\nreal_command = "caddy-real"'); + await writeFile(join(s.directory, 'cadder.toml'), '[caddy]\nreal_path = "untrusted-project"'); + s.env.PATH = ['.', 'relative', s.directory, s.root].join(delimiter); + const resolver = s.resolver({ + environment: { + ...s.env, + CADDER_CADDY_REAL_COMMAND: 'wrong', + CADDER_CADDY__REAL_PATH: 'wrong', + CADDER_CADDY_SHIM_PATH: chosen, + }, + }); + expect((await resolver.pin()).path).toBe(await realpath(chosen)); +}); + +it.skipIf(process.platform !== 'win32')( + 'native Windows PATH preserves quoted absolute directories containing separators', + async () => { + const s = await setup(); + const original = await s.fake(); + const directory = join(s.directory, 'quoted; directory'); + await mkdir(directory); + const path = join(directory, nativeName('caddy')); + await rename(original, path); + await rename(`${original}.json`, `${path}.json`); + const image = await s + .resolver({ environment: { ...s.env, PATH: `.;"${directory}";relative` } }) + .pin(); + expect(image.path).toBe(await realpath(path)); + }, +); + +async function waitForProbe(marker: string): Promise { + for (let attempt = 0; attempt < 300; attempt++) { + try { + if ((await readFile(marker, 'utf8')).includes('"version"')) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await delay(20); + } + throw new Error('Owned version probe did not become ready.'); +} + +it('caller cancellation during the first probe cannot poison concurrent or later pin callers', async () => { + const s = await setup(); + const marker = join(s.directory, 'probe-commands'); + const releaseVersion = join(s.directory, 'release-version'); + await s.fake('caddy', { marker, releaseVersion }); + const resolver = s.resolver(); + const controller = new AbortController(); + const cancelled = resolver.pin(controller.signal).catch((error: unknown) => error); + const otherController = new AbortController(); + const concurrent = resolver.pin(otherController.signal).catch((error: unknown) => error); + await waitForProbe(marker); + controller.abort(); + expect(await cancelled).toMatchObject({ code: 'abort' }); + await writeFile(releaseVersion, 'go'); + const image = await resolver.pin(); + expect(await concurrent).toBe(image); + expect(image.evidence.version).toBe('2.11.4'); + expect((await readFile(marker, 'utf8')).trim().split('\n')).toHaveLength(2); +}); + +it('resolver close cancels its owned pending probe and rejects future pin callers', async () => { + const s = await setup(); + const marker = join(s.directory, 'probe-commands'); + await s.fake('caddy', { marker, releaseVersion: join(s.directory, 'never-released') }); + const resolver = s.resolver(); + const pending = resolver.pin().catch((error: unknown) => error); + await waitForProbe(marker); + await resolver.close(); + expect(await pending).toMatchObject({ code: 'abort' }); + await expect(resolver.pin()).rejects.toThrow('closed'); +}); + +it.each(['caddy --version', '../caddy', '', 'caddy; evil', 'C:caddy', 'caddy\\relative'])( + 'denies arbitrary configured commands %j', + async (command) => { + const s = await setup(); + await s.fake(); + await writeFile( + s.configurationPaths.user, + `[caddy]\nreal_command = ${JSON.stringify(command)}`, + ); + await expect(s.resolver().pin()).rejects.toThrow('single program'); + }, +); + +it('invalid selected native image fails instead of trying a lower source', async () => { + const s = await setup(); + await s.fake(); + await writeFile(s.configurationPaths.portable, '[caddy]\nreal_path = "relative"'); + await expect(s.resolver().pin()).rejects.toThrow('absolute'); +}); + +it.each(['.cmd', '.ps1', '.js', ''])( + 'rejects npm script wrapper %j before executing any help probe', + async (suffix) => { + const s = await setup(); + const path = join(s.directory, `wrapper${suffix}`); + await writeFile(path, '#!/usr/bin/env node\nthrow new Error("recursive shim")', { + mode: 0o755, + }); + await expect(s.resolver({ explicitOverride: path }).pin()).rejects.toThrow(/native|wrapper/); + }, +); + +it('rejects hardlinked npm bin identities and skips aliased entries on PATH', async () => { + const s = await setup(); + const real = await s.fake('upstream'); + const shim = join(s.root, 'dist', 'caddy.js'); + await copyFile(process.execPath, shim); + const candidate = join(s.directory, nativeName('caddy')); + await link(shim, candidate); + await expect(s.resolver({ explicitOverride: candidate }).pin()).rejects.toThrow( + 'Cadder distribution', + ); + const upstreamDir = join(s.directory, 'upstream-bin'); + await mkdir(upstreamDir); + const upstream = join(upstreamDir, nativeName('caddy')); + await rename(real, upstream); + await rename(`${real}.json`, `${upstream}.json`); + expect( + ( + await s + .resolver({ environment: { ...s.env, PATH: [s.directory, upstreamDir].join(delimiter) } }) + .pin() + ).path, + ).toBe(await realpath(upstream)); +}); + +it('SEA root denies known executable and sibling hardlink aliases', async () => { + const s = await setup(); + const sea = join(s.root, nativeName('cadderd')); + await copyFile(process.execPath, sea); + const candidate = join(s.directory, nativeName('upstream')); + await link(sea, candidate); + await expect( + s + .resolver({ + explicitOverride: candidate, + installation: { root: s.root, distribution: 'sea', executable: sea }, + }) + .pin(), + ).rejects.toThrow('Cadder distribution'); + expect( + await installationEntries({ root: s.root, distribution: 'sea', executable: sea }), + ).toContain(join(s.root, nativeName('caddy'))); +}); + +it('realistic physical npm local/global and SEA anchors do not use cwd/shared Node', async () => { + const s = await setup(); + const entry = join(s.root, 'dist', 'cadderd.js'); + await writeFile(entry, '// entry'); + const moduleUrl = pathToFileURL(entry).href; + expect(resolveInstallationRoot({ moduleUrl })).toBe(await realpath(s.root)); + expect(resolveInstallationRoot({ sea: true, executable: entry })).toBe( + await realpath(join(s.root, 'dist')), + ); + const entries = await installationEntries({ root: s.root, distribution: 'npm' }); + expect(entries).toContain(join(s.root, 'dist', 'caddy.js')); + expect(entries).toContain(join(s.directory, 'project', 'node_modules', '.bin', 'caddy.cmd')); + expect(entries).not.toContain(process.execPath); +}); + +it.skipIf(process.platform !== 'win32')( + 'native Scoop descriptor selects only its checked native target and denies Cadder alias targets', + async () => { + const s = await setup(); + const wrapper = await s.fake(); + const target = await s.fake('scoop-target'); + await writeFile(wrapper.replace(/\.exe$/, '.shim'), `path = "${target}"\nargs = "--ignored"`); + expect((await s.resolver().pin()).path).toBe(await realpath(target)); + const sea = join(s.root, nativeName('cadderd')); + await copyFile(process.execPath, sea); + await writeFile(wrapper.replace(/\.exe$/, '.shim'), `path = "${sea}"`); + await expect( + s.resolver({ installation: { root: s.root, distribution: 'sea', executable: sea } }).pin(), + ).rejects.toThrow('safe absolute PATH'); + }, +); + +it.skipIf(process.platform === 'win32')( + 'native Unix symlinks follow identity but scripts/non-executable files fail', + async () => { + const s = await setup(); + const first = await s.fake(); + const alias = join(s.directory, 'alias'); + await symlink(first, alias); + expect(await sameFile(first, alias)).toBe(true); + expect(await nativeExecutable(alias)).toBe(await realpath(first)); + const noExec = join(s.directory, 'not-executable'); + await writeFile(noExec, Buffer.from('7f454c46', 'hex'), { mode: 0o600 }); + await expect(nativeExecutable(noExec)).rejects.toThrow('not executable'); + }, +); + +it.skipIf(process.platform !== 'win32')( + 'native Windows final symlink/reparse executable is denied', + async (context) => { + const s = await setup(); + const path = await s.fake(); + const alias = join(s.directory, nativeName('symlink-alias')); + try { + await symlink(path, alias, 'file'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') { + context.skip(); + return; + } + throw error; + } + expect(await sameFile(path, alias)).toBe(true); + await expect(nativeExecutable(alias)).rejects.toThrow('reparse'); + }, +); + +it('realistic global npm prefix inventories are physical, not shared Node', async () => { + const s = await setup(); + const globalRoot = join(s.directory, 'prefix', 'lib', 'node_modules', 'cadder'); + await mkdir(globalRoot, { recursive: true }); + await writeFile( + join(globalRoot, 'package.json'), + JSON.stringify({ name: 'cadder', bin: 'dist/caddy.js' }), + ); + const entries = await installationEntries({ root: globalRoot, distribution: 'npm' }); + expect(entries).toContain(join(s.directory, 'prefix', 'bin', 'caddy')); + expect(entries).toContain(join(globalRoot, 'dist', 'caddy.js')); +}); + +it('missing PATH and only relative PATH never select cwd', async () => { + const s = await setup(); + await s.fake(); + const environment = { ...s.env }; + for (const key of Object.keys(environment)) + if (key.toLowerCase() === 'path') delete (environment as NodeJS.ProcessEnv)[key]; + await expect(s.resolver({ environment }).pin()).rejects.toThrow('PATH is not set'); + await expect( + s.resolver({ environment: { ...environment, PATH: '.;relative' } }).pin(), + ).rejects.toThrow('safe absolute PATH'); +}); + +it('incompatible real native probes and missing modules fail without usable evidence', async () => { + const s = await setup(); + const path = await s.fake('caddy', { version: 'v3.0.0' }); + await expect(s.resolver().pin()).rejects.toThrow('Unsupported Caddy'); + await writeFile(`${path}.json`, JSON.stringify({ modules: [] })); + await expect(s.resolver().pin()).rejects.toThrow('missing required modules'); + expect(requiredCaddyModules).toHaveLength(19); +}); + +it.each(['version', 'list-modules'])( + 'probe %s failure is sticky and never tries another image', + async (fail) => { + const s = await setup(); + const marker = join(s.directory, 'commands'); + const path = await s.fake('caddy', { fail, marker }); + const resolver = s.resolver(); + await expect(resolver.pin()).rejects.toThrow('probe failed'); + await writeFile(`${path}.json`, JSON.stringify({ modules: requiredCaddyModules, marker })); + await expect(resolver.pin()).rejects.toThrow('probe failed'); + const commands = (await readFile(marker, 'utf8')).trim().split('\n'); + expect(commands).toHaveLength(fail === 'version' ? 1 : 2); + }, +); + +it.each(['v2.11.3', 'v3.0.0', 'v2.11.4-rc.1', 'garbage', ''])( + 'denies incompatible version %j', + (version) => { + expect(() => parseVersion(Buffer.from(version))).toThrow(); + }, +); +it.each(['v2.11.4 h1:sum', '2.12.0-rc.1', 'v2.11.4+custom'])( + 'accepts retained compatible version %j', + (version) => { + expect(parseVersion(Buffer.from(version))).toBe(version.split(' ')[0]!.replace(/^v/, '')); + }, +); +it.each(['[]', '{}', 'not JSON', '[{"module_name":7}]'])( + 'denies missing/malformed module inventory %j', + (body) => { + expect(() => parseModules(Buffer.from(body))).toThrow(); + }, +); + +it('pin replacement and hardlink content mutation are detected, with no reselection', async () => { + const s = await setup(); + const path = await s.fake(); + const resolver = s.resolver(); + const image = await resolver.pin(); + const alias = join(s.directory, nativeName('alias')); + await link(path, alias); + await writeFile(alias, Buffer.from('mutated')); + await expect(image.verify()).rejects.toThrow('digest changed'); + await expect(image.run(['version'], { env: s.env })).rejects.toThrow('digest changed'); + expect(await resolver.pin()).toBe(image); + const replacement = await s.fake('replacement'); + await rename(path, join(s.directory, nativeName('old'))); + await rename(replacement, path); + await expect(image.verify()).rejects.toThrow('identity changed'); +}); + +it.skipIf(process.platform !== 'win32')( + 'mutation during wrapper startup is rejected before native execution', + async () => { + const s = await setup(); + const path = await s.fake(); + const image = await PinnedCaddyImage.open(path, 'override'); + cleanup.push(() => image.close()); + const marker = join(s.directory, 'must-not-execute'); + const command = runOwnedCommand(path, ['marker', marker], { + env: s.env, + beforeSpawn: () => image.verify(), + afterSpawn: () => image.verify(), + }); + const mutation = new Promise((resolve, reject) => + setTimeout(() => { + void writeFile(path, Buffer.from('mutation during compilation')).then( + () => resolve(), + reject, + ); + }, 100), + ); + await mutation; + await expect(command).rejects.toThrow('digest changed'); + await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' }); + }, +); + +it.skipIf(process.platform !== 'win32')( + 'post-native-create pin mismatch kills the suspended owned Job before any execution', + async () => { + const s = await setup(); + const path = await s.fake(); + const image = await PinnedCaddyImage.open(path, 'override'); + cleanup.push(() => image.close()); + const marker = join(s.directory, 'must-not-execute'); + await expect( + runOwnedCommand(path, ['marker', marker], { + env: s.env, + beforeSpawn: () => image.verify(), + afterSpawn: async () => { + throw new Error('post-native-create mismatch'); + }, + }), + ).rejects.toThrow('post-native-create mismatch'); + await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' }); + }, +); diff --git a/test/caddy-runtime.test.ts b/test/caddy-runtime.test.ts new file mode 100644 index 0000000..2d5e981 --- /dev/null +++ b/test/caddy-runtime.test.ts @@ -0,0 +1,423 @@ +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { normalizeCaddyConfig, type SecureAdminPolicy } from '../src/caddy/admin-policy.ts'; +import { CaddyRuntime } from '../src/caddy/runtime.ts'; +import { configHash } from '../src/caddy/preparation.ts'; +import { + ConfigurationTransactions, + type ConfigurationSnapshot, +} from '../src/daemon/configuration-transactions.ts'; +import { assertProtected } from '../src/platform/runtime-security.ts'; +import { backend, fixtureResolver } from './fixtures/caddy-runtime-backend.ts'; + +// Only this test's policy/path and HTTPS seams are fake. The real validator and queue are exercised. +vi.mock('../src/platform/runtime-security.ts', () => ({ + assertProtected: vi.fn(async () => undefined), + assertRuntimeDescendant: vi.fn(async () => undefined), + createProtectedFile: async (path: string, _owner: unknown, body: string) => { + await writeFile(path, body, { flag: 'wx', mode: 0o600 }); + return true; + }, +})); +vi.mock('../src/caddy/admin-client.ts', async (original) => { + const actual = await original(); + return { + CaddyAdminClient: class extends actual.CaddyAdminClient { + override apply = backend.apply.bind(backend); + override readActive = backend.readActive.bind(backend); + override stop = backend.stop.bind(backend); + }, + }; +}); +let directory: string; +const runtimes: CaddyRuntime[] = []; +function candidate(tag = 'first') { + return normalizeCaddyConfig( + JSON.stringify({ + admin: { disabled: true, remote: { listen: 'fixture' } }, + apps: { + pki: { certificate_authorities: { local: { install_trust: false } } }, + http: { tag }, + }, + }), + ); +} +function policy(): SecureAdminPolicy { + return { + config: candidate(), + paths: { + base: directory, + data: join(directory, 'data'), + config: join(directory, 'config'), + home: join(directory, 'home'), + scratch: join(directory, 'scratch'), + defaultStorage: join(directory, 'data', 'caddy'), + autosaveDirectory: join(directory, 'config', 'caddy'), + }, + environment: { FIXTURE_STARTUP: 'immutable' }, + client: { + host: '127.0.0.1', + port: 12345, + servername: 'localhost', + rejectUnauthorized: true, + ca: 'fixture-ca', + cert: 'fixture-cert', + key: 'fixture-key', + }, + }; +} +function runtime(input = policy()): CaddyRuntime { + const value = new CaddyRuntime( + input, + { id: 'fixture-owner', elevated: false }, + fixtureResolver(), + { startupMs: 150, stopMs: 30, pollMs: 2 }, + ); + runtimes.push(value); + return value; +} +beforeEach(async () => { + backend.reset(); + vi.mocked(assertProtected).mockReset().mockResolvedValue(undefined); + directory = await mkdtemp(join(tmpdir(), 'cadder-runtime-test-')); + const { mkdir } = await import('node:fs/promises'); + await mkdir(join(directory, 'scratch')); +}); +afterEach(async () => { + for (const value of runtimes.splice(0)) await value.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); +}); + +it('idle -> secure initial run -> independently observed active -> reload -> actual idle', async () => { + const value = runtime(); + expect(await value.readActive()).toEqual({ ok: true, value: null }); + expect(await value.apply(candidate())).toEqual({ status: 'applied' }); + expect(backend.runCalls[0]!.args.slice(0, 2)).toEqual(['run', '--config']); + expect(backend.runCalls[0]!.args).not.toContain('--resume'); + expect(backend.runCalls[0]!.options.longLived).toBe(true); + expect(await value.readActive()).toEqual({ ok: true, value: candidate().effectiveConfigHash }); + expect(await value.apply(candidate('reload'))).toEqual({ status: 'applied' }); + expect(await value.readActive()).toEqual({ + ok: true, + value: candidate('reload').effectiveConfigHash, + }); + expect(backend.runCalls).toHaveLength(1); + expect(await value.apply(null)).toEqual({ status: 'applied' }); + expect(backend.stops).toBe(1); + expect(await value.readActive()).toEqual({ ok: true, value: null }); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); +}); + +it('performs only a conflict check before verified native startup', async () => { + let release!: () => void; + backend.launchGate = new Promise((resolve) => { + release = resolve; + }); + const value = runtime(); + const pending = value.apply(candidate()); + await vi.waitFor(() => expect(backend.runCalls).toHaveLength(1), { interval: 2 }); + expect(backend.reads).toBe(1); + expect(backend.stops).toBe(0); + release(); + expect(await pending).toEqual({ status: 'applied' }); + expect(backend.reads).toBeGreaterThan(0); +}); + +it.each(['ready', 'silent', 'exit', 'launch-failed'] as const)( + 'refuses an existing matching endpoint without launching or stopping it (%s)', + async (mode) => { + backend.active = candidate().effectiveConfigHash; + backend.startup = mode; + const value = runtime(); + expect(await value.apply(candidate())).toEqual({ status: 'definitely-rejected' }); + await value.close(); + expect(backend.runCalls).toHaveLength(0); + expect(backend.reads).toBe(1); + expect(backend.stops).toBe(0); + expect(backend.active).toBe(candidate().effectiveConfigHash); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); + }, +); + +it('close cancels delayed native startup without administering an unstarted process', async () => { + backend.launchGate = new Promise(() => undefined); + const value = runtime(); + const pending = value.apply(candidate()); + await vi.waitFor(() => expect(backend.runCalls).toHaveLength(1), { interval: 2 }); + await value.close(); + expect(await pending).toEqual({ status: 'ambiguous' }); + expect(backend.reads).toBe(1); + expect(backend.stops).toBe(0); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); +}); + +it('rejection and ambiguous reload never substitute receipts for independent state', async () => { + const value = runtime(); + await value.apply(candidate()); + backend.loadStatus = 'definitely-rejected'; + expect(await value.apply(candidate('rejected'))).toEqual({ status: 'definitely-rejected' }); + expect(await value.readActive()).toEqual({ ok: true, value: candidate().effectiveConfigHash }); + backend.loadStatus = 'ambiguous'; + backend.uncertainLoadChanges = true; + expect(await value.apply(candidate('uncertain'))).toEqual({ status: 'ambiguous' }); + expect(await value.readActive()).toEqual({ + ok: true, + value: candidate('uncertain').effectiveConfigHash, + }); + backend.active = null; + expect((await value.readActive()).ok).toBe(false); +}); + +it('snapshots candidates and startup environment before asynchronous effects', async () => { + const original = policy(); + const value = runtime(original); + Object.assign(original.environment, { FIXTURE_STARTUP: 'mutated' }); + const first = structuredClone(candidate()); + const hash = first.effectiveConfigHash; + const pending = value.apply(first); + Object.assign(first.adaptedConfig, { body: '{}' }); + Object.assign(first, { effectiveConfigHash: 'mutated' }); + expect(await pending).toEqual({ status: 'applied' }); + expect(backend.runCalls[0]!.options.env!.FIXTURE_STARTUP).toBe('immutable'); + expect(await value.readActive()).toEqual({ ok: true, value: hash }); +}); + +it('refuses malformed, noncanonical and security-changing candidates before any effects', async () => { + const value = runtime(); + expect(await value.apply({ ...candidate(), effectiveConfigHash: 'wrong' })).toEqual({ + status: 'definitely-rejected', + }); + const changed = normalizeCaddyConfig('{"admin":{"disabled":false},"apps":{"pki":{}}}'); + expect(await value.apply(changed)).toEqual({ status: 'definitely-rejected' }); + expect(await value.apply(normalizeCaddyConfig('{}'))).toEqual({ status: 'definitely-rejected' }); + const spaced = ` ${candidate().adaptedConfig.body}`; + expect( + await value.apply({ + adaptedConfig: { format: 'json', body: spaced }, + effectiveConfigHash: configHash(spaced), + }), + ).toEqual({ status: 'definitely-rejected' }); + expect(backend.runCalls).toEqual([]); + expect(backend.reads).toBe(0); + expect(vi.mocked(assertProtected)).not.toHaveBeenCalled(); +}); + +it.each(['silent', 'mismatch', 'exit'] as const)( + 'settles startup %s and supports explicit retry only', + async (mode) => { + backend.startup = mode; + const value = runtime(); + expect(await value.apply(candidate())).toEqual({ status: 'ambiguous' }); + expect(await value.readActive()).toEqual({ ok: true, value: null }); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); + expect(backend.runCalls).toHaveLength(1); + backend.startup = 'ready'; + expect(await value.apply(candidate('retry'))).toEqual({ status: 'applied' }); + expect(backend.runCalls).toHaveLength(2); + }, +); + +it('unexpected exit is unavailable, never cached active/idle, until explicit reconciliation', async () => { + const value = runtime(); + await value.apply(candidate()); + backend.settle?.(); + await Promise.resolve(); + await Promise.resolve(); + expect((await value.readActive()).ok).toBe(false); + expect(backend.runCalls).toHaveLength(1); + expect(await value.apply(candidate('explicit'))).toEqual({ status: 'applied' }); + expect(backend.runCalls).toHaveLength(2); +}); + +it.each(['ignored', 'hanging'] as const)( + 'forces only owned work after bounded %s stop', + async (mode) => { + const value = runtime(); + await value.apply(candidate()); + backend.stopMode = mode; + expect(await value.apply(null)).toEqual({ status: 'applied' }); + expect(await value.readActive()).toEqual({ ok: true, value: null }); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); + }, +); + +it('close cancels startup readback, refuses queued admissions and drains before return', async () => { + backend.startup = 'silent'; + backend.blockRead = true; + const value = runtime(); + const starting = value.apply(candidate()); + await vi.waitFor(() => expect(backend.runCalls).toHaveLength(1)); + const queued = value.apply(candidate('never')); + const closing = value.close(); + expect(await value.apply(candidate('closed'))).toEqual({ status: 'definitely-rejected' }); + expect(await queued).toEqual({ status: 'definitely-rejected' }); + expect(await starting).toEqual({ status: 'ambiguous' }); + await closing; + expect(value.close()).toBe(closing); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); + expect((await value.readActive()).ok).toBe(false); +}); + +it.each(['graceful', 'ignored', 'hanging'] as const)( + 'healthy close attempts bounded %s stop before owned force', + async (mode) => { + const value = runtime(); + await value.apply(candidate()); + backend.stopMode = mode; + await value.close(); + expect(backend.stops).toBe(1); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); + expect(await value.apply(candidate('closed'))).toEqual({ status: 'definitely-rejected' }); + }, +); + +it('close aborts a reload in flight without waiting for its normal admin deadline', async () => { + const value = runtime(); + await value.apply(candidate()); + backend.blockRead = true; + const loading = value.apply(candidate('reload')); + await vi.waitFor(() => expect(backend.loads).toBe(1)); + await value.close(); + expect(await loading).toEqual({ status: 'ambiguous' }); + expect(await readdir(join(directory, 'scratch'))).toEqual([]); +}); + +it('cleanup faults stay ambiguous, retain staging and prevent false idle/restart', async () => { + const value = runtime(); + await value.apply(candidate()); + backend.stopMode = 'ignored'; + backend.cleanupFault = true; + expect(await value.apply(null)).toEqual({ status: 'ambiguous' }); + expect((await value.readActive()).ok).toBe(false); + expect(await value.apply(candidate('unsafe-restart'))).toEqual({ status: 'ambiguous' }); + expect(await readdir(join(directory, 'scratch'))).toHaveLength(1); + await expect(value.close()).rejects.toThrow('teardown could not be proved'); +}); + +it('refuses unsafe roots and validator rejection without a native start', async () => { + vi.mocked(assertProtected).mockRejectedValue(new Error('private ACL parser diagnostic')); + const value = runtime(); + expect(await value.apply(candidate())).toEqual({ status: 'definitely-rejected' }); + expect(backend.runCalls).toEqual([]); + vi.mocked(assertProtected).mockResolvedValue(undefined); + backend.validationFails = true; + expect(await value.apply(candidate())).toEqual({ status: 'definitely-rejected' }); + expect(backend.runCalls).toEqual([]); +}); + +it('close cancels between strict root checks without acquiring native or admin work', async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + vi.mocked(assertProtected).mockImplementationOnce(() => blocked); + const value = runtime(); + const pending = value.apply(candidate()); + await vi.waitFor(() => expect(assertProtected).toHaveBeenCalledTimes(1), { interval: 2 }); + const closing = value.close(); + release(); + expect(await pending).toEqual({ status: 'definitely-rejected' }); + await closing; + expect(assertProtected).toHaveBeenCalledTimes(1); + expect(backend.runCalls).toEqual([]); + expect(backend.reads).toBe(0); +}); + +it('staging deletion failure remains unavailable and close refuses false success', async () => { + const value = runtime(); + await value.apply(candidate()); + vi.mocked(assertProtected).mockRejectedValue(new Error('staging replaced')); + expect(await value.apply(null)).toEqual({ status: 'ambiguous' }); + expect((await value.readActive()).ok).toBe(false); + await expect(value.close()).rejects.toThrow('teardown could not be proved'); +}); + +it('real transaction queue rolls persistence-failed first start back to actual idle and reconciles', async () => { + const value = runtime(); + const idle: ConfigurationSnapshot = { + config: null, + desiredState: { projects: [] }, + registrations: [], + }; + const queue = new ConfigurationTransactions( + idle, + { + validate: async (config) => ({ + ok: true, + value: { effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }, + }), + }, + { + persistDesiredState: async () => ({ + ok: false, + error: { + kind: 'storage', + code: 'fixture_persist_failed', + message: 'Fixture persistence failure.', + guidance: null, + retryable: false, + requestId: null, + }, + }), + }, + value, + ); + expect( + (await queue.submit(() => ({ ok: true, value: { ...idle, config: candidate() } }))).ok, + ).toBe(false); + expect(queue.readCommitted()).toEqual(idle); + expect(await value.readActive()).toEqual({ ok: true, value: null }); + expect((await queue.reconcile()).ok).toBe(true); + await queue.close(); +}); + +it.each([false, true])( + 'real queue recovers an unexpected exit only after proved cleanup (cleanupFault=%s)', + async (cleanupFault) => { + const value = runtime(); + const idle: ConfigurationSnapshot = { + config: null, + desiredState: { projects: [] }, + registrations: [], + }; + const persist = vi.fn(async () => ({ ok: true as const, value: undefined })); + const queue = new ConfigurationTransactions( + idle, + { + validate: async (config) => ({ + ok: true, + value: { effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }, + }), + }, + { persistDesiredState: persist }, + value, + ); + const committed = { ...idle, config: candidate() }; + expect((await queue.submit(() => ({ ok: true, value: committed }))).ok).toBe(true); + if (cleanupFault) { + backend.stopMode = 'ignored'; + backend.cleanupFault = true; + expect(await value.apply(null)).toEqual({ status: 'ambiguous' }); + } else { + backend.settle?.(); + } + expect((await value.readActive()).ok).toBe(false); + expect((await queue.reconcile()).ok).toBe(!cleanupFault); + expect(queue.readCommitted()).toEqual(committed); + expect(persist).toHaveBeenCalledTimes(1); + expect(backend.runCalls).toHaveLength(cleanupFault ? 1 : 2); + if (cleanupFault) { + expect(queue.readFence()?.code).toBe('config_reconciliation_failed'); + expect((await value.readActive()).ok).toBe(false); + } else { + expect(queue.readFence()).toBeNull(); + expect(await value.readActive()).toEqual({ + ok: true, + value: committed.config.effectiveConfigHash, + }); + } + await queue.close(); + }, +); diff --git a/test/channel.test.ts b/test/channel.test.ts new file mode 100644 index 0000000..2a365c3 --- /dev/null +++ b/test/channel.test.ts @@ -0,0 +1,61 @@ +import { createConnection, createServer } from 'node:net'; +import { once } from 'node:events'; +import { expect, it } from 'vitest'; +import { JsonChannel } from '../src/protocol/channel.ts'; +import { MAX_FRAME_BYTES } from '../src/protocol/version.ts'; + +async function fixture() { + const server = createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing fixture port'); + const incoming = once(server, 'connection'); + const socket = createConnection(address.port, '127.0.0.1'); + const [peer] = await incoming; + const channel = new JsonChannel(socket); + return { + peer, + channel, + close() { + peer.destroy(); + channel.close(); + server.close(); + }, + }; +} + +it('parses split and batched NDJSON frames', async () => { + const pair = await fixture(); + try { + pair.peer.write('{"a":'); + pair.peer.write('1}\n{"b":2}\n'); + expect(await pair.channel.next()).toEqual({ a: 1 }); + expect(await pair.channel.next()).toEqual({ b: 2 }); + } finally { + pair.close(); + } +}); +it('bounds incomplete frames and refuses malformed JSON', async () => { + for (const frame of ['{invalid}\n', 'x'.repeat(MAX_FRAME_BYTES + 1)]) { + const pair = await fixture(); + try { + const result = pair.channel.next(); + pair.peer.write(frame); + await expect(result).rejects.toThrow('Invalid or oversized'); + } finally { + pair.close(); + } + } +}); +it('bounds outgoing frames and rejects concurrent reads and timeouts', async () => { + const pair = await fixture(); + try { + expect(() => pair.channel.send('x'.repeat(MAX_FRAME_BYTES))).toThrow('size limit'); + const pending = pair.channel.next(10); + await expect(pair.channel.next()).rejects.toThrow('Only one IPC read'); + await expect(pending).rejects.toThrow('deadline'); + } finally { + pair.close(); + } +}); diff --git a/test/client-service.test.ts b/test/client-service.test.ts new file mode 100644 index 0000000..893466b --- /dev/null +++ b/test/client-service.test.ts @@ -0,0 +1,121 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { ClientService } from '../src/client/service.ts'; +import { daemonCommand, launchDaemon } from '../src/client/launch.ts'; +import { rpc } from '../src/client/connection.ts'; +import { CadderError, RpcError } from '../src/protocol/errors.ts'; +import { fakeSnapshot } from './fixtures/rpc-data.ts'; +import { runtimeLogStream } from '../src/storage/schema.ts'; +import type { ChildProcess } from 'node:child_process'; + +vi.mock('../src/client/connection.ts', () => ({ rpc: vi.fn() })); +vi.mock('../src/client/launch.ts', async (original) => ({ + ...(await original()), + launchDaemon: vi.fn(), +})); +afterEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); +}); +const basic = { + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: 'Done', +}; +const ready = { ...basic, snapshot: fakeSnapshot() }; +const offline = () => new CadderError('daemon-unavailable', 'Offline'); + +it('shares the closed RPC operations and retains typed offline/rejection failures', async () => { + const client = new ClientService(); + vi.mocked(rpc).mockResolvedValue(ready); + expect((await client.queryState()).ok).toBe(true); + await client.queryLogs({ stream: runtimeLogStream, limit: 5 }); + await client.setProjectEnabled({ registrationId: 'project', enabled: false }); + await client.setDomainEnabled({ + registrationId: 'project', + domainKey: 'app.localhost', + enabled: false, + }); + await client.shutdown(); + expect(vi.mocked(rpc).mock.calls.map((call) => call[1])).toEqual([ + 'query-state-request', + 'query-logs-request', + 'set-entrypoint-enabled-request', + 'set-domain-enabled-request', + 'shutdown-daemon-request', + ]); + const conflict = new RpcError({ + kind: 'conflict', + code: 'conflict', + message: 'Conflict', + guidance: null, + retryable: false, + requestId: null, + }); + vi.mocked(rpc).mockRejectedValueOnce(conflict); + expect(await client.setProjectEnabled({ registrationId: 'project', enabled: true })).toEqual({ + ok: false, + error: conflict.protocolError, + }); + vi.mocked(rpc).mockRejectedValueOnce(offline()); + expect(await client.queryState()).toHaveProperty('error.code', 'daemon_unavailable'); +}); + +it('attaches first and launches only the packaged daemon when genuinely missing', async () => { + const client = new ClientService(); + vi.mocked(rpc).mockResolvedValue(ready); + await client.start(); + expect(launchDaemon).not.toHaveBeenCalled(); + vi.mocked(rpc).mockRejectedValueOnce(offline()); + await client.start(); + expect(launchDaemon).toHaveBeenCalledOnce(); + const command = await daemonCommand(); + expect(command.file).toBe(process.execPath); + expect(command.args.at(-1)).toMatch(/entrypoints[/\\]cadderd\.ts$/); + vi.mocked(rpc).mockRejectedValueOnce(new CadderError('authentication-failed', 'Wrong peer')); + await expect(client.start()).rejects.toMatchObject({ code: 'authentication-failed' }); + expect(launchDaemon).toHaveBeenCalledOnce(); +}); + +it('waits through owned shutdown before restart instead of treating closing as stopped', async () => { + const client = new ClientService(); + vi.mocked(rpc) + .mockResolvedValueOnce(basic) + .mockRejectedValueOnce(new CadderError('daemon_shutting_down', 'Closing')) + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce(offline()) + .mockResolvedValue(ready); + await client.restart(); + const methods = vi.mocked(rpc).mock.calls.map((call) => call[1]); + expect(methods[0]).toBe('shutdown-daemon-request'); + expect(methods.filter((method) => method === 'query-state-request')).toHaveLength(6); + expect(launchDaemon).toHaveBeenCalledOnce(); + vi.mocked(rpc).mockRejectedValueOnce(offline()); + await client.stop(); +}); + +it('waits for startup ACL publication without accepting an unsafe connection or masking later failures', async () => { + const client = new ClientService(); + const kill = vi.fn(); + vi.mocked(launchDaemon).mockResolvedValue({ exitCode: null, kill } as unknown as ChildProcess); + vi.mocked(rpc) + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce( + new CadderError('unsafe-runtime-permissions', 'ACL is not published yet'), + ) + .mockResolvedValueOnce(ready); + vi.spyOn(Date, 'now').mockReturnValue(31000).mockReturnValueOnce(0); + await client.start(); + expect(kill).not.toHaveBeenCalled(); + vi.mocked(rpc) + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce(new CadderError('authentication-failed', 'Wrong peer')); + await expect(client.start()).rejects.toMatchObject({ code: 'authentication-failed' }); + expect(kill).toHaveBeenCalledOnce(); + vi.mocked(rpc).mockRejectedValueOnce( + new CadderError('unsafe-runtime-permissions', 'Existing unsafe path'), + ); + await expect(client.start()).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(launchDaemon).toHaveBeenCalledTimes(2); +}); diff --git a/test/compatibility/catalog.test.ts b/test/compatibility/catalog.test.ts new file mode 100644 index 0000000..26695d7 --- /dev/null +++ b/test/compatibility/catalog.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from 'vitest'; +import { requestSchema, responseSchema, errorResponseSchema } from '../../src/protocol/rpc.ts'; +import { status, projects, domains, logs } from '../../src/cli/output.ts'; +import { fakeSnapshot } from '../fixtures/rpc-data.ts'; +import { humanOutputFixture, rpcExamples, rpcOperationFixtures } from './catalog.ts'; + +it('accepts the frozen released RPC catalog through the actual Node schemas', () => { + for (const fixture of rpcOperationFixtures) { + const request = { + protocolVersion: 3, + requestId: fixture.result.requestId, + method: fixture.method, + params: fixture.params, + }; + expect(requestSchema.parse(request)).toEqual(request); + const response = { protocolVersion: 3, requestId: request.requestId, result: fixture.result }; + expect(responseSchema(fixture.method).parse(response)).toEqual(response); + expect(responseSchema(fixture.method).safeParse({ ...response, extra: true }).success).toBe( + false, + ); + } + expect( + errorResponseSchema.parse({ + protocolVersion: 3, + requestId: rpcExamples.protocolError.requestId, + error: rpcExamples.protocolError.error, + }), + ).toHaveProperty('error'); +}); + +it('matches the released empty-state and bounded-log human output using the Node renderer', () => { + const snapshot = fakeSnapshot(); + snapshot.registrations = []; + snapshot.runtime.status = 'idle'; + const actual = [status(snapshot), projects(snapshot), domains(snapshot)].join('\n') + '\n'; + expect(humanOutputFixture.cases[0]!.output.startsWith(actual)).toBe(true); + const empty = logs({ + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: '', + stream: { + streamId: 'domain-active.localhost', + domainKey: 'active.localhost', + channel: 'caddy', + }, + streamStatus: 'empty', + entries: [], + }); + expect(humanOutputFixture.cases[3]!.output).toContain(empty + '\n'); +}); diff --git a/test/compatibility/catalog.ts b/test/compatibility/catalog.ts new file mode 100644 index 0000000..a35f99e --- /dev/null +++ b/test/compatibility/catalog.ts @@ -0,0 +1,913 @@ +import { readFileSync } from 'node:fs'; + +export const compatibilityCatalogVersion = '1.0.5-rust-baseline'; + +export type SourceAnchor = { + path: string; + markers: readonly string[]; +}; + +export const sourceAnchors: readonly SourceAnchor[] = [ + { + path: 'crates/cadder-client/src/cli/mod.rs', + markers: [ + 'arg_required_else_help = true', + 'pub(crate) enum Command', + 'start_daemon: bool', + 'value_parser = parse_log_limit', + 'Status,', + 'Daemon {', + 'Projects {', + 'Domains {', + 'Port {', + 'Caddyfile {', + 'Diagnostics,', + 'Logs {', + 'Tui {', + 'default_value_t = 50', + ], + }, + { + path: 'crates/cadder-client/src/main.rs', + markers: [ + 'DisplayHelpOnMissingArgumentOrSubcommand', + 'AppExit::InvalidUsage', + 'let _ = error.print();', + ], + }, + { + path: 'crates/cadder-api/src/error.rs', + markers: [ + 'Success = 0', + 'InvalidUsage = 2', + 'DaemonUnavailable = 3', + 'DaemonStartFailure = 4', + 'TargetNotFound = 5', + 'ConflictOrRejected = 6', + 'PermissionOrElevation = 7', + 'UnsupportedOperation = 8', + 'IpcFailure = 9', + ], + }, + { + path: 'crates/cadder-client/src/cli/output.rs', + markers: [ + 'cadderd running', + 'Caddy {}', + 'Config {}', + 'No projects registered.', + 'No domains registered.', + 'No log entries.', + 'PROJECT STATE', + 'STATE", "DOMAIN", "UPSTREAM", "PROJECT', + 'TIME (UTC)', + 'terminal_safe', + 'list_and_status_outputs_cover_empty_and_populated_snapshots', + 'port_output_covers_socket_and_registration_availability', + 'route_outputs_cover_registered_unregistered_and_socket_notes', + 'diagnostics_and_logs_cover_empty_and_detailed_reports', + ], + }, + { + path: 'crates/cadder-shim/src/command_policy.rs', + markers: [ + 'SHIM_COMMAND_POLICY_TABLE', + 'normalized_caddy_command', + 'None | Some("--help" | "-h" | "help")', + 'Some("--version" | "-v" | "version")', + '"run"', + '"adapt"', + '"build-info"', + '"environ"', + '"help"', + '"list-modules"', + '"validate"', + '"version"', + '"completion"', + '"file-server"', + '"fmt"', + '"manpage"', + '"reverse-proxy"', + '"add-package"', + '"reload"', + '"remove-package"', + '"start"', + '"stop"', + '"trust"', + '"untrust"', + '"upgrade"', + ], + }, + { + path: 'crates/cadder-shim/src/main.rs', + markers: [ + 'allow_hyphen_values', + '--cadder-shim-info', + 'Stdio::inherit()', + 'status.code().unwrap_or(1) as u8', + 'ExitCode::FAILURE', + 'run_managed_until', + 'delegate_to_real_caddy', + ], + }, + { + path: 'crates/cadder-daemon/tests/fixtures/test_process.rs', + markers: [ + 'shim-passthrough-', + 'FAKE_CADDY_PASSTHROUGH_STDOUT', + 'FAKE_CADDY_PASSTHROUGH_STDERR', + '"stdin_bytes"', + ], + }, + { + path: 'crates/cadder-shim/tests/shim_binary.rs', + markers: [ + 'real_caddy_passthrough_preserves_arguments_stdin_streams_and_success_exit', + 'real_caddy_passthrough_propagates_nonzero_child_exit', + 'CARGO_BIN_EXE_cadder-shim', + ], + }, + { + path: 'crates/cadder-shim/src/registration.rs', + markers: [ + 'cwd.join("Caddyfile")', + '"--config" | "-c"', + '"--adapter" | "-a"', + 'shim_session_nonce', + ], + }, + { + path: 'crates/cadder-daemon/src/config.rs', + markers: ['CONFIG_FILE_NAME', 'real_command', 'real_path', 'cannot both be configured'], + }, + { + path: 'crates/cadder-daemon/src/caddy/resolver.rs', + markers: [ + 'ExplicitDaemonOverride', + 'PortableConfiguration', + 'UserConfiguration', + 'SystemConfiguration', + 'resolve_caddy_on_path', + 'Project files, registration working directories, environment selectors, and shim flags', + ], + }, + { + path: 'crates/cadder-client/src/inspection.rs', + markers: [ + 'routes_for_port', + 'routes_for_domain', + 'canonicalize_domain', + 'local_upstream', + 'sort_routes', + ], + }, + { + path: 'crates/cadder-ipc/src/message_types.rs', + markers: [ + 'register-entrypoint-request', + 'unregister-entrypoint-request', + 'heartbeat-entrypoint-request', + 'query-state-request', + 'set-entrypoint-enabled-request', + 'set-domain-enabled-request', + 'query-logs-request', + 'shutdown-daemon-request', + 'PROTOCOL_ERROR_RESPONSE', + ], + }, + { + path: 'crates/cadder-ipc/src/operations.rs', + markers: ['OperationAccess::ReadOnly', 'OperationAccess::Mutation', 'OperationShape::Unary'], + }, + { + path: 'crates/cadder-ipc/src/wire.rs', + markers: [ + 'deny_unknown_fields', + 'a response must contain `result` or `error`', + 'a response cannot contain both `result` and `error`', + 'the response and protocol error request IDs must match', + ], + }, + { + path: 'crates/cadder-ipc/src/mutations.rs', + markers: [ + 'pub struct RegisterEntrypointPayload', + 'pub struct UnregisterEntrypointPayload', + 'pub struct HeartbeatEntrypointPayload', + 'pub struct SetEntrypointEnabledPayload', + 'pub struct SetDomainEnabledPayload', + 'pub struct ShutdownDaemonPayload', + 'pub shim_session_nonce: Option', + ], + }, + { + path: 'crates/cadder-ipc/src/commands.rs', + markers: ['pub struct QueryStatePayload', 'pub fn new_request_id'], + }, + { + path: 'crates/cadder-ipc/src/responses.rs', + markers: [ + 'pub struct RegisterEntrypointResponse', + 'pub struct BasicResponse', + 'pub struct QueryStateResponse', + 'pub registration_id: Option', + '#[serde(default)]', + ], + }, + { + path: 'crates/cadder-daemon/src/state/log_queries.rs', + markers: ['request.limit.unwrap_or(100).clamp(1, 200)', 'Caddy logs returned.'], + }, + { + path: 'crates/cadder-daemon/src/database.rs', + markers: ['limit.clamp(1, 200)', 'LIMIT 1000', 'LIMIT 5000'], + }, + { + path: 'crates/cadder-ipc/src/state.rs', + markers: [ + 'pub struct EntrypointRegistration', + 'pub struct EntrypointInstanceIdentity', + 'pub struct OwnerProcessIdentity', + 'pub struct RegisteredDomain', + 'pub struct RuntimeState', + 'pub struct ConfigState', + 'registration_id must match instance_id', + 'entrypoint and owner shim session nonce values must match', + ], + }, + { + path: 'crates/cadder-ipc/src/logs.rs', + markers: [ + 'pub struct LogStreamIdentity', + 'pub struct LogEntry', + 'pub enum LogStreamStatus', + 'pub struct QueryLogsPayload', + ], + }, + { + path: 'openspec/specs/operator-cli/spec.md', + markers: [ + 'Profile, machine-output, history, export, continuous tail, watch, IIS and autostart commands SHALL be rejected as unsupported.', + ], + }, + { + path: 'openspec/specs/operator-inspection/spec.md', + markers: ['INSPECT-001:', 'INSPECT-002:', 'INSPECT-003:', 'INSPECT-004:'], + }, + { + path: 'openspec/specs/operator-tui/spec.md', + markers: ['TUI-001:', 'TUI-002:', 'TUI-003:', 'TUI-004:'], + }, + { + path: 'openspec/specs/local-control-plane/spec.md', + markers: ['IPC-001:', 'IPC-002:', 'IPC-003:'], + }, + { + path: 'openspec/specs/observability/spec.md', + markers: ['OBS-001:', 'OBS-002:', 'OBS-003:', 'OBS-004:'], + }, + { + path: 'openspec/specs/product-topology/spec.md', + markers: ['TOP-001:', 'TOP-002:'], + }, + { + path: 'openspec/changes/reset-cadder-architecture/specs/project-registration/spec.md', + markers: ['REG-004:', 'active domain conflicts SHALL preserve the previous committed state'], + }, + { + path: 'openspec/changes/reset-cadder-architecture/specs/caddy-shim-integration/spec.md', + markers: ['SHIM-001:', 'SHIM-002:', 'SHIM-003:', 'standard streams and exit codes'], + }, +]; + +export type CliCase = { + argv: readonly string[]; + expected: 'success' | 'invalid-usage'; + exitCode: 0 | 2; + stream: 'stdout' | 'stderr'; + notes: string; +}; + +const success = ( + argv: readonly string[], + notes: string, + stream: CliCase['stream'] = 'stdout', +): CliCase => ({ argv, expected: 'success', exitCode: 0, stream, notes }); +const invalid = (argv: readonly string[], notes: string): CliCase => ({ + argv, + expected: 'invalid-usage', + exitCode: 2, + stream: 'stderr', + notes, +}); + +export const cliCases: readonly CliCase[] = [ + success(['cadder'], 'bare invocation prints help', 'stderr'), + success(['cadder', '--help'], 'explicit help', 'stdout'), + success(['cadder', '--version'], 'explicit version', 'stdout'), + success(['cadder', 'help'], 'help subcommand', 'stdout'), + success(['cadder', 'help', 'status'], 'nested command help', 'stdout'), + success(['cadder', 'status'], 'daemon-backed status'), + success(['cadder', 'daemon', 'start'], 'explicit daemon start'), + success(['cadder', 'daemon', 'stop'], 'explicit daemon stop'), + success(['cadder', 'daemon', 'restart'], 'ordered restart'), + success(['cadder', 'daemon'], 'nested group prints help', 'stderr'), + success(['cadder', 'projects', 'list'], 'project listing'), + success(['cadder', 'projects', 'enable', 'Caddyfile'], 'project activation'), + success(['cadder', 'projects', 'disable', 'Caddyfile'], 'project deactivation'), + success(['cadder', 'projects'], 'nested group prints help', 'stderr'), + success(['cadder', 'domains', 'list'], 'domain listing'), + success(['cadder', 'domains', 'inspect', 'app.localhost'], 'domain inspection'), + success( + ['cadder', 'domains', 'inspect', 'app.localhost', '--caddyfile', 'Caddyfile'], + 'domain inspection with project selector', + ), + success( + ['cadder', 'domains', 'enable', 'app.localhost', '--caddyfile', 'Caddyfile'], + 'domain activation with project selector', + ), + success(['cadder', 'domains', 'disable', 'app.localhost'], 'domain deactivation'), + success( + ['cadder', 'domains', 'disable', 'app.localhost', '--caddyfile', 'Caddyfile'], + 'domain deactivation with project selector', + ), + success(['cadder', 'domains'], 'nested group prints help', 'stderr'), + success(['cadder', 'port', 'inspect', '3000'], 'port inspection'), + success(['cadder', 'port', 'kill', '3000', '--pid', '42'], 'guarded process termination'), + success(['cadder', 'port'], 'nested group prints help', 'stderr'), + invalid(['cadder', 'port', 'kill', '3000'], 'kill requires observed PID'), + success(['cadder', 'caddyfile', 'inspect', 'Caddyfile'], 'Caddyfile inspection'), + success(['cadder', 'caddyfile'], 'nested group prints help', 'stderr'), + success(['cadder', 'diagnostics'], 'explicit diagnostics'), + success(['cadder', 'logs', 'runtime'], 'bounded runtime logs'), + success(['cadder', 'logs', 'runtime', '--limit', '200'], 'bounded runtime logs at upper bound'), + success(['cadder', 'logs', 'project', 'Caddyfile', '--limit', '200'], 'bounded project logs'), + success(['cadder', 'logs', 'domain', 'app.localhost', '--limit', '1'], 'bounded domain logs'), + success( + ['cadder', 'logs', 'domain', 'app.localhost', '--caddyfile', 'Caddyfile', '--limit', '1'], + 'bounded domain logs with project selector', + ), + invalid(['cadder', 'logs', 'runtime', '--limit', '0'], 'log limit lower bound'), + invalid(['cadder', 'logs', 'runtime', '--limit', '201'], 'log limit upper bound'), + success(['cadder', 'logs'], 'nested group prints help', 'stderr'), + success(['cadder', 'tui'], 'attach-first routes TUI'), + success(['cadder', 'tui', '--start-daemon'], 'explicit offline/start path'), + invalid(['cadder', '--runtime-dir', 'fixture', 'tui'], 'runtime selection is not CLI grammar'), + invalid(['cadder', 'web'], 'unknown command is rejected'), + invalid(['cadder', 'profile', 'dev'], 'removed profile input is clap invalid usage'), + invalid(['cadder', '--json', 'status'], 'removed machine output is clap invalid usage'), + invalid(['cadder', 'status', '--json'], 'removed machine output after subcommand'), + invalid(['cadder', 'tui', '--json'], 'removed machine output on TUI'), + invalid(['cadder', 'history'], 'removed history input is clap invalid usage'), + invalid(['cadder', 'export'], 'removed export input is clap invalid usage'), + invalid(['cadder', 'logs', 'runtime', '--tail'], 'removed continuous tail is clap invalid usage'), + invalid(['cadder', 'watch'], 'removed watch input is clap invalid usage'), + invalid(['cadder', 'autostart'], 'removed autostart input is clap invalid usage'), + invalid(['cadder', 'iis'], 'removed IIS input is clap invalid usage'), +]; + +export const exitCodes = { + success: 0, + invalidUsage: 2, + daemonUnavailable: 3, + daemonStartFailure: 4, + targetNotFound: 5, + conflictOrRejected: 6, + permissionOrElevation: 7, + unsupportedOperation: 8, + ipcFailure: 9, +} as const; + +type HumanOutputFixture = { + sourceRevision: string; + version: string; + platform: string; + capture: string; + sourceState: string; + portability: string; + cases: readonly { name: string; output: string }[]; +}; + +export const humanOutputFixture = JSON.parse( + readFileSync(new URL('./human-output.json', import.meta.url), 'utf8'), +) as HumanOutputFixture; + +export const humanOutput = { + status: ['cadderd running', 'Caddy running', 'Config idle'], + emptyProjects: ['No projects registered.'], + emptyDomains: ['No domains registered.'], + emptyLogs: ['No log entries.'], + logHeader: ['SEQ', 'TIME (UTC)', 'LEVEL', 'MESSAGE'], + diagnosticHeaders: ['Runtime diagnostics', 'Configuration diagnostics'], + populatedProjects: { + headers: ['STATE', 'PROJECT', 'CADDYFILE'], + }, + populatedDomains: { + headers: ['STATE', 'DOMAIN', 'UPSTREAM', 'PROJECT'], + }, + populatedPort: { + sections: ['Port {port}', 'Sockets', 'Cadder routes'], + ownerHeaders: ['PROTOCOL', 'ADDRESS', 'PID', 'PROCESS', 'EXECUTABLE'], + }, + populatedCaddyfile: { + sections: ['Registered yes', 'Domains', 'Local upstream sockets'], + headers: ['PROJECT STATE', 'DOMAIN STATE', 'DOMAIN', 'UPSTREAM'], + }, + populatedLogs: { + status: 'active', + channel: 'caddy', + domain: 'active.localhost', + }, + captureNames: [ + 'list_and_status_outputs_cover_empty_and_populated_snapshots', + 'port_output_covers_socket_and_registration_availability', + 'route_outputs_cover_registered_unregistered_and_socket_notes', + 'diagnostics_and_logs_cover_empty_and_detailed_reports', + ], +} as const; + +export type ShimPolicy = + | 'managed' + | 'read-only-inspection' + | 'explicit-passthrough' + | 'unsupported'; + +export const shimCommandCases: readonly { command: string; policy: ShimPolicy }[] = [ + { command: 'run', policy: 'managed' }, + ...['adapt', 'build-info', 'environ', 'help', 'list-modules', 'validate', 'version'].map( + (command) => ({ command, policy: 'read-only-inspection' as const }), + ), + ...['completion', 'file-server', 'fmt', 'manpage', 'reverse-proxy'].map((command) => ({ + command, + policy: 'explicit-passthrough' as const, + })), + ...[ + 'add-package', + 'reload', + 'remove-package', + 'start', + 'stop', + 'trust', + 'untrust', + 'upgrade', + ].map((command) => ({ command, policy: 'unsupported' as const })), + { command: 'not-a-caddy-command', policy: 'unsupported' }, +]; + +export const shimAliases = [ + { argv: [], normalized: 'help' }, + { argv: ['--help'], normalized: 'help' }, + { argv: ['-h'], normalized: 'help' }, + { argv: ['help'], normalized: 'help' }, + { argv: ['--version'], normalized: 'version' }, + { argv: ['-v'], normalized: 'version' }, + { argv: ['version'], normalized: 'version' }, +] as const; + +export const shimRunMetadata = { + defaultConfig: 'Caddyfile', + configOptions: ['--config', '-c'], + adapterOptions: ['--adapter', '-a'], + preservesRawArguments: true, + preservesJoinedCommandLine: true, + managedRun: { + streams: 'no child delegation; registration session owns no real-Caddy child', + exitCodes: { clean: 0, backendOrRecoveryFailure: 1 }, + missingDaemon: 'start managed daemon; never delegate to independent Caddy', + }, + delegation: { + classes: ['read-only-inspection', 'explicit-passthrough'], + streams: ['stdin', 'stdout', 'stderr'], + exitStatus: 'status.code() as u8, or 1 when no status exists', + }, +} as const; + +export const caddyResolutionPrecedence = [ + 'absolute daemon-start override', + 'portable cadder.toml beside the executable', + 'per-user trusted cadder.toml', + 'system trusted cadder.toml', + 'safe native caddy on PATH', +] as const; + +export const caddyResolutionAuthority = { + path: 'crates/cadder-daemon/src/caddy/tests.rs', + tests: [ + 'trusted_caddy_source_explicit_override_precedes_user_and_system_configuration', + 'trusted_caddy_source_user_configuration_precedes_system_configuration', + 'trusted_caddy_source_uses_system_default_after_empty_user_config', + 'trusted_caddy_source_invalid_higher_priority_config_fails_without_fallback', + 'trusted_caddy_source_uses_portable_configuration_and_ignores_project_and_environment_selectors', + 'trusted_caddy_source_portable_command_selects_the_named_path_executable', + 'trusted_caddy_source_path_skips_shim_identity_and_uses_next_candidate', + ], +} as const; + +export const configurationCases = [ + { + toml: '[caddy]\nreal_command = "caddy-real"', + result: 'command:caddy-real', + sourceMarker: 'real_command', + }, + { + toml: '[caddy]\nreal_path = "/opt/caddy"', + result: 'path:/opt/caddy', + sourceMarker: 'real_path', + }, + { + toml: '[caddy]\nreal_command = "caddy-real"\nreal_path = "/opt/caddy"', + result: 'reject:both selectors', + sourceMarker: 'cannot both be configured', + }, + { + toml: '[defaults]\nreal_caddy = "/opt/caddy"', + result: 'reject:removed defaults shape', + sourceMarker: 'deny_unknown_fields', + }, + { + toml: '[caddy]\nunknown = "caddy"', + result: 'reject:unknown field', + sourceMarker: 'deny_unknown_fields', + }, + { + toml: '[caddy\nreal_path = "/opt/caddy"', + result: 'reject:invalid TOML', + sourceMarker: 'from_reader', + }, +] as const; + +// Expected adaptation outcomes are migration examples, not executed real-Caddy parser goldens. +// Each citation proves only its named inspection/composition/mock aspect. +export const caddyfileExamples = [ + { + source: 'app.localhost {\n reverse_proxy 127.0.0.1:3000\n}', + domain: 'app.localhost', + upstream: '127.0.0.1:3000', + localPort: 3000, + expected: 'accepted', + evidence: { + aspect: 'local upstream port correlation, not Caddyfile adaptation', + path: 'crates/cadder-client/src/inspection.rs', + test: 'recognizes_local_upstream_aliases', + }, + }, + { + source: 'remote.example {\n reverse_proxy api.example.com:3000\n}', + domain: 'remote.example', + upstream: 'api.example.com:3000', + localPort: null, + expected: 'accepted', + evidence: { + aspect: 'remote upstream exclusion from local ports, not Caddyfile adaptation', + path: 'crates/cadder-client/src/inspection.rs', + test: 'excludes_remote_and_dynamic_upstreams', + }, + }, + { + source: ':3000 {\n respond "ok"\n}', + domain: '', + upstream: null, + localPort: null, + expected: 'accepted', + evidence: { + aspect: 'adapted hostless sibling route composition, not acceptance of this Caddyfile', + path: 'crates/cadder-daemon/src/caddy/tests.rs', + test: 'compose_config_guards_hostless_sibling_routes_with_registration_hosts', + }, + }, + { + source: 'app.localhost {\n', + domain: null, + upstream: null, + localPort: null, + expected: 'rejected', + evidence: { + aspect: 'malformed input denial by the retained test-only mock adapter', + path: 'crates/cadder-daemon/src/caddy/tests.rs', + test: 'mock_adapter_reports_invalid_caddyfile', + }, + }, +] as const; + +export const registrationFixture = { + registrationId: 'shim-fixture-01', + entrypointInstance: { + instanceId: 'shim-fixture-01', + startedAtUtc: '2026-01-01T00:00:00Z', + shimSessionNonce: 'session-fixture-01', + }, + sourceWorkingDirectory: { raw: '/workspace/project', canonical: '/workspace/project' }, + sourceConfigPath: { raw: 'Caddyfile', canonical: '/workspace/project/Caddyfile' }, + registeredDomains: [ + { + name: { raw: 'App.Localhost.', canonical: 'app.localhost' }, + activationState: 'active', + upstream: '127.0.0.1:3000', + logStream: { streamId: 'domain-app.localhost', domainKey: 'app.localhost', channel: 'caddy' }, + }, + ], + activationState: 'active', + ownerProcess: { + processId: 42, + processStartTimeUtc: '2026-01-01T00:00:00Z', + shimSessionNonce: 'session-fixture-01', + executablePath: null, + }, + logStream: { streamId: 'entrypoint-shim-fixture-01', domainKey: null, channel: 'caddy' }, + shimRun: { + adapter: 'caddyfile', + rawArguments: ['run', '--config', 'Caddyfile', '--adapter', 'caddyfile'], + commandLine: 'run --config Caddyfile --adapter caddyfile', + }, + createdAtUtc: '2026-01-01T00:00:00Z', + lastHeartbeatUtc: '2026-01-01T00:00:00Z', +} as const; + +export const stateModelFields = { + runtime: ['status', 'binaryPath', 'version', 'processId', 'adminEndpoint', 'diagnostics'], + config: [ + 'status', + 'lastAttemptedAtUtc', + 'lastSuccessfulReloadAtUtc', + 'effectiveConfigHash', + 'diagnostics', + ], + registration: [ + 'registrationId', + 'entrypointInstance', + 'sourceWorkingDirectory', + 'sourceConfigPath', + 'registeredDomains', + 'activationState', + 'ownerProcess', + 'logStream', + 'shimRun', + 'createdAtUtc', + 'lastHeartbeatUtc', + ], + entrypointInstance: ['instanceId', 'startedAtUtc', 'shimSessionNonce'], + ownerProcess: ['processId', 'processStartTimeUtc', 'shimSessionNonce', 'executablePath'], + domain: ['name', 'activationState', 'upstream', 'logStream'], + logEntry: [ + 'sequenceNumber', + 'timestampUtc', + 'severity', + 'stream', + 'attributionKind', + 'entryKind', + 'rawMessage', + 'domainKey', + 'sourceRegistrationId', + 'sourceInstanceId', + 'operation', + ], +} as const; + +type Field = { name: string; optional: boolean }; +type OperationFixture = { + operation: string; + payloadType: string; + payloadFields: readonly Field[]; + result: string; + resultType: string; + resultFields: readonly Field[]; + access: 'mutation' | 'read-only'; +}; + +const required = (name: string): Field => ({ name, optional: false }); +const optional = (name: string): Field => ({ name, optional: true }); + +export const operationCatalog: readonly OperationFixture[] = [ + { + operation: 'register-entrypoint-request', + payloadType: 'RegisterEntrypointPayload', + payloadFields: [required('registration')], + result: 'register-entrypoint-response', + resultType: 'RegisterEntrypointResponse', + resultFields: [ + required('requestId'), + required('accepted'), + required('message'), + optional('registrationId'), + ], + access: 'mutation', + }, + { + operation: 'unregister-entrypoint-request', + payloadType: 'UnregisterEntrypointPayload', + payloadFields: [required('registrationId'), required('shimSessionNonce')], + result: 'unregister-entrypoint-response', + resultType: 'BasicResponse', + resultFields: [required('requestId'), required('accepted'), required('message')], + access: 'mutation', + }, + { + operation: 'heartbeat-entrypoint-request', + payloadType: 'HeartbeatEntrypointPayload', + payloadFields: [required('registrationId'), required('shimSessionNonce')], + result: 'heartbeat-entrypoint-response', + resultType: 'BasicResponse', + resultFields: [required('requestId'), required('accepted'), required('message')], + access: 'mutation', + }, + { + operation: 'query-state-request', + payloadType: 'QueryStatePayload', + payloadFields: [], + result: 'query-state-response', + resultType: 'QueryStateResponse', + resultFields: [ + required('requestId'), + required('accepted'), + required('message'), + optional('snapshot'), + ], + access: 'read-only', + }, + { + operation: 'set-entrypoint-enabled-request', + payloadType: 'SetEntrypointEnabledPayload', + payloadFields: [required('registrationId'), optional('shimSessionNonce'), required('enabled')], + result: 'set-entrypoint-enabled-response', + resultType: 'BasicResponse', + resultFields: [required('requestId'), required('accepted'), required('message')], + access: 'mutation', + }, + { + operation: 'set-domain-enabled-request', + payloadType: 'SetDomainEnabledPayload', + payloadFields: [required('registrationId'), required('domainKey'), required('enabled')], + result: 'set-domain-enabled-response', + resultType: 'BasicResponse', + resultFields: [required('requestId'), required('accepted'), required('message')], + access: 'mutation', + }, + { + operation: 'query-logs-request', + payloadType: 'QueryLogsPayload', + payloadFields: [required('stream'), optional('limit')], + result: 'query-logs-response', + resultType: 'QueryLogsResponse', + resultFields: [ + required('requestId'), + required('accepted'), + required('message'), + required('stream'), + required('streamStatus'), + required('entries'), + ], + access: 'read-only', + }, + { + operation: 'shutdown-daemon-request', + payloadType: 'ShutdownDaemonPayload', + payloadFields: [], + result: 'shutdown-daemon-response', + resultType: 'BasicResponse', + resultFields: [required('requestId'), required('accepted'), required('message')], + access: 'mutation', + }, +]; + +export const responseContract = { + requestFields: ['protocolVersion', 'operation', 'requestId', 'payload'], + successFields: ['protocolVersion', 'operation', 'requestId', 'result'], + errorFields: ['protocolVersion', 'operation', 'requestId', 'error'], + protocolErrorResponse: { name: 'protocol-error-response', fields: ['requestId', 'error'] }, + errorRequestIdRule: 'response and protocol error request IDs must match', + exactlyOneOutcome: true, + unknownFieldsRejected: true, + streamStatuses: ['unknown', 'empty', 'active', 'stale', 'removed', 'readError'], + logLimit: { minimum: 1, maximum: 200, cliDefault: 50, rpcDefault: 100 }, +} as const; + +const rpcRequestId = '00000000-0000-4000-8000-000000000001'; +const rpcTimestamp = '2026-01-01T00:00:00.000Z'; +const rpcBasicResult = { + requestId: rpcRequestId, + accepted: true, + message: 'Fixture operation accepted.', +}; + +export const rpcSnapshotFixture = { + capturedAtUtc: rpcTimestamp, + registrations: [registrationFixture], + runtime: { + status: 'idle', + binaryPath: null, + version: null, + processId: null, + adminEndpoint: null, + diagnostics: [], + }, + config: { + status: 'idle', + lastAttemptedAtUtc: null, + lastSuccessfulReloadAtUtc: null, + effectiveConfigHash: null, + diagnostics: [], + }, + storage: null, +} as const; + +export const rpcOperationFixtures = [ + { + method: 'register-entrypoint-request', + params: { registration: registrationFixture }, + result: { ...rpcBasicResult, registrationId: registrationFixture.registrationId }, + }, + { + method: 'unregister-entrypoint-request', + params: { + registrationId: registrationFixture.registrationId, + shimSessionNonce: 'session-fixture-01', + }, + result: rpcBasicResult, + }, + { + method: 'heartbeat-entrypoint-request', + params: { + registrationId: registrationFixture.registrationId, + shimSessionNonce: 'session-fixture-01', + }, + result: rpcBasicResult, + }, + { + method: 'query-state-request', + params: {}, + result: { ...rpcBasicResult, snapshot: rpcSnapshotFixture }, + }, + { + method: 'set-entrypoint-enabled-request', + params: { + registrationId: registrationFixture.registrationId, + shimSessionNonce: null, + enabled: true, + }, + result: rpcBasicResult, + }, + { + method: 'set-domain-enabled-request', + params: { + registrationId: registrationFixture.registrationId, + domainKey: 'app.localhost', + enabled: false, + }, + result: rpcBasicResult, + }, + { + method: 'query-logs-request', + params: { + stream: { streamId: 'domain-app.localhost', domainKey: 'app.localhost', channel: 'caddy' }, + limit: 100, + }, + result: { + ...rpcBasicResult, + stream: { streamId: 'domain-app.localhost', domainKey: 'app.localhost', channel: 'caddy' }, + streamStatus: 'active', + entries: [], + }, + }, + { + method: 'shutdown-daemon-request', + params: {}, + result: rpcBasicResult, + }, +] as const; + +export const rpcExamples = { + queryLogsRequest: { + protocolVersion: 3, + operation: 'query-logs-request', + requestId: '00000000-0000-4000-8000-000000000002', + payload: { + stream: { streamId: 'domain-app.localhost', domainKey: 'app.localhost', channel: 'caddy' }, + limit: 100, + }, + }, + queryLogsSuccess: { + protocolVersion: 3, + operation: 'query-logs-response', + requestId: '00000000-0000-4000-8000-000000000002', + result: { + requestId: '00000000-0000-4000-8000-000000000002', + accepted: true, + message: 'Caddy logs returned.', + streamStatus: 'active', + entries: [], + }, + }, + protocolError: { + protocolVersion: 3, + operation: 'query-logs-request', + requestId: '00000000-0000-4000-8000-000000000003', + error: { + kind: 'unsupportedOperation', + code: 'unsupported_operation', + message: 'Unsupported operation', + guidance: null, + retryable: false, + requestId: '00000000-0000-4000-8000-000000000003', + }, + }, +} as const; diff --git a/test/compatibility/cli-output.json b/test/compatibility/cli-output.json new file mode 100644 index 0000000..471107d --- /dev/null +++ b/test/compatibility/cli-output.json @@ -0,0 +1,50 @@ +{ + "sourceRevision": "93942af84f555eab2bd68e0a4a9db487ff82d040", + "version": "1.0.5", + "platform": "win32", + "capture": "redirected non-TTY stdout and stderr; cargo build --locked -p cadder-client -p cadder-shim", + "cases": [ + { + "argv": [], + "exitCode": 0, + "stdout": "", + "stderr": "Inspect and manage Cadder routes\n\nUsage: cadder \n\nCommands:\n status Show cadderd, Caddy, and configuration state\n daemon Start, stop, or restart cadderd\n projects Inspect and manage registered projects\n domains Inspect and manage registered domains\n port Inspect a local port or terminate its expected owner\n caddyfile Inspect a Caddyfile registration and its local upstreams\n diagnostics Show explicit runtime and configuration diagnostics\n logs Read bounded, redacted daemon logs\n tui Open the full-screen operator\n help Print this message or the help of the given subcommand(s)\n\nOptions:\n -h, --help Print help\n -V, --version Print version\n" + }, + { + "argv": ["--help"], + "exitCode": 0, + "stdout": "Inspect and manage Cadder routes\n\nUsage: cadder \n\nCommands:\n status Show cadderd, Caddy, and configuration state\n daemon Start, stop, or restart cadderd\n projects Inspect and manage registered projects\n domains Inspect and manage registered domains\n port Inspect a local port or terminate its expected owner\n caddyfile Inspect a Caddyfile registration and its local upstreams\n diagnostics Show explicit runtime and configuration diagnostics\n logs Read bounded, redacted daemon logs\n tui Open the full-screen operator\n help Print this message or the help of the given subcommand(s)\n\nOptions:\n -h, --help Print help\n -V, --version Print version\n", + "stderr": "" + }, + { + "argv": ["--version"], + "exitCode": 0, + "stdout": "cadder 1.0.5\n", + "stderr": "" + }, + { + "argv": ["history"], + "exitCode": 2, + "stdout": "", + "stderr": "error: unrecognized subcommand 'history'\n\nUsage: cadder \n\nFor more information, try '--help'.\n" + }, + { + "argv": ["status", "--json"], + "exitCode": 2, + "stdout": "", + "stderr": "error: unexpected argument '--json' found\n\nUsage: cadder status\n\nFor more information, try '--help'.\n" + }, + { + "argv": ["daemon"], + "exitCode": 0, + "stdout": "", + "stderr": "Start, stop, or restart cadderd\n\nUsage: cadder daemon \n\nCommands:\n start Start cadderd or attach when it is already running\n stop Stop cadderd and its owned Caddy process\n restart Stop and start cadderd in order\n help Print this message or the help of the given subcommand(s)\n\nOptions:\n -h, --help Print help\n" + }, + { + "argv": ["tui", "--json"], + "exitCode": 2, + "stdout": "", + "stderr": "error: unexpected argument '--json' found\n\nUsage: cadder tui [OPTIONS]\n\nFor more information, try '--help'.\n" + } + ] +} diff --git a/test/compatibility/human-output.json b/test/compatibility/human-output.json new file mode 100644 index 0000000..10af78e --- /dev/null +++ b/test/compatibility/human-output.json @@ -0,0 +1,26 @@ +{ + "sourceRevision": "93942af84f555eab2bd68e0a4a9db487ff82d040", + "version": "1.0.5", + "platform": "win32", + "capture": "cargo test --locked -p cadder-client --bin cadder-client cli::output::tests:: -- --exact --nocapture --test-threads=1 --color never; renderer stdout between libtest markers, including its final LF", + "sourceState": "Released renderer with uncommitted fixed-time test inputs; no production changes", + "portability": "Windows renderer goldens; path separators are platform-specific. Native platform capture or explicit path normalization is required for cross-platform parity.", + "cases": [ + { + "name": "list_and_status_outputs_cover_empty_and_populated_snapshots", + "output": "cadderd running\nCaddy idle\nConfig idle\nNo projects registered.\nNo domains registered.\ncadderd running\nCaddy running\nConfig idle\n STATE PROJECT CADDYFILE \n faulted /workspace/project-a /workspace/project-a\\Caddyfile \n active /workspace/project-b /workspace/project-b\\Caddyfile \n STATE DOMAIN UPSTREAM PROJECT \n faulted faulted.localhost api.example.com:443 /workspace/project-a \n active active.localhost :3000 /workspace/project-b \n inactive inactive.localhost :4000 /workspace/project-b \n pending pending.localhost - /workspace/project-b \n" + }, + { + "name": "port_output_covers_socket_and_registration_availability", + "output": "Port 3000\n\nSockets\n PROTOCOL ADDRESS PID PROCESS EXECUTABLE \n tcp :3000 42 server /usr/bin/server \n udp :3000 unknown unknown - \n tcp 192.0.2.1:3000 84 server /usr/bin/server \n tcp [2001:db8::1]:3000 126 server /usr/bin/server \n\nCadder routes\n PROJECT STATE DOMAIN STATE DOMAIN UPSTREAM PROJECT CADDYFILE \n active active active.localhost :3000 /workspace/project-b /workspace/project-b\\Caddyfile \nPort 3000\n\nSockets\nUnavailable: socket inspection unavailable\n\nCadder routes\nRegistration data unavailable.\n\nCadder: daemon unavailable\nPort 3000\n\nSockets\nNo listening socket found.\n\nCadder routes\nNo matching routes.\n" + }, + { + "name": "route_outputs_cover_registered_unregistered_and_socket_notes", + "output": "Caddyfile /workspace/project-b\\Caddyfile\ncadderd running\nCaddy running\nConfig idle\nRegistered yes\n\n PROJECT STATE PROJECT CADDYFILE \n active /workspace/project-b /workspace/project-b\\Caddyfile \n\nDomains\n PROJECT STATE DOMAIN STATE DOMAIN UPSTREAM \n active active active.localhost :3000 \n active inactive inactive.localhost :4000 \n active registered pending.localhost - \n\nLocal upstream sockets\n PROTOCOL ADDRESS PID PROCESS EXECUTABLE \n tcp :3000 42 server /usr/bin/server \nCaddyfile missing/Caddyfile\ncadderd running\nCaddy running\nConfig idle\nRegistered no\nCaddyfile /workspace/project-b\\Caddyfile\ncadderd running\nCaddy running\nConfig idle\nRegistered yes\n\n PROJECT STATE PROJECT CADDYFILE \n active /workspace/project-b /workspace/project-b\\Caddyfile \n\nDomains\n PROJECT STATE DOMAIN STATE DOMAIN UPSTREAM \n active active active.localhost :3000 \n active inactive inactive.localhost :4000 \n active registered pending.localhost - \n\nLocal upstream sockets\nUnavailable: socket inspection unavailable\nDomain active.localhost\ncadderd running\nCaddy running\nConfig idle\n\n PROJECT STATE DOMAIN STATE DOMAIN UPSTREAM PROJECT CADDYFILE \n active active active.localhost :3000 /workspace/project-b /workspace/project-b\\Caddyfile \n\nLocal upstream sockets\n PROTOCOL ADDRESS PID PROCESS EXECUTABLE \n tcp :3000 42 server /usr/bin/server \nDomain active.localhost\ncadderd running\nCaddy running\nConfig idle\n\n PROJECT STATE DOMAIN STATE DOMAIN UPSTREAM PROJECT CADDYFILE \n active active active.localhost :3000 /workspace/project-b /workspace/project-b\\Caddyfile \n\nLocal upstream sockets\nUnavailable: socket inspection unavailable\n" + }, + { + "name": "diagnostics_and_logs_cover_empty_and_detailed_reports", + "output": "Caddy idle\nConfig idle\n\nRuntime diagnostics\nNone.\n\nConfiguration diagnostics\nNone.\nCaddy running\nConfig idle\nPID 42\nVersion v2.11.3\nBinary /usr/bin/caddy\nAdmin localhost:2019\n\nRuntime diagnostics\n CODE OPERATION MESSAGE \n runtime-warning reload runtime message \n\nConfiguration diagnostics\n CODE DOMAIN CADDYFILES MESSAGE \n config-warning active.localhost /workspace/project-b/Caddyfile config message \nStatus empty\nChannel caddy\nDomain active.localhost\n\nNo log entries.\nStatus active\nChannel caddy\nDomain active.localhost\n\n SEQ TIME (UTC) LEVEL MESSAGE \n 7 2026-01-01T00:00:00+00:00 warn redacted log message \n" + } + ] +} diff --git a/test/configuration-transactions.test.ts b/test/configuration-transactions.test.ts new file mode 100644 index 0000000..6c14477 --- /dev/null +++ b/test/configuration-transactions.test.ts @@ -0,0 +1,803 @@ +import { expect, it, vi } from 'vitest'; +import { composeRoutePlan } from '../src/caddy/composition.ts'; +import { configHash, maxCaddyConfigurationBytes } from '../src/caddy/preparation.ts'; +import type { CaddyConfig, DesiredState, PortResult } from '../src/contracts/ports.ts'; +import { + ConfigurationTransactions, + type ConfigurationApplyOutcome, + type ConfigurationSnapshot, + type PrepareConfiguration, +} from '../src/daemon/configuration-transactions.ts'; +import { protocolErrorSchema, type ProtocolError } from '../src/protocol/errors.ts'; +import { fakeRegistration } from './fixtures/rpc-data.ts'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function ok(value: T): PortResult { + return { ok: true, value }; +} +const secret = 'private project exception text'; +const adapterError: ProtocolError = { + kind: 'invalidInput', + code: 'adapter_failed', + message: secret, + guidance: secret, + retryable: false, + requestId: null, +}; +function rejected(): PortResult { + return { ok: false, error: adapterError }; +} +function config(body: string): CaddyConfig { + return { adaptedConfig: { format: 'json', body }, effectiveConfigHash: configHash(body) }; +} +function snapshot(label = 'old', idle = false): ConfigurationSnapshot { + return { + config: idle ? null : config(JSON.stringify({ fixture: label })), + desiredState: { + projects: [ + { + projectKey: label, + sourceWorkingDirectory: '/fixture', + sourceConfigPath: '/fixture/Caddyfile', + enabled: true, + domains: [{ canonicalDomain: 'example.localhost', upstream: null, enabled: true }], + }, + ], + }, + registrations: [{ ...fakeRegistration(), registrationId: label }], + }; +} +function fixture(initial = snapshot()) { + const phases: string[] = []; + let active = initial.config?.effectiveConfigHash ?? null; + let durable = structuredClone(initial.desiredState); + const validate = vi.fn(async (target: CaddyConfig) => { + phases.push('validate'); + return ok({ effectiveConfigHash: target.effectiveConfigHash, diagnostics: [] }); + }); + const apply = vi.fn(async (target: CaddyConfig | null): Promise => { + phases.push(`apply:${target?.effectiveConfigHash ?? 'idle'}`); + active = target?.effectiveConfigHash ?? null; + return { status: 'applied' }; + }); + const readActive = vi.fn(async (): Promise> => { + phases.push('verify'); + return ok(active); + }); + const persistDesiredState = vi.fn(async (state: DesiredState): Promise> => { + phases.push('persist'); + durable = structuredClone(state); + return ok(undefined); + }); + const transactions = new ConfigurationTransactions( + initial, + { validate }, + { persistDesiredState }, + { apply, readActive }, + ); + const prepare = + (target = snapshot('new')): PrepareConfiguration => + () => { + phases.push('prepare'); + return ok(target); + }; + return { + transactions, + phases, + validate, + apply, + readActive, + persistDesiredState, + prepare, + durable: () => durable, + active: () => active, + }; +} +function errorCode(result: PortResult) { + expect(result.ok).toBe(false); + if (result.ok) throw new Error('Expected failure.'); + expect(protocolErrorSchema.safeParse(result.error).success).toBe(true); + expect(JSON.stringify(result.error)).not.toContain(secret); + expect(result.error.message.length).toBeLessThan(200); + return result.error.code; +} + +it('orders preparation, validation, apply, independent verification, persistence and publication', async () => { + const f = fixture(); + const target = snapshot('new'); + const result = await f.transactions.submit(f.prepare(target)); + expect(result).toEqual(ok(target)); + expect(f.phases).toEqual([ + 'prepare', + 'validate', + `apply:${target.config!.effectiveConfigHash}`, + 'verify', + 'persist', + ]); + expect(f.transactions.readCommitted()).toEqual(target); + expect(f.durable()).toEqual(target.desiredState); + expect(Object.keys(f.persistDesiredState.mock.calls[0]![0])).toEqual(['projects']); + expect(JSON.stringify(f.durable())).not.toMatch(/nonce|registrationId|processId|lastHeartbeat/); +}); + +it('serializes competing preparations, consumes route plans inside the queue and reads the newest commit', async () => { + const f = fixture(); + const entered = deferred(); + const release = deferred(); + f.persistDesiredState.mockImplementationOnce(async () => { + f.phases.push('persist-pending'); + entered.resolve(); + await release.promise; + return ok(undefined); + }); + const first = f.transactions.submit(f.prepare(snapshot('first'))); + const secondPrepare = vi.fn((committed: ConfigurationSnapshot) => { + f.phases.push('compose'); + expect(committed).toEqual(snapshot('first')); + const plan = composeRoutePlan([ + { + registration: committed.registrations[0]!, + config: config( + JSON.stringify({ + apps: { + http: { + servers: { + project: { + routes: [ + { + match: [{ host: ['example.localhost'] }], + handle: [{ handler: 'static_response', body: 'fixture' }], + }, + ], + }, + }, + }, + }, + }), + ), + }, + ]); + expect(plan.ok).toBe(true); + if (!plan.ok) throw new Error('Expected route plan.'); + expect(plan.value.kind).toBe('caddy-route-plan'); + expect(plan.value.tlsSubjects).toEqual(['example.localhost']); + // This test candidate is independent: a route plan is not a runnable configuration. + return ok(snapshot('second')); + }); + const second = f.transactions.submit(secondPrepare); + await entered.promise; + expect(secondPrepare).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.validate).toHaveBeenCalledTimes(1); + release.resolve(); + expect((await first).ok).toBe(true); + expect((await second).ok).toBe(true); + expect(f.transactions.readCommitted()).toEqual(snapshot('second')); + expect(f.phases.indexOf('compose')).toBeGreaterThan(f.phases.indexOf('persist-pending')); +}); + +it.each(['prepare', 'validate', 'apply', 'verify', 'persist'] as const)( + 'keeps pending %s state invisible and the next preparation blocked', + async (phase) => { + const f = fixture(); + const entered = deferred(); + const release = deferred(); + const wait = async () => { + entered.resolve(); + await release.promise; + }; + let prepare = f.prepare(); + if (phase === 'prepare') + prepare = async () => { + await wait(); + return ok(snapshot('new')); + }; + if (phase === 'validate') + f.validate.mockImplementationOnce(async (target) => { + await wait(); + return ok({ effectiveConfigHash: target.effectiveConfigHash, diagnostics: [] }); + }); + if (phase === 'apply') { + const apply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(async (target) => { + await wait(); + return apply(target); + }); + } + if (phase === 'verify') + f.readActive.mockImplementationOnce(async () => { + await wait(); + return ok(snapshot('new').config!.effectiveConfigHash); + }); + if (phase === 'persist') + f.persistDesiredState.mockImplementationOnce(async () => { + await wait(); + return ok(undefined); + }); + const first = f.transactions.submit(prepare); + const next = vi.fn(f.prepare(snapshot('later'))); + const second = f.transactions.submit(next); + await entered.promise; + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(next).not.toHaveBeenCalled(); + release.resolve(); + expect((await first).ok).toBe(true); + expect((await second).ok).toBe(true); + }, +); + +it.each([ + { + ...snapshot('bad'), + config: { adaptedConfig: { format: 'json', body: '{' }, effectiveConfigHash: configHash('{') }, + }, + { ...snapshot('bad'), config: { ...config('{}'), effectiveConfigHash: 'wrong' } }, + { + ...snapshot('bad'), + config: { ...config('{}'), adaptedConfig: { format: 'yaml', body: '{}' } }, + }, + { ...snapshot('bad'), desiredState: { projects: [], nonce: 'forbidden' } }, + { ...snapshot('bad'), registrations: [{}] }, + { + ...snapshot('bad'), + config: { + effectiveConfigHash: 'wrong', + adaptedConfig: { format: 'json', body: ' '.repeat(maxCaddyConfigurationBytes + 1) }, + }, + }, +])('rejects malformed/unstable/hash-invalid candidates before adapters', async (invalid) => { + const f = fixture(); + expect(errorCode(await f.transactions.submit(() => ok(invalid as ConfigurationSnapshot)))).toBe( + 'config_candidate_invalid', + ); + expect(f.validate).not.toHaveBeenCalled(); + expect(f.apply).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.transactions.readFence()).toBeNull(); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); +}); + +it('validates the supplied initial shape without inventing startup verification or storage work', () => { + expect(() => fixture({ ...snapshot(), config: config('{') })).toThrow( + 'Invalid committed configuration snapshot.', + ); + const f = fixture(snapshot('idle', true)); + expect(f.phases).toEqual([]); + expect(f.transactions.readFence()).toBeNull(); +}); + +it.each(['rejected', 'throw'] as const)( + 'retains old state after preparation %s and continues the tail', + async (failure) => { + const f = fixture(); + expect( + errorCode( + await f.transactions.submit(() => { + if (failure === 'throw') throw new Error(secret); + return rejected(); + }), + ), + ).toBe('config_prepare_failed'); + expect(f.apply).not.toHaveBeenCalled(); + expect(f.transactions.readFence()).toBeNull(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); + }, +); + +it.each(['rejected', 'throw', 'hash'] as const)( + 'retains old state after validator %s without fencing', + async (failure) => { + const f = fixture(); + f.validate.mockImplementationOnce(async () => { + if (failure === 'throw') throw new Error(secret); + if (failure === 'hash') return ok({ effectiveConfigHash: 'wrong', diagnostics: [] }); + return rejected(); + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe( + failure === 'hash' ? 'config_validation_mismatch' : 'config_validation_failed', + ); + expect(f.apply).not.toHaveBeenCalled(); + expect(f.transactions.readFence()).toBeNull(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); + }, +); + +it('skips validation only for a real idle target, and still applies, observes and persists', async () => { + const f = fixture(); + const idle = snapshot('idle', true); + expect(await f.transactions.submit(f.prepare(idle))).toEqual(ok(idle)); + expect(f.validate).not.toHaveBeenCalled(); + expect(f.apply).toHaveBeenCalledWith(null); + expect(f.phases).toEqual(['prepare', 'apply:idle', 'verify', 'persist']); +}); + +it('isolates caller, adapter, initial, read and result data with private deeply frozen copies', async () => { + const initial = snapshot(); + const target = snapshot('new'); + const original = structuredClone(target); + const f = fixture(initial); + const entered = deferred(); + const release = deferred(); + initial.registrations[0]!.registrationId = 'caller changed initial'; + const attempt = (value: object, key: string) => { + expect(Object.isFrozen(value)).toBe(true); + expect(() => { + Reflect.set(value, key, 'tampered'); + }).not.toThrow(); + expect(Reflect.set(value, key, 'tampered')).toBe(false); + }; + f.validate.mockImplementationOnce(async (config) => { + attempt(config.adaptedConfig, 'body'); + entered.resolve(); + await release.promise; + return ok({ effectiveConfigHash: config.effectiveConfigHash, diagnostics: [] }); + }); + f.apply.mockImplementationOnce(async (config) => { + attempt(config!.adaptedConfig, 'body'); + return { status: 'applied' }; + }); + f.readActive.mockResolvedValueOnce(ok(target.config!.effectiveConfigHash)); + f.persistDesiredState.mockImplementationOnce(async (state) => { + attempt(state.projects[0]!.domains[0]!, 'canonicalDomain'); + return ok(undefined); + }); + const pending = f.transactions.submit((committed) => { + attempt(committed.registrations[0]!.entrypointInstance, 'shimSessionNonce'); + return ok(target); + }); + await entered.promise; + target.registrations[0]!.registrationId = 'caller changed pending'; + Reflect.set(target.config!.adaptedConfig, 'body', '{}'); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + release.resolve(); + const result = await pending; + expect(result).toEqual(ok(original)); + if (!result.ok) throw new Error('Expected commit.'); + attempt(result.value.registrations[0]!.ownerProcess, 'processId'); + attempt(f.transactions.readCommitted().registrations[0]!, 'registrationId'); + expect(f.transactions.readCommitted()).toEqual(original); +}); + +it('closes admissions and drains outstanding work without starting later side effects early', async () => { + const f = fixture(); + const entered = deferred(); + const release = deferred(); + const pending = f.transactions.submit(async () => { + entered.resolve(); + await release.promise; + return ok(snapshot('new')); + }); + const alreadyQueued = f.transactions.submit(f.prepare(snapshot('last'))); + await entered.promise; + let drained = false; + const drain = f.transactions.close().then(() => { + drained = true; + }); + const refused = vi.fn(f.prepare()); + expect(errorCode(await f.transactions.submit(refused))).toBe('config_transactions_closed'); + expect(drained).toBe(false); + expect(refused).not.toHaveBeenCalled(); + expect(f.apply).not.toHaveBeenCalled(); + release.resolve(); + await pending; + await alreadyQueued; + await drain; + expect(drained).toBe(true); + expect(f.transactions.readCommitted()).toEqual(snapshot('last')); + await f.transactions.close(); + expect(errorCode(await f.transactions.reconcile())).toBe('config_transactions_closed'); +}); + +it('continues the queue after explicit definite rejection without observation, persistence or fencing', async () => { + const f = fixture(); + f.apply.mockResolvedValueOnce({ status: 'definitely-rejected' }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_apply_rejected'); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.transactions.readFence()).toBeNull(); + expect(f.readActive).not.toHaveBeenCalled(); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + expect((await f.transactions.submit(f.prepare(snapshot('later')))).ok).toBe(true); +}); + +it.each(['ambiguous', 'throw', 'invalid', 'receipt', 'extra'] as const)( + 'fences %s apply results without inferring rejection from an error kind', + async (failure) => { + const f = fixture(); + f.apply.mockImplementationOnce(async () => { + if (failure === 'throw') throw { ...adapterError, kind: 'invalidInput' }; + if (failure === 'invalid') return { status: 'other' } as unknown as ConfigurationApplyOutcome; + if (failure === 'receipt') + return { + effectiveConfigHash: snapshot('new').config!.effectiveConfigHash, + } as unknown as ConfigurationApplyOutcome; + if (failure === 'extra') + return { status: 'applied', error: adapterError } as ConfigurationApplyOutcome; + return { status: 'ambiguous' }; + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_apply_uncertain'); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.transactions.readFence()?.code).toBe('config_apply_uncertain'); + expect(f.readActive).not.toHaveBeenCalled(); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + const later = vi.fn(f.prepare()); + expect(errorCode(await f.transactions.submit(later))).toBe('config_mutations_fenced'); + expect(later).not.toHaveBeenCalled(); + }, +); + +it.each(['hash', 'null', 'rejected', 'throw', 'invalid'] as const)( + 'fences authoritative verification %s failures even after an applied outcome', + async (failure) => { + const f = fixture(); + f.readActive.mockImplementationOnce(async () => { + if (failure === 'throw') throw new Error(secret); + if (failure === 'rejected') return rejected(); + if (failure === 'invalid') return ok(123) as unknown as PortResult; + return ok(failure === 'null' ? null : 'wrong'); + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_verification_failed'); + expect(f.transactions.readFence()?.code).toBe('config_verification_failed'); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + }, +); + +it.each([false, true])( + 'requires independent verification for idle=%s, never receipt-only success', + async (idle) => { + const f = fixture(snapshot('old', !idle)); + const target = snapshot('new', idle); + f.readActive.mockResolvedValueOnce(ok(idle ? snapshot().config!.effectiveConfigHash : null)); + expect(errorCode(await f.transactions.submit(f.prepare(target)))).toBe( + 'config_verification_failed', + ); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + }, +); + +it.each([false, true])( + 'restores and verifies atomic persistence failures, including first apply idle=%s', + async (idle) => { + for (const failure of ['rejected', 'throw', 'invalid'] as const) { + const initial = snapshot('old', idle); + const f = fixture(initial); + f.persistDesiredState.mockImplementationOnce(async () => { + f.phases.push('persist-failed'); + if (failure === 'throw') throw new Error(secret); + if (failure === 'invalid') return null as unknown as PortResult; + return rejected(); + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_persist_failed'); + expect(f.apply.mock.calls.map(([config]) => config)).toEqual([ + snapshot('new').config, + initial.config, + ]); + expect(f.readActive).toHaveBeenCalledTimes(2); + expect(f.phases).toEqual([ + 'prepare', + 'validate', + `apply:${snapshot('new').config!.effectiveConfigHash}`, + 'verify', + 'persist-failed', + `apply:${initial.config?.effectiveConfigHash ?? 'idle'}`, + 'verify', + ]); + expect(f.active()).toBe(initial.config?.effectiveConfigHash ?? null); + expect(f.durable()).toEqual(initial.desiredState); + expect(f.transactions.readCommitted()).toEqual(initial); + expect(f.transactions.readFence()).toBeNull(); + expect((await f.transactions.submit(f.prepare(snapshot('later')))).ok).toBe(true); + } + }, +); + +const recoveryFailures = [ + 'definitely-rejected', + 'ambiguous', + 'throw', + 'invalid', + 'hash', + 'read-rejected', + 'read-throw', + 'read-invalid', +] as const; +function failRestore(f: ReturnType, failure: (typeof recoveryFailures)[number]) { + if (['definitely-rejected', 'ambiguous', 'throw', 'invalid'].includes(failure)) { + f.apply.mockImplementationOnce(async () => { + if (failure === 'throw') throw new Error(secret); + if (failure === 'invalid') return {} as ConfigurationApplyOutcome; + return { status: failure as 'definitely-rejected' | 'ambiguous' }; + }); + } else { + f.readActive.mockImplementationOnce(async () => { + if (failure === 'read-throw') throw new Error(secret); + if (failure === 'read-rejected') return rejected(); + if (failure === 'read-invalid') return undefined as unknown as PortResult; + return ok('wrong'); + }); + } +} + +it.each(recoveryFailures)( + 'fences rollback %s, reports storage failure and never publishes the abandoned candidate', + async (failure) => { + const f = fixture(); + const initialApply = f.apply.getMockImplementation()!; + const initialRead = f.readActive.getMockImplementation()!; + f.apply.mockImplementationOnce(initialApply); + f.readActive.mockImplementationOnce(initialRead); + failRestore(f, failure); + f.persistDesiredState.mockResolvedValueOnce(rejected()); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_persist_failed'); + expect(f.transactions.readFence()?.code).toBe('config_rollback_failed'); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.durable()).toEqual(snapshot().desiredState); + const refused = vi.fn(f.prepare(snapshot('later'))); + expect(errorCode(await f.transactions.submit(refused))).toBe('config_mutations_fenced'); + expect(refused).not.toHaveBeenCalled(); + }, +); + +it('holds the queue through unfinished rollback; no timer or later preparation can overlap it', async () => { + const f = fixture(snapshot('idle', true)); + const entered = deferred(); + const release = deferred(); + const initialApply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(initialApply); + f.apply.mockImplementationOnce(async (target) => { + expect(target).toBeNull(); + entered.resolve(); + await release.promise; + return initialApply(target); + }); + f.persistDesiredState.mockResolvedValueOnce(rejected()); + const failed = f.transactions.submit(f.prepare()); + const nextPrepare = vi.fn(f.prepare(snapshot('later'))); + const next = f.transactions.submit(nextPrepare); + await entered.promise; + expect(nextPrepare).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(snapshot('idle', true)); + release.resolve(); + expect(errorCode(await failed)).toBe('config_persist_failed'); + expect((await next).ok).toBe(true); +}); + +it('queues fence refusal, reconciliation and recovery, restoring old state rather than committing an observed abandoned candidate', async () => { + const f = fixture(); + const entered = deferred(); + const release = deferred(); + const actualApply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(async (target) => { + await actualApply(target); + entered.resolve(); + await release.promise; + return { status: 'ambiguous' }; + }); + const uncertain = f.transactions.submit(f.prepare()); + const refusedPrepare = vi.fn(f.prepare(snapshot('refused'))); + const refused = f.transactions.submit(refusedPrepare); + const reconciliation = f.transactions.reconcile(); + const recoveredPrepare = vi.fn((committed: ConfigurationSnapshot) => { + expect(committed).toEqual(snapshot()); + return ok(snapshot('recovered')); + }); + const recovered = f.transactions.submit(recoveredPrepare); + await entered.promise; + expect(f.readActive).not.toHaveBeenCalled(); + expect(refusedPrepare).not.toHaveBeenCalled(); + expect(recoveredPrepare).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + release.resolve(); + expect(errorCode(await uncertain)).toBe('config_apply_uncertain'); + expect(errorCode(await refused)).toBe('config_mutations_fenced'); + expect(refusedPrepare).not.toHaveBeenCalled(); + expect(await reconciliation).toEqual(ok(snapshot())); + expect((await recovered).ok).toBe(true); + expect(f.apply.mock.calls.map(([target]) => target)).toEqual([ + snapshot('new').config, + snapshot().config, + snapshot('recovered').config, + ]); + expect(f.persistDesiredState).toHaveBeenCalledTimes(1); + expect(f.transactions.readCommitted()).toEqual(snapshot('recovered')); + expect(f.transactions.readFence()).toBeNull(); +}); + +it.each([false, true])( + 'clears a fence on independent proof of unchanged committed state idle=%s without apply or persist', + async (idle) => { + const initial = snapshot('old', idle); + const f = fixture(initial); + f.apply.mockResolvedValueOnce({ status: 'ambiguous' }); + await f.transactions.submit(f.prepare()); + const fence = f.transactions.readFence()!; + fence.message = secret; + expect(f.transactions.readFence()?.message).not.toBe(secret); + expect(await f.transactions.reconcile()).toEqual(ok(initial)); + expect(f.apply).toHaveBeenCalledTimes(1); + expect(f.readActive).toHaveBeenCalledTimes(1); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + expect(f.transactions.readFence()).toBeNull(); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); + }, +); + +it('never treats unavailable readback as idle proof after an ambiguous first apply', async () => { + const initial = snapshot('idle', true); + const f = fixture(initial); + const actualApply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(async (target) => { + await actualApply(target); + return { status: 'ambiguous' }; + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_apply_uncertain'); + expect(f.active()).toBe(snapshot('new').config!.effectiveConfigHash); + // The backend contract reports unknown/unavailable state as failure, never ok(null). + f.readActive.mockResolvedValueOnce(rejected()).mockResolvedValueOnce(rejected()); + expect(errorCode(await f.transactions.reconcile())).toBe('config_reconciliation_failed'); + expect(f.transactions.readFence()).not.toBeNull(); + expect(f.transactions.readCommitted()).toEqual(initial); + expect(f.apply).toHaveBeenCalledTimes(2); + const refused = vi.fn(f.prepare(snapshot('refused'))); + expect(errorCode(await f.transactions.submit(refused))).toBe('config_mutations_fenced'); + expect(refused).not.toHaveBeenCalled(); + expect(await f.transactions.reconcile()).toEqual(ok(initial)); + expect(f.apply.mock.calls.map(([target]) => target)).toEqual([snapshot('new').config, null]); + expect(f.readActive).toHaveBeenCalledTimes(3); + expect(f.active()).toBeNull(); + expect(f.transactions.readFence()).toBeNull(); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(initial); +}); + +it.each(['rejected', 'throw', 'invalid'] as const)( + 'keeps reconciliation observation %s fenced when restoration cannot be independently verified', + async (failure) => { + const f = fixture(); + f.apply.mockResolvedValueOnce({ status: 'ambiguous' }); + await f.transactions.submit(f.prepare()); + const unavailableRead = async (): Promise> => { + if (failure === 'throw') throw new Error(secret); + if (failure === 'invalid') return {} as PortResult; + return rejected(); + }; + f.readActive.mockImplementationOnce(unavailableRead).mockImplementationOnce(unavailableRead); + expect(errorCode(await f.transactions.reconcile())).toBe('config_reconciliation_failed'); + expect(f.apply).toHaveBeenCalledTimes(2); + expect(f.transactions.readFence()).not.toBeNull(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect((await f.transactions.reconcile()).ok).toBe(true); + }, +); + +it.each(['rejected', 'throw', 'invalid'] as const)( + 'restores committed state after initial observation %s only with independent final proof', + async (failure) => { + const f = fixture(); + f.apply.mockResolvedValueOnce({ status: 'ambiguous' }); + await f.transactions.submit(f.prepare()); + f.readActive.mockImplementationOnce(async () => { + if (failure === 'throw') throw new Error(secret); + if (failure === 'invalid') return {} as PortResult; + return rejected(); + }); + expect(await f.transactions.reconcile()).toEqual(ok(snapshot())); + expect(f.apply.mock.calls.map(([target]) => target)).toEqual([ + snapshot('new').config, + snapshot().config, + ]); + expect(f.readActive).toHaveBeenCalledTimes(2); + expect(f.transactions.readFence()).toBeNull(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + }, +); + +it.each(recoveryFailures)( + 'keeps reconciliation restoration %s fenced and permits only a subsequent verified recovery', + async (failure) => { + const f = fixture(snapshot('idle', true)); + const actualApply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(async (target) => { + await actualApply(target); + return { status: 'ambiguous' }; + }); + await f.transactions.submit(f.prepare()); + const actualRead = f.readActive.getMockImplementation()!; + f.readActive.mockImplementationOnce(actualRead); + failRestore(f, failure); + expect(errorCode(await f.transactions.reconcile())).toBe('config_reconciliation_failed'); + expect(f.transactions.readFence()).not.toBeNull(); + expect(f.transactions.readCommitted()).toEqual(snapshot('idle', true)); + expect(f.persistDesiredState).not.toHaveBeenCalled(); + expect((await f.transactions.reconcile()).ok).toBe(true); + expect(f.transactions.readFence()).toBeNull(); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); + }, +); + +it('never clears the fence until reconciliation readback settles and proves the committed target', async () => { + const f = fixture(); + const entered = deferred(); + const release = deferred(); + const actualApply = f.apply.getMockImplementation()!; + f.apply.mockImplementationOnce(async (target) => { + await actualApply(target); + return { status: 'ambiguous' }; + }); + await f.transactions.submit(f.prepare()); + const actualRead = f.readActive.getMockImplementation()!; + f.readActive.mockImplementationOnce(actualRead); + f.readActive.mockImplementationOnce(async () => { + entered.resolve(); + await release.promise; + return actualRead(); + }); + const reconcile = f.transactions.reconcile(); + const nextPrepare = vi.fn(f.prepare(snapshot('later'))); + const next = f.transactions.submit(nextPrepare); + await entered.promise; + expect(f.transactions.readFence()).not.toBeNull(); + expect(nextPrepare).not.toHaveBeenCalled(); + expect(f.transactions.readCommitted()).toEqual(snapshot()); + release.resolve(); + expect(await reconcile).toEqual(ok(snapshot())); + expect((await next).ok).toBe(true); +}); + +it('redacts invalid callback/validator carriers without poisoning the tail or unnecessarily fencing', async () => { + const f = fixture(); + expect( + errorCode( + await f.transactions.submit(() => null as unknown as PortResult), + ), + ).toBe('config_prepare_failed'); + f.validate.mockResolvedValueOnce({ ok: true } as never); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_validation_failed'); + expect(f.transactions.readFence()).toBeNull(); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); +}); + +it('treats attempted callback/validator mutation as bounded pre-apply failure, not runtime uncertainty', async () => { + const f = fixture(); + expect( + errorCode( + await f.transactions.submit((committed) => { + committed.registrations[0]!.registrationId = secret; + return ok(snapshot('new')); + }), + ), + ).toBe('config_prepare_failed'); + f.validate.mockImplementationOnce(async (target) => { + Object.defineProperty(target.adaptedConfig, 'body', { value: '{}' }); + return ok({ effectiveConfigHash: target.effectiveConfigHash, diagnostics: [] }); + }); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_validation_failed'); + expect(f.transactions.readFence()).toBeNull(); + expect(f.apply).not.toHaveBeenCalled(); + expect((await f.transactions.submit(f.prepare())).ok).toBe(true); +}); + +it('settles even unexpected queue errors; close drains failures and leaves admissions closed', async () => { + const f = fixture(); + vi.spyOn(f.transactions, 'readCommitted').mockImplementationOnce(() => { + throw new Error(secret); + }); + expect(errorCode(await f.transactions.reconcile())).toBe('config_transaction_failed'); + expect(f.transactions.readFence()).not.toBeNull(); + expect((await f.transactions.reconcile()).ok).toBe(true); + const failed = f.transactions.submit(() => { + throw new Error(secret); + }); + await f.transactions.close(); + expect(errorCode(await failed)).toBe('config_prepare_failed'); + expect(errorCode(await f.transactions.submit(f.prepare()))).toBe('config_transactions_closed'); +}); diff --git a/test/daemon-application.test.ts b/test/daemon-application.test.ts new file mode 100644 index 0000000..1ae5cb8 --- /dev/null +++ b/test/daemon-application.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +import { DaemonApplication } from '../src/daemon/application.ts'; +import { ConfigurationTransactions } from '../src/daemon/configuration-transactions.ts'; +import { normalizeCaddyConfig } from '../src/caddy/admin-policy.ts'; +import type { DesiredState, CaddyConfig, PortResult } from '../src/contracts/ports.ts'; +import { dispatchRpc, type RpcMethod, type RpcParams } from '../src/protocol/rpc.ts'; +import { fakeRegistration } from './fixtures/rpc-data.ts'; +import { PROTOCOL_VERSION } from '../src/protocol/version.ts'; +import { runtimeLogStream } from '../src/storage/schema.ts'; + +const roots: string[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + for (const path of roots.splice(0)) await rm(path, { recursive: true, force: true }); +}); +async function fixture(initial: DesiredState = { projects: [] }) { + const root = await mkdtemp(join(tmpdir(), 'cadder-app-')); + roots.push(root); + const config = join(root, 'Caddyfile'); + await writeFile(config, 'app.localhost { respond ok }'); + let durable = structuredClone(initial); + let active: string | null = null; + let rejected = false; + let persistFailed = false; + const queue = new ConfigurationTransactions( + { config: null, registrations: [], desiredState: durable }, + { + validate: async (candidate) => ({ + ok: true, + value: { effectiveConfigHash: candidate.effectiveConfigHash, diagnostics: [] }, + }), + }, + { + persistDesiredState: async (value) => { + if (persistFailed) + return { + ok: false, + error: { + kind: 'storage', + code: 'storage', + message: 'Rejected', + guidance: null, + retryable: false, + requestId: null, + }, + }; + durable = structuredClone(value); + return { ok: true, value: undefined }; + }, + }, + { + apply: async (value) => { + if (rejected) return { status: 'definitely-rejected' }; + active = value?.effectiveConfigHash ?? null; + return { status: 'applied' }; + }, + readActive: async () => ({ ok: true, value: active }), + }, + ); + const adapt = vi.fn( + async (): Promise> => ({ + ok: true, + value: normalizeCaddyConfig( + JSON.stringify({ + apps: { + http: { + servers: { + project: { + routes: [ + { + match: [{ host: ['app.localhost'] }], + handle: [{ handler: 'static_response', body: 'ok' }], + }, + ], + }, + }, + }, + }, + }), + ), + }), + ); + const stop = vi.fn(); + const app = new DaemonApplication({ + transactions: queue, + adapt, + secure: async (plan) => ({ ok: true, value: normalizeCaddyConfig(JSON.stringify({ plan })) }), + queryLogs: async () => [], + log: async () => {}, + shutdown: stop, + storage: null, + runtime: () => ({ + status: active ? 'running' : 'idle', + binaryPath: null, + version: null, + processId: null, + adminEndpoint: null, + diagnostics: [], + }), + }); + const registration = fakeRegistration(); + registration.entrypointInstance.shimSessionNonce = registration.ownerProcess.shimSessionNonce = + randomUUID(); + registration.sourceWorkingDirectory = { raw: root, canonical: null }; + registration.sourceConfigPath = { raw: config, canonical: null }; + async function call(method: M, params: RpcParams) { + return dispatchRpc( + { protocolVersion: PROTOCOL_VERSION, requestId: randomUUID(), method, params }, + app.handle, + ); + } + async function register() { + const response = await call('register-entrypoint-request', { registration }); + expect(response).not.toHaveProperty('error'); + return app.snapshot().registrations[0]!.registrationId; + } + return { + app, + call, + registration, + register, + adapt, + queue, + stop, + durable: () => durable, + active: () => active, + reject: () => { + rejected = true; + }, + failPersist: () => { + persistFailed = true; + }, + }; +} + +it('registers from actual canonical sources, derives domains and rejects a competing or stale session', async () => { + const f = await fixture(); + const id = await f.register(); + expect(f.app.snapshot().registrations[0]).toMatchObject({ + registrationId: id, + activationState: 'active', + registeredDomains: [{ name: { canonical: 'app.localhost' } }], + }); + const intruder = structuredClone(f.registration); + intruder.entrypointInstance.shimSessionNonce = intruder.ownerProcess.shimSessionNonce = 'other'; + expect(await f.call('register-entrypoint-request', { registration: intruder })).toHaveProperty( + 'error', + ); + expect( + await f.call('unregister-entrypoint-request', { + registrationId: id, + shimSessionNonce: 'other', + }), + ).toHaveProperty('error'); + expect( + await f.call('heartbeat-entrypoint-request', { registrationId: id, shimSessionNonce: 'other' }), + ).toHaveProperty('error'); + expect(f.app.snapshot().registrations).toHaveLength(1); + expect( + await f.call('heartbeat-entrypoint-request', { + registrationId: id, + shimSessionNonce: f.registration.entrypointInstance.shimSessionNonce, + }), + ).not.toHaveProperty('error'); + expect(await f.call('query-state-request', {})).toHaveProperty( + 'result.snapshot.registrations.0.registrationId', + id, + ); +}); + +it('toggles project/domain intent through apply and keeps disabled domains disabled after project re-enable', async () => { + const f = await fixture(); + const id = await f.register(); + expect( + await f.call('set-domain-enabled-request', { + registrationId: id, + domainKey: 'APP.localhost', + enabled: false, + }), + ).not.toHaveProperty('error'); + expect(f.active()).toBeNull(); + expect( + await f.call('set-entrypoint-enabled-request', { registrationId: id, enabled: false }), + ).not.toHaveProperty('error'); + expect( + await f.call('set-entrypoint-enabled-request', { registrationId: id, enabled: true }), + ).not.toHaveProperty('error'); + expect(f.app.snapshot().registrations[0]!.registeredDomains[0]?.activationState).toBe('inactive'); + expect(f.durable().projects[0]?.domains[0]?.enabled).toBe(false); + expect( + await f.call('set-domain-enabled-request', { + registrationId: id, + domainKey: 'missing.localhost', + enabled: true, + }), + ).toHaveProperty('error'); + expect( + await f.call('set-entrypoint-enabled-request', { registrationId: 'missing', enabled: true }), + ).toHaveProperty('error'); + expect( + await f.call('set-entrypoint-enabled-request', { + registrationId: id, + shimSessionNonce: 'stale', + enabled: false, + }), + ).toHaveProperty('error'); +}); + +it('preserves committed routes after apply rejection or storage failure and rolls back the real queue', async () => { + for (const mode of ['reject', 'persist'] as const) { + const f = await fixture(); + const id = await f.register(); + const before = f.active(); + if (mode === 'reject') f.reject(); + else f.failPersist(); + expect( + await f.call('set-entrypoint-enabled-request', { registrationId: id, enabled: false }), + ).toHaveProperty('error'); + expect(f.active()).toBe(before); + expect(f.app.snapshot().registrations[0]?.activationState).toBe('active'); + expect(f.durable().projects[0]?.enabled).toBe(true); + } +}); + +it('expires live sessions, preserves durable intent, and restores no lease on restart', async () => { + const f = await fixture(); + const id = await f.register(); + await f.app.expire(Date.now() + 1); + expect(f.app.snapshot().registrations).toEqual([]); + expect(f.active()).toBeNull(); + expect(f.durable().projects).toHaveLength(1); + const restarted = await fixture(f.durable()); + expect(restarted.app.snapshot().registrations).toEqual([]); + await f.register(); + expect( + await f.call('unregister-entrypoint-request', { + registrationId: id, + shimSessionNonce: f.registration.entrypointInstance.shimSessionNonce, + }), + ).not.toHaveProperty('error'); + expect(f.active()).toBeNull(); +}); + +it('rejects forged paths/domains and accepts only bounded canonical log selections', async () => { + const f = await fixture(); + const relative = structuredClone(f.registration); + relative.sourceConfigPath.raw = 'Caddyfile'; + expect(await f.call('register-entrypoint-request', { registration: relative })).toHaveProperty( + 'error', + ); + await f.register(); + for (const limit of [0, 201]) + expect(await f.call('query-logs-request', { stream: runtimeLogStream, limit })).toHaveProperty( + 'error', + ); + expect(await f.call('query-logs-request', { stream: runtimeLogStream })).toHaveProperty( + 'result.entries', + ); + expect( + await f.call('query-logs-request', { stream: { ...runtimeLogStream, streamId: 'forged' } }), + ).toHaveProperty('error'); + expect( + await f.call('query-logs-request', { + stream: f.app.snapshot().registrations[0]!.registeredDomains[0]!.logStream, + }), + ).toHaveProperty('result.entries'); +}); + +it('acknowledges shutdown before closing admission and scheduling owned teardown', async () => { + const f = await fixture(); + expect(await f.call('shutdown-daemon-request', {})).not.toHaveProperty('error'); + expect(await f.call('query-state-request', {})).toHaveProperty('error'); + expect(f.stop).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(f.stop).toHaveBeenCalledOnce()); +}); diff --git a/test/fixtures/caddy-orphan.ts b/test/fixtures/caddy-orphan.ts new file mode 100644 index 0000000..57a985f --- /dev/null +++ b/test/fixtures/caddy-orphan.ts @@ -0,0 +1,28 @@ +import { spawn } from 'node:child_process'; +import { existsSync, writeFileSync } from 'node:fs'; + +const [mode, marker] = process.argv.slice(2); +if (!marker) throw new Error('Missing owned test marker.'); +if (mode === 'grandchild') { + writeFileSync(marker, String(process.pid)); + process.stdout.write('inherited stdout\n'); + process.stderr.write('inherited stderr\n'); + setInterval(() => {}, 1000); +} else { + const child = spawn(process.execPath, [process.argv[1]!, 'grandchild', marker], { + shell: false, + // Avoid Node/libuv's own non-detached child Job Object; a native adapter may leave an orphan. + detached: process.platform === 'win32', + stdio: ['ignore', 'inherit', 'inherit'], + }); + child.once('error', (error) => { + throw error; + }); + child.unref(); + const ready = setInterval(() => { + if (existsSync(marker)) { + clearInterval(ready); + process.exit(0); + } + }, 10); +} diff --git a/test/fixtures/caddy-preload.ts b/test/fixtures/caddy-preload.ts new file mode 100644 index 0000000..47cf971 --- /dev/null +++ b/test/fixtures/caddy-preload.ts @@ -0,0 +1,40 @@ +import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename } from 'node:path'; +import Module from 'node:module'; + +// The async owned fixture must not let Node execute the Caddy command as a script. +Module.runMain = () => {}; + +const behavior = JSON.parse(readFileSync(`${process.execPath}.json`, 'utf8')) as { + version?: string; + modules: string[]; + fail?: string; + output?: string; + marker?: string; + releaseVersion?: string; +}; +const args = process.argv.slice(1); +// Node resolves argv[1] as a script path before preloads; the disposable native fake intercepts it. +args[0] = basename(args[0]!); +if (behavior.marker) appendFileSync(behavior.marker, JSON.stringify(args) + '\n'); +if (behavior.fail === args[0]) process.exit(7); +function finish(): void { + if (args[0] === 'version') process.stdout.write(behavior.version ?? 'v2.11.4 h1:fixture\n'); + else if (args[0] === 'list-modules') + process.stdout.write( + JSON.stringify( + behavior.modules.map((module_name) => ({ module_name, module_type: 'standard' })), + ), + ); + else if (args[0] === 'marker') writeFileSync(args[1]!, 'executed'); + else process.stdout.write(behavior.output ?? '{}'); + process.exitCode = 0; +} +if (args[0] === 'version' && behavior.releaseVersion) { + const timer = setInterval(() => { + if (existsSync(behavior.releaseVersion!)) { + clearInterval(timer); + finish(); + } + }, 20); +} else finish(); diff --git a/test/fixtures/caddy-preparation-preload.ts b/test/fixtures/caddy-preparation-preload.ts new file mode 100644 index 0000000..ddb605b --- /dev/null +++ b/test/fixtures/caddy-preparation-preload.ts @@ -0,0 +1,150 @@ +import { spawn } from 'node:child_process'; +import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import Module from 'node:module'; +import { basename } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { once } from 'node:events'; + +// A copied native Node image is the disposable executable. Its owned preload handles Caddy argv +// instead of letting Node interpret 'adapt'/'validate' as JavaScript source paths. +Module.runMain = () => {}; +const args = process.argv.slice(1); +args[0] = basename(args[0]!); +type Behavior = { + mode?: + | 'success' + | 'malformed' + | 'invalid-utf8' + | 'invalid-stderr' + | 'exit' + | 'stdout' + | 'stderr' + | 'block' + | 'orphan' + | 'tree' + | 'wait'; + body?: string; + bytes?: number; + marker?: string; + release?: string; +}; +const settings = JSON.parse(readFileSync(`${process.execPath}.json`, 'utf8')) as { + modules: string[]; + log: string; + adapt?: Behavior; + validate?: Behavior; +}; + +async function write(stream: NodeJS.WriteStream, bytes: Buffer | string): Promise { + if (!stream.write(bytes)) await once(stream, 'drain'); +} +async function main(): Promise { + const command = args[0]; + if (command === 'version') { + await write(process.stdout, 'v2.11.4 h1:fixture\n'); + return; + } + if (command === 'list-modules') { + await write( + process.stdout, + JSON.stringify(settings.modules.map((module_name) => ({ module_name }))), + ); + return; + } + if (command === 'descendant') { + writeFileSync(args[1]!, String(process.pid)); + await write(process.stdout, 'descendant stdout\n'); + await write(process.stderr, 'descendant stderr\n'); + setInterval(() => {}, 1000); + return; + } + if (command !== 'adapt' && command !== 'validate') + throw new Error('Unexpected fake-Caddy command.'); + const path = args[args.indexOf('--config') + 1]!; + const body = command === 'validate' ? readFileSync(path, 'utf8') : undefined; + const sourceBody = command === 'adapt' ? readFileSync(path, 'utf8') : undefined; + appendFileSync( + settings.log, + JSON.stringify({ + command, + args, + cwd: process.cwd(), + pid: process.pid, + image: process.execPath, + path, + body, + sourceBody, + }) + '\n', + ); + const behavior = settings[command] ?? {}; + if (behavior.mode === 'tree' || behavior.mode === 'orphan') { + const child = spawn(process.execPath, ['descendant', behavior.marker!], { + shell: false, + detached: process.platform === 'win32', + stdio: ['ignore', 'inherit', 'inherit'], + }); + child.once('error', (error) => { + throw error; + }); + child.unref(); + while (!existsSync(behavior.marker!)) await delay(10); + if (behavior.mode === 'tree') { + setInterval(() => {}, 1000); + return; + } + } + if (behavior.mode === 'block') { + setInterval(() => {}, 1000); + return; + } + if (behavior.mode === 'wait') while (!existsSync(behavior.release!)) await delay(10); + if (behavior.mode === 'invalid-utf8') { + await write(process.stdout, Buffer.from([0xff])); + return; + } + if (behavior.mode === 'invalid-stderr') { + await write(process.stderr, Buffer.from([0xff])); + return; + } + if (behavior.mode === 'exit') { + await write(process.stderr, behavior.body ?? 'fixture validation rejected'); + process.exitCode = 7; + return; + } + if (behavior.mode === 'stdout' || behavior.mode === 'stderr') { + const size = behavior.bytes!; + if (behavior.mode === 'stdout' && command === 'adapt') { + const prefix = '{"padding":"'; + const suffix = '"}'; + await write( + process.stdout, + prefix + 'x'.repeat(size - prefix.length - suffix.length) + suffix, + ); + } else + await write( + behavior.mode === 'stdout' ? process.stdout : process.stderr, + Buffer.alloc(size, 0x20), + ); + if (behavior.mode === 'stderr' && command === 'adapt') await write(process.stdout, '{}'); + return; + } + if (command === 'adapt') + await write( + process.stdout, + behavior.mode === 'malformed' + ? '{"partial":' + : (behavior.body ?? '{"apps":{"http":{"servers":{}}}}\n'), + ); +} +void main().then( + () => { + // Tree/block modes deliberately retain the event loop until the owned helper kills them. + const behavior = args[0] === 'adapt' ? settings.adapt : settings.validate; + if (args[0] !== 'descendant' && behavior?.mode !== 'tree' && behavior?.mode !== 'block') + process.exitCode ??= 0; + }, + (error: unknown) => { + console.error(error); + process.exitCode = 1; + }, +); diff --git a/test/fixtures/caddy-runtime-backend.ts b/test/fixtures/caddy-runtime-backend.ts new file mode 100644 index 0000000..7d80ad9 --- /dev/null +++ b/test/fixtures/caddy-runtime-backend.ts @@ -0,0 +1,138 @@ +import { readFile } from 'node:fs/promises'; +import type { CaddyConfig, PortResult } from '../../src/contracts/ports.ts'; +import type { ConfigurationApplyOutcome } from '../../src/daemon/configuration-transactions.ts'; +import { + OwnedCommandError, + type OwnedCommandOptions, + type OwnedCommandOutput, +} from '../../src/platform/owned-command.ts'; +import type { PinnedCaddyImage } from '../../src/caddy/image.ts'; +import type { RealCaddyResolver } from '../../src/caddy/resolver.ts'; +import { normalizeCaddyConfig } from '../../src/caddy/admin-policy.ts'; + +const empty = (): OwnedCommandOutput => ({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + exitCode: 0, +}); +export const backend = { + active: null as string | null, + runCalls: [] as { args: readonly string[]; options: OwnedCommandOptions; body: string }[], + reads: 0, + stops: 0, + loads: 0, + loadStatus: 'applied' as ConfigurationApplyOutcome['status'], + uncertainLoadChanges: false, + startup: 'ready' as 'ready' | 'silent' | 'mismatch' | 'exit' | 'launch-failed', + launchGate: undefined as Promise | undefined, + stopMode: 'graceful' as 'graceful' | 'ignored' | 'hanging', + cleanupFault: false, + validationFails: false, + blockRead: false, + settle: undefined as (() => void) | undefined, + reset(): void { + this.active = null; + this.runCalls = []; + this.reads = 0; + this.stops = 0; + this.loads = 0; + this.loadStatus = 'applied'; + this.uncertainLoadChanges = false; + this.startup = 'ready'; + this.launchGate = undefined; + this.stopMode = 'graceful'; + this.cleanupFault = false; + this.validationFails = false; + this.blockRead = false; + this.settle = undefined; + }, + async readActive(signal?: AbortSignal): Promise> { + this.reads++; + if (this.blockRead && this.runCalls.length > 0 && !signal?.aborted) await cancelled(signal); + if (!signal?.aborted && this.active !== null) return { ok: true, value: this.active }; + return { + ok: false, + error: { + kind: 'caddyRuntime', + code: 'fixture_unavailable', + message: 'Fixture unavailable.', + guidance: null, + retryable: false, + requestId: null, + }, + }; + }, + async apply(config: CaddyConfig, signal?: AbortSignal): Promise { + this.loads++; + if (this.blockRead && !signal?.aborted) await cancelled(signal); + if (signal?.aborted) return { status: 'ambiguous' }; + if (this.loadStatus === 'applied' || this.uncertainLoadChanges) + this.active = config.effectiveConfigHash; + return { status: this.loadStatus }; + }, + async stop(signal?: AbortSignal): Promise { + this.stops++; + if (this.stopMode === 'hanging' && !signal?.aborted) await cancelled(signal); + if (this.stopMode === 'graceful') this.settle?.(); + return { status: this.stopMode === 'graceful' ? 'applied' : 'ambiguous' }; + }, +}; +function cancelled(signal?: AbortSignal): Promise { + return new Promise((resolve) => + signal?.addEventListener('abort', () => resolve(), { once: true }), + ); +} + +export function fixtureResolver(): RealCaddyResolver { + const image = { + run(args: readonly string[], options: OwnedCommandOptions = {}): Promise { + if (args[0] === 'validate') + return Promise.resolve({ ...empty(), exitCode: backend.validationFails ? 1 : 0 }); + return new Promise((resolve, reject) => { + let done = false; + const finish = (error?: unknown): void => { + if (done) return; + done = true; + options.signal?.removeEventListener('abort', abort); + backend.active = null; + if (error) reject(error); + else resolve(empty()); + }; + const abort = (): void => + finish( + new OwnedCommandError( + backend.cleanupFault ? 'cleanup' : 'abort', + 'Fixture settlement.', + ), + ); + backend.settle = () => finish(); + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) { + abort(); + return; + } + void readFile(args[2]!, 'utf8') + .then(async (body) => { + if (done) return; + backend.runCalls.push({ args: [...args], options, body }); + await backend.launchGate; + if (done) return; + if (backend.startup === 'launch-failed') { + finish(new OwnedCommandError('spawn', 'Fixture launch failed.')); + return; + } + if (backend.startup === 'exit') { + finish(); + return; + } + options.onStarted?.(); + if (backend.startup === 'ready') + backend.active = normalizeCaddyConfig(body).effectiveConfigHash; + else if (backend.startup === 'mismatch') backend.active = 'independent-mismatch'; + }, finish) + .catch(finish); + }); + }, + } as PinnedCaddyImage; + return { pin: async () => image } as unknown as RealCaddyResolver; +} diff --git a/test/fixtures/installation-runtime-smoke.ts b/test/fixtures/installation-runtime-smoke.ts new file mode 100644 index 0000000..a39adec --- /dev/null +++ b/test/fixtures/installation-runtime-smoke.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { startLocalRuntime, type LocalRuntime } from '../../src/daemon/local-runtime.ts'; +import { rpc } from '../../src/client/connection.ts'; +import { resolvePaths } from '../../src/daemon/paths.ts'; +import { fakeStateResult } from './rpc-data.ts'; + +// Test-only distribution-root inputs; no product packaging or storage worker is implied. +export async function verifyInstallationRuntimes(): Promise { + // Keep the synthetic macOS home short and exclusively owned; never write to + // the real user's home. /tmp's OS alias does not prove runtime ancestor safety. + const root = await mkdtemp( + process.platform === 'darwin' ? '/tmp/c-' : join(tmpdir(), 'cadder-two-'), + ); + const keys = [ + 'CADDER_RUNTIME_DIR', + 'CADDER_RUNTIME_PROFILE', + 'LOCALAPPDATA', + 'XDG_RUNTIME_DIR', + 'HOME', + ] as const; + const environment = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + const runtimes: LocalRuntime[] = []; + try { + delete process.env.CADDER_RUNTIME_DIR; + process.env.CADDER_RUNTIME_PROFILE = 'default'; + process.env.LOCALAPPDATA = root; + process.env.XDG_RUNTIME_DIR = root; + if (process.platform === 'darwin') process.env.HOME = root; + const a = join(root, 'A'); + const b = join(root, 'B'); + await mkdir(a); + await mkdir(b); + const firstOptions = { installationRoot: a, profile: 'default' }; + const secondOptions = { installationRoot: b, profile: 'default' }; + const first = await startLocalRuntime(firstOptions, async () => fakeStateResult(101)); + runtimes.push(first); + const second = await startLocalRuntime(secondOptions, async () => fakeStateResult(202)); + runtimes.push(second); + assert.deepEqual(first.paths, resolvePaths(firstOptions)); + assert.deepEqual(second.paths, resolvePaths(secondOptions)); + for (const key of [ + 'directory', + 'instance', + 'endpoint', + 'secret', + 'lock', + 'metadata', + 'discovery', + 'history', + ] as const) + assert.notEqual(first.paths[key], second.paths[key]); + assert.notDeepEqual(await readFile(first.paths.secret), await readFile(second.paths.secret)); + for (const runtime of runtimes) { + for (const path of [runtime.paths.metadata, runtime.paths.discovery]) { + const diagnostic = JSON.parse(await readFile(path, 'utf8')) as { + runtimeDir: string; + endpoint: string; + }; + assert.equal(diagnostic.runtimeDir, runtime.paths.directory); + assert.equal(diagnostic.endpoint, runtime.paths.endpoint); + } + } + assert.equal((await rpc(first.paths, 'query-state-request')).snapshot?.runtime.processId, 101); + assert.equal((await rpc(second.paths, 'query-state-request')).snapshot?.runtime.processId, 202); + await assert.rejects( + startLocalRuntime(firstOptions, async () => fakeStateResult()), + /already locked/, + ); + await assert.rejects( + startLocalRuntime(secondOptions, async () => fakeStateResult()), + /already locked/, + ); + await first.stop(); + await assert.rejects( + rpc(first.paths, 'query-state-request'), + /Start it with cadder daemon start/, + ); + assert.equal((await rpc(second.paths, 'query-state-request')).snapshot?.runtime.processId, 202); + await assert.rejects( + startLocalRuntime(secondOptions, async () => fakeStateResult()), + /already locked/, + ); + const restarted = await startLocalRuntime(firstOptions, async () => fakeStateResult(303)); + runtimes.push(restarted); + assert.equal( + (await rpc(restarted.paths, 'query-state-request')).snapshot?.runtime.processId, + 303, + ); + assert.equal((await rpc(second.paths, 'query-state-request')).snapshot?.runtime.processId, 202); + } finally { + try { + const stopping = runtimes.map((runtime) => runtime.stop()); + // Wait for every finalizer before cleanup, then propagate any rejection. + await Promise.allSettled(stopping); + await Promise.all(stopping); + } finally { + for (const key of keys) { + if (environment[key] === undefined) delete process.env[key]; + else process.env[key] = environment[key]; + } + await rm(root, { recursive: true, force: true }); + } + } +} diff --git a/test/fixtures/rpc-data.ts b/test/fixtures/rpc-data.ts new file mode 100644 index 0000000..cfa7382 --- /dev/null +++ b/test/fixtures/rpc-data.ts @@ -0,0 +1,60 @@ +import type { EntrypointRegistration, StateSnapshot } from '../../src/protocol/dto.ts'; + +export const timestamp = '2026-01-01T00:00:00.000Z'; +export const stream = { streamId: 'runtime-control', domainKey: null, channel: 'control' }; +export function fakeRegistration(): EntrypointRegistration { + return { + registrationId: 'shim-fixture', + entrypointInstance: { + instanceId: 'shim-fixture', + startedAtUtc: timestamp, + shimSessionNonce: 'nonce', + }, + sourceWorkingDirectory: { raw: '/fixture', canonical: null }, + sourceConfigPath: { raw: '/fixture/Caddyfile', canonical: '/fixture/Caddyfile' }, + registeredDomains: [ + { + name: { raw: 'example.localhost', canonical: 'example.localhost' }, + activationState: 'active', + logStream: stream, + }, + ], + activationState: 'registered', + ownerProcess: { + processId: 123, + processStartTimeUtc: timestamp, + shimSessionNonce: 'nonce', + executablePath: null, + }, + logStream: stream, + shimRun: { adapter: null, rawArguments: ['run'], commandLine: 'caddy run' }, + createdAtUtc: timestamp, + lastHeartbeatUtc: timestamp, + }; +} +export function fakeSnapshot(processId = process.pid): StateSnapshot { + return { + capturedAtUtc: timestamp, + registrations: [fakeRegistration()], + runtime: { + status: 'running', + binaryPath: null, + version: null, + processId, + adminEndpoint: null, + diagnostics: [], + }, + config: { + status: 'idle', + lastAttemptedAtUtc: null, + lastSuccessfulReloadAtUtc: null, + effectiveConfigHash: null, + diagnostics: [], + }, + storage: { backend: 'fixture', path: null, schemaVersion: 1, diagnostics: [] }, + }; +} +export function fakeStateResult(processId = process.pid) { + return { accepted: true, message: 'Fixture state.', snapshot: fakeSnapshot(processId) }; +} +export const basicResult = { accepted: true, message: 'Fixture accepted.' }; diff --git a/test/fixtures/runtime-child.ts b/test/fixtures/runtime-child.ts new file mode 100644 index 0000000..d397dca --- /dev/null +++ b/test/fixtures/runtime-child.ts @@ -0,0 +1,30 @@ +import { startLocalRuntime } from '../../src/daemon/local-runtime.ts'; +import { basicResult, fakeStateResult } from './rpc-data.ts'; + +const options: { runtimeDir?: string; runtimeOwner?: number } = {}; +if (process.argv[2] !== undefined) options.runtimeDir = process.argv[2]; +if (process.getuid && process.argv[3] !== undefined) options.runtimeOwner = Number(process.argv[3]); +const runtime = await startLocalRuntime(options, async (request) => { + if (request.method === 'query-state-request') return fakeStateResult(); + if (request.method === 'shutdown-daemon-request') { + setTimeout(() => { + void shutdown(); + }, 30); + return basicResult; + } + throw new Error('Unknown fixture request'); +}); +process.stdout.write(`READY recovered=${runtime.recovered}\n`); +async function shutdown() { + await runtime.stop(); + process.exitCode = 0; +} +process.on('SIGTERM', () => { + void shutdown(); +}); +setTimeout( + () => { + void shutdown(); + }, + process.argv.includes('--interactive-gate') ? 300000 : 30000, +).unref(); diff --git a/test/fixtures/runtime-denied-client.ts b/test/fixtures/runtime-denied-client.ts new file mode 100644 index 0000000..23cc8e9 --- /dev/null +++ b/test/fixtures/runtime-denied-client.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { rpc } from '../../src/client/connection.ts'; +import { resolvePaths } from '../../src/daemon/paths.ts'; +import { errorCode } from '../../src/protocol/errors.ts'; + +const runtimeDir = process.argv[2]; +if (!runtimeDir) throw new Error('Pass the owner runtime directory.'); +try { + await rpc(resolvePaths({ runtimeDir }), 'query-state-request'); + throw new Error('An unauthorized account contacted the runtime.'); +} catch (error) { + assert.ok( + ['unsafe-runtime-permissions', 'EACCES', 'EPERM'].includes(errorCode(error) ?? ''), + 'The account must be denied by the runtime owner boundary.', + ); + process.stdout.write('DENIED\n'); +} diff --git a/test/fixtures/runtime-privilege-smoke.ts b/test/fixtures/runtime-privilege-smoke.ts new file mode 100644 index 0000000..61ca8c8 --- /dev/null +++ b/test/fixtures/runtime-privilege-smoke.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { once } from 'node:events'; +import { chmod, copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { rpc } from '../../src/client/connection.ts'; +import { resolvePaths } from '../../src/daemon/paths.ts'; + +if (process.platform === 'win32' || !process.getuid || process.getuid() === 0) + throw new Error('Run this Unix privilege gate as a non-root user with sudo -n available.'); +const daemonEntry = process.argv[2]; +const deniedEntry = process.argv[3]; +if (!daemonEntry || !deniedEntry) + throw new Error('Pass packed runtime-child and runtime-denied-client entrypoints.'); +// macOS runner tool caches and per-user temp directories are not traversable by nobody. +// Publish only the executable and test bundle in a disposable shared directory. +const inputs = await mkdtemp('/tmp/cadder-privilege-inputs-'); +const root = await mkdtemp('/tmp/cadder-privilege-runtime-'); +const runtimeDirectory = join(root, 'new-runtime'); +const paths = resolvePaths({ runtimeDir: runtimeDirectory }); +const environment = { ...process.env }; +delete environment.NODE_OPTIONS; +let daemon: ChildProcessWithoutNullStreams | undefined; + +try { + const outsiderNode = join(inputs, 'node'); + const outsiderEntry = join(inputs, 'runtime-denied-client.mjs'); + await copyFile(process.execPath, outsiderNode); + await copyFile(deniedEntry, outsiderEntry); + await chmod(outsiderNode, 0o755); + await chmod(outsiderEntry, 0o644); + await chmod(inputs, 0o755); + await chmod(root, 0o755); + daemon = spawn( + 'sudo', + ['-n', process.execPath, daemonEntry, runtimeDirectory, String(process.getuid())], + { + env: environment, + }, + ); + let error = ''; + daemon.stderr.on('data', (data: Buffer) => { + error += data.toString(); + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Elevated daemon startup timeout: ${error}`)); + }, 12000); + daemon!.stdout.once('data', () => { + clearTimeout(timer); + resolve(); + }); + daemon!.once('exit', () => { + clearTimeout(timer); + reject(new Error(error)); + }); + daemon!.once('error', reject); + }); + const status = await rpc(paths, 'query-state-request'); + assert.ok((status.snapshot?.runtime.processId ?? 0) > 0); + const discovery = JSON.parse(await readFile(paths.discovery, 'utf8')) as { + elevated: boolean; + owner: string; + }; + assert.equal(discovery.elevated, true); + assert.equal(discovery.owner, `uid:${process.getuid()}`); + const outsider = spawn( + 'sudo', + ['-n', '-u', 'nobody', outsiderNode, outsiderEntry, runtimeDirectory], + { + env: environment, + }, + ); + let deniedOutput = ''; + let deniedError = ''; + outsider.stdout.on('data', (data: Buffer) => { + deniedOutput += data.toString(); + }); + outsider.stderr.on('data', (data: Buffer) => { + deniedError += data.toString(); + }); + const [code] = await once(outsider, 'exit'); + assert.equal(code, 0, deniedError); + assert.equal(deniedOutput.trim(), 'DENIED'); + await rpc(paths, 'query-state-request'); + const exited = once(daemon, 'exit'); + await rpc(paths, 'shutdown-daemon-request'); + await exited; + daemon = undefined; + process.stdout.write(`Native privilege gate passed on ${process.platform}/${process.arch}.\n`); +} finally { + if (daemon?.exitCode === null) { + const exited = once(daemon, 'exit'); + await rpc(paths, 'shutdown-daemon-request').catch(() => {}); + // The fixture's fixed 30-second lifetime bounds cleanup if authentication failed. + await exited; + } + await rm(root, { recursive: true, force: true }); + await rm(inputs, { recursive: true, force: true }); +} diff --git a/test/fixtures/runtime-smoke.ts b/test/fixtures/runtime-smoke.ts new file mode 100644 index 0000000..8f7b23c --- /dev/null +++ b/test/fixtures/runtime-smoke.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { resolvePaths } from '../../src/daemon/paths.ts'; +import { rpc } from '../../src/client/connection.ts'; + +// Native-platform gate for the packed runtime fixture, not a product release. +const entry = process.argv[2]; +if (!entry) throw new Error('Pass the absolute path to packed runtime-child.mjs.'); +const root = await mkdtemp(join(tmpdir(), 'cadder-native-')); +const paths = resolvePaths({ runtimeDir: root }); +const environment = { ...process.env }; +delete environment.NODE_OPTIONS; +let child: ChildProcessWithoutNullStreams | undefined; + +async function start(recovered = false): Promise { + const processHandle = spawn( + process.execPath, + [entry!, root, ...(process.getuid ? [String(process.getuid())] : [])], + { windowsHide: true, env: environment }, + ); + let stderr = ''; + processHandle.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + processHandle.kill(); + reject(new Error(`Startup timeout: ${stderr}`)); + }, 12000); + let stdout = ''; + processHandle.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + if (!stdout.includes('\n')) return; + clearTimeout(timer); + if (stdout.trim() !== `READY recovered=${recovered}`) + reject(new Error(`Unexpected fixture readiness: ${stdout}`)); + else resolve(); + }); + processHandle.once('exit', () => { + clearTimeout(timer); + reject(new Error(stderr)); + }); + processHandle.once('error', reject); + }); + return processHandle; +} + +try { + child = await start(); + assert.equal((await rpc(paths, 'query-state-request')).snapshot?.runtime.processId, child.pid); + await assert.rejects(() => start(), /already locked/); + let exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + child = await start(true); + assert.equal((await rpc(paths, 'query-state-request')).snapshot?.runtime.processId, child.pid); + exited = once(child, 'exit'); + assert.equal((await rpc(paths, 'shutdown-daemon-request')).accepted, true); + await exited; + child = undefined; + await assert.rejects( + () => rpc(paths, 'query-state-request'), + /Start it with cadder daemon start/, + ); + process.stdout.write(`Native runtime gate passed on ${process.platform}/${process.arch}.\n`); +} finally { + if (child?.exitCode === null) { + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + } + await rm(root, { recursive: true, force: true }); +} diff --git a/test/fixtures/runtime-windows-contact.ts b/test/fixtures/runtime-windows-contact.ts new file mode 100644 index 0000000..1394dd6 --- /dev/null +++ b/test/fixtures/runtime-windows-contact.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { rpc } from '../../src/client/connection.ts'; +import { resolvePaths } from '../../src/daemon/paths.ts'; +import { prepareRuntime, runtimeOwner } from '../../src/platform/runtime-security.ts'; + +if (process.platform !== 'win32') throw new Error('This interactive gate requires Windows.'); +const [action, runtimeDir] = process.argv.slice(2); +if (!runtimeDir) + throw new Error('Pass prepare, elevated-status or shutdown and a test runtime directory.'); +const paths = resolvePaths({ runtimeDir }); +const owner = await runtimeOwner(); + +if (action === 'prepare' || action === 'elevated-status') { + assert.equal(owner.elevated, false, 'Run the client from a non-elevated shell.'); +} +if (action === 'prepare') { + await prepareRuntime(paths.directory, owner); + process.stdout.write('PREPARED\n'); +} else if (action === 'elevated-status') { + const discovery = JSON.parse(await readFile(paths.discovery, 'utf8')) as { + elevated: boolean; + owner: string; + }; + assert.equal(discovery.elevated, true, 'The fixture daemon must be elevated.'); + assert.equal(discovery.owner, owner.id, 'The daemon must have the same owner SID.'); + const result = await rpc(paths, 'query-state-request'); + assert.ok((result.snapshot?.runtime.processId ?? 0) > 0); + process.stdout.write('SAME_OWNER_ELEVATED_CONTACT_PASSED\n'); +} else if (action === 'shutdown') { + assert.equal((await rpc(paths, 'shutdown-daemon-request')).accepted, true); + process.stdout.write('STOPPED\n'); +} else { + throw new Error('Expected prepare, elevated-status or shutdown.'); +} diff --git a/test/installation-identity.test.ts b/test/installation-identity.test.ts new file mode 100644 index 0000000..1ec0ea3 --- /dev/null +++ b/test/installation-identity.test.ts @@ -0,0 +1,276 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, join, posix, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, expect, it, vi } from 'vitest'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { resolveInstallationRoot } from '../src/daemon/installation-root.ts'; + +vi.mock('node:os', async (original) => { + const os = await original(); + return { ...os, homedir: vi.fn(os.homedir) }; +}); + +const roots: string[] = []; +afterEach(async () => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + vi.mocked(homedir).mockReset(); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'cadder-identity-')); + roots.push(root); + vi.stubEnv('CADDER_RUNTIME_DIR', undefined); + vi.stubEnv('CADDER_RUNTIME_PROFILE', 'default'); + return root; +} + +it('separates all default runtime artifacts by canonical installation, not version', async () => { + const root = await fixture(); + const a = join(root, 'A'); + const b = join(root, 'B'); + await mkdir(a); + await mkdir(b); + const first = resolvePaths({ installationRoot: a }); + const second = resolvePaths({ installationRoot: b }); + expect(resolvePaths({ installationRoot: a })).toEqual(first); + for (const key of [ + 'directory', + 'instance', + 'endpoint', + 'secret', + 'lock', + 'metadata', + 'discovery', + 'history', + ] as const) + expect(first[key]).not.toBe(second[key]); + expect(first.directory).toMatch(/v2[/\\][a-f0-9]{16}$/); + expect(first.history).toBe(join(first.directory, 'runtime.sqlite3')); + expect(second.history).toBe(join(second.directory, 'runtime.sqlite3')); +}); + +it('keeps the actual platform base and all paths coherent with the full installation digest', async () => { + const root = await fixture(); + const digest = createHash('sha256') + .update(await realpath(root)) + .digest('hex') + .slice(0, 16); + let base: string; + if (process.platform === 'win32') + base = join( + process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), + 'Cadder', + 'Cadder', + 'run', + ); + else if (process.platform === 'darwin') + base = join(homedir(), 'Library', 'Application Support', 'dev.Cadder.Cadder', 'run'); + else if (process.env.XDG_RUNTIME_DIR) base = join(process.env.XDG_RUNTIME_DIR, 'cadder'); + else + base = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'cadder', 'run'); + const paths = resolvePaths({ installationRoot: root }); + expect(paths.directory).toBe(join(resolve(base), 'v2', digest)); + expect(paths.instance).toBe( + createHash('sha256').update(paths.directory).digest('hex').slice(0, 16), + ); + expect(paths.endpoint).toBe( + process.platform === 'win32' + ? `\\\\.\\pipe\\cadder-v2-${paths.instance}` + : join(paths.directory, 'cadder.sock'), + ); + for (const [key, filename] of [ + ['secret', 'ipc-secret'], + ['lock', 'lock.sqlite3'], + ['metadata', 'cadder.lock.json'], + ['discovery', 'cadder-ipc.json'], + ['history', 'runtime.sqlite3'], + ] as const) + expect(paths[key]).toBe(join(paths.directory, filename)); +}); + +it('fits a modeled normal macOS default without weakening the portable byte guard', async () => { + const root = await fixture(); + vi.mocked(homedir).mockReturnValue('/Users/normaluser'); + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + const digest = createHash('sha256') + .update(await realpath(root)) + .digest('hex') + .slice(0, 16); + const paths = resolvePaths({ installationRoot: root }); + const components = [ + 'Library', + 'Application Support', + 'dev.Cadder.Cadder', + 'run', + 'v2', + digest, + 'cadder.sock', + ]; + expect(paths.endpoint).toBe(join(resolve('/Users/normaluser'), ...components)); + expect(Buffer.byteLength(paths.endpoint)).toBeLessThanOrEqual(103); + // POSIX modeling is explicit: Windows native path resolution may add a drive. + expect(Buffer.byteLength(posix.join('/Users/normaluser', ...components))).toBe(99); + vi.mocked(homedir).mockReturnValue('/Users/' + 'x'.repeat(40)); + expect(() => resolvePaths({ installationRoot: root })).toThrow('103 UTF-8 bytes'); + vi.mocked(homedir).mockReturnValue('/Users/' + 'é'.repeat(20)); + expect(() => resolvePaths({ installationRoot: root })).toThrow('103 UTF-8 bytes'); +}); + +it('canonicalizes physical installation aliases including native junctions or symlinks', async () => { + const root = await fixture(); + const installation = join(root, 'package'); + await mkdir(join(installation, 'sub'), { recursive: true }); + const alias = join(root, 'alias'); + await symlink(installation, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const paths = resolvePaths({ installationRoot: installation }); + for (const spelling of [alias, join(installation, 'sub', '..'), `${installation}/`]) + expect(resolvePaths({ installationRoot: spelling })).toEqual(paths); + if (process.platform === 'win32') + expect(resolvePaths({ installationRoot: installation.toUpperCase() })).toEqual(paths); +}); + +it('anchors npm to the nearest physical cadder package, never shared Node, argv or cwd', async () => { + const root = await fixture(); + await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'cadder' })); + const packageRoot = join(root, 'nested'); + const module = join(packageRoot, 'dist', 'daemon', 'paths.js'); + await mkdir(dirname(module), { recursive: true }); + await writeFile(join(packageRoot, 'package.json'), JSON.stringify({ name: 'cadder' })); + await writeFile(join(packageRoot, 'dist', 'package.json'), JSON.stringify({ name: 'other' })); + await writeFile(module, ''); + const alias = join(root, 'alias'); + await symlink(packageRoot, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const input = { sea: false, executable: process.execPath }; + expect(resolveInstallationRoot({ ...input, moduleUrl: pathToFileURL(module).href })).toBe( + await realpath(packageRoot), + ); + expect( + resolveInstallationRoot({ + ...input, + moduleUrl: pathToFileURL(join(alias, 'dist', 'daemon', 'paths.js')).href, + }), + ).toBe(await realpath(packageRoot)); + expect(await realpath(packageRoot)).not.toBe(dirname(process.execPath)); + expect(resolveInstallationRoot()).toBe(await realpath(resolve('.'))); +}); + +it('makes all three SEA siblings agree and follows a real executable alias', async () => { + const root = await fixture(); + const installation = join(root, 'sea'); + await mkdir(installation); + const paths = resolvePaths({ installationRoot: installation }); + for (const name of ['cadder', 'cadderd', 'caddy']) { + const executable = join(installation, name); + await writeFile(executable, ''); + const installationRoot = resolveInstallationRoot({ sea: true, executable }); + expect(installationRoot).toBe(await realpath(installation)); + expect(resolvePaths({ installationRoot })).toEqual(paths); + } + const alias = join(root, 'alias'); + await symlink(installation, alias, process.platform === 'win32' ? 'junction' : 'dir'); + expect(resolveInstallationRoot({ sea: true, executable: join(alias, 'caddy') })).toBe( + await realpath(installation), + ); +}); + +it('fails closed on missing roots, non-directories, missing executables and npm anchors', async () => { + const root = await fixture(); + const module = join(root, 'module.js'); + await writeFile(module, ''); + for (const input of [ + { installationRoot: join(root, 'missing') }, + { installationRoot: module }, + { sea: true, executable: join(root, 'missing') }, + { sea: false, moduleUrl: pathToFileURL(module).href }, + { sea: false, moduleUrl: pathToFileURL(join(root, 'missing')).href }, + { sea: false, moduleUrl: 'https://invalid.example/module.js' }, + ]) + expect(() => resolveInstallationRoot(input)).toThrow('Cannot resolve'); + await writeFile(join(root, 'package.json'), '{'); + expect(() => + resolveInstallationRoot({ sea: false, moduleUrl: pathToFileURL(module).href }), + ).toThrow('Cannot resolve'); + expect(() => resolvePaths({ installationRoot: join(root, 'missing') })).toThrow('Cannot resolve'); +}); + +it('preserves option/env overrides and explicit profile behavior without requiring an anchor', async () => { + const root = await fixture(); + vi.stubEnv('CADDER_RUNTIME_DIR', join(root, 'env')); + vi.stubEnv('CADDER_RUNTIME_PROFILE', 'dev'); + const missing = join(root, 'missing'); + const env = resolvePaths({ installationRoot: missing }); + expect(env.directory).toBe(join(root, 'env', 'v2')); + expect(env.profile).toBe('default'); + const explicit = resolvePaths({ + runtimeDir: join(root, 'option'), + installationRoot: missing, + profile: 'dev', + }); + expect(explicit.directory).toBe(join(root, 'option', 'v2')); + expect(explicit.profile).toBe('dev'); +}); + +it('preserves existing empty override cwd resolution without installation anchoring', async () => { + const root = await fixture(); + const missing = join(root, 'missing'); + expect(resolvePaths({ runtimeDir: '', installationRoot: missing }).directory).toBe( + join(resolve(''), 'v2'), + ); + vi.stubEnv('CADDER_RUNTIME_DIR', ''); + expect(resolvePaths({ installationRoot: missing }).directory).toBe(join(resolve(''), 'v2')); +}); + +it('keeps dev namespaces beneath each installation and validates dev ids', async () => { + const root = await fixture(); + // Socket length has its own boundary cases below. + if (process.platform !== 'win32') vi.mocked(homedir).mockReturnValue('/h'); + const a = join(root, 'A'); + const b = join(root, 'B'); + await mkdir(a); + await mkdir(b); + vi.stubEnv('CADDER_DEV_ID', 'test'); + const first = resolvePaths({ installationRoot: a, profile: 'dev' }); + const second = resolvePaths({ installationRoot: b, profile: 'dev' }); + expect(first.directory).toMatch(/v2[/\\][a-f0-9]{16}[/\\]profiles[/\\]dev[/\\]test$/); + expect(first.endpoint).not.toBe(second.endpoint); + vi.stubEnv('CADDER_DEV_ID', '..'); + expect(() => resolvePaths({ installationRoot: a, profile: 'dev' })).toThrow( + 'Invalid CADDER_DEV_ID', + ); +}); + +it.each(['linux', 'darwin'] as const)( + 'bounds %s sockets at 103 UTF-8 bytes, not characters', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); + const prefix = resolve('/r'); + const suffixBytes = + Buffer.byteLength(join(prefix, 'v2', 'cadder.sock')) - Buffer.byteLength(prefix); + const runtimeDir = prefix + 'x'.repeat(103 - suffixBytes - Buffer.byteLength(prefix)); + expect(Buffer.byteLength(resolvePaths({ runtimeDir }).endpoint)).toBe(103); + expect(() => resolvePaths({ runtimeDir: runtimeDir + 'x' })).toThrow('103 UTF-8 bytes'); + const multibyte = runtimeDir.slice(0, -1) + 'é'; + expect(() => resolvePaths({ runtimeDir: multibyte })).toThrow('103 UTF-8 bytes'); + }, +); + +it('checks representative Unix default bases and fails closed on long configured bases', async () => { + const root = await fixture(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + vi.stubEnv('XDG_RUNTIME_DIR', '/run/user/1000'); + expect(Buffer.byteLength(resolvePaths({ installationRoot: root }).endpoint)).toBeLessThanOrEqual( + 103, + ); + vi.stubEnv('XDG_RUNTIME_DIR', ''); + vi.stubEnv('XDG_DATA_HOME', '/home/user/.local/share'); + expect(Buffer.byteLength(resolvePaths({ installationRoot: root }).endpoint)).toBeLessThanOrEqual( + 103, + ); + vi.stubEnv('XDG_DATA_HOME', '/home/' + 'x'.repeat(80)); + expect(() => resolvePaths({ installationRoot: root })).toThrow( + 'Select a shorter CADDER_RUNTIME_DIR', + ); +}); diff --git a/test/installation-runtime.test.ts b/test/installation-runtime.test.ts new file mode 100644 index 0000000..9c6f2e8 --- /dev/null +++ b/test/installation-runtime.test.ts @@ -0,0 +1,17 @@ +import { expectTypeOf, it } from 'vitest'; +import type { startLocalRuntime } from '../src/daemon/local-runtime.ts'; +import { verifyInstallationRuntimes } from './fixtures/installation-runtime-smoke.ts'; + +it( + 'holds two real SQLite lifetime locks and authenticated local listeners independently', + { + skip: process.getuid?.() === 0, + timeout: 30000, + }, + async () => { + expectTypeOf[0]['installationRoot']>().toEqualTypeOf< + string | undefined + >(); + await verifyInstallationRuntimes(); + }, +); diff --git a/test/ipc-publication-faults.test.ts b/test/ipc-publication-faults.test.ts new file mode 100644 index 0000000..ac42630 --- /dev/null +++ b/test/ipc-publication-faults.test.ts @@ -0,0 +1,187 @@ +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs/promises'; +import { createServer, type Server, type Socket } from 'node:net'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { serveIpc } from '../src/daemon/ipc-server.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import * as security from '../src/platform/runtime-security.ts'; +import { JsonChannel } from '../src/protocol/channel.ts'; +import { authenticateServer } from '../src/protocol/authentication.ts'; + +vi.mock('node:fs/promises', () => ({ + chmod: vi.fn(), + lstat: vi.fn(), + unlink: vi.fn(), + writeFile: vi.fn(), +})); +vi.mock('node:net', () => ({ createServer: vi.fn() })); +vi.mock('../src/protocol/channel.ts', () => ({ JsonChannel: vi.fn() })); +vi.mock('../src/protocol/authentication.ts', () => ({ authenticateServer: vi.fn() })); +vi.mock('../src/platform/runtime-security.ts', () => ({ + createProtectedFile: vi.fn(), + protectCreated: vi.fn(), +})); +const paths = resolvePaths({ runtimeDir: '/publication-faults' }); +const owner = { id: 'uid:42', uid: 42, elevated: false }; +const failure = Object.assign(new Error('publication disk failure'), { code: 'ENOSPC' }); +let server: EventEmitter & { listen: ReturnType; close: ReturnType }; + +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + server = Object.assign(new EventEmitter(), { + listen: vi.fn(() => { + queueMicrotask(() => server.emit('listening')); + }), + close: vi.fn((callback: (error?: Error) => void) => callback()), + }); + vi.mocked(createServer).mockReturnValue(server as unknown as Server); + vi.mocked(fs.lstat).mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + vi.mocked(security.createProtectedFile).mockResolvedValue(true); + vi.mocked(JsonChannel).mockImplementation(function () { + return { close: vi.fn() } as never; + }); +}); +afterEach(() => vi.restoreAllMocks()); + +it.each(['sync-bind', 'async-bind', 'chmod', 'protection', 'discovery-create', 'discovery-write'])( + 'closes startup resources after %s', + async (phase) => { + if (phase === 'sync-bind') + server.listen.mockImplementationOnce(() => { + throw failure; + }); + if (phase === 'async-bind') + server.listen.mockImplementationOnce(() => { + queueMicrotask(() => server.emit('error', failure)); + }); + if (phase === 'chmod') vi.mocked(fs.chmod).mockRejectedValueOnce(failure); + if (phase === 'protection') vi.mocked(security.protectCreated).mockRejectedValueOnce(failure); + if (phase === 'discovery-create') + vi.mocked(security.createProtectedFile).mockRejectedValueOnce(failure); + if (phase === 'discovery-write') vi.mocked(fs.writeFile).mockRejectedValueOnce(failure); + const handler = vi.fn(); + await expect(serveIpc(paths, owner, Buffer.alloc(32), handler)).rejects.toBe(failure); + expect(server.close).toHaveBeenCalledOnce(); + if (phase === 'discovery-write') expect(fs.unlink).toHaveBeenCalledWith(paths.discovery); + else expect(fs.unlink).not.toHaveBeenCalledWith(paths.discovery); + expect(handler).not.toHaveBeenCalled(); + }, +); + +it('preserves unvalidated discovery and foreign endpoint paths', async () => { + vi.mocked(fs.lstat).mockResolvedValueOnce({ isSocket: () => false, uid: 42 } as never); + await expect(serveIpc(paths, owner, Buffer.alloc(32), vi.fn())).rejects.toMatchObject({ + code: 'unsafe-endpoint', + }); + expect(createServer).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + vi.mocked(security.createProtectedFile).mockRejectedValueOnce(failure); + await expect(serveIpc(paths, owner, Buffer.alloc(32), vi.fn())).rejects.toBe(failure); + expect(fs.unlink).not.toHaveBeenCalledWith(paths.discovery); +}); + +it.each(['callback', 'throw'])( + 'unlinks validated discovery even if listener close fails: %s', + async (phase) => { + const listener = await serveIpc(paths, owner, Buffer.alloc(32), vi.fn()); + if (phase === 'callback') + server.close.mockImplementationOnce((callback: (error: Error) => void) => callback(failure)); + else + server.close.mockImplementationOnce(() => { + throw failure; + }); + const closing = listener.close(); + expect(listener.close()).toBe(closing); + await expect(closing).rejects.toBe(failure); + await expect(listener.close()).rejects.toBe(failure); + expect(fs.unlink).toHaveBeenCalledWith(paths.discovery); + }, +); + +it('retains publication failure and every cleanup failure', async () => { + vi.mocked(fs.writeFile).mockRejectedValueOnce(failure); + server.close.mockImplementationOnce((callback: (error: Error) => void) => + callback(new Error('close')), + ); + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('unlink')); + const error = await serveIpc(paths, owner, Buffer.alloc(32), vi.fn()).catch( + (error: unknown) => error, + ); + expect((error as AggregateError).errors).toHaveLength(2); + expect(error).toMatchObject({ cause: failure }); + expect(fs.unlink).toHaveBeenCalledWith(paths.discovery); +}); + +it('revokes queued requests and rejects new admission after startup failure', async () => { + let deliver: (request: unknown) => void = () => {}; + const session = { + receive: vi.fn( + () => + new Promise((resolve) => { + deliver = resolve; + }), + ), + close: vi.fn(), + }; + vi.mocked(authenticateServer).mockResolvedValue(session as never); + const socket = Object.assign(new EventEmitter(), { destroy: vi.fn() }); + vi.mocked(fs.writeFile).mockImplementationOnce(async () => { + const accept = vi.mocked(createServer).mock.calls[0]![0] as (socket: Socket) => void; + accept(socket as unknown as Socket); + await Promise.resolve(); + throw failure; + }); + const handler = vi.fn(); + await expect(serveIpc(paths, owner, Buffer.alloc(32), handler)).rejects.toBe(failure); + expect(session.receive).toHaveBeenCalledOnce(); + deliver({ + protocolVersion: 3, + requestId: '00000000-0000-4000-8000-000000000001', + method: 'query-state-request', + params: {}, + }); + await Promise.resolve(); + expect(handler).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(socket.destroy).toHaveBeenCalledOnce(); + const lateSocket = Object.assign(new EventEmitter(), { destroy: vi.fn() }); + const accept = vi.mocked(createServer).mock.calls[0]![0] as (socket: Socket) => void; + accept(lateSocket as unknown as Socket); + expect(lateSocket.destroy).toHaveBeenCalledOnce(); + expect(authenticateServer).toHaveBeenCalledOnce(); +}); + +it('attempts every socket, listener and publication finalizer when a socket destroy throws', async () => { + vi.mocked(authenticateServer).mockReturnValue(new Promise(() => {})); + const listener = await serveIpc(paths, owner, Buffer.alloc(32), vi.fn()); + const accept = vi.mocked(createServer).mock.calls[0]![0] as (socket: Socket) => void; + const first = Object.assign(new EventEmitter(), { + destroy: vi.fn(() => { + throw failure; + }), + }); + const second = Object.assign(new EventEmitter(), { destroy: vi.fn() }); + accept(first as unknown as Socket); + accept(second as unknown as Socket); + await expect(listener.close()).rejects.toBe(failure); + expect(second.destroy).toHaveBeenCalledOnce(); + expect(server.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledWith(paths.discovery); +}); + +it('cleans validated safe discovery residue after a partial replacement fails', async () => { + vi.mocked(security.createProtectedFile).mockResolvedValueOnce(false); + vi.mocked(fs.writeFile).mockRejectedValueOnce(failure); + await expect(serveIpc(paths, owner, Buffer.alloc(32), vi.fn())).rejects.toBe(failure); + expect(fs.unlink).toHaveBeenCalledWith(paths.discovery); +}); + +it('reports discovery unlink failure consistently without repeating finalizers', async () => { + const listener = await serveIpc(paths, owner, Buffer.alloc(32), vi.fn()); + vi.mocked(fs.unlink).mockRejectedValueOnce(failure); + await expect(listener.close()).rejects.toBe(failure); + await expect(listener.close()).rejects.toBe(failure); + expect(server.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledOnce(); +}); diff --git a/test/ipc-server.test.ts b/test/ipc-server.test.ts new file mode 100644 index 0000000..d78183b --- /dev/null +++ b/test/ipc-server.test.ts @@ -0,0 +1,360 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import { createConnection, createServer } from 'node:net'; +import { once } from 'node:events'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { rpc } from '../src/client/connection.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { serveIpc } from '../src/daemon/ipc-server.ts'; +import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.ts'; +import { RpcError, toWireError } from '../src/protocol/errors.ts'; +import { JsonChannel } from '../src/protocol/channel.ts'; +import { authenticateClient, authenticateServer } from '../src/protocol/authentication.ts'; +import { + errorResponseSchema, + requestSchema, + responseSchema, + UNCORRELATED_REQUEST_ID, + type HandlerResult, + type RpcRequest, +} from '../src/protocol/rpc.ts'; +import { basicResult, fakeStateResult } from './fixtures/rpc-data.ts'; +import { MAX_FRAME_BYTES } from '../src/protocol/version.ts'; + +const id = randomUUID(); +const request = { protocolVersion: 3, requestId: id, method: 'query-state-request', params: {} }; +const typedError = { + kind: 'busy' as const, + code: 'busy', + message: 'A safe fixture error', + guidance: null, + retryable: true, + requestId: null, +}; + +it( + 'serves one authenticated correlated outcome, rejects malformed requests before handlers', + { timeout: 90000 }, + async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-ipc-')); + const owner = await runtimeOwner(); + const paths = resolvePaths({ runtimeDir: root }); + await prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + let denied = 0; + let called = 0; + const server = await serveIpc( + paths, + owner, + lock.secret, + async (request) => { + called++; + if (request.method === 'query-state-request') + return { ...fakeStateResult(), requestId: randomUUID() }; + if (request.method === 'shutdown-daemon-request') throw new RpcError(typedError); + if (request.method === 'heartbeat-entrypoint-request') + return { accepted: false, message: 'Wrong nonce.' }; + if (request.method === 'unregister-entrypoint-request') + return { arbitrary: true } as unknown as HandlerResult; + throw new Error('Do not expose internal error text'); + }, + () => { + denied++; + }, + ); + async function raw(input: unknown) { + const channel = new JsonChannel(createConnection(paths.endpoint)); + const session = await authenticateClient(channel, lock.secret, paths.instance); + try { + session.send(input); + session.send(request); // Pipelined input must not produce a second response or handler call. + const response = await session.receive(); + await expect(session.receive()).rejects.toThrow('closed'); + return response; + } finally { + session.close(); + channel.close(); + } + } + try { + const state = await rpc(paths, 'query-state-request'); + expect(state).toMatchObject(fakeStateResult()); + expect(state.requestId).toMatch(/^[a-f0-9-]{36}$/); + await expect(rpc(paths, 'shutdown-daemon-request')).rejects.toMatchObject({ + code: 'busy', + message: 'A safe fixture error', + protocolError: { retryable: true }, + }); + expect( + await rpc(paths, 'heartbeat-entrypoint-request', { + registrationId: 'missing', + shimSessionNonce: 'wrong', + }), + ).toMatchObject({ accepted: false }); + for (const method of [ + 'unregister-entrypoint-request', + 'set-domain-enabled-request', + ] as const) { + const params = + method === 'unregister-entrypoint-request' + ? { registrationId: 'id', shimSessionNonce: 'nonce' } + : { registrationId: 'id', domainKey: 'domain', enabled: true }; + await expect(rpc(paths, method, params)).rejects.toMatchObject({ + code: 'internal', + message: 'Daemon request failed. See diagnostics.', + }); + } + const before = called; + for (const input of [ + { ...request, method: 'status' }, + { ...request, method: 'shutdown' }, + { ...request, params: { extra: true } }, + { ...request, extra: true }, + { ...request, protocolVersion: 2 }, + { ...request, method: 'heartbeat-entrypoint-request', params: { registrationId: 'id' } }, + { + ...request, + method: 'query-logs-request', + params: { stream: { streamId: 's', domainKey: null, channel: 'c', extra: 1 } }, + }, + { ...request, requestId: 'malformed' }, + null, + ]) { + const response = errorResponseSchema.parse(await raw(input)); + expect(response.requestId).toBe( + input && input.requestId === id ? id : UNCORRELATED_REQUEST_ID, + ); + expect(response.error.kind).toBe( + input && ['status', 'shutdown'].includes(input.method) + ? 'unsupportedOperation' + : 'payloadDecodeFailed', + ); + expect(called).toBe(before); + } + // Runtime callers cannot bypass request validation even with untyped inputs. + // @ts-expect-error Unknown fields are also rejected statically. + await expect(rpc(paths, 'query-state-request', { extra: true })).rejects.toMatchObject({ + code: 'invalid-request', + }); + expect(called).toBe(before); + const channel = new JsonChannel(createConnection(paths.endpoint)); + channel.send(request); + await expect(channel.next()).rejects.toThrow('closed'); + channel.close(); + expect(called).toBe(before); + expect(denied).toBe(1); + await writeFile(paths.secret, randomBytes(32)); + await expect(rpc(paths, 'query-state-request')).rejects.toThrow('authentication failed'); + expect(called).toBe(before); + } finally { + await server.close(); + await lock.release(); + await rm(root, { recursive: true, force: true }); + } + }, +); + +it.each(['result', 'error', 'escaped-result', 'escaped-error'] as const)( + 'replaces an oversized valid %s with one bounded correlated frame error', + { timeout: 45000 }, + async (scenario) => { + const root = await mkdtemp(join(tmpdir(), 'cadder-oversized-')); + const owner = await runtimeOwner(); + const paths = resolvePaths({ runtimeDir: root }); + await prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + const escaped = scenario.startsWith('escaped-'); + const largeMessage = escaped + ? '"'.repeat(Math.floor(MAX_FRAME_BYTES / 3)) + : 'x'.repeat(MAX_FRAME_BYTES + 1); + const method = + scenario === 'escaped-result' ? 'shutdown-daemon-request' : 'query-state-request'; + const oversizedResult = + scenario === 'escaped-result' + ? { ...basicResult, message: largeMessage } + : { ...fakeStateResult(), message: largeMessage }; + const oversizedError = new RpcError({ ...typedError, message: largeMessage }); + const isError = scenario.endsWith('error'); + const originalResponse = { + protocolVersion: 3, + requestId: id, + ...(isError + ? { error: { ...oversizedError.protocolError, requestId: id } } + : { result: { ...oversizedResult, requestId: id } }), + }; + expect(responseSchema(method).safeParse(originalResponse).success).toBe(true); + const payload = JSON.stringify(originalResponse); + if (escaped) expect(Buffer.byteLength(payload)).toBeLessThan(MAX_FRAME_BYTES); + else expect(Buffer.byteLength(payload)).toBeGreaterThan(MAX_FRAME_BYTES); + expect( + Buffer.byteLength(JSON.stringify({ sequence: 0, payload, mac: '0'.repeat(64) }) + '\n'), + ).toBeGreaterThan(MAX_FRAME_BYTES); + let called = 0; + let denied = 0; + let requestId = ''; + const server = await serveIpc( + paths, + owner, + lock.secret, + async (request) => { + called++; + requestId = request.requestId; + if (isError) throw oversizedError; + return oversizedResult; + }, + () => { + denied++; + }, + ); + try { + const failure = await rpc(paths, method).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(RpcError); + expect(failure).toMatchObject({ + code: 'frame', + protocolError: { kind: 'frame', requestId, retryable: false }, + }); + expect(called).toBe(1); + const channel = new JsonChannel(createConnection(paths.endpoint)); + const session = await authenticateClient(channel, lock.secret, paths.instance); + try { + session.send({ ...request, method }); + const response = errorResponseSchema.parse(await session.receive()); + expect(response.requestId).toBe(id); + expect(response.error).toEqual({ + kind: 'frame', + code: 'frame', + message: 'Daemon response exceeds the IPC frame size limit.', + guidance: null, + retryable: false, + requestId: id, + }); + expect(Buffer.byteLength(JSON.stringify(response))).toBeLessThan(MAX_FRAME_BYTES); + expect(JSON.stringify(response)).not.toContain(largeMessage); + expect(JSON.stringify(response)).not.toContain(lock.secret.toString('hex')); + await expect(session.receive()).rejects.toThrow('closed'); + expect(called).toBe(2); + expect(denied).toBe(0); + } finally { + session.close(); + channel.close(); + } + } finally { + await server.close(); + await lock.release(); + await rm(root, { recursive: true, force: true }); + } + }, +); + +it( + 'client rejects authenticated malformed peers, non-exclusive outcomes and correlation failures', + { timeout: 90000 }, + async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-peer-')); + const owner = await runtimeOwner(); + const paths = resolvePaths({ runtimeDir: root }); + await prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + const mutations: Array<(request: RpcRequest) => unknown> = [ + (request) => ({ protocolVersion: 3, requestId: request.requestId }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + result: { ...fakeStateResult(), requestId: request.requestId }, + error: { ...typedError, requestId: request.requestId }, + }), + (request) => ({ + protocolVersion: 3, + requestId: randomUUID(), + result: { ...fakeStateResult(), requestId: request.requestId }, + }), + () => { + const requestId = randomUUID(); + return { protocolVersion: 3, requestId, result: { ...fakeStateResult(), requestId } }; + }, + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + result: { ...fakeStateResult(), requestId: randomUUID() }, + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + error: { ...typedError, requestId: randomUUID() }, + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + result: { ...basicResult, requestId: request.requestId }, + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + result: { ...fakeStateResult(), requestId: request.requestId, recovered: true }, + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + error: { code: 'busy', message: 'Incomplete error' }, + }), + (request) => ({ + protocolVersion: 2, + requestId: request.requestId, + result: { ...fakeStateResult(), requestId: request.requestId }, + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + result: fakeStateResult(), + }), + (request) => ({ + protocolVersion: 3, + requestId: request.requestId, + error: { ...typedError, requestId: request.requestId, extra: true }, + }), + () => ({ + protocolVersion: 3, + requestId: UNCORRELATED_REQUEST_ID, + error: toWireError(undefined, UNCORRELATED_REQUEST_ID), + }), + () => null, + ]; + let index = 0; + const failures: unknown[] = []; + const server = createServer((socket) => { + const channel = new JsonChannel(socket); + void (async () => { + const session = await authenticateServer(channel, lock.secret, paths.instance); + try { + const incoming: RpcRequest = requestSchema.parse(await session.receive()); + session.send(mutations[index++]!(incoming)); + await new Promise((resolve) => socket.end(resolve)); + } finally { + session.close(); + channel.close(); + } + })().catch((error: unknown) => { + failures.push(error); + channel.close(); + }); + }); + server.listen(paths.endpoint); + await once(server, 'listening'); + try { + for (let attempt = 0; attempt < mutations.length; attempt++) { + await expect(rpc(paths, 'query-state-request')).rejects.toMatchObject({ + code: 'invalid-response', + }); + } + expect(index).toBe(mutations.length); + expect(failures).toEqual([]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await lock.release(); + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/test/local-runtime-faults.test.ts b/test/local-runtime-faults.test.ts new file mode 100644 index 0000000..58f5311 --- /dev/null +++ b/test/local-runtime-faults.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { startLocalRuntime } from '../src/daemon/local-runtime.ts'; +import * as security from '../src/platform/runtime-security.ts'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { serveIpc } from '../src/daemon/ipc-server.ts'; + +vi.mock('../src/platform/runtime-security.ts', () => ({ + runtimeOwner: vi.fn(), + prepareRuntime: vi.fn(), +})); +vi.mock('../src/daemon/runtime-lock.ts', () => ({ acquireRuntimeLock: vi.fn() })); +vi.mock('../src/daemon/ipc-server.ts', () => ({ serveIpc: vi.fn() })); +const release = vi.fn(); +const close = vi.fn(); +const failure = new Error('startup failure'); +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(security.runtimeOwner).mockResolvedValue({ id: 'test', elevated: false }); + vi.mocked(acquireRuntimeLock).mockResolvedValue({ + recovered: false, + secret: Buffer.alloc(32), + release, + }); + vi.mocked(serveIpc).mockResolvedValue({ close }); +}); + +it.each(['owner', 'directory', 'lock'])( + 'does not bind or publish after %s failure', + async (phase) => { + if (phase === 'owner') vi.mocked(security.runtimeOwner).mockRejectedValueOnce(failure); + if (phase === 'directory') vi.mocked(security.prepareRuntime).mockRejectedValueOnce(failure); + if (phase === 'lock') vi.mocked(acquireRuntimeLock).mockRejectedValueOnce(failure); + await expect(startLocalRuntime({ runtimeDir: '/fault-runtime' }, vi.fn())).rejects.toBe( + failure, + ); + expect(serveIpc).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + }, +); + +it('retains startup failure when releasing exclusion also fails', async () => { + vi.mocked(serveIpc).mockRejectedValueOnce(failure); + release.mockRejectedValueOnce(new Error('release failure')); + const error = await startLocalRuntime({ runtimeDir: '/fault-runtime' }, vi.fn()).catch( + (error: unknown) => error, + ); + expect(error).toMatchObject({ cause: failure }); + expect((error as AggregateError).errors).toHaveLength(2); + expect(release).toHaveBeenCalledOnce(); +}); + +it('settles concurrent stop consistently while attempting listener and lock finalizers', async () => { + const runtime = await startLocalRuntime({ runtimeDir: '/fault-runtime' }, vi.fn()); + close.mockRejectedValueOnce(failure); + release.mockRejectedValueOnce(new Error('release failure')); + const stopping = runtime.stop(); + expect(runtime.stop()).toBe(stopping); + const error = await stopping.catch((error: unknown) => error); + expect((error as AggregateError).errors).toHaveLength(2); + await expect(runtime.stop()).rejects.toBe(error); + expect(close).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); +}); + +it('initializes before readiness and retains endpoint/exclusion until owned teardown settles', async () => { + let finish!: () => void; + const owned = { + close: vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + }; + const initialize = vi.fn(async () => owned); + const runtime = await startLocalRuntime({ runtimeDir: '/fault-runtime', initialize }, vi.fn()); + expect(initialize.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(serveIpc).mock.invocationCallOrder[0]!, + ); + const stopping = runtime.stop(); + expect(close).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + finish(); + await stopping; + expect(close).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); +}); + +it('does not release exclusion after unproved owned cleanup', async () => { + const owned = { + close: vi.fn(async () => { + throw failure; + }), + }; + const runtime = await startLocalRuntime( + { runtimeDir: '/fault-runtime', initialize: async () => owned }, + vi.fn(), + ); + await expect(runtime.stop()).rejects.toBe(failure); + expect(close).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); +}); diff --git a/test/local-runtime.test.ts b/test/local-runtime.test.ts new file mode 100644 index 0000000..a705cd4 --- /dev/null +++ b/test/local-runtime.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { startLocalRuntime } from '../src/daemon/local-runtime.ts'; +import { rpc } from '../src/client/connection.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.ts'; +import { basicResult, fakeStateResult } from './fixtures/rpc-data.ts'; + +it('owns the full bind/lock lifetime and releases it idempotently', async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-local-')); + const options = { + runtimeDir: root, + ...(process.getuid ? { runtimeOwner: process.getuid() } : {}), + }; + try { + const runtime = await startLocalRuntime(options, async () => fakeStateResult()); + try { + expect(await rpc(runtime.paths, 'query-state-request')).toMatchObject(fakeStateResult()); + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toThrow( + 'already locked', + ); + } finally { + await Promise.all([runtime.stop(), runtime.stop()]); + } + const restarted = await startLocalRuntime(options, async () => basicResult); + expect(restarted.recovered).toBe(false); + await restarted.stop(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it('closes the listener and releases the lock when protected discovery publication fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-local-failure-')); + const options = { + runtimeDir: root, + ...(process.getuid ? { runtimeOwner: process.getuid() } : {}), + }; + const paths = resolvePaths(options); + const owner = await runtimeOwner(options.runtimeOwner); + try { + await prepareRuntime(paths.directory, owner); + await writeFile(paths.discovery, 'unsafe-existing-metadata', { mode: 0o644 }); + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await readFile(paths.discovery, 'utf8')).toBe('unsafe-existing-metadata'); + await unlink(paths.discovery); + const runtime = await startLocalRuntime(options, async () => basicResult); + await runtime.stop(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/native-inspection.test.ts b/test/native-inspection.test.ts new file mode 100644 index 0000000..91be5e3 --- /dev/null +++ b/test/native-inspection.test.ts @@ -0,0 +1,36 @@ +import { createServer } from 'node:net'; +import { once } from 'node:events'; +import { expect, it } from 'vitest'; +import { inspectPort, terminatePortOwner } from '../src/platform/inspection.ts'; +import { createSocket } from 'node:dgram'; + +it('observes a real listener and refuses a stale PID without affecting its owner', async () => { + const server = createServer(); + const datagram = createSocket('udp4'); + let datagramBound = false; + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing listener.'); + const owners = await inspectPort(address.port); + expect(owners, `Native TCP owner for port ${address.port}, PID ${process.pid}`).toContainEqual( + expect.objectContaining({ protocol: 'tcp', pid: process.pid }), + ); + datagram.bind(0, '127.0.0.1'); + await once(datagram, 'listening'); + datagramBound = true; + expect(await inspectPort(datagram.address().port)).toContainEqual( + expect.objectContaining({ protocol: 'udp', pid: process.pid }), + ); + await expect(terminatePortOwner(address.port, 2147483646)).rejects.toMatchObject({ + code: 'target-conflict', + }); + expect(server.listening).toBe(true); + } finally { + if (datagramBound) datagram.close(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}, 30000); diff --git a/test/operator-cli.test.ts b/test/operator-cli.test.ts new file mode 100644 index 0000000..4adcc2f --- /dev/null +++ b/test/operator-cli.test.ts @@ -0,0 +1,140 @@ +import { resolve } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +import { runCli, exitCode } from '../src/cli/run.ts'; +import { ClientService } from '../src/client/service.ts'; +import { CadderError, RpcError } from '../src/protocol/errors.ts'; +import { fakeSnapshot } from './fixtures/rpc-data.ts'; +import { cliCases } from './compatibility/catalog.ts'; +import { inspectPort, terminatePortOwner } from '../src/platform/inspection.ts'; +import { localUpstreamPort } from '../src/client/selectors.ts'; +import { logs, table } from '../src/cli/output.ts'; + +vi.mock('../src/platform/inspection.ts', () => ({ + inspectPort: vi.fn(async () => []), + terminatePortOwner: vi.fn(async () => {}), +})); +afterEach(() => vi.restoreAllMocks()); +function fixture() { + const client = new ClientService(); + const snapshot = fakeSnapshot(); + const project = snapshot.registrations[0]!; + project.sourceWorkingDirectory.raw = process.cwd(); + project.sourceConfigPath.raw = project.sourceConfigPath.canonical = resolve('Caddyfile'); + project.registeredDomains[0]!.name = { raw: 'app.localhost', canonical: 'app.localhost' }; + project.registeredDomains[0]!.upstream = 'localhost:3000'; + const state = vi.spyOn(client, 'snapshot').mockResolvedValue(snapshot); + const start = vi.spyOn(client, 'start').mockResolvedValue(); + vi.spyOn(client, 'stop').mockResolvedValue(); + vi.spyOn(client, 'restart').mockResolvedValue(); + const basic = { + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: 'Done.', + }; + vi.spyOn(client, 'setProjectEnabled').mockResolvedValue({ ok: true, value: basic }); + vi.spyOn(client, 'setDomainEnabled').mockResolvedValue({ ok: true, value: basic }); + vi.spyOn(client, 'queryLogs').mockResolvedValue({ + ok: true, + value: { ...basic, stream: project.logStream, streamStatus: 'empty', entries: [] }, + }); + const run = async (argv: readonly string[]) => { + let stdout = ''; + let stderr = ''; + const code = await runCli(argv, client, { + out: (text) => { + stdout += text; + }, + error: (text) => { + stderr += text; + }, + }); + return { code, stdout, stderr }; + }; + return { client, state, start, run }; +} + +it('executes the retained command catalog and rejects unsupported grammar without new surfaces', async () => { + const f = fixture(); + for (const entry of cliCases.filter((entry) => !entry.argv.includes('tui'))) { + const result = await f.run(entry.argv.slice(1)); + expect(result.code, entry.argv.join(' ')).toBe(entry.exitCode); + expect(result[entry.stream], entry.argv.join(' ')).not.toBe(''); + } +}); + +it('bare/group help and attach-only reads never start a daemon and retain the offline exit', async () => { + const f = fixture(); + expect((await f.run([])).stderr).toContain('Usage: cadder'); + for (const group of ['daemon', 'projects', 'domains', 'logs', 'port', 'caddyfile']) + expect((await f.run([group])).code).toBe(0); + expect(f.start).not.toHaveBeenCalled(); + expect(f.state).not.toHaveBeenCalled(); + f.state.mockRejectedValue(new CadderError('daemon-unavailable', 'cadderd is not running.')); + expect((await f.run(['status'])).code).toBe(3); + expect(f.start).not.toHaveBeenCalled(); + const inspect = await f.run(['port', 'inspect', '3000']); + expect(inspect.code).toBe(0); + expect(inspect.stdout).toContain('Registration data unavailable.'); + expect(inspectPort).toHaveBeenCalledWith(3000); +}); + +it('validates bounded logs and expected PID before any operation and maps operation errors', async () => { + const f = fixture(); + vi.mocked(terminatePortOwner).mockClear(); + expect((await f.run(['logs', 'runtime', '--limit', '0'])).code).toBe(2); + expect((await f.run(['port', 'kill', '3000'])).code).toBe(2); + expect(terminatePortOwner).not.toHaveBeenCalled(); + expect((await f.run(['port', 'kill', '3000', '--pid', '42'])).code).toBe(0); + expect(terminatePortOwner).toHaveBeenCalledWith(3000, 42); + expect((await f.run(['domains', 'enable', 'absent.localhost'])).code).toBe(5); + for (const [code, expected] of [ + ['daemon-start-failed', 4], + ['target-not-found', 5], + ['target-conflict', 6], + ['unsafe-runtime-permissions', 7], + ['unsupported-operation', 8], + ['invalid-input', 2], + ] as const) + expect(exitCode(new CadderError(code, code))).toBe(expected); + expect( + exitCode( + new RpcError({ + kind: 'conflict', + code: 'conflict', + message: 'Conflict', + guidance: null, + retryable: false, + requestId: null, + }), + ), + ).toBe(6); +}); + +it('renders control-safe tables and bounded log output without terminal escape execution', () => { + expect(table(['NAME'], [['\x1b[31mvalue\x1b[0m\r\n']])).toContain('value'); + expect(table(['NAME'], [['\x1b[31mvalue\x1b[0m\r\n']])).not.toContain('\x1b'); + expect( + logs({ + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: '', + stream: { streamId: 'domain-app.localhost', domainKey: 'app.localhost', channel: 'caddy' }, + streamStatus: 'empty', + entries: [], + }), + ).toContain('No log entries.'); + for (const upstream of [ + ':3000', + '3000', + ' 127.0.0.2:3000 ', + '[0:0:0:0:0:0:0:1]:3000', + 'localhost:3000', + '127.0.0.1:3000', + '[::1]:3000', + 'http://localhost:3000/path', + ]) + expect(localUpstreamPort(upstream)).toBe(3000); + expect(localUpstreamPort('api.example.com:3000')).toBeUndefined(); + expect(localUpstreamPort('localhost')).toBeUndefined(); + expect(localUpstreamPort('unix//tmp/app.sock')).toBeUndefined(); +}); diff --git a/test/paths.test.ts b/test/paths.test.ts new file mode 100644 index 0000000..5cab018 --- /dev/null +++ b/test/paths.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { join, resolve } from 'node:path'; +import { resolvePaths } from '../src/daemon/paths.ts'; + +describe('v2 runtime identity', () => { + it('isolates explicitly configured runtime without changing the old runtime', () => { + const paths = resolvePaths({ runtimeDir: 'fixture-runtime' }); + expect(paths.directory).toBe(join(resolve('fixture-runtime'), 'v2')); + expect(resolvePaths({ runtimeDir: 'fixture-runtime' })).toEqual(paths); + expect(paths.lock).toContain('lock.sqlite3'); + expect(paths.profile).toBe('default'); + }); + it.each(['default', 'prod', 'production', ''])('accepts default profile %s', (profile) => { + expect(resolvePaths({ runtimeDir: 'fixture', profile }).profile).toBe('default'); + }); + it.each(['dev', 'development'])('accepts dev profile %s', (profile) => { + expect(resolvePaths({ runtimeDir: 'fixture', profile }).profile).toBe('dev'); + }); + it('rejects unrecognized profiles', () => { + expect(() => resolvePaths({ profile: 'other' })).toThrow('Expected runtime profile'); + }); +}); diff --git a/test/ports.test.ts b/test/ports.test.ts new file mode 100644 index 0000000..2dfa2c6 --- /dev/null +++ b/test/ports.test.ts @@ -0,0 +1,11 @@ +import { readFile } from 'node:fs/promises'; +import { expect, it } from 'vitest'; + +it('keeps shared port contracts independent of runtime implementations', async () => { + const source = await readFile(new URL('../src/contracts/ports.ts', import.meta.url), 'utf8'); + const imports = [...source.matchAll(/\bfrom\s+['"]([^'"]+)['"]/g)].map( + ([, specifier]) => specifier, + ); + expect(imports.length).toBeGreaterThan(0); + expect(imports.every((specifier) => specifier?.startsWith('../protocol/'))).toBe(true); +}); diff --git a/test/product-daemon.test.ts b/test/product-daemon.test.ts new file mode 100644 index 0000000..2c0e5de --- /dev/null +++ b/test/product-daemon.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { startDaemon } from '../src/daemon/start.ts'; +import { rpc } from '../src/client/connection.ts'; +import { runtimeLogStream } from '../src/storage/schema.ts'; + +it('runs actual protected product handlers and storage through authenticated IPC, then releases the runtime', async () => { + const directory = await mkdtemp(join(tmpdir(), 'cadder-product-')); + let daemon: Awaited> | undefined; + try { + daemon = await startDaemon({ runtimeDir: directory }); + const state = await rpc(daemon.paths, 'query-state-request'); + expect(state.snapshot).toMatchObject({ + registrations: [], + runtime: { status: 'idle' }, + config: { status: 'idle' }, + storage: { backend: 'sqlite', schemaVersion: 1 }, + }); + const logs = await rpc(daemon.paths, 'query-logs-request', { + stream: runtimeLogStream, + limit: 10, + }); + expect(logs.entries[0]?.rawMessage).toBe('Daemon started.'); + expect((await rpc(daemon.paths, 'shutdown-daemon-request')).accepted).toBe(true); + await daemon.stop(); + await expect(rpc(daemon.paths, 'query-state-request')).rejects.toMatchObject({ + code: 'daemon-unavailable', + }); + daemon = await startDaemon({ runtimeDir: directory }); + expect((await rpc(daemon.paths, 'query-state-request')).snapshot?.registrations).toEqual([]); + } finally { + await daemon?.stop(); + await rm(directory, { recursive: true, force: true }); + } +}, 60000); diff --git a/test/protected-file-faults.test.ts b/test/protected-file-faults.test.ts new file mode 100644 index 0000000..3296bd4 --- /dev/null +++ b/test/protected-file-faults.test.ts @@ -0,0 +1,150 @@ +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createProtectedFile, prepareRuntime } from '../src/platform/runtime-security.ts'; +import { powershell } from '../src/platform/powershell.ts'; + +vi.mock('node:fs/promises', () => ({ + open: vi.fn(), + lstat: vi.fn(), + mkdir: vi.fn(), + chown: vi.fn(), + unlink: vi.fn(), +})); +vi.mock('../src/platform/powershell.ts', async (original) => ({ + ...(await original()), + powershell: vi.fn(), +})); +const owner = { id: 'uid:42', uid: 42, elevated: false }; +const failure = Object.assign(new Error('disk failure'), { code: 'ENOSPC' }); +const handle = { writeFile: vi.fn(), sync: vi.fn(), close: vi.fn() }; +const uidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid'); + +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + Object.defineProperty(process, 'getuid', { configurable: true, value: () => 0 }); + vi.mocked(fs.open).mockResolvedValue(handle as never); + vi.mocked(fs.lstat).mockImplementation(async (path) => { + const target = ['secret', 'metadata', 'foreign', 'new', '/runtime/v2'].includes(String(path)); + return { + isSymbolicLink: () => false, + isFile: () => target && path !== '/runtime/v2', + isDirectory: () => !target || path === '/runtime/v2', + uid: target ? 42 : 0, + mode: target ? 0o600 : 0o777, + } as never; + }); +}); +afterEach(() => { + vi.restoreAllMocks(); + if (uidDescriptor) Object.defineProperty(process, 'getuid', uidDescriptor); + else Reflect.deleteProperty(process, 'getuid'); +}); + +it.each(['writeFile', 'sync', 'close'] as const)( + 'removes a new partial file after %s fails', + async (phase) => { + handle[phase].mockRejectedValueOnce(failure); + await expect(createProtectedFile('secret', owner, 'private')).rejects.toBe(failure); + expect(handle.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledWith('secret'); + }, +); + +it('removes only its new file when protection or final validation fails', async () => { + vi.mocked(fs.chown).mockRejectedValueOnce(failure); + await expect(createProtectedFile('secret', owner)).rejects.toBe(failure); + expect(fs.unlink).toHaveBeenCalledWith('secret'); + vi.mocked(fs.unlink).mockClear(); + for (const phase of Object.values(handle)) phase.mockClear(); + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path, options) => { + if (path === 'metadata') throw failure; + return inspect(path, options); + }); + await expect(createProtectedFile('metadata', owner)).rejects.toBe(failure); + expect(fs.lstat).toHaveBeenCalledWith('metadata'); + expect(handle.writeFile).toHaveBeenCalledWith(''); + expect(handle.sync).toHaveBeenCalledOnce(); + expect(handle.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledWith('metadata'); +}); + +it('retains the primary failure while attempting close and unlink', async () => { + handle.writeFile.mockRejectedValueOnce(failure); + handle.close.mockRejectedValueOnce(new Error('close failed')); + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('unlink failed')); + const error = await createProtectedFile('secret', owner).catch((error: unknown) => error); + expect(error).toMatchObject({ cause: failure }); + expect((error as AggregateError).errors).toHaveLength(3); + expect(fs.unlink).toHaveBeenCalledWith('secret'); +}); + +it('does not remove or repair unsafe existing files or failed exclusive opens', async () => { + vi.mocked(fs.open).mockRejectedValueOnce(Object.assign(new Error('exists'), { code: 'EEXIST' })); + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path, options) => { + if (path === 'foreign') return { isSymbolicLink: () => true } as never; + return inspect(path, options); + }); + await expect(createProtectedFile('foreign', owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + vi.mocked(fs.open).mockRejectedValueOnce(failure); + await expect(createProtectedFile('new', owner)).rejects.toBe(failure); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.chown).not.toHaveBeenCalled(); + expect(fs.open).toHaveBeenCalledWith( + 'new', + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + 0o600, + ); +}); + +it('removes its new Windows file after ACL protection fails', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.mocked(powershell).mockRejectedValueOnce(failure); + await expect( + createProtectedFile('secret', { id: 'S-1-5-21-test', elevated: false }), + ).rejects.toBe(failure); + expect(handle.close).toHaveBeenCalledOnce(); + expect(fs.unlink).toHaveBeenCalledWith('secret'); +}); + +it('rejects an unsafe existing directory without repairing or deleting it', async () => { + await expect(prepareRuntime('/runtime/v2', owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.chown).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); +}); + +it.each(['mkdir-parent', 'mkdir-leaf', 'protection', 'validation'])( + 'fails directory preparation without repairing existing paths: %s', + async (phase) => { + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + let targetChecks = 0; + vi.mocked(fs.lstat).mockImplementation(async (path, options) => { + if (path !== '/runtime/v2') return inspect(path, options); + targetChecks += 1; + if (targetChecks === 1) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + throw failure; + }); + if (phase === 'mkdir-parent') vi.mocked(fs.mkdir).mockRejectedValueOnce(failure); + if (phase === 'mkdir-leaf') + vi.mocked(fs.mkdir).mockResolvedValueOnce(undefined).mockRejectedValueOnce(failure); + if (phase === 'protection') vi.mocked(fs.chown).mockRejectedValueOnce(failure); + await expect(prepareRuntime('/runtime/v2', owner)).rejects.toBe(failure); + expect(fs.mkdir).toHaveBeenCalledWith('/runtime', { mode: 0o700, recursive: true }); + expect(fs.mkdir).toHaveBeenCalledTimes(phase === 'mkdir-parent' ? 1 : 2); + if (phase !== 'mkdir-parent') + expect(fs.mkdir).toHaveBeenCalledWith('/runtime/v2', { mode: 0o700 }); + if (phase === 'protection' || phase === 'validation') + expect(fs.chown).toHaveBeenCalledWith('/runtime/v2', 42, 42); + else expect(fs.chown).not.toHaveBeenCalled(); + expect(targetChecks).toBe(phase === 'validation' ? 2 : 1); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); diff --git a/test/routes-tui.test.tsx b/test/routes-tui.test.tsx new file mode 100644 index 0000000..062d6f6 --- /dev/null +++ b/test/routes-tui.test.tsx @@ -0,0 +1,65 @@ +import { render, cleanup } from 'ink-testing-library'; +import { afterEach, expect, it, vi } from 'vitest'; +import { RoutesApp } from '../src/tui/app.tsx'; +import { ClientService } from '../src/client/service.ts'; +import { CadderError } from '../src/protocol/errors.ts'; +import { fakeSnapshot } from './fixtures/rpc-data.ts'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); +it('renders real client state with tree rows and toggles the selected project/domain', async () => { + const client = new ClientService(); + vi.spyOn(client, 'snapshot').mockResolvedValue(fakeSnapshot()); + const result = { + ok: true as const, + value: { requestId: '00000000-0000-4000-8000-000000000001', accepted: true, message: 'Done' }, + }; + const project = vi.spyOn(client, 'setProjectEnabled').mockResolvedValue(result); + const domain = vi.spyOn(client, 'setDomainEnabled').mockResolvedValue(result); + const screen = render(); + await vi.waitFor(() => expect(screen.lastFrame()).toContain('example.localhost')); + expect(screen.lastFrame()).toContain('cadderd online'); + expect(screen.lastFrame()).not.toContain('runtime-control'); + screen.stdin.write(' '); + await vi.waitFor(() => + expect(project).toHaveBeenCalledWith({ registrationId: 'shim-fixture', enabled: true }), + ); + await vi.waitFor(() => expect(screen.lastFrame()).not.toContain('Working...')); + screen.stdin.write('\x1b[B'); + await vi.waitFor(() => expect(screen.lastFrame()).toMatch(/>.*example\.localhost/)); + screen.stdin.write('\r'); + await vi.waitFor(() => + expect(domain).toHaveBeenCalledWith({ + registrationId: 'shim-fixture', + domainKey: 'example.localhost', + enabled: false, + }), + ); +}); + +it('keeps offline entry attach-only, starts explicitly and confirms stop', async () => { + const client = new ClientService(); + const snapshot = vi + .spyOn(client, 'snapshot') + .mockRejectedValue(new CadderError('daemon-unavailable', 'Offline')); + const start = vi.spyOn(client, 'start').mockImplementation(async () => { + snapshot.mockResolvedValue(fakeSnapshot()); + }); + const stop = vi.spyOn(client, 'stop').mockResolvedValue(); + const screen = render(); + await vi.waitFor(() => expect(screen.lastFrame()).toContain('Press Enter')); + expect(start).not.toHaveBeenCalled(); + screen.stdin.write('\r'); + await vi.waitFor(() => expect(screen.lastFrame()).toContain('cadderd online')); + screen.stdin.write('s'); + await vi.waitFor(() => expect(screen.lastFrame()).toContain('y / n')); + expect(stop).not.toHaveBeenCalled(); + screen.stdin.write('n'); + await vi.waitFor(() => expect(screen.lastFrame()).not.toContain('y / n')); + screen.stdin.write('s'); + await vi.waitFor(() => expect(screen.lastFrame()).toContain('y / n')); + screen.stdin.write('y'); + await vi.waitFor(() => expect(stop).toHaveBeenCalledOnce()); +}); diff --git a/test/rpc.test.ts b/test/rpc.test.ts new file mode 100644 index 0000000..a84096b --- /dev/null +++ b/test/rpc.test.ts @@ -0,0 +1,420 @@ +import { randomUUID } from 'node:crypto'; +import { expect, expectTypeOf, it } from 'vitest'; +import { rpc } from '../src/client/connection.ts'; +import type { RuntimePaths } from '../src/daemon/paths.ts'; +import { + catalog, + dispatchRpc, + errorResponseSchema, + requestSchema, + responseSchema, + UNCORRELATED_REQUEST_ID, + type RpcMethod, + type RpcParams, + type RpcResult, +} from '../src/protocol/rpc.ts'; +import { protocolErrorSchema, RpcError, toWireError } from '../src/protocol/errors.ts'; +import { + fakeRegistration, + fakeSnapshot, + fakeStateResult, + basicResult, + stream, + timestamp, +} from './fixtures/rpc-data.ts'; + +const id = randomUUID(); +const header = { protocolVersion: 3, requestId: id }; +const params: { [M in RpcMethod]: RpcParams } = { + 'register-entrypoint-request': { registration: fakeRegistration() }, + 'unregister-entrypoint-request': { registrationId: 'id', shimSessionNonce: 'nonce' }, + 'heartbeat-entrypoint-request': { registrationId: 'id', shimSessionNonce: 'nonce' }, + 'query-state-request': {}, + 'set-entrypoint-enabled-request': { registrationId: 'id', shimSessionNonce: null, enabled: true }, + 'set-domain-enabled-request': { registrationId: 'id', domainKey: 'domain', enabled: false }, + 'query-logs-request': { stream, limit: null }, + 'shutdown-daemon-request': {}, +}; +const entry = { + sequenceNumber: 1, + timestampUtc: timestamp, + severity: 'info' as const, + stream, + attributionKind: 'runtimeControl' as const, + entryKind: 'normal' as const, + rawMessage: 'Fixture log.', + domainKey: null, + sourceRegistrationId: null, + sourceInstanceId: null, + operation: null, +}; +const results: { [M in RpcMethod]: RpcResult } = { + 'register-entrypoint-request': { ...basicResult, requestId: id, registrationId: 'id' }, + 'unregister-entrypoint-request': { ...basicResult, requestId: id }, + 'heartbeat-entrypoint-request': { ...basicResult, requestId: id }, + 'query-state-request': { ...fakeStateResult(), requestId: id }, + 'set-entrypoint-enabled-request': { ...basicResult, requestId: id }, + 'set-domain-enabled-request': { ...basicResult, requestId: id }, + 'query-logs-request': { + ...basicResult, + requestId: id, + stream, + streamStatus: 'active', + entries: [entry], + }, + 'shutdown-daemon-request': { ...basicResult, requestId: id }, +}; +const methods = Object.keys(catalog) as RpcMethod[]; + +it('exposes exactly the eight released operation strings', () => { + expect(methods).toEqual([ + 'register-entrypoint-request', + 'unregister-entrypoint-request', + 'heartbeat-entrypoint-request', + 'query-state-request', + 'set-entrypoint-enabled-request', + 'set-domain-enabled-request', + 'query-logs-request', + 'shutdown-daemon-request', + ]); +}); + +it.each(methods)('%s round trips closed params, results and typed errors', async (method) => { + const request = { ...header, method, params: params[method] }; + expect(requestSchema.parse(JSON.parse(JSON.stringify(request)))).toEqual(request); + expect(requestSchema.safeParse({ ...request, extra: true }).success).toBe(false); + expect( + requestSchema.safeParse({ ...request, params: { ...params[method], extra: true } }).success, + ).toBe(false); + expect(requestSchema.safeParse({ ...request, params: null }).success).toBe(false); + expect(requestSchema.safeParse({ ...request, params: [] }).success).toBe(false); + const response = { ...header, result: results[method] }; + expect(responseSchema(method).parse(JSON.parse(JSON.stringify(response)))).toEqual(response); + expect(responseSchema(method).safeParse({ ...response, extra: true }).success).toBe(false); + expect( + responseSchema(method).safeParse({ ...response, result: { ...results[method], extra: true } }) + .success, + ).toBe(false); + expect( + responseSchema(method).safeParse({ + ...response, + result: { ...results[method], accepted: 'true' }, + }).success, + ).toBe(false); + expect( + responseSchema(method).safeParse({ + ...response, + result: { ...results[method], requestId: randomUUID() }, + }).success, + ).toBe(false); + const rejected = { + ...header, + error: { ...toWireError(undefined, id), kind: 'invalidInput', code: 'invalid_input' }, + }; + expect(responseSchema(method).parse(rejected)).toEqual(rejected); + expect(responseSchema(method).safeParse({ ...response, error: rejected.error }).success).toBe( + false, + ); + expect(responseSchema(method).safeParse(header).success).toBe(false); + expect( + responseSchema(method).safeParse({ ...rejected, error: { ...rejected.error, requestId: null } }) + .success, + ).toBe(false); + expect(await dispatchRpc(request, async () => results[method])).toEqual(response); +}); + +it('keeps identity and nonce ownership decisions in business results', async () => { + const registration = { ...fakeRegistration(), registrationId: '' }; + registration.entrypointInstance.shimSessionNonce = ''; + const request = { ...header, method: 'register-entrypoint-request', params: { registration } }; + expect(requestSchema.safeParse(request).success).toBe(true); + expect( + await dispatchRpc(request, async () => ({ + accepted: false, + message: 'Owner mismatch.', + registrationId: null, + })), + ).toEqual({ + ...header, + result: { requestId: id, accepted: false, message: 'Owner mismatch.', registrationId: null }, + }); +}); + +it('normalizes omitted serde Options to null without adding handler log defaults', () => { + const omitted = { + ...fakeRegistration(), + sourceWorkingDirectory: { raw: '/fixture' }, + sourceConfigPath: { raw: '/fixture/Caddyfile' }, + ownerProcess: { processId: 123, processStartTimeUtc: timestamp, shimSessionNonce: 'nonce' }, + logStream: { streamId: 'runtime-control', channel: 'control' }, + shimRun: undefined, + registeredDomains: [ + { + name: { raw: 'x', canonical: 'x' }, + activationState: 'active', + logStream: { streamId: 's', channel: 'c' }, + }, + ], + }; + const normalized = catalog['register-entrypoint-request'].params.parse( + JSON.parse(JSON.stringify({ registration: omitted })), + ).registration; + expect(normalized.sourceWorkingDirectory.canonical).toBe(null); + expect(normalized.sourceConfigPath.canonical).toBe(null); + expect(normalized.ownerProcess.executablePath).toBe(null); + expect(normalized.logStream.domainKey).toBe(null); + expect(normalized.shimRun).toBe(null); + expect(normalized.registeredDomains[0]!.logStream.domainKey).toBe(null); + expect(normalized.registeredDomains[0]).not.toHaveProperty('upstream'); + expect( + catalog['register-entrypoint-request'].params.parse({ + registration: { ...omitted, shimRun: { rawArguments: [], commandLine: 'run' } }, + }).registration.shimRun?.adapter, + ).toBe(null); + expect( + catalog['query-logs-request'].params.parse({ stream: { streamId: 's', channel: 'c' } }), + ).toEqual({ stream: { streamId: 's', channel: 'c', domainKey: null }, limit: null }); + for (const upstream of [undefined, null, '127.0.0.1:3000']) { + const registration = { + ...fakeRegistration(), + shimRun: null, + registeredDomains: [{ ...fakeRegistration().registeredDomains[0], upstream }], + }; + const normalized = catalog['register-entrypoint-request'].params.parse({ registration }); + const serialized = JSON.parse(JSON.stringify(normalized)); + if (upstream === undefined || upstream === null) + expect(serialized.registration.registeredDomains[0]).not.toHaveProperty('upstream'); + else expect(serialized.registration.registeredDomains[0].upstream).toBe(upstream); + expect( + catalog['query-state-request'].result.safeParse({ + ...basicResult, + requestId: id, + snapshot: { ...fakeSnapshot(), registrations: [registration] }, + }).success, + ).toBe(true); + } + const badUpstream = { + ...fakeRegistration(), + registeredDomains: [{ ...fakeRegistration().registeredDomains[0], upstream: 1 }], + }; + expect( + catalog['register-entrypoint-request'].params.safeParse({ registration: badUpstream }).success, + ).toBe(false); + for (const shimSessionNonce of [undefined, null, 'nonce']) { + expect( + catalog['set-entrypoint-enabled-request'].params.safeParse({ + registrationId: 'id', + enabled: false, + shimSessionNonce, + }).success, + ).toBe(true); + } + expect( + catalog['query-state-request'].result.parse({ ...basicResult, requestId: id, snapshot: null }) + .snapshot, + ).toBe(null); + expect( + catalog['register-entrypoint-request'].result.parse({ + ...basicResult, + requestId: id, + registrationId: null, + }).registrationId, + ).toBe(null); + for (const limit of [undefined, null, 0, 1, 100, 200, 500, Number.MAX_SAFE_INTEGER]) { + const input = { stream, limit }; + expect(catalog['query-logs-request'].params.parse(input)).toEqual({ + stream, + limit: limit ?? null, + }); + } + for (const limit of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, Infinity, '100']) { + expect(catalog['query-logs-request'].params.safeParse({ stream, limit }).success).toBe(false); + } +}); + +it('leaves RPC log default and clamping to the handler, not the schemas', async () => { + for (const limit of [undefined, null, 0, 1, 50, 100, 200, 500]) { + let observed: number | null | undefined; + const result = await dispatchRpc( + { ...header, method: 'query-logs-request', params: { stream, limit } }, + async (request) => { + if (request.method !== 'query-logs-request') throw new Error('Wrong catalog dispatch.'); + observed = request.params.limit; + const count = Math.max(1, Math.min(200, request.params.limit ?? 100)); + return { + ...results['query-logs-request'], + entries: Array.from({ length: count }, () => entry), + }; + }, + ); + expect(observed).toBe(limit ?? null); + const response = responseSchema('query-logs-request').parse(result); + expect( + 'result' in response && 'entries' in response.result ? response.result.entries.length : -1, + ).toBe(Math.max(1, Math.min(200, limit ?? 100))); + } +}); + +it('rejects nested unknown fields, missing fields, bad enum/time/number representations', () => { + const registration = fakeRegistration(); + const badRegistrations = [ + { ...registration, entrypointInstance: { ...registration.entrypointInstance, extra: true } }, + { ...registration, ownerProcess: { ...registration.ownerProcess, processId: 0x100000000 } }, + { ...registration, sourceConfigPath: { raw: 'x', canonical: 1 } }, + { ...registration, activationState: 'enabled' }, + { ...registration, createdAtUtc: 'yesterday' }, + { ...registration, logStream: { ...stream, extra: 1 } }, + { + ...registration, + registeredDomains: [ + { ...registration.registeredDomains[0], name: { raw: 'x', canonical: 'x', extra: true } }, + ], + }, + { ...registration, shimRun: { ...registration.shimRun, extra: true } }, + ]; + for (const candidate of badRegistrations) + expect( + catalog['register-entrypoint-request'].params.safeParse({ registration: candidate }).success, + ).toBe(false); + for (const method of ['unregister-entrypoint-request', 'heartbeat-entrypoint-request'] as const) + expect(catalog[method].params.safeParse({ registrationId: 'id' }).success).toBe(false); + expect( + catalog['set-domain-enabled-request'].params.safeParse({ registrationId: 'id', enabled: true }) + .success, + ).toBe(false); + expect( + catalog['set-entrypoint-enabled-request'].params.safeParse({ registrationId: 'id', enabled: 1 }) + .success, + ).toBe(false); + const snapshot = fakeSnapshot(); + for (const candidate of [ + { ...snapshot, recovered: true }, + { ...snapshot, runtime: { ...snapshot.runtime, status: 'online' } }, + { + ...snapshot, + runtime: { + ...snapshot.runtime, + diagnostics: [{ code: 'x', message: 'x', operation: null, extra: true }], + }, + }, + { + ...snapshot, + config: { + ...snapshot.config, + diagnostics: [ + { code: 'x', message: 'x', domainKey: null, sourceConfigPaths: [], extra: true }, + ], + }, + }, + { ...snapshot, storage: { ...snapshot.storage, schemaVersion: -1 } }, + ]) + expect( + catalog['query-state-request'].result.safeParse({ + ...basicResult, + requestId: id, + snapshot: candidate, + }).success, + ).toBe(false); + const logs = results['query-logs-request']; + expect( + catalog['query-logs-request'].result.safeParse({ + ...logs, + entries: Array.from({ length: 200 }, () => entry), + }).success, + ).toBe(true); + expect( + catalog['query-logs-request'].result.safeParse({ + ...logs, + entries: Array.from({ length: 201 }, () => entry), + }).success, + ).toBe(false); + for (const candidate of [ + { ...entry, sequenceNumber: Number.MAX_SAFE_INTEGER + 1 }, + { ...entry, severity: 'verbose' }, + { ...entry, attributionKind: 'server' }, + { ...entry, entryKind: 'gap' }, + { ...entry, stream: { ...stream, extra: 1 } }, + ]) + expect( + catalog['query-logs-request'].result.safeParse({ ...logs, entries: [candidate] }).success, + ).toBe(false); +}); + +it('round trips all typed error kinds and rejects malformed closed errors', () => { + for (const kind of protocolErrorSchema.shape.kind.options) { + const error = { ...toWireError(undefined, id), kind }; + expect(protocolErrorSchema.parse(JSON.parse(JSON.stringify(error)))).toEqual(error); + expect(new RpcError(error).protocolError).toEqual(error); + } + const error = { + ...toWireError(undefined, id), + kind: 'accessDenied', + deniedOperation: 'shutdown-daemon-request', + }; + expect(protocolErrorSchema.parse(error)).toEqual(error); + for (const deniedOperation of [undefined, null]) { + const normalized = protocolErrorSchema.parse({ ...error, deniedOperation }); + expect(JSON.parse(JSON.stringify(normalized))).not.toHaveProperty('deniedOperation'); + expect( + errorResponseSchema.safeParse({ ...header, error: { ...error, deniedOperation } }).success, + ).toBe(true); + } + expect(protocolErrorSchema.safeParse({ ...error, deniedOperation: 1 }).success).toBe(false); + for (const code of ['', '_code', 'a_', 'A', 'a-b', 'a'.repeat(65)]) + expect(protocolErrorSchema.safeParse({ ...error, code }).success).toBe(false); + for (const candidate of [ + { ...error, extra: true }, + { ...error, guidance: undefined }, + { ...error, requestId: undefined }, + { ...error, retryable: 'false' }, + { ...error, kind: 'generic' }, + ]) + expect(protocolErrorSchema.safeParse(candidate).success).toBe(false); +}); + +it('dispatch sanitizes invalid handler output and untrusted throws', async () => { + const request = { ...header, method: 'query-state-request', params: {} }; + const result = await dispatchRpc(request, async () => ({ + ...fakeStateResult(), + requestId: 'handler cannot choose id', + })); + expect(responseSchema('query-state-request').parse(result)).toEqual({ + ...header, + result: { ...fakeStateResult(), requestId: id }, + }); + const rejected = await dispatchRpc(request, async () => { + throw new Error('sensitive details'); + }); + expect(errorResponseSchema.parse(rejected).error).toEqual(toWireError(undefined, id)); + expect(JSON.stringify(rejected)).not.toContain('sensitive'); + let called = false; + expect( + errorResponseSchema.parse( + await dispatchRpc({}, async () => { + called = true; + return basicResult; + }), + ).requestId, + ).toBe(UNCORRELATED_REQUEST_ID); + expect(called).toBe(false); +}); + +// Checked by tsc, never invoked: payload requirements and method-indexed returns. +function staticContract(paths: RuntimePaths) { + expectTypeOf(rpc(paths, 'query-state-request')).toEqualTypeOf< + Promise> + >(); + expectTypeOf(rpc(paths, 'query-logs-request', { stream })).toEqualTypeOf< + Promise> + >(); + // @ts-expect-error No arbitrary method surface. + void rpc(paths, 'status'); + // @ts-expect-error Mutation params are required. + void rpc(paths, 'heartbeat-entrypoint-request'); + // @ts-expect-error Domain key is required. + void rpc(paths, 'set-domain-enabled-request', { registrationId: 'id', enabled: true }); + // @ts-expect-error State return cannot be mistaken for a log result. + const logs: Promise> = rpc(paths, 'query-state-request'); + void logs; +} +void staticContract; diff --git a/test/runtime-ancestors.test.ts b/test/runtime-ancestors.test.ts new file mode 100644 index 0000000..7e6ed49 --- /dev/null +++ b/test/runtime-ancestors.test.ts @@ -0,0 +1,274 @@ +import type { Stats } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, +} from '../src/platform/runtime-security.ts'; +import { powershell } from '../src/platform/powershell.ts'; + +// POSIX paths and Unix/Darwin filesystem data are modeled on the Windows runner; +// these cases are not native Unix acceptance. +vi.mock('node:path', async (original) => { + const path = await original(); + return { ...path, ...path.posix }; +}); +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + lstat: vi.fn(), + realpath: vi.fn(), + mkdir: vi.fn(), + open: vi.fn(), + unlink: vi.fn(), + chown: vi.fn(), +})); +vi.mock('../src/platform/powershell.ts', async (original) => ({ + ...(await original()), + powershell: vi.fn(), +})); +const owner = { id: 'uid:42', uid: 42, elevated: false }; +function info(uid: number, mode: number, directory = true, link = false): Stats { + return { + uid, + mode, + isDirectory: () => directory, + isFile: () => !directory, + isSymbolicLink: () => link, + } as Stats; +} +function filesystem(ancestor: string, uid: number, leaf: string) { + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === ancestor) return info(uid, 0o777, false, true); + if (path === leaf) return info(42, 0o600, false); + // No new owner/mode policy for ordinary real ancestors, even broad modes. + return info(500, 0o777); + }); +} +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); +}); +afterEach(() => vi.restoreAllMocks()); + +it.each(['prepareRuntime', 'assertProtected'] as const)( + 'rejects modeled POSIX trailing-separator leaf links during %s', + async (operation) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === '/runtime/link') return info(42, 0o777, false, true); + // POSIX lstat with a final slash follows the link to a directory. + if (path === '/runtime/link/' || path === '/runtime/link//') return info(42, 0o700); + return info(0, 0o755); + }); + for (const path of ['/runtime/link/', '/runtime/link//']) + await expect( + operation === 'prepareRuntime' + ? prepareRuntime(path, owner) + : assertProtected(path, owner, true), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(fs.lstat).toHaveBeenCalledWith('/runtime/link'); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); + expect(fs.chown).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); + +it.each(['/runtime/real/', '/runtime/real//', '/', '///'])( + 'retains modeled POSIX real directory/root behavior for %s', + async (path) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + vi.mocked(fs.lstat).mockResolvedValue(info(42, 0o700)); + await prepareRuntime(path, owner); + await assertProtected(path, owner, true); + const leaf = path.replace(/\/+$/, '') || '/'; + expect(fs.lstat).toHaveBeenLastCalledWith(leaf); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.realpath).not.toHaveBeenCalled(); + }, +); + +it('trims modeled POSIX separators without erasing unverified dot-dot components', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + filesystem('/home/link', 42, '/home/link/../real/'); + await expect(assertProtected('/home/link/../real/', owner, true)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(fs.lstat).toHaveBeenCalledWith('/home/link'); + expect(fs.lstat).not.toHaveBeenCalledWith('/home/real'); +}); + +it.each(['/tmp', '/var', '/etc'])( + 'accepts only the modeled standard Darwin alias %s', + async (ancestor) => { + const leaf = `${ancestor}/runtime/secret`; + filesystem(ancestor, 0, leaf); + vi.mocked(fs.realpath).mockResolvedValue(`/private${ancestor}`); + await assertProtected(leaf, owner); + expect(fs.realpath).toHaveBeenCalledWith(ancestor); + expect(fs.lstat).toHaveBeenCalledWith(`/private${ancestor}`); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); + expect(powershell).not.toHaveBeenCalled(); + }, +); + +it.each([ + ['user-owned tmp', 'darwin', '/tmp', 42, '/private/tmp'], + ['user-owned var', 'darwin', '/var', 42, '/private/var'], + ['user-owned etc', 'darwin', '/etc', 42, '/private/etc'], + ['wrong tmp target', 'darwin', '/tmp', 0, '/elsewhere/tmp'], + ['wrong var target', 'darwin', '/var', 0, '/private/var-extra'], + ['wrong etc target', 'darwin', '/etc', 0, '/private/tmp'], + ['nested tmp', 'darwin', '/home/tmp', 0, '/private/tmp'], + ['nested var', 'darwin', '/home/var', 0, '/private/var'], + ['nested etc', 'darwin', '/home/etc', 0, '/private/etc'], + ['different case', 'darwin', '/TMP', 0, '/private/tmp'], + ['other root-owned link', 'darwin', '/opt', 0, '/private/opt'], + ['Linux tmp', 'linux', '/tmp', 0, '/private/tmp'], + ['Linux var', 'linux', '/var', 0, '/private/var'], + ['Linux etc', 'linux', '/etc', 0, '/private/etc'], + ['Windows tmp model', 'win32', '/tmp', 0, '/private/tmp'], +] as const)( + 'rejects modeled %s before trust or mutation', + async (_description, platform, ancestor, uid, target) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); + const leaf = `${ancestor}/missing/secret`; + filesystem(ancestor, uid, leaf); + vi.mocked(fs.realpath).mockResolvedValue(target); + for (const operation of [ + () => assertProtected(leaf, owner), + () => prepareRuntime(leaf, owner), + () => createProtectedFile(leaf, owner, 'must-not-be-created'), + ]) + await expect(operation()).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(fs.lstat).not.toHaveBeenCalledWith(leaf); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.chown).not.toHaveBeenCalled(); + expect(powershell).not.toHaveBeenCalled(); + }, +); + +it.each(['missing-target', 'inaccessible-target', 'linked-target', 'file-target'])( + 'rejects an unverifiable modeled OS alias: %s', + async (phase) => { + filesystem('/tmp', 0, '/tmp/runtime/secret'); + vi.mocked(fs.realpath).mockResolvedValue('/private/tmp'); + if (phase === 'missing-target') + vi.mocked(fs.realpath).mockRejectedValue( + Object.assign(new Error('missing'), { code: 'ENOENT' }), + ); + else { + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path !== '/private/tmp') return inspect(path); + if (phase === 'inaccessible-target') + throw Object.assign(new Error('denied'), { code: 'EACCES' }); + return info(0, 0o777, false, phase === 'linked-target'); + }); + } + await expect(assertProtected('/tmp/runtime/secret', owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + }, +); + +it('does not introduce owner/mode restrictions on ordinary modeled real ancestors', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + filesystem('/unused-link', 42, '/ordinary/runtime/secret'); + await assertProtected('/ordinary/runtime/secret', owner); + expect(fs.realpath).not.toHaveBeenCalled(); +}); + +it('checks the linked prefix before dot-dot or deeper missing paths in modeled Unix', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + filesystem('/home/link', 42, '/home/link/../secret'); + await expect(assertProtected('/home/link/../secret', owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(fs.lstat).not.toHaveBeenCalledWith('/home/link/..'); + expect(fs.lstat).not.toHaveBeenCalledWith('/home/link/../secret'); +}); + +it('checks modeled missing/dot-dot paths instead of stopping before an existing link', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + const leaf = '/home/missing/../link/secret'; + filesystem('/home/link', 42, leaf); + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === '/home/missing') throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + return inspect(path); + }); + for (const operation of [ + () => prepareRuntime(leaf, owner), + () => createProtectedFile(leaf, owner), + ]) + await expect(operation()).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(fs.lstat).toHaveBeenCalledWith('/home/link'); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); +}); + +it.each(['/tmp', '/var', '/etc'])( + 'retains modeled Darwin %s dot/dot-dot semantics without rewriting the caller path', + async (alias) => { + const leaf = `${alias}/nested/./../runtime/secret`; + filesystem(alias, 0, leaf); + vi.mocked(fs.realpath).mockResolvedValue(`/private${alias}`); + await assertProtected(leaf, owner); + expect(fs.lstat).toHaveBeenCalledWith(`/private${alias}/runtime`); + expect(fs.lstat).toHaveBeenLastCalledWith(leaf); + }, +); + +it('checks the fixed Darwin physical cursor after missing/dot-dot rather than normalizing to root', async () => { + const leaf = '/tmp/missing/../../hidden/secret'; + filesystem('/tmp', 0, leaf); + vi.mocked(fs.realpath).mockResolvedValue('/private/tmp'); + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === '/private/tmp/missing') + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + if (path === '/private/hidden') return info(42, 0o777, false, true); + return inspect(path); + }); + for (const operation of [ + () => assertProtected(leaf, owner), + () => prepareRuntime(leaf, owner), + () => createProtectedFile(leaf, owner), + ]) + await expect(operation()).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(fs.lstat).not.toHaveBeenCalledWith('/hidden'); + expect(fs.lstat).not.toHaveBeenCalledWith(leaf); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); +}); + +it('does not extend Darwin recognition to a root-owned nested alias below a permitted alias', async () => { + const leaf = '/tmp/nested/tmp/secret'; + filesystem('/tmp', 0, leaf); + vi.mocked(fs.realpath).mockResolvedValue('/private/tmp'); + const inspect = vi.mocked(fs.lstat).getMockImplementation()!; + vi.mocked(fs.lstat).mockImplementation(async (path) => + path === '/private/tmp/nested/tmp' ? info(0, 0o777, false, true) : inspect(path), + ); + await expect(assertProtected(leaf, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(fs.realpath).toHaveBeenCalledOnce(); + expect(fs.realpath).toHaveBeenCalledWith('/tmp'); +}); + +it('retains unexpected filesystem failures without attempting creation', async () => { + filesystem('/unused-link', 42, '/ordinary/runtime/secret'); + const failure = Object.assign(new Error('I/O failure'), { code: 'EIO' }); + vi.mocked(fs.lstat).mockRejectedValueOnce(failure); + await expect(prepareRuntime('/ordinary/runtime', owner)).rejects.toBe(failure); + vi.mocked(fs.lstat).mockRejectedValueOnce(failure); + await expect(createProtectedFile('/ordinary/runtime/secret', owner)).rejects.toBe(failure); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); +}); diff --git a/test/runtime-boundaries.test.ts b/test/runtime-boundaries.test.ts new file mode 100644 index 0000000..829d6ca --- /dev/null +++ b/test/runtime-boundaries.test.ts @@ -0,0 +1,331 @@ +import { + chmod, + lstat, + mkdtemp, + mkdir, + readFile, + readlink, + readdir, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; +import { createConnection } from 'node:net'; +import { once } from 'node:events'; +import { afterEach, expect, it, vi } from 'vitest'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { startLocalRuntime } from '../src/daemon/local-runtime.ts'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, + runtimeOwner, +} from '../src/platform/runtime-security.ts'; +import { powershell, psLiteral } from '../src/platform/powershell.ts'; +import { basicResult } from './fixtures/rpc-data.ts'; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +const artifacts = ['directory', 'secret', 'lock', 'metadata', 'discovery'] as const; +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'cadder-boundary-')); + roots.push(root); + const options = { + runtimeDir: root, + ...(process.getuid ? { runtimeOwner: process.getuid() } : {}), + }; + const paths = resolvePaths(options); + const owner = await runtimeOwner(options.runtimeOwner); + return { root, options, paths, owner }; +} +async function snapshot(path: string) { + if (process.platform === 'win32') + return powershell(`(Get-Acl -LiteralPath ${psLiteral(path)}).Sddl`); + const info = await lstat(path); + return JSON.stringify({ uid: info.uid, gid: info.gid, mode: info.mode }); +} + +it.each(['prepareRuntime', 'assertProtected'] as const)( + 'denies a native trailing-separator leaf link during %s without modifying the target', + async (operation) => { + const { root, owner } = await fixture(); + const target = join(root, 'target'); + await prepareRuntime(target, owner); + const sentinel = join(target, 'sentinel'); + await createProtectedFile(sentinel, owner, 'leave-target-unchanged'); + const before = await snapshot(target); + const alias = join(root, 'alias'); + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const link = await readlink(alias); + const suffixes = process.platform === 'win32' ? ['\\', '/', '\\//'] : ['/', '//']; + for (const suffix of suffixes) { + const path = alias + suffix; + await expect( + operation === 'prepareRuntime' + ? prepareRuntime(path, owner) + : assertProtected(path, owner, true), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + } + expect(await readdir(target)).toEqual(['sentinel']); + expect(await readFile(sentinel, 'utf8')).toBe('leave-target-unchanged'); + expect(await snapshot(target)).toBe(before); + expect(await readlink(alias)).toBe(link); + }, +); + +it('retains ordinary native real-directory trailing-separator behavior', async () => { + const { root, owner } = await fixture(); + const directory = join(root, 'real'); + await prepareRuntime(directory + sep + sep, owner); + await assertProtected(directory + sep, owner, true); + await createProtectedFile(join(directory, 'sentinel'), owner, 'ordinary-real-path'); + expect(await readFile(join(directory, 'sentinel'), 'utf8')).toBe('ordinary-real-path'); +}); + +it('rejects a native ancestor junction before trusting a protected leaf or starting a listener', async () => { + const { root, owner } = await fixture(); + const target = join(root, 'target'); + const physical = resolvePaths({ runtimeDir: target }); + await prepareRuntime(physical.directory, owner); + for (const artifact of artifacts.filter((key) => key !== 'directory')) + await createProtectedFile( + physical[artifact], + owner, + artifact === 'secret' ? 'x'.repeat(32) : '', + ); + const sentinel = join(physical.directory, 'sentinel'); + await createProtectedFile(sentinel, owner, 'leave-target-unchanged'); + const contents = await Promise.all( + artifacts.filter((key) => key !== 'directory').map((key) => readFile(physical[key])), + ); + const acls = await Promise.all(artifacts.map((key) => snapshot(physical[key]))); + const alias = join(root, 'alias'); + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const paths = resolvePaths({ runtimeDir: alias }); + const link = await readlink(alias); + for (const artifact of artifacts) + await expect( + assertProtected(paths[artifact], owner, artifact === 'directory'), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + const handler = vi.fn(async () => basicResult); + let started: Awaited> | undefined; + try { + await expect( + startLocalRuntime( + { runtimeDir: alias, ...(process.getuid ? { runtimeOwner: process.getuid() } : {}) }, + handler, + ).then((runtime) => { + started = runtime; + return runtime; + }), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + } finally { + await started?.stop(); + } + const socket = createConnection(paths.endpoint); + try { + await expect(once(socket, 'connect')).rejects.toBeDefined(); + } finally { + socket.destroy(); + } + expect(handler).not.toHaveBeenCalled(); + expect(await readlink(alias)).toBe(link); + expect(await readFile(sentinel, 'utf8')).toBe('leave-target-unchanged'); + expect( + await Promise.all( + artifacts.filter((key) => key !== 'directory').map((key) => readFile(physical[key])), + ), + ).toEqual(contents); + expect(await Promise.all(artifacts.map((key) => snapshot(physical[key])))).toEqual(acls); +}); + +it.each(['prepareRuntime', 'createProtectedFile'] as const)( + 'rejects native linked ancestors before %s creates a missing path', + async (operation) => { + const { root, owner } = await fixture(); + const target = join(root, 'target'); + await prepareRuntime(target, owner); + const sentinel = join(target, 'sentinel'); + await createProtectedFile(sentinel, owner, 'leave-target-unchanged'); + const before = await snapshot(target); + const alias = join(root, 'alias'); + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const link = await readlink(alias); + const denied = + operation === 'prepareRuntime' + ? prepareRuntime(join(alias, 'missing', 'v2'), owner) + : createProtectedFile(join(alias, 'new-secret'), owner, 'must-not-be-created'); + await expect(denied).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + await expect( + startLocalRuntime( + { + runtimeDir: join(alias, 'missing'), + ...(process.getuid ? { runtimeOwner: process.getuid() } : {}), + }, + async () => basicResult, + ), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(await readdir(target)).toEqual(['sentinel']); + expect(await readFile(sentinel, 'utf8')).toBe('leave-target-unchanged'); + expect(await snapshot(target)).toBe(before); + expect(await readlink(alias)).toBe(link); + }, +); + +it.each(['prepareRuntime', 'createProtectedFile'] as const)( + 'checks native missing/dot-dot/link paths before %s mutates a target', + async (operation) => { + const { root, owner } = await fixture(); + const target = join(root, 'target'); + await prepareRuntime(target, owner); + const sentinel = join(target, 'sentinel'); + await createProtectedFile(sentinel, owner, 'leave-target-unchanged'); + const before = await snapshot(target); + const alias = join(root, 'alias'); + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const link = await readlink(alias); + const path = `${root}${sep}missing${sep}..${sep}alias${sep}escaped`; + const denied = + operation === 'prepareRuntime' + ? prepareRuntime(path, owner) + : createProtectedFile(path, owner, 'must-not-be-created'); + await expect(denied).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(await readdir(target)).toEqual(['sentinel']); + expect(await readFile(sentinel, 'utf8')).toBe('leave-target-unchanged'); + expect(await snapshot(target)).toBe(before); + expect(await readlink(alias)).toBe(link); + }, +); + +it('retains ordinary native real-directory dot and dot-dot semantics', async () => { + const { root, owner } = await fixture(); + await prepareRuntime(join(root, 'real', 'nested'), owner); + const directory = `${root}${sep}real${sep}nested${sep}.${sep}..${sep}v2`; + await prepareRuntime(directory, owner); + await assertProtected(directory, owner, true); + await createProtectedFile(`${directory}${sep}secret`, owner, 'ordinary-real-path'); + expect(await readFile(join(root, 'real', 'v2', 'secret'), 'utf8')).toBe('ordinary-real-path'); +}); + +it('does not erase a native linked ancestor with lexical dot-dot normalization', async () => { + const { root, owner } = await fixture(); + const target = join(root, 'target'); + await mkdir(join(target, 'nested'), { recursive: true }); + await prepareRuntime(join(target, 'v2'), owner); + const alias = join(root, 'alias'); + await symlink(join(target, 'nested'), alias, process.platform === 'win32' ? 'junction' : 'dir'); + await expect(assertProtected(`${alias}${sep}..${sep}v2`, owner, true)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); +}); + +it.each(artifacts)( + 'denies a native %s link/reparse substitution without touching the target', + async (artifact) => { + const { root, options, paths, owner } = await fixture(); + const target = join(root, 'target'); + const sentinel = join(target, 'sentinel'); + await mkdir(target, { mode: 0o700 }); + await writeFile(sentinel, 'leave-target-unchanged', { mode: 0o600 }); + if (artifact !== 'directory') await prepareRuntime(paths.directory, owner); + // Junction creation on Windows needs no symlink privilege; file artifacts are + // deliberately replaced by directory reparse points, not claimed file symlinks. + await symlink(target, paths[artifact], process.platform === 'win32' ? 'junction' : 'dir'); + const before = await snapshot(target); + const link = await readlink(paths[artifact]); + await expect( + assertProtected(paths[artifact], owner, artifact === 'directory'), + ).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toMatchObject({ + code: artifact === 'metadata' ? 'EISDIR' : 'unsafe-runtime-permissions', + }); + if (artifact !== 'discovery') + await expect(lstat(paths.discovery)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readlink(paths[artifact])).toBe(link); + expect(await readFile(sentinel, 'utf8')).toBe('leave-target-unchanged'); + expect(await snapshot(target)).toBe(before); + }, +); + +it.each(artifacts)( + 'denies unsafe native %s permissions/ACLs without repairing existing content', + async (artifact) => { + const { options, paths, owner } = await fixture(); + await prepareRuntime(paths.directory, owner); + const path = paths[artifact]; + const sentinel = artifact === 'directory' ? join(path, 'sentinel') : path; + const content = artifact === 'secret' ? 'x'.repeat(32) : 'untouched-existing-content'; + if (artifact === 'directory') await createProtectedFile(sentinel, owner, content); + else await createProtectedFile(path, owner, content); + if (process.platform === 'win32') { + await powershell(` + $sections=[System.Security.AccessControl.AccessControlSections]::Owner -bor [System.Security.AccessControl.AccessControlSections]::Access; + $acl=[System.IO.${artifact === 'directory' ? 'Directory' : 'File'}]::GetAccessControl(${psLiteral(path)},$sections); + $sid=New-Object System.Security.Principal.SecurityIdentifier('S-1-1-0'); + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'Read','Allow'))); + [System.IO.${artifact === 'directory' ? 'Directory' : 'File'}]::SetAccessControl(${psLiteral(path)},$acl); + `); + } else await chmod(path, artifact === 'directory' ? 0o755 : 0o644); + const before = await snapshot(path); + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await readFile(sentinel, 'utf8')).toBe(content); + expect(await snapshot(path)).toBe(before); + }, +); + +it.each(artifacts)( + 'denies a native %s owner mismatch without changing its owner or content', + async (artifact) => { + const { paths, owner } = await fixture(); + await prepareRuntime(paths.directory, owner); + const path = paths[artifact]; + const sentinel = artifact === 'directory' ? join(path, 'sentinel') : path; + await createProtectedFile(sentinel, owner, 'untouched-owner-mismatch'); + const before = await snapshot(path); + // Read the real ACL/stat against a deliberately different expected owner. + // This does not create an account or transfer ownership to another host user. + const other = + process.platform === 'win32' + ? { id: 'S-1-1-0', elevated: false } + : { id: `uid:${owner.uid! + 1}`, uid: owner.uid! + 1, elevated: false }; + await expect(assertProtected(path, other, artifact === 'directory')).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await snapshot(path)).toBe(before); + expect(await readFile(sentinel, 'utf8')).toBe('untouched-owner-mismatch'); + }, +); + +it.each(artifacts)( + 'denies inherited native Windows %s ACLs without rewriting them', + { + skip: process.platform !== 'win32', + }, + async (artifact) => { + const { options, paths, owner } = await fixture(); + await prepareRuntime(paths.directory, owner); + const path = paths[artifact]; + const sentinel = artifact === 'directory' ? join(path, 'sentinel') : path; + await createProtectedFile(sentinel, owner, 'untouched-inherited-acl'); + await powershell(` + $sections=[System.Security.AccessControl.AccessControlSections]::Owner -bor [System.Security.AccessControl.AccessControlSections]::Access; + $acl=[System.IO.${artifact === 'directory' ? 'Directory' : 'File'}]::GetAccessControl(${psLiteral(path)},$sections); + $acl.SetAccessRuleProtection($false,$true); + [System.IO.${artifact === 'directory' ? 'Directory' : 'File'}]::SetAccessControl(${psLiteral(path)},$acl); + `); + const before = await snapshot(path); + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await snapshot(path)).toBe(before); + expect(await readFile(sentinel, 'utf8')).toBe('untouched-inherited-acl'); + }, +); diff --git a/test/runtime-creation-owner.test.ts b/test/runtime-creation-owner.test.ts new file mode 100644 index 0000000..52d2fd1 --- /dev/null +++ b/test/runtime-creation-owner.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { normalizeRuntimeCreationOwner } from '../src/platform/runtime-creation-owner.ts'; +import { powershell } from '../src/platform/powershell.ts'; + +vi.mock('../src/platform/powershell.ts', async (original) => ({ + ...(await original()), + powershell: vi.fn(), +})); +const owner = { id: 'S-1-5-21-123-456-789-1001', elevated: true }; +const proof = { + ProcessId: process.pid, + User: owner.id, + Before: owner.id, + After: owner.id, + Changed: false, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.mocked(powershell).mockResolvedValue(JSON.stringify(proof)); +}); +afterEach(() => vi.restoreAllMocks()); + +it.each(['linux', 'darwin'] as const)('is a no-op on %s', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); + await normalizeRuntimeCreationOwner({ id: 'uid:1001', uid: 1001, elevated: false }); + expect(powershell).not.toHaveBeenCalled(); +}); + +it('targets the actual Node primary token with limited process access and user proof', async () => { + await normalizeRuntimeCreationOwner(owner); + expect(powershell).toHaveBeenCalledOnce(); + const script = vi.mocked(powershell).mock.calls[0]![0]; + expect(script).toContain(`::Normalize(${process.pid}, '${owner.id}')`); + expect(script).toContain('OpenProcess(0x1000, false, pid)'); + expect(script).toContain('OpenProcessToken(process, 0x0008, out token)'); + expect(script).toContain('string actual = Sid(token, 1)'); + expect(script).toContain('if (actual != expected) throw'); + expect(script).not.toMatch(/WindowsIdentity|GetCurrentProcess|AdjustTokenPrivileges|Impersonate/); +}); + +it('keeps adjustment access and TokenOwner mutation inside the mismatch branch', async () => { + await normalizeRuntimeCreationOwner(owner); + const script = vi.mocked(powershell).mock.calls[0]![0]; + const branch = script.slice(script.indexOf('if (changed) {'), script.indexOf('// Independently')); + expect(branch).toContain('OpenProcessToken(process, 0x0008 | 0x0080, out token)'); + expect(branch).toContain('user = Information(token, 1)'); + expect(branch).toContain('Marshal.WriteIntPtr(owner, Marshal.ReadIntPtr(user))'); + expect(branch).toContain('SetTokenInformation(token, 4, owner, IntPtr.Size)'); + expect(script.match(/0x0080/g)).toHaveLength(1); + expect(script.match(/if \(!SetTokenInformation/g)).toHaveLength(1); + expect(script).toContain('bool changed = before != actual'); + const readback = script.slice( + script.indexOf('// Independently'), + script.indexOf('return new Result'), + ); + expect(readback).toContain('CloseToken(ref token)'); + expect(readback).toContain('OpenProcessToken(process, 0x0008, out token)'); + expect(readback).toContain('if (Sid(token, 1) != expected) throw'); + expect(readback).toContain('string after = Sid(token, 4)'); + expect(readback).toContain('if (after != actual) throw'); + expect(script).toContain('finally { Marshal.FreeHGlobal(data); }'); + for (const resource of ['owner', 'user']) + expect(script).toContain(`if (${resource} != IntPtr.Zero) Marshal.FreeHGlobal(${resource})`); + for (const resource of ['token', 'process']) + expect(script).toContain(`if (${resource} != IntPtr.Zero) CloseHandle(${resource})`); +}); + +it('accepts a mismatched default owner only after successful user-owner readback', async () => { + vi.mocked(powershell).mockResolvedValue( + JSON.stringify({ ...proof, Before: 'S-1-5-32-544', Changed: true }), + ); + await expect(normalizeRuntimeCreationOwner(owner)).resolves.toBeUndefined(); +}); + +it('passes the owner as an escaped PowerShell literal', async () => { + const quoted = { ...owner, id: "S-1-5-21-1001'; throw 'injected" }; + vi.mocked(powershell).mockResolvedValue( + JSON.stringify({ ...proof, User: quoted.id, After: quoted.id }), + ); + await expect(normalizeRuntimeCreationOwner(quoted)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(vi.mocked(powershell).mock.calls[0]![0]).toContain("'S-1-5-21-1001''; throw ''injected'"); +}); + +it.each([ + null, + [], + {}, + { ...proof, ProcessId: process.pid + 1 }, + { ...proof, ProcessId: String(process.pid) }, + { ...proof, User: 'S-1-5-18' }, + { ...proof, After: 'S-1-5-32-544' }, + { ...proof, Before: '' }, + { ...proof, Before: 'not-a-sid' }, + { ...proof, Before: null }, + { ...proof, Changed: true }, + { ...proof, Changed: 'false' }, + { ...proof, Before: 'S-1-5-32-544', Changed: false }, +])('rejects invalid native proof %#', async (invalid) => { + vi.mocked(powershell).mockResolvedValue(JSON.stringify(invalid)); + await expect(normalizeRuntimeCreationOwner(owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); +}); + +it.each(['', 'not JSON', '{', 'null'])('fails closed on malformed output %j', async (output) => { + vi.mocked(powershell).mockResolvedValue(output); + await expect(normalizeRuntimeCreationOwner(owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); +}); + +it.each([ + 'OpenProcess', + 'TokenUser', + 'TokenOwner', + 'adjust access', + 'SetTokenInformation', + 'readback', + 'timeout', +])('fails closed on native/helper failure: %s', async (phase) => { + vi.mocked(powershell).mockRejectedValue(new Error(phase)); + await expect(normalizeRuntimeCreationOwner(owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); +}); diff --git a/test/runtime-descendants.test.ts b/test/runtime-descendants.test.ts new file mode 100644 index 0000000..4a0af9c --- /dev/null +++ b/test/runtime-descendants.test.ts @@ -0,0 +1,297 @@ +import * as fs from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import * as shell from '../src/platform/powershell.ts'; +import * as security from '../src/platform/runtime-security.ts'; + +vi.mock('node:fs/promises', async (original) => { + const actual = await original(); + return { ...actual, lstat: vi.fn(actual.lstat) }; +}); + +const nativeFs = await vi.importActual('node:fs/promises'); +let fixture: string | undefined; +let root: string; +let owner: security.RuntimeOwner; + +beforeEach(async (context) => { + vi.mocked(fs.lstat).mockReset().mockImplementation(nativeFs.lstat); + owner = await security.runtimeOwner(process.getuid?.()); + if (process.platform === 'win32' && owner.elevated) context.skip(); + fixture = await fs.realpath( + await fs.mkdtemp(join(process.platform === 'darwin' ? '/tmp' : tmpdir(), 'cadder-descendant-')), + ); + root = join(fixture, 'protected'); + await security.prepareRuntime(root, owner); +}); +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (fixture) await fs.rm(fixture, { recursive: true, force: true }); + fixture = undefined; +}); + +async function snapshot(path: string): Promise { + const acl = + process.platform === 'win32' + ? await shell.powershell(`(Get-Acl -LiteralPath ${shell.psLiteral(path)}).Sddl`) + : JSON.stringify(await fs.lstat(path)); + return createHash('sha256').update(acl).digest('hex'); +} +async function descendants(): Promise<{ directory: string; file: string }> { + const directory = join(root, 'native', 'nested'); + await fs.mkdir(join(root, 'native'), { mode: 0o700 }); + await fs.mkdir(directory, { mode: 0o700 }); + const file = join(directory, 'leaf'); + await fs.writeFile(file, 'unchanged content', { mode: 0o600 }); + return { directory, file }; +} + +it('accepts native nested owner-only storage without modifying ACLs or content', async () => { + const { directory, file } = await descendants(); + const paths = [root, join(root, 'native'), directory, file]; + const before = await Promise.all(paths.map(snapshot)); + await security.assertRuntimeDescendant(root, directory, owner, true); + await security.assertRuntimeDescendant(root, file, owner); + expect(await Promise.all(paths.map(snapshot))).toEqual(before); + expect(await fs.readFile(file, 'utf8')).toBe('unchanged content'); + if (process.platform === 'win32') { + expect(owner.elevated).toBe(false); + await expect(security.assertProtected(file, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + } +}); + +it('accepts a real SQLite DELETE journal while preserving strict primary database checks', async () => { + const paths = resolvePaths({ runtimeDir: root }); + await security.prepareRuntime(paths.directory, owner); + const lock = await acquireRuntimeLock(paths, owner); + try { + const journal = `${paths.lock}-journal`; + await security.assertProtected(paths.lock, owner); + await security.assertRuntimeDescendant(paths.directory, journal, owner); + if (process.platform === 'win32') + await expect(security.assertProtected(journal, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + } finally { + await lock.release(); + } +}); + +it('rejects escapes, equal roots and lexical dot aliases before inspecting their target', async () => { + const { file } = await descendants(); + const inspect = vi.spyOn(fs, 'lstat').mockClear(); + for (const path of [ + root, + join(fixture!, 'outside'), + `${root}-sibling${sep}leaf`, + 'relative-leaf', + `${root}${sep}native${sep}..${sep}native${sep}nested${sep}leaf`, + `${root}${sep}.${sep}native${sep}nested${sep}leaf`, + ]) { + await expect(security.assertRuntimeDescendant(root, path, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + } + expect(inspect).not.toHaveBeenCalled(); + await security.assertRuntimeDescendant(root, file, owner); +}); + +it.skipIf(process.platform !== 'win32')( + 'does not relax an inherited root even when its descendants are owner-only', + async () => { + const { file } = await descendants(); + await expect(security.assertRuntimeDescendant(fixture!, file, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + }, +); + +it('refuses in-root junctions/links, including final separators and linked directory chains', async () => { + const { directory, file } = await descendants(); + const alias = join(root, 'alias'); + await fs.symlink(directory, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const before = await snapshot(directory); + for (const path of [alias, alias + sep, alias + sep + sep]) + await expect(security.assertRuntimeDescendant(root, path, owner, true)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + await expect( + security.assertRuntimeDescendant(root, join(alias, 'leaf'), owner), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(await snapshot(directory)).toBe(before); + expect(await fs.readFile(file, 'utf8')).toBe('unchanged content'); +}); + +it('refuses wrong kinds, missing root/chain/leaf and inspection faults without mutation', async () => { + const { directory, file } = await descendants(); + for (const [path, isDirectory] of [ + [directory, false], + [file, true], + [file + sep, false], + [join(file, 'child'), false], + ] as const) + await expect( + security.assertRuntimeDescendant(root, path, owner, isDirectory), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + for (const missing of [join(root, 'missing'), join(root, 'missing', 'leaf')]) + await expect(security.assertRuntimeDescendant(root, missing, owner)).rejects.toMatchObject({ + code: 'ENOENT', + path: join(root, 'missing'), + }); + const missingRoot = join(fixture!, 'missing-root'); + await expect( + security.assertRuntimeDescendant(missingRoot, join(missingRoot, 'leaf'), owner), + ).rejects.toMatchObject({ code: 'ENOENT', path: missingRoot }); + const original = nativeFs.lstat; + const failure = Object.assign(new Error('inspection failed'), { code: 'EACCES' }); + vi.spyOn(fs, 'lstat').mockImplementation((async (path: unknown) => { + if (path === file) throw failure; + return original(path as string); + }) as typeof fs.lstat); + await expect(security.assertRuntimeDescendant(root, file, owner)).rejects.toBe(failure); +}); + +it('refuses a root junction before accepting any descendant', async () => { + const { file } = await descendants(); + const alias = join(fixture!, 'root-alias'); + await fs.symlink(root, alias, process.platform === 'win32' ? 'junction' : 'dir'); + await expect( + security.assertRuntimeDescendant(alias + sep, join(alias, 'native', 'nested', 'leaf'), owner), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(await fs.readFile(file, 'utf8')).toBe('unchanged content'); +}); + +async function fixtureAcl(path: string, directory: boolean, extra: string): Promise { + await shell.powershell(` + $sid = New-Object Security.Principal.SecurityIdentifier(${shell.psLiteral(owner.id)}); + $foreign = New-Object Security.Principal.SecurityIdentifier('S-1-1-0'); + $acl = New-Object Security.AccessControl.${directory ? 'DirectorySecurity' : 'FileSecurity'}; + $acl.SetAccessRuleProtection($true,$false); + ${extra} + [IO.${directory ? 'Directory' : 'File'}]::SetAccessControl(${shell.psLiteral(path)},$acl); + `); +} + +it('refuses an unsafe root without repairing it', async () => { + const { file } = await descendants(); + if (process.platform === 'win32') + await fixtureAcl( + root, + true, + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow'))); $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($foreign,'Read','Allow')));", + ); + else await fs.chmod(root, 0o755); + const before = await snapshot(root); + await expect(security.assertRuntimeDescendant(root, file, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await snapshot(root)).toBe(before); +}); + +it.skipIf(process.platform !== 'win32')( + 'refuses foreign, denied, insufficient and InheritOnly-only full access on native Windows fixtures', + async () => { + const rules = [ + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow'))); $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($foreign,'Read','Allow')));", + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'Read','Allow')));", + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow'))); $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'Write','Deny')));", + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'Read','Allow'))); $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','InheritOnly','Allow')));", + ]; + for (let index = 0; index < rules.length; index++) { + const directory = join(root, `unsafe-${index}`); + await fs.mkdir(directory); + await fixtureAcl(directory, true, rules[index]!); + const before = await snapshot(directory); + await expect( + security.assertRuntimeDescendant(root, directory, owner, true), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + // Reject the unsafe chain before inspecting even an absent target beneath it. + await expect( + security.assertRuntimeDescendant(root, join(directory, 'absent-leaf'), owner), + ).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + expect(await snapshot(directory)).toBe(before); + } + }, +); + +it.skipIf(process.platform !== 'win32')( + 'models a different Windows owner despite owner-only FullControl without assigning a native owner', + async () => { + const { file } = await descendants(); + const before = await snapshot(file); + const original = shell.powershell; + vi.spyOn(shell, 'powershell').mockImplementation(async (source) => { + // Change only the in-memory descriptor returned for this leaf, never the actual ACL. + const get = `$acl = Get-Acl -LiteralPath ${shell.psLiteral(file)};`; + return original( + source.replace( + get, + `${get} $acl.SetOwner((New-Object Security.Principal.SecurityIdentifier('S-1-5-18')));`, + ), + ); + }); + await expect(security.assertRuntimeDescendant(root, file, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + vi.restoreAllMocks(); + expect(await snapshot(file)).toBe(before); + }, +); + +it('models Unix exact uid/mode checks for roots, directories and files without chown', async () => { + const { directory, file } = await descendants(); + const original = nativeFs.lstat; + const uid = 101; + const unixOwner = { id: `uid:${uid}`, uid, elevated: false }; + vi.stubGlobal('process', Object.create(process, { platform: { value: 'linux' } })); + let unsafe: { path: string; uid?: number; mode?: number } | undefined; + vi.spyOn(fs, 'lstat').mockImplementation((async (path: unknown) => { + const info = await original(path as string); + const normalMode = info.isDirectory() ? 0o700 : 0o600; + const requestedMode = unsafe && unsafe.path === path ? unsafe.mode : undefined; + return Object.create(info, { + uid: { value: unsafe && unsafe.path === path && unsafe.uid !== undefined ? unsafe.uid : uid }, + mode: { value: (info.mode & ~0o777) | (requestedMode ?? normalMode) }, + }); + }) as typeof fs.lstat); + await security.assertRuntimeDescendant(root, file, unixOwner); + for (const path of [root, directory, file]) { + unsafe = { path, uid: uid + 1 }; + await expect(security.assertRuntimeDescendant(root, file, unixOwner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + unsafe = { path, mode: path === file ? 0o640 : 0o750 }; + await expect(security.assertRuntimeDescendant(root, file, unixOwner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + } +}); + +it('refuses a pre-existing unsafe SQLite journal without consuming or deleting it', async () => { + const paths = resolvePaths({ runtimeDir: root }); + await security.prepareRuntime(paths.directory, owner); + await security.createProtectedFile(paths.lock, owner); + const journal = `${paths.lock}-journal`; + await fs.writeFile(journal, 'untrusted journal sentinel', { mode: 0o600 }); + if (process.platform === 'win32') + await fixtureAcl( + journal, + false, + "$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow'))); $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($foreign,'Read','Allow')));", + ); + else await fs.chmod(journal, 0o644); + const before = await snapshot(journal); + await expect(acquireRuntimeLock(paths, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await snapshot(journal)).toBe(before); + expect(await fs.readFile(journal, 'utf8')).toBe('untrusted journal sentinel'); + await expect(fs.lstat(paths.metadata)).rejects.toMatchObject({ code: 'ENOENT' }); +}); diff --git a/test/runtime-lock-faults.test.ts b/test/runtime-lock-faults.test.ts new file mode 100644 index 0000000..8ce4df6 --- /dev/null +++ b/test/runtime-lock-faults.test.ts @@ -0,0 +1,333 @@ +import { randomBytes } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import { DatabaseSync } from 'node:sqlite'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import * as security from '../src/platform/runtime-security.ts'; +import { normalizeRuntimeCreationOwner } from '../src/platform/runtime-creation-owner.ts'; + +vi.mock('../src/platform/runtime-creation-owner.ts', () => ({ + normalizeRuntimeCreationOwner: vi.fn(), +})); + +vi.mock('node:crypto', async (original) => ({ + ...(await original()), + randomBytes: vi.fn(), +})); +vi.mock('node:fs/promises', () => ({ readFile: vi.fn(), unlink: vi.fn(), writeFile: vi.fn() })); +vi.mock('node:sqlite', () => ({ DatabaseSync: vi.fn() })); +vi.mock('../src/platform/runtime-security.ts', () => ({ + assertProtected: vi.fn(), + assertRuntimeDescendant: vi.fn(), + createProtectedFile: vi.fn(), +})); +const paths = resolvePaths({ runtimeDir: '/fault-runtime' }); +const owner = { id: 'test-owner', elevated: false }; +const database = { exec: vi.fn(), close: vi.fn() }; +let secret: Buffer; +let generated: Buffer; +const ioFailure = Object.assign(new Error('disk I/O error'), { + code: 'ERR_SQLITE_ERROR', + errcode: 10, +}); + +beforeEach(() => { + vi.resetAllMocks(); + secret = Buffer.alloc(32, 7); + generated = Buffer.alloc(32, 9); + vi.mocked(randomBytes).mockReturnValue(generated as never); + vi.mocked(DatabaseSync).mockImplementation(function () { + return database as never; + }); + vi.mocked(security.createProtectedFile).mockResolvedValue(true); + vi.mocked(fs.readFile).mockImplementation(async (path) => { + if (path === paths.metadata) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + return secret; + }); +}); + +it.each(['unsafe', 'inspection', 'missing-root', 'missing-intermediate'])( + 'refuses an existing journal before SQLite open: %s', + async (kind) => { + const failure = Object.assign(new Error(kind), { + code: + ( + { + 'missing-root': 'ENOENT', + 'missing-intermediate': 'ENOENT', + inspection: 'EACCES', + } as Record + )[kind] ?? 'unsafe-runtime-permissions', + path: + ( + { 'missing-root': paths.directory, 'missing-intermediate': '/missing-parent' } as Record< + string, + string + > + )[kind] ?? `${paths.lock}-journal`, + }); + vi.mocked(security.assertRuntimeDescendant).mockRejectedValueOnce(failure); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(normalizeRuntimeCreationOwner).not.toHaveBeenCalled(); + expect(DatabaseSync).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + }, +); + +it('does not treat unlocated ENOENT as journal leaf absence', async () => { + const failure = Object.assign(new Error('unlocated missing path'), { code: 'ENOENT' }); + vi.mocked(security.assertRuntimeDescendant).mockRejectedValueOnce(failure); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(DatabaseSync).not.toHaveBeenCalled(); +}); + +it('allows only known journal leaf absence and checks again while exclusion is held', async () => { + const journal = `${paths.lock}-journal`; + vi.mocked(security.assertRuntimeDescendant).mockRejectedValue( + Object.assign(new Error('absent leaf'), { code: 'ENOENT', path: journal }), + ); + const lock = await acquireRuntimeLock(paths, owner); + expect(security.assertRuntimeDescendant).toHaveBeenCalledTimes(2); + expect(security.assertRuntimeDescendant).toHaveBeenCalledWith(paths.directory, journal, owner); + expect(normalizeRuntimeCreationOwner).toHaveBeenCalledExactlyOnceWith(owner); + expect(vi.mocked(security.assertRuntimeDescendant).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(normalizeRuntimeCreationOwner).mock.invocationCallOrder[0]!, + ); + expect(vi.mocked(normalizeRuntimeCreationOwner).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(DatabaseSync).mock.invocationCallOrder[0]!, + ); + expect(vi.mocked(security.assertRuntimeDescendant).mock.invocationCallOrder[1]).toBeGreaterThan( + database.exec.mock.invocationCallOrder[1]!, + ); + expect(vi.mocked(security.assertRuntimeDescendant).mock.invocationCallOrder[1]).toBeLessThan( + vi.mocked(fs.writeFile).mock.invocationCallOrder[0]!, + ); + await lock.release(); +}); + +it('awaits creation owner normalization before opening SQLite', async () => { + let complete!: () => void; + vi.mocked(normalizeRuntimeCreationOwner).mockReturnValueOnce( + new Promise((resolve) => { + complete = resolve; + }), + ); + const acquiring = acquireRuntimeLock(paths, owner); + await vi.waitFor(() => expect(normalizeRuntimeCreationOwner).toHaveBeenCalledOnce()); + expect(DatabaseSync).not.toHaveBeenCalled(); + expect(fs.readFile).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + complete(); + const lock = await acquiring; + expect(DatabaseSync).toHaveBeenCalledOnce(); + await lock.release(); +}); + +it('leaves SQLite, metadata and secret untouched when creation owner normalization fails', async () => { + const failure = Object.assign(new Error('native owner failure'), { + code: 'unsafe-runtime-permissions', + }); + vi.mocked(normalizeRuntimeCreationOwner).mockRejectedValueOnce(failure); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(normalizeRuntimeCreationOwner).toHaveBeenCalledExactlyOnceWith(owner); + expect(security.assertProtected).toHaveBeenCalledWith(paths.directory, owner, true); + expect(security.createProtectedFile).toHaveBeenCalledExactlyOnceWith(paths.lock, owner); + expect(security.assertRuntimeDescendant).toHaveBeenCalledOnce(); + expect(DatabaseSync).not.toHaveBeenCalled(); + expect(database.exec).not.toHaveBeenCalled(); + expect(database.close).not.toHaveBeenCalled(); + expect(fs.readFile).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(randomBytes).not.toHaveBeenCalled(); +}); + +it('refuses an unsafe generated journal and finalizes exclusion before publication', async () => { + const failure = new Error('unsafe generated journal'); + vi.mocked(security.assertRuntimeDescendant) + .mockResolvedValueOnce() + .mockRejectedValueOnce(failure); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(database.exec).toHaveBeenCalledWith('ROLLBACK;'); + expect(database.close).toHaveBeenCalledOnce(); + expect(security.createProtectedFile).toHaveBeenCalledTimes(1); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); +}); + +it('propagates SQLite open failure without pretending contention or deleting foreign artifacts', async () => { + vi.mocked(DatabaseSync).mockImplementationOnce(function () { + throw ioFailure; + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(ioFailure); + expect(fs.unlink).not.toHaveBeenCalled(); +}); + +it.each(['PRAGMA', 'BEGIN'])( + 'preserves storage errors during %s and closes the connection', + async (phase) => { + database.exec.mockImplementation((sql: string) => { + if (sql.includes(phase)) throw ioFailure; + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(ioFailure); + expect(database.close).toHaveBeenCalledOnce(); + expect(fs.unlink).not.toHaveBeenCalled(); + }, +); + +it.each([10, 13, 778])( + 'does not misclassify SQLite I/O or full-disk errors: %s', + async (errcode) => { + const failure = Object.assign(new Error('storage failure'), { + code: 'ERR_SQLITE_ERROR', + errcode, + }); + database.exec.mockImplementationOnce(() => { + throw failure; + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(database.close).toHaveBeenCalledOnce(); + }, +); + +it.each(['directory', 'lock-create', 'metadata-read', 'secret-create'])( + 'does not publish or delete unvalidated paths after %s fails', + async (phase) => { + const failure = new Error(phase); + if (phase === 'directory') vi.mocked(security.assertProtected).mockRejectedValueOnce(failure); + if (phase === 'lock-create') + vi.mocked(security.createProtectedFile).mockRejectedValueOnce(failure); + if (phase === 'metadata-read') vi.mocked(fs.readFile).mockRejectedValueOnce(failure); + if (phase === 'secret-create') + vi.mocked(security.createProtectedFile).mockImplementation(async (path) => { + if (path === paths.secret) throw failure; + return true; + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(failure); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + if (phase === 'directory' || phase === 'lock-create') { + expect(normalizeRuntimeCreationOwner).not.toHaveBeenCalled(); + expect(DatabaseSync).not.toHaveBeenCalled(); + } else expect(database.close).toHaveBeenCalledOnce(); + if (phase === 'secret-create') expect(generated.every((byte) => byte === 0)).toBe(true); + }, +); + +it.each([5, 6, 261])( + 'classifies only SQLite busy/locked codes as contention: %s', + async (errcode) => { + database.exec.mockImplementationOnce(() => { + throw Object.assign(new Error('busy'), { code: 'ERR_SQLITE_ERROR', errcode }); + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toMatchObject({ + code: 'runtime-already-running', + }); + expect(database.close).toHaveBeenCalledOnce(); + }, +); + +it.each(['metadata-create', 'metadata-write', 'secret-read', 'invalid-secret'])( + 'finalizes owned resources after %s', + async (phase) => { + const failure = new Error(phase); + if (phase === 'metadata-create') + vi.mocked(security.createProtectedFile).mockImplementation(async (path) => { + if (path === paths.metadata) throw failure; + return true; + }); + if (phase === 'metadata-write') vi.mocked(fs.writeFile).mockRejectedValueOnce(failure); + if (phase === 'secret-read') + vi.mocked(fs.readFile) + .mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' })) + .mockRejectedValueOnce(failure); + if (phase === 'invalid-secret') secret = Buffer.alloc(5, 7); + await expect(acquireRuntimeLock(paths, owner)).rejects.toThrow(); + expect(database.close).toHaveBeenCalledOnce(); + expect(database.exec).toHaveBeenCalledWith('ROLLBACK;'); + expect(generated.every((byte) => byte === 0)).toBe(true); + if (phase !== 'secret-read') expect(secret.every((byte) => byte === 0)).toBe(true); + expect(fs.unlink).toHaveBeenCalledWith(paths.secret); + if (phase === 'metadata-write') expect(fs.unlink).toHaveBeenCalledWith(paths.metadata); + else expect(fs.unlink).not.toHaveBeenCalledWith(paths.metadata); + }, +); + +it('preserves pre-existing secret and unvalidated metadata on acquisition failure', async () => { + vi.mocked(security.createProtectedFile).mockImplementation(async (path) => { + if (path === paths.metadata) throw new Error('unsafe metadata'); + return false; + }); + await expect(acquireRuntimeLock(paths, owner)).rejects.toThrow('unsafe metadata'); + expect(fs.unlink).not.toHaveBeenCalled(); + expect(secret.every((byte) => byte === 0)).toBe(true); + expect(generated.every((byte) => byte === 0)).toBe(true); +}); + +it.each(['unlink', 'rollback', 'close'])( + 'attempts every finalizer and shares release failure: %s', + async (phase) => { + const lock = await acquireRuntimeLock(paths, owner); + const failure = new Error(phase); + if (phase === 'unlink') vi.mocked(fs.unlink).mockRejectedValueOnce(failure); + if (phase === 'rollback') + database.exec.mockImplementation((sql: string) => { + if (sql === 'ROLLBACK;') throw failure; + }); + if (phase === 'close') + database.close.mockImplementationOnce(() => { + throw failure; + }); + const first = lock.release(); + expect(lock.release()).toBe(first); + await expect(first).rejects.toBe(failure); + await expect(lock.release()).rejects.toBe(failure); + expect(database.exec).toHaveBeenCalledWith('ROLLBACK;'); + expect(database.close).toHaveBeenCalledOnce(); + expect(secret.every((byte) => byte === 0)).toBe(true); + }, +); + +it('cleans validated stale diagnostic metadata but preserves an existing secret after write failure', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(Buffer.from('stale')).mockResolvedValueOnce(secret); + vi.mocked(security.createProtectedFile).mockResolvedValue(false); + vi.mocked(fs.writeFile).mockRejectedValueOnce(ioFailure); + await expect(acquireRuntimeLock(paths, owner)).rejects.toBe(ioFailure); + expect(fs.unlink).toHaveBeenCalledWith(paths.metadata); + expect(fs.unlink).not.toHaveBeenCalledWith(paths.secret); + expect(secret.every((byte) => byte === 0)).toBe(true); +}); + +it('retains all release failures and zeroes the secret even when no finalizer succeeds', async () => { + const lock = await acquireRuntimeLock(paths, owner); + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('unlink')); + database.exec.mockImplementation((sql: string) => { + if (sql === 'ROLLBACK;') throw new Error('rollback'); + }); + database.close.mockImplementationOnce(() => { + throw new Error('close'); + }); + const error = await lock.release().catch((error: unknown) => error); + expect((error as AggregateError).errors).toHaveLength(3); + await expect(lock.release()).rejects.toBe(error); + expect(database.close).toHaveBeenCalledOnce(); + expect(secret.every((byte) => byte === 0)).toBe(true); +}); + +it('retains acquisition failure alongside rollback/close/unlink failures', async () => { + vi.mocked(fs.writeFile).mockRejectedValueOnce(ioFailure); + vi.mocked(fs.unlink).mockRejectedValue(new Error('unlink')); + database.exec.mockImplementation((sql: string) => { + if (sql === 'ROLLBACK;') throw new Error('rollback'); + }); + database.close.mockImplementationOnce(() => { + throw new Error('close'); + }); + const error = await acquireRuntimeLock(paths, owner).catch((error: unknown) => error); + expect(error).toMatchObject({ cause: ioFailure }); + expect((error as AggregateError).errors).toHaveLength(5); + expect(database.close).toHaveBeenCalledOnce(); + expect(secret.every((byte) => byte === 0)).toBe(true); +}); diff --git a/test/runtime-lock.test.ts b/test/runtime-lock.test.ts new file mode 100644 index 0000000..8b93832 --- /dev/null +++ b/test/runtime-lock.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { + createProtectedFile, + prepareRuntime, + runtimeOwner, +} from '../src/platform/runtime-security.ts'; + +it('uses actual SQLite exclusion rather than diagnostic PID or expiry', async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-lock-')); + const paths = resolvePaths({ runtimeDir: root }); + const owner = await runtimeOwner(); + try { + await prepareRuntime(paths.directory, owner); + const first = await acquireRuntimeLock(paths, owner); + try { + expect(first.recovered).toBe(false); + expect(first.secret.length).toBe(32); + await writeFile(paths.metadata, JSON.stringify({ processId: -1, acquiredAt: '1970-01-01' })); + await expect(acquireRuntimeLock(paths, owner)).rejects.toThrow('already locked'); + } finally { + await first.release(); + await first.release(); + } + await createProtectedFile(paths.metadata, owner, 'stale metadata'); + const second = await acquireRuntimeLock(paths, owner); + try { + expect(second.recovered).toBe(true); + expect(JSON.parse(await readFile(paths.metadata, 'utf8')).processId).toBe(process.pid); + } finally { + await second.release(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/runtime-native-faults.test.ts b/test/runtime-native-faults.test.ts new file mode 100644 index 0000000..e92c714 --- /dev/null +++ b/test/runtime-native-faults.test.ts @@ -0,0 +1,181 @@ +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createConnection, createServer } from 'node:net'; +import { once } from 'node:events'; +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { startLocalRuntime } from '../src/daemon/local-runtime.ts'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { acquireRuntimeLock } from '../src/daemon/runtime-lock.ts'; +import { prepareRuntime, runtimeOwner } from '../src/platform/runtime-security.ts'; +import { basicResult } from './fixtures/rpc-data.ts'; + +vi.mock('node:fs/promises', async (original) => { + const actual = await original(); + return { + ...actual, + readFile: vi.fn(actual.readFile), + writeFile: vi.fn(actual.writeFile), + unlink: vi.fn(actual.unlink), + }; +}); +vi.mock('node:net', async (original) => { + const actual = await original(); + return { ...actual, createServer: vi.fn(actual.createServer) }; +}); +vi.mock('node:sqlite', async (original) => { + const actual = await original(); + return { + ...actual, + DatabaseSync: vi.fn(function (...args: ConstructorParameters) { + return new actual.DatabaseSync(...args); + }), + }; +}); +const realFs = await vi.importActual('node:fs/promises'); +const realNet = await vi.importActual('node:net'); +const realSqlite = await vi.importActual('node:sqlite'); +let root: string; +let options: { runtimeDir: string; runtimeOwner?: number }; +let paths: ReturnType; +const failure = Object.assign(new Error('simulated storage exhaustion'), { code: 'ENOSPC' }); + +beforeEach(async () => { + vi.mocked(fs.readFile).mockImplementation(realFs.readFile); + vi.mocked(fs.writeFile).mockImplementation(realFs.writeFile); + vi.mocked(fs.unlink).mockImplementation(realFs.unlink); + vi.mocked(createServer).mockImplementation(realNet.createServer); + vi.mocked(DatabaseSync).mockImplementation(function (...args) { + return new realSqlite.DatabaseSync(...args); + }); + root = await fs.mkdtemp(join(tmpdir(), 'cadder-phase-fault-')); + options = { runtimeDir: root, ...(process.getuid ? { runtimeOwner: process.getuid() } : {}) }; + paths = resolvePaths(options); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await realFs.rm(root, { recursive: true, force: true }); +}); + +async function assertStopped(): Promise { + const socket = createConnection(paths.endpoint); + const result = once(socket, 'connect').then( + () => true, + () => false, + ); + expect(await result).toBe(false); + socket.destroy(); +} + +it.each(['metadata', 'discovery'] as const)( + 'cleans real partial %s after ENOSPC and permits restart', + async (artifact) => { + let liveSecret: Buffer | undefined; + vi.mocked(fs.readFile).mockImplementation(async (path, options) => { + const result = await realFs.readFile(path, options); + if (path === paths.secret && Buffer.isBuffer(result)) liveSecret = result; + return result; + }); + vi.mocked(fs.writeFile).mockImplementation(async (path, data, options) => { + if (path !== paths[artifact]) return realFs.writeFile(path, data, options); + vi.mocked(fs.writeFile).mockImplementation(realFs.writeFile); + await realFs.writeFile(path, String(data).slice(0, 8), options); + throw failure; + }); + const handler = vi.fn(async () => basicResult); + await expect(startLocalRuntime(options, handler)).rejects.toBe(failure); + await assertStopped(); + expect(liveSecret?.every((byte) => byte === 0)).toBe(true); + await expect(fs.lstat(paths.metadata)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.lstat(paths.discovery)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(handler).not.toHaveBeenCalled(); + const runtime = await startLocalRuntime(options, handler); + await runtime.stop(); + }, +); + +it.each(['discovery-unlink', 'metadata-unlink', 'rollback', 'database-close', 'listener-close'])( + 'finalizes native resources and permits reacquisition despite %s error', + async (phase) => { + let secret: Buffer | undefined; + vi.mocked(fs.readFile).mockImplementation(async (path, options) => { + const result = await realFs.readFile(path, options); + if (path === paths.secret && Buffer.isBuffer(result)) secret = result; + return result; + }); + if (phase === 'rollback' || phase === 'database-close') { + vi.mocked(DatabaseSync).mockImplementationOnce(function (...args) { + const database = new realSqlite.DatabaseSync(...args); + const exec = database.exec.bind(database); + const close = database.close.bind(database); + vi.spyOn(database, 'exec').mockImplementation((sql) => { + if (phase === 'rollback' && sql === 'ROLLBACK;') throw failure; + return exec(sql); + }); + vi.spyOn(database, 'close').mockImplementation(() => { + close(); + if (phase === 'database-close') throw failure; + }); + return database; + }); + } + if (phase === 'listener-close') { + vi.mocked(createServer).mockImplementationOnce((...args) => { + const server = realNet.createServer(...args); + const close = server.close.bind(server); + vi.spyOn(server, 'close').mockImplementation((callback) => + close(() => callback?.(failure)), + ); + return server; + }); + } + const runtime = await startLocalRuntime(options, async () => basicResult); + if (phase.endsWith('unlink')) { + const target = phase === 'discovery-unlink' ? paths.discovery : paths.metadata; + vi.mocked(fs.unlink).mockImplementation(async (path) => { + if (path === target) { + vi.mocked(fs.unlink).mockImplementation(realFs.unlink); + throw failure; + } + return realFs.unlink(path); + }); + } + const stopping = runtime.stop(); + expect(runtime.stop()).toBe(stopping); + await expect(stopping).rejects.toBe(failure); + await expect(runtime.stop()).rejects.toBe(failure); + await assertStopped(); + expect(secret?.every((byte) => byte === 0)).toBe(true); + const owner = await runtimeOwner(options.runtimeOwner); + const lock = await acquireRuntimeLock(paths, owner); + secret = lock.secret; + await lock.release(); + expect(secret.every((byte) => byte === 0)).toBe(true); + const restarted = await startLocalRuntime(options, async () => basicResult); + await restarted.stop(); + }, +); + +it( + 'refuses an occupied listener without closing the unrelated server and releases its exclusion', + { skip: process.platform !== 'win32' }, + async () => { + const owner = await runtimeOwner(options.runtimeOwner); + await prepareRuntime(paths.directory, owner); + const foreign = realNet.createServer(); + foreign.listen(paths.endpoint); + await once(foreign, 'listening'); + try { + await expect(startLocalRuntime(options, async () => basicResult)).rejects.toMatchObject({ + code: 'EADDRINUSE', + }); + expect(foreign.listening).toBe(true); + await expect(fs.lstat(paths.discovery)).rejects.toMatchObject({ code: 'ENOENT' }); + const lock = await acquireRuntimeLock(paths, owner); + await lock.release(); + } finally { + await new Promise((resolve) => foreign.close(() => resolve())); + } + }, +); diff --git a/test/runtime-security.test.ts b/test/runtime-security.test.ts new file mode 100644 index 0000000..a826193 --- /dev/null +++ b/test/runtime-security.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, + runtimeOwner, +} from '../src/platform/runtime-security.ts'; +import { powershell, psLiteral } from '../src/platform/powershell.ts'; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +it('creates protected runtime and secret and validates existing paths', async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-security-')); + directories.push(root); + const owner = await runtimeOwner(); + const directory = join(root, 'v2'); + await prepareRuntime(directory, owner); + await prepareRuntime(directory, owner); + const path = join(directory, 'secret'); + expect(await createProtectedFile(path, owner, 'secret')).toBe(true); + expect(await createProtectedFile(path, owner)).toBe(false); + await assertProtected(path, owner); + await expect(assertProtected(directory, owner)).rejects.toThrow('regular files'); +}); + +it( + 'rejects insecure Windows ACL rather than repairing it', + { skip: process.platform !== 'win32' }, + async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-security-')); + directories.push(root); + const owner = await runtimeOwner(); + await expect(prepareRuntime(root, owner)).rejects.toThrow('ACL'); + const directory = join(root, 'v2'); + await prepareRuntime(directory, owner); + await powershell( + `$acl=New-Object System.Security.AccessControl.DirectorySecurity; $owner=New-Object System.Security.Principal.SecurityIdentifier(${psLiteral(owner.id)}); $acl.SetOwner($owner); $acl.SetAccessRuleProtection($true,$false); $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($owner,'FullControl','Allow'))); $sid=New-Object System.Security.Principal.SecurityIdentifier('S-1-1-0'); $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'Read','Allow'))); [System.IO.Directory]::SetAccessControl(${psLiteral(directory)}, $acl)`, + ); + await expect(prepareRuntime(directory, owner)).rejects.toThrow('ACL'); + }, +); + +it('quotes PowerShell literals without allowing interpolation', () => { + expect(psLiteral("a'b$()")).toBe("'a''b$()'"); +}); diff --git a/test/runtime-system.test.ts b/test/runtime-system.test.ts new file mode 100644 index 0000000..18d46c3 --- /dev/null +++ b/test/runtime-system.test.ts @@ -0,0 +1,92 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import { expect, it } from 'vitest'; +import { resolvePaths } from '../src/daemon/paths.ts'; +import { rpc } from '../src/client/connection.ts'; + +async function start( + root: string, + entry: string, + recovered = false, +): Promise { + const environment = { ...process.env }; + delete environment.NODE_OPTIONS; + const child = spawn( + process.execPath, + [entry, root, ...(process.getuid ? [String(process.getuid())] : [])], + { windowsHide: true, env: environment }, + ); + let error = ''; + child.stderr.on('data', (data: Buffer) => { + error += data.toString(); + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`Fixture startup timed out: ${error}`)); + }, 12000); + let stdout = ''; + child.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + if (!stdout.includes('\n')) return; + clearTimeout(timer); + if (stdout.trim() !== `READY recovered=${recovered}`) + reject(new Error(`Unexpected fixture readiness: ${stdout}`)); + else resolve(); + }); + child.once('exit', () => { + clearTimeout(timer); + reject(new Error(error)); + }); + child.once('error', reject); + }); + return child; +} + +it( + 'system gate: real child process lock, authenticated local transport and crash recovery', + { timeout: 30000 }, + async () => { + const root = await mkdtemp(join(tmpdir(), 'cadder-system-')); + const paths = resolvePaths({ runtimeDir: root }); + let child: ChildProcessWithoutNullStreams | undefined; + try { + const entry = join(root, 'runtime-child.mjs'); + await build({ + entryPoints: [fileURLToPath(new URL('./fixtures/runtime-child.ts', import.meta.url))], + outfile: entry, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node24.18', + }); + child = await start(root, entry); + expect((await rpc(paths, 'query-state-request')).snapshot?.runtime.processId).toBe(child.pid); + await expect(start(root, entry)).rejects.toThrow('already locked'); + const crashed = once(child, 'exit'); + child.kill('SIGKILL'); + await crashed; + child = await start(root, entry, true); + expect((await rpc(paths, 'query-state-request')).snapshot?.runtime.processId).toBe(child.pid); + const exited = once(child, 'exit'); + expect((await rpc(paths, 'shutdown-daemon-request')).accepted).toBe(true); + await exited; + child = undefined; + await expect(rpc(paths, 'query-state-request')).rejects.toThrow( + 'Start it with cadder daemon start', + ); + } finally { + if (child?.exitCode === null) { + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + } + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/test/runtime-windows-ancestors.test.ts b/test/runtime-windows-ancestors.test.ts new file mode 100644 index 0000000..493ca39 --- /dev/null +++ b/test/runtime-windows-ancestors.test.ts @@ -0,0 +1,118 @@ +import type { Stats } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { parse } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, +} from '../src/platform/runtime-security.ts'; +import { powershell } from '../src/platform/powershell.ts'; + +// Drive and UNC parsing uses Windows path primitives with a modeled filesystem; +// no UNC server or share is contacted by these cases. +vi.mock('node:path', async (original) => { + const path = await original(); + return { ...path, ...path.win32 }; +}); +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + lstat: vi.fn(), + realpath: vi.fn(), + mkdir: vi.fn(), + open: vi.fn(), +})); +vi.mock('../src/platform/powershell.ts', async (original) => ({ + ...(await original()), + powershell: vi.fn(), +})); +const owner = { id: 'S-1-5-21-fixture', elevated: false }; +function info(directory = true, link = false): Stats { + return { + isSymbolicLink: () => link, + isDirectory: () => directory, + isFile: () => !directory, + } as Stats; +} +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.mocked(powershell).mockResolvedValue(''); +}); +afterEach(() => vi.restoreAllMocks()); + +it.each([ + 'C:\\', + 'C:\\\\\\', + 'C:/', + 'C:////', + '\\\\server\\share', + '\\\\server\\share\\', + '\\\\server\\share\\\\\\', +])('preserves modeled Windows drive/UNC root validation for %s', async (path) => { + vi.mocked(fs.lstat).mockResolvedValue(info()); + await prepareRuntime(path, owner); + await assertProtected(path, owner, true); + expect(fs.lstat).toHaveBeenLastCalledWith(parse(path).root); + expect(fs.mkdir).not.toHaveBeenCalled(); +}); + +it('trims only modeled Windows real leaf separators while keeping caller ACL spelling', async () => { + const path = 'C:/runtime/real\\//'; + vi.mocked(fs.lstat).mockResolvedValue(info()); + await prepareRuntime(path, owner); + await assertProtected(path, owner, true); + expect(fs.lstat).toHaveBeenLastCalledWith('C:/runtime/real'); + expect(powershell).toHaveBeenLastCalledWith(expect.stringContaining(path)); + expect(fs.mkdir).not.toHaveBeenCalled(); +}); + +it.each(['C:\\', '\\\\server\\share\\'])( + 'checks modeled Windows %s linked/dot-dot and missing/dot-dot ancestors', + async (root) => { + const linked = `${root}link`; + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === `${root}missing`) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + return info(true, path === linked); + }); + for (const path of [`${root}link\\..\\safe\\secret`, `${root}missing\\..\\link\\secret`]) { + for (const operation of [ + () => assertProtected(path, owner), + () => prepareRuntime(path, owner), + () => createProtectedFile(path, owner), + ]) + await expect(operation()).rejects.toMatchObject({ code: 'unsafe-runtime-permissions' }); + } + expect(fs.lstat).toHaveBeenCalledWith(root); + expect(fs.lstat).toHaveBeenCalledWith(linked); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.open).not.toHaveBeenCalled(); + expect(powershell).not.toHaveBeenCalled(); + }, +); + +it.each(['C:\\', '\\\\server\\share\\'])( + 'retains modeled Windows %s real dot/dot-dot semantics and leaf spelling', + async (root) => { + const leaf = `${root}real\\nested\\.\\..\\v2\\secret`; + vi.mocked(fs.lstat).mockImplementation(async (path) => info(path !== leaf)); + await assertProtected(leaf, owner); + expect(fs.lstat).toHaveBeenCalledWith(root); + expect(fs.lstat).toHaveBeenCalledWith(`${root}real\\v2`); + expect(fs.lstat).toHaveBeenLastCalledWith(leaf); + }, +); + +it('uses native Windows separator rules for mixed-separator input without erasing a link', async () => { + const leaf = 'C:/runtime/missing/../link/secret'; + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === 'C:\\runtime\\missing') + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + return info(true, path === 'C:\\runtime\\link'); + }); + await expect(assertProtected(leaf, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(fs.lstat).toHaveBeenCalledWith('C:\\runtime\\link'); + expect(fs.lstat).not.toHaveBeenCalledWith(leaf); +}); diff --git a/test/runtime-windows-created-security.test.ts b/test/runtime-windows-created-security.test.ts new file mode 100644 index 0000000..377bf94 --- /dev/null +++ b/test/runtime-windows-created-security.test.ts @@ -0,0 +1,158 @@ +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as shell from '../src/platform/powershell.ts'; +import { + assertProtected, + createProtectedFile, + prepareRuntime, + runtimeOwner, + type RuntimeOwner, +} from '../src/platform/runtime-security.ts'; + +vi.mock('node:fs/promises', async (original) => { + const actual = await original(); + return { ...actual, open: vi.fn(actual.open) }; +}); + +type SecuritySnapshot = { + ownerMatches: boolean; + protected: boolean; + rules: { owner: boolean; fullControl: boolean; writeOwner: boolean; inherited: boolean }[]; +}; + +async function security(path: string, owner: RuntimeOwner): Promise { + return JSON.parse( + await shell.powershell(` + $acl = Get-Acl -LiteralPath ${shell.psLiteral(path)}; + $rules = @(); + foreach ($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { + $rules += @{ + owner=($rule.IdentityReference.Value -eq ${shell.psLiteral(owner.id)}); + fullControl=(($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -eq [Security.AccessControl.FileSystemRights]::FullControl); + writeOwner=(($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::TakeOwnership) -ne 0); + inherited=$rule.IsInherited; + }; + }; + @{ ownerMatches=($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -eq ${shell.psLiteral(owner.id)}); protected=$acl.AreAccessRulesProtected; rules=@($rules) } | ConvertTo-Json -Depth 4 -Compress + `), + ) as SecuritySnapshot; +} + +// This is an ordinary-token native regression, not an elevated or modeled acceptance claim. +describe.skipIf(process.platform !== 'win32')( + 'ordinary-token Windows paths under Modify-only inheritance', + () => { + let owner: RuntimeOwner; + let parent: string | undefined; + let parentSecurity: SecuritySnapshot; + + beforeAll(async () => { + owner = await runtimeOwner(); + }); + beforeEach(async (context) => { + vi.clearAllMocks(); + if (owner.elevated) context.skip(); + parent = await fs.mkdtemp(join(tmpdir(), 'cadder-created-security-')); + // Mutate only this exclusively created empty fixture, never the system temp parent. + await shell.powershell(` + $sid = New-Object Security.Principal.SecurityIdentifier(${shell.psLiteral(owner.id)}); + $current = Get-Acl -LiteralPath ${shell.psLiteral(parent)}; + if ($current.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'Fixture owner mismatch' }; + $acl = New-Object Security.AccessControl.DirectorySecurity; + $acl.SetAccessRuleProtection($true,$false); + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'Modify','ContainerInherit,ObjectInherit','None','Allow'))); + [IO.Directory]::SetAccessControl(${shell.psLiteral(parent)},$acl); + `); + parentSecurity = await security(parent, owner); + expect(owner.elevated).toBe(false); + expect(parentSecurity).toEqual({ + ownerMatches: true, + protected: true, + rules: [{ owner: true, fullControl: false, writeOwner: false, inherited: false }], + }); + }); + afterEach(async () => { + vi.restoreAllMocks(); + if (parent !== undefined) await fs.rm(parent, { recursive: true, force: true }); + parent = undefined; + }); + + it.each(['directory', 'file'] as const)( + 'protects a newly created %s without requiring owner reassignment', + { timeout: 30000 }, + async (kind) => { + const path = join(parent!, kind); + if (kind === 'directory') await prepareRuntime(path, owner); + else expect(await createProtectedFile(path, owner, 'fixture')).toBe(true); + await assertProtected(path, owner, kind === 'directory'); + expect(await security(path, owner)).toEqual({ + ownerMatches: true, + protected: true, + rules: [{ owner: true, fullControl: true, writeOwner: true, inherited: false }], + }); + expect(await security(parent!, owner)).toEqual(parentSecurity); + }, + ); + + it( + 'rejects unsafe existing directory and file without repairing them', + { timeout: 30000 }, + async () => { + const directory = join(parent!, 'existing-directory'); + const file = join(parent!, 'existing-file'); + await fs.mkdir(directory); + await fs.writeFile(file, 'retained fixture'); + const beforeDirectory = await security(directory, owner); + const beforeFile = await security(file, owner); + await expect(prepareRuntime(directory, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + await expect(createProtectedFile(file, owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(await security(directory, owner)).toEqual(beforeDirectory); + expect(await security(file, owner)).toEqual(beforeFile); + expect(await fs.readFile(file, 'utf8')).toBe('retained fixture'); + }, + ); + + it( + 'retains owner-reassignment refusal and cleans only its new file', + { timeout: 30000 }, + async () => { + const file = join(parent!, 'different-owner'); + const inspect = vi.spyOn(shell, 'powershell'); + // An ordinary token cannot assign its new file to the well-known SYSTEM account. + await expect( + createProtectedFile(file, { id: 'S-1-5-18', elevated: false }, 'must-not-be-written'), + ).rejects.toMatchObject({ code: 1 }); + expect(inspect).toHaveBeenCalledOnce(); + expect(inspect.mock.calls[0]![0]).toContain( + 'if ($actualOwner -ne $sid.Value) { $acl.SetOwner($sid) }', + ); + const handle = await vi.mocked(fs.open).mock.results[0]!.value; + expect(handle.fd).toBe(-1); + await expect(fs.lstat(file)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await security(parent!, owner)).toEqual(parentSecurity); + }, + ); + + it( + 'closes and unlinks its exclusive file when reading the new owner fails', + { timeout: 30000 }, + async () => { + const file = join(parent!, 'owner-read-failure'); + const failure = new Error('Simulated owner inspection failure'); + const inspect = vi.spyOn(shell, 'powershell').mockRejectedValueOnce(failure); + await expect(createProtectedFile(file, owner)).rejects.toBe(failure); + expect(inspect).toHaveBeenCalledOnce(); + expect(inspect.mock.calls[0]![0]).toContain('.GetOwner('); + const handle = await vi.mocked(fs.open).mock.results[0]!.value; + expect(handle.fd).toBe(-1); + await expect(fs.lstat(file)).rejects.toMatchObject({ code: 'ENOENT' }); + }, + ); + }, +); diff --git a/test/shim.test.ts b/test/shim.test.ts new file mode 100644 index 0000000..3973283 --- /dev/null +++ b/test/shim.test.ts @@ -0,0 +1,84 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { resolve } from 'node:path'; +import { buildRegistration, managedRun, shimPolicy } from '../src/shim/run.ts'; +import { ClientService } from '../src/client/service.ts'; +import { rpc } from '../src/client/connection.ts'; +import { CadderError } from '../src/protocol/errors.ts'; +import { registrationSchema } from '../src/protocol/dto.ts'; +import { shimCommandCases, shimAliases } from './compatibility/catalog.ts'; + +vi.mock('../src/client/connection.ts', () => ({ rpc: vi.fn() })); +vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async () => {}) })); +afterEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); +}); +const basic = { + requestId: '00000000-0000-4000-8000-000000000001', + accepted: true, + message: 'Done', +}; + +it('implements the released shim policy instead of testing the old Rust source text', () => { + const policies = { + managed: 'managed', + 'read-only-inspection': 'inspection', + 'explicit-passthrough': 'passthrough', + unsupported: 'unsupported', + }; + for (const entry of shimCommandCases) + expect(shimPolicy([entry.command])).toBe(policies[entry.policy]); + for (const alias of shimAliases) expect(shimPolicy(alias.argv)).not.toBe('unsupported'); +}); + +it('keeps argv metadata exact and gives each real registration one matching fresh session identity', () => { + const argv = ['run', '--config', 'folder with spaces/Caddyfile', '--adapter', 'caddyfile']; + const registration = buildRegistration(argv); + expect(registrationSchema.parse(registration)).toEqual(registration); + expect(registration.registrationId).toBe(registration.entrypointInstance.instanceId); + expect(registration.ownerProcess.shimSessionNonce).toBe( + registration.entrypointInstance.shimSessionNonce, + ); + expect(registration.entrypointInstance.shimSessionNonce).not.toBe( + buildRegistration(argv).entrypointInstance.shimSessionNonce, + ); + expect(registration.sourceConfigPath.raw).toBe(resolve('folder with spaces/Caddyfile')); + expect(registration.shimRun?.rawArguments).toEqual(argv); + expect(() => buildRegistration(['run', '--config'])).toThrow(); +}); + +it('reconnects the same shim after a daemon restart and unregisters only its latest returned session', async () => { + const client = new ClientService(); + vi.spyOn(client, 'start').mockResolvedValue(); + const abort = new AbortController(); + let registrations = 0; + let heartbeats = 0; + vi.mocked(rpc).mockImplementation((async (_paths: unknown, method: string) => { + if (method === 'register-entrypoint-request') + return { ...basic, registrationId: `returned-${++registrations}` }; + if (method === 'heartbeat-entrypoint-request') { + heartbeats++; + if (heartbeats === 1) throw new CadderError('daemon-unavailable', 'Offline'); + if (heartbeats === 2) throw new CadderError('stale_session', 'Restarted'); + abort.abort(); + } + return basic; + }) as typeof rpc); + await managedRun(['run'], client, abort.signal); + expect(client.start).toHaveBeenCalledOnce(); + expect(registrations).toBe(2); + expect(vi.mocked(rpc).mock.calls.at(-1)?.[1]).toBe('unregister-entrypoint-request'); + expect(vi.mocked(rpc).mock.calls.at(-1)?.[2]).toMatchObject({ registrationId: 'returned-2' }); +}); + +it('never delegates to independent Caddy on failed managed startup or cancellation', async () => { + const client = new ClientService(); + const start = vi + .spyOn(client, 'start') + .mockRejectedValue(new CadderError('daemon-start-failed', 'Failed')); + await expect(managedRun(['run'], client)).rejects.toMatchObject({ code: 'daemon-start-failed' }); + expect(rpc).not.toHaveBeenCalled(); + start.mockClear(); + await managedRun(['run'], client, AbortSignal.abort()); + expect(start).not.toHaveBeenCalled(); +}); diff --git a/test/unix-security-policy.test.ts b/test/unix-security-policy.test.ts new file mode 100644 index 0000000..b4fb181 --- /dev/null +++ b/test/unix-security-policy.test.ts @@ -0,0 +1,71 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import type { Stats } from 'node:fs'; +import { lstat, chown } from 'node:fs/promises'; +import { assertProtected, protectCreated, runtimeOwner } from '../src/platform/runtime-security.ts'; + +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + lstat: vi.fn(), + chown: vi.fn(), +})); +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); +}); + +function unix(uid: number) { + vi.stubGlobal('process', { ...process, platform: 'linux', getuid: () => uid }); +} +function info(uid: number, mode: number, directory = false, link = false): Stats { + return { + uid, + mode, + isDirectory: () => directory, + isFile: () => !directory, + isSymbolicLink: () => link, + } as Stats; +} + +it('requires an explicit root owner and rejects invalid owner UIDs', async () => { + unix(0); + await expect(runtimeOwner()).rejects.toMatchObject({ code: 'runtime-owner-required' }); + expect(await runtimeOwner(1234)).toEqual({ id: 'uid:1234', uid: 1234, elevated: true }); + await expect(runtimeOwner(-1)).rejects.toMatchObject({ code: 'invalid-runtime-owner' }); + unix(1234); + await expect(runtimeOwner(1235)).rejects.toMatchObject({ code: 'invalid-runtime-owner' }); + await expect(runtimeOwner(1.5)).rejects.toMatchObject({ code: 'invalid-runtime-owner' }); + expect(await runtimeOwner()).toEqual({ id: 'uid:1234', uid: 1234, elevated: false }); +}); + +function leafInfo(value: Stats) { + vi.mocked(lstat).mockImplementation(async (path) => + path === 'fixture' ? value : info(0, 0o777, true), + ); +} + +it('fails closed on broad permissions, another owner and symlinks', async () => { + unix(1234); + const owner = { id: 'uid:1234', uid: 1234, elevated: false }; + leafInfo(info(1234, 0o700, true)); + await assertProtected('fixture', owner, true); + leafInfo(info(1234, 0o600)); + await assertProtected('fixture', owner); + for (const value of [info(1234, 0o644), info(1235, 0o600), info(1234, 0o600, false, true)]) { + leafInfo(value); + await expect(assertProtected('fixture', owner)).rejects.toMatchObject({ + code: 'unsafe-runtime-permissions', + }); + expect(lstat).toHaveBeenLastCalledWith('fixture'); + } +}); + +it('sets the explicit user ownership for newly created root-owned paths', async () => { + unix(0); + const owner = { id: 'uid:1234', uid: 1234, elevated: true }; + await protectCreated('fixture', owner); + expect(chown).toHaveBeenCalledWith('fixture', 1234, 1234); + vi.mocked(chown).mockClear(); + unix(1234); + await protectCreated('fixture', owner); + expect(chown).not.toHaveBeenCalled(); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c7f25a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "rewriteRelativeImportExtensions": true, + "jsx": "react-jsx", + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "test/**/*.tsx", + "scripts/**/*.ts", + "vitest.config.ts", + "eslint.config.ts" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e7f021c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.{ts,tsx}'], + testTimeout: 15000, + // Native fixtures execute owned helpers and change process/file state. + fileParallelism: false, + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + thresholds: { lines: 85 }, + reporter: ['text', 'json-summary', 'lcov'], + reportsDirectory: + process.env.CADDER_COVERAGE_DIR ?? join(tmpdir(), `cadder-coverage-${process.pid}`), + }, + }, +});