diff --git a/.env.example b/.env.example index 404bd3eb..61bede72 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,28 @@ # ── Proxy (runtime, loaded via Node --env-file) ── PORTA_HOST=127.0.0.1 # Loopback only; set to an explicit private LAN IP for home network use PORTA_PORT=3170 -PORTA_CORS_ORIGINS= # Comma-separated allowed origins (e.g. https://porta.pages.dev) +PORTA_CORS_ORIGINS= # Required for a public host, e.g. https://porta.example.com # ── Cloudflare deployment (used by pnpm dev:cloud / pnpm deploy) ── # PORTA_TUNNEL_NAME= # cloudflared tunnel name (e.g. my-porta-tunnel) # PORTA_CF_PROJECT= # Cloudflare Pages project name (e.g. porta) +# ── Public web dev server (used by pnpm dev / the Windows tunnel recipe) ── +PORTA_WEB_PORT=3070 # Must match the watchdog origin port and cloudflared ingress service +# PORTA_ALLOWED_HOSTS= # Exact public hostname(s), comma-separated; no scheme or path +# # Example: porta.example.com +# For a public host, set PORTA_CORS_ORIGINS above to its full https:// origin. + +# ── Access control (REQUIRED before exposing the dev server publicly) ── +# When PORTA_REQUIRE_AUTH is 1/true/yes/on, every request (page, /api, WebSocket) must present the +# token below or it is rejected (fail-closed). Sign in once via +# https:///?access_token= (sets an HttpOnly cookie). +# Any other non-empty PORTA_REQUIRE_AUTH value is rejected as a configuration error. +# Use at least 32 characters. Generate a token with: +# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" +# PORTA_REQUIRE_AUTH=1 +# PORTA_ACCESS_TOKEN= + # ── Web (build-time, production only) ── # VITE_API_BASE= # Absolute API URL (e.g. https://api.example.com) # # Leave unset for local dev — Vite's proxy handles /api/* routing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb32977a..a19efd13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main, develop] paths: - ".github/workflows/ci.yml" + - "deploy/windows/**" - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" @@ -18,12 +19,14 @@ on: - "packages/web/package.json" - "packages/web/e2e/**" - "packages/web/tsconfig*.json" + - "packages/web/vite-access-gate*.ts" - "packages/web/vite.config.ts" - "packages/web/vitest.config.ts" - "packages/web/vitest.e2e.config.ts" pull_request: paths: - ".github/workflows/ci.yml" + - "deploy/windows/**" - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" @@ -37,6 +40,7 @@ on: - "packages/web/package.json" - "packages/web/e2e/**" - "packages/web/tsconfig*.json" + - "packages/web/vite-access-gate*.ts" - "packages/web/vite.config.ts" - "packages/web/vitest.config.ts" - "packages/web/vitest.e2e.config.ts" @@ -72,6 +76,32 @@ jobs: - name: Test run: pnpm test + - name: Validate Windows PowerShell scripts + if: runner.os == 'Windows' + shell: powershell + run: | + $failed = $false + Get-ChildItem deploy/windows -Filter *.ps1 | ForEach-Object { + $tokens = $null + $parseErrors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $_.FullName, + [ref] $tokens, + [ref] $parseErrors + ) | Out-Null + + if ($parseErrors.Count -gt 0) { + $failed = $true + Write-Error "$($_.Name): $($parseErrors.Message -join '; ')" + } + } + + if ($failed) { + exit 1 + } + + & ./deploy/windows/watchdog-common.tests.ps1 + e2e: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.gitignore b/.gitignore index 3e054a38..ccc3cb59 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ npm-debug.log* # Cloudflare .wrangler/ +# Tooling output +graphify-out/ + # Secrets .oauth-token .agent diff --git a/CHANGELOG.md b/CHANGELOG.md index fd81421f..f8bd925f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.14.0] - 2026-07-26 + +### Added + +- An optional Windows deployment recipe can now keep Porta and a Cloudflare + Tunnel available with a self-healing scheduled watchdog, ownership-tracked + process lifecycle, validated configuration, and a safe stop/uninstall path. + (#110) + +### Security + +- Public Vite deployments can now require token authentication for every HTTP, + API, and WebSocket request. The access gate validates deployment settings and + fails closed for missing or malformed credentials. (#110) +- Updated `brace-expansion`, `@hono/node-server`, Hono, and `fast-uri` to + versions containing upstream security fixes. (#111, #114) + ## [0.13.0] - 2026-07-13 ### Added @@ -270,7 +287,8 @@ Initial public release. - Remote access via Cloudflare Named Tunnel + Pages + Zero Trust - Cross-platform support: Linux (Tier 1), Windows (Tier 2), macOS (Tier 3) -[Unreleased]: https://github.com/L1M80/porta/compare/v0.13.0...HEAD +[Unreleased]: https://github.com/L1M80/porta/compare/v0.14.0...HEAD +[0.14.0]: https://github.com/L1M80/porta/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/L1M80/porta/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/L1M80/porta/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/L1M80/porta/compare/v0.10.0...v0.11.0 diff --git a/README.md b/README.md index e131ffa5..1f6b7009 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/L1M80/porta/actions/workflows/ci.yml/badge.svg)](https://github.com/L1M80/porta/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -![Version](https://img.shields.io/badge/version-0.13.0-green) +![Version](https://img.shields.io/badge/version-0.14.0-green) Remote web interface for [Antigravity](https://antigravity.google/) Agent Manager. Access your local Antigravity sessions from your phone, tablet, or any remote browser through a lightweight LSP bridge. diff --git a/deploy/windows/README.md b/deploy/windows/README.md new file mode 100644 index 00000000..175af7d0 --- /dev/null +++ b/deploy/windows/README.md @@ -0,0 +1,170 @@ +# Self-healing Porta over a Cloudflare tunnel (Windows) + +This directory is an optional **self-hosting recipe** for keeping a Porta instance reachable +at a public hostname on a Windows machine, with a watchdog that restarts it automatically +if either half dies. + +The no-admin scheduled task runs as the current user with an **Interactive** logon, so the +watchdog is active only while that user is signed in. It resumes at the next logon. If you need +unattended 24/7 recovery before anyone signs in after a reboot, install Porta as a service or +create a credential-backed task instead; that is outside this recipe. + +It exists because this exact setup went down in a way that was hard to diagnose: the site +returned a Cloudflare **502 Bad Gateway**, and a home-grown watchdog *detected* the outage but +every restart attempt failed, spraying `Windows cannot find 'pnpm'` dialogs across the desktop. +The scripts here encode the fixes so the failure can't recur and the whole thing is reproducible +from a clean checkout. + +## ⚠️ Security — this exposes your machine to the internet + +A Porta instance can read local files and talk to a local agent. **Exposing it publicly with +no authentication means anyone who learns the hostname can do the same.** Use both layers below: + +1. **Application access gate (required, in-repo).** Set the exact public hostname, browser + origin, and access gate in `.env` before you run the installer: + + ``` + PORTA_HOST=127.0.0.1 + PORTA_PORT=3170 + PORTA_WEB_PORT=3070 + PORTA_ALLOWED_HOSTS=porta.example.com + PORTA_CORS_ORIGINS=https://porta.example.com + PORTA_REQUIRE_AUTH=1 + PORTA_ACCESS_TOKEN= + ``` + + `PORTA_ALLOWED_HOSTS` is a hostname with no scheme or path. + `PORTA_CORS_ORIGINS` is the full `https://` origin used by the browser and WebSocket client. + Do not use `PORTA_ALLOWED_HOSTS=*` for a public deployment. + + Every request — page, `/api`, and WebSocket — is then rejected unless it carries the token. + Sign in once by visiting `https:///?access_token=`; that sets an HttpOnly + cookie for all later requests. The gate is **fail-closed**: enabled with no token = deny all. + The managed runner pins these validated values into its process environment, so an inherited + variable or a higher-priority Vite `.env.local`/mode file cannot disable the gate. + +2. **Cloudflare Access (strongly recommended, edge).** Put a Zero Trust *Self-hosted* + application in front of the hostname and an *Allow* policy scoped to your email. This blocks + unauthorized traffic at Cloudflare's edge so it never reaches your machine at all. Porta is + built for this — it forwards `CF-Access-Client-Id/Secret` service-token headers to the proxy. + +## The two halves + +``` + ┌─────────────────────────── this machine ───────────────────────────┐ + Internet ──TLS──► Cloudflare edge ──tunnel──► cloudflared ──► Porta web :3070 ──► Porta proxy :3170 + (your host) (run-tunnel) (run-porta, `pnpm dev`) +``` + +* **Porta app** — `pnpm dev` runs the proxy (`127.0.0.1:3170`) and web (`127.0.0.1:3070`). +* **Cloudflare tunnel** — `cloudflared` maps the public hostname to `http://127.0.0.1:3070`. + The hostname → port mapping lives in `%USERPROFILE%\.cloudflared\config.yml` (`ingress:` block), + which also names the tunnel id, so we always run the tunnel **by `--config`, never by a typed name**. + +## Root causes this recipe fixes + +| Symptom | Root cause | Fix in these scripts | +|---|---|---| +| `Windows cannot find 'pnpm'` on every restart | `pnpm` was not on `PATH`; `start /b pnpm` resolves via ShellExecute and fails | `install-watchdog.ps1` installs a corepack pnpm shim into a PATH dir; launchers use `pnpm` from PATH | +| Hidden restart hangs forever | corepack's `[Y/n]` download prompt has no console to answer it | prompt disabled (`COREPACK_ENABLE_DOWNLOAD_PROMPT=0`), version pre-activated, and `run-porta.bat` feeds `< NUL` | +| Tunnel restart always failed | it ran `cloudflared tunnel run ` with a name that didn't exist | `run-tunnel.bat` runs by `--config`, so the id/ingress come from the config file | +| Watchdog killed a healthy or unrelated tunnel | it treated every `cloudflared` process as this deployment | each component is tracked by PID, start time, runner path, and a repo/task-specific id; only an unhealthy matching tree is restarted | +| Desktop spammed with modal dialogs | watchdog used `Wscript.Shell.Popup` | watchdog logs silently to an identity-scoped `watchdog-.log` | +| Duplicate launches while booting | no guard between the 5-min ticks | single-instance lock + per-component cooldown | +| A stopped runner left its children alive | ordinary parent/child processes have no shared lifetime on Windows | each runner places its component tree in a kill-on-close Windows Job Object | + +## Install + +Prerequisites: [Node.js](https://nodejs.org) (includes corepack) and +[cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) +with a tunnel already created. Its `%USERPROFILE%\.cloudflared\config.yml` must route the same +public hostname to Porta's web port: + +```yaml +ingress: + - hostname: porta.example.com + service: http://127.0.0.1:3070 + - service: http_status:404 +``` + +```powershell +# from the repo root +Copy-Item .env.example .env +# Edit .env: set the public hostname/origin and required access token shown above. +pnpm install # or: corepack pnpm install +./deploy/windows/install-watchdog.ps1 -PublicHost porta.example.com +``` + +The installer validates the public host against `PORTA_ALLOWED_HOSTS` and +`PORTA_CORS_ORIGINS`, requires the application gate to be enabled, and requires an access token +of at least 32 characters. It also asks `cloudflared` to validate the config and confirms that +the first ingress rule matching the public URL routes exactly to `http://127.0.0.1:3070`. +The watchdog repeats these fail-closed checks before every start. It exits before registering +or starting the task if any check fails. + +That installs pnpm on PATH, disables the corepack prompt, registers the **PortaWatchdog** +scheduled task (every 5 minutes + at logon), and brings the pipeline up immediately. + +The installer never overwrites an existing same-name task. Stop the current installation with +`stop-watchdog.ps1` before reinstalling. It also refuses to start while an untracked launcher +from the legacy VBS recipe is still using this checkout, and reports the PID for manual review +instead of guessing that it is safe to kill. + +To customise the origin port, keep all three values identical: `PORTA_WEB_PORT` in `.env`, +the installer's `-OriginPort` argument, and the `service` port in Cloudflare's `config.yml`. +The tunnel readiness endpoint uses `127.0.0.1:20241` by default; choose another unused port +with `-TunnelMetricsPort` if needed. + +## Files + +| File | Role | +|---|---| +| `install-watchdog.ps1` | one-time, no-admin setup: pnpm-on-PATH + scheduled task | +| `stop-watchdog.ps1` | unregister the task and stop only its identity-validated, tracked process trees | +| `porta-watchdog.ps1` | checks the tracked Porta web/proxy and tunnel readiness, then heals either component independently | +| `managed-runner.ps1` | owns one component tree in a kill-on-close Windows Job Object and records its PID, start time, runner path, and deployment id | +| `watchdog-common.ps1` | shared identity, state, discovery, and safe process-tree helpers | +| `run-porta.bat` | runs the Porta app under its managed runner | +| `run-tunnel.bat` | runs the configured tunnel with a loopback-only readiness endpoint | + +## Verify / operate + +```powershell +# is the scheduled watchdog active, and is its exact tunnel connected? +Get-ScheduledTask -TaskName PortaWatchdog +Invoke-WebRequest http://127.0.0.1:20241/ready -UseBasicParsing | + Select-Object StatusCode + +# An unsigned request should be denied by the application gate (401), or by Cloudflare Access. +try { + (Invoke-WebRequest https:/// -UseBasicParsing).StatusCode +} catch { + $_.Exception.Response.StatusCode.value__ +} + +# watch the newest identity-scoped watchdog log +$watchdogLog = Get-ChildItem .\deploy\windows\watchdog-*.log | + Sort-Object LastWriteTime | Select-Object -Last 1 +Get-Content $watchdogLog.FullName -Tail 20 -Wait + +# tunnel output is identity-scoped under LocalAppData +$repoRoot = (Resolve-Path .).Path +. .\deploy\windows\watchdog-common.ps1 +$instanceId = Get-PortaInstanceId -RepoRoot $repoRoot -TaskName PortaWatchdog +$stateDir = Get-PortaStateDirectory -InstanceId $instanceId +Get-Content (Join-Path $stateDir tunnel.log) -Tail 20 -Wait + +# force a health check now +Start-ScheduledTask -TaskName PortaWatchdog + +# unregister the task and safely stop its tracked Porta + tunnel processes +.\deploy\windows\stop-watchdog.ps1 +``` + +`Unregister-ScheduledTask` by itself only disables future healing; it does **not** stop +already-running Porta or tunnel processes. Use `stop-watchdog.ps1` to stop the identity-validated +process trees too. A legacy or otherwise untracked launcher is reported but not killed +automatically. + +> Note: `watchdog-*.log`, `logs/`, and `*.log` are git-ignored — these scripts only ever write +> logs outside version control. diff --git a/deploy/windows/install-watchdog.ps1 b/deploy/windows/install-watchdog.ps1 new file mode 100644 index 00000000..8c4712d3 --- /dev/null +++ b/deploy/windows/install-watchdog.ps1 @@ -0,0 +1,295 @@ +# One-time setup for the identity-scoped Porta + Cloudflare watchdog. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $PublicHost, + + [ValidateRange(1, 65535)] + [int] $OriginPort = 3070, + + [ValidateRange(1, 65535)] + [int] $ProxyPort = 3170, + + [ValidateRange(1, 65535)] + [int] $TunnelMetricsPort = 20241, + + [ValidateNotNullOrEmpty()] + [string] $CloudflaredConfig = (Join-Path $env:USERPROFILE '.cloudflared\config.yml'), + + [ValidateNotNullOrEmpty()] + [string] $BinDir = (Join-Path $env:USERPROFILE 'bin'), + + [string] $PnpmVersion = '11.15.1', + + [ValidateRange(1, 1440)] + [int] $IntervalMinutes = 5, + + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string] $TaskName = 'PortaWatchdog' +) + +$ErrorActionPreference = 'Stop' +$ScriptDir = $PSScriptRoot +$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir '..\..')) +$EnvFile = Join-Path $RepoRoot '.env' +$CloudflaredConfig = [System.IO.Path]::GetFullPath($CloudflaredConfig) +$BinDir = [System.IO.Path]::GetFullPath($BinDir) +if ($BinDir.Contains(';')) { + throw 'BinDir cannot contain a semicolon because it is added to PATH.' +} +$WatchdogScript = Join-Path $ScriptDir 'porta-watchdog.ps1' +$LegacyWatchdogScript = Join-Path $ScriptDir 'silent-watchdog.vbs' +$RunnerScript = Join-Path $ScriptDir 'managed-runner.ps1' + +. (Join-Path $ScriptDir 'watchdog-common.ps1') + +$InstanceId = Get-PortaInstanceId -RepoRoot $RepoRoot -TaskName $TaskName +$StateDirectory = Get-PortaStateDirectory -InstanceId $InstanceId + +if ($PublicHost.Contains('://') -or $PublicHost.Contains('/') -or $PublicHost.Contains('\') -or + $PublicHost.Contains(':') -or $PublicHost -notmatch '^[A-Za-z0-9.-]+$') { + throw 'PublicHost must be one hostname only, without a scheme, port, path, or wildcard.' +} +$distinctPorts = @($OriginPort, $ProxyPort, $TunnelMetricsPort) | Select-Object -Unique +if (@($distinctPorts).Count -ne 3) { + throw 'OriginPort, ProxyPort, and TunnelMetricsPort must use three different ports.' +} + +if (-not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) { + throw "Missing $EnvFile. Copy .env.example to .env and configure the public deployment first." +} +if (-not (Test-Path -LiteralPath $CloudflaredConfig -PathType Leaf)) { + throw "Cloudflared config does not exist: $CloudflaredConfig" +} + +$envValues = Read-PortaEnvFile -Path $EnvFile +$requireAuth = [string] $envValues['PORTA_REQUIRE_AUTH'] +if ($requireAuth -notmatch '^(?i:1|true|yes|on)$') { + throw 'PORTA_REQUIRE_AUTH must be enabled with 1, true, yes, or on before installing the public watchdog.' +} + +$accessToken = [string] $envValues['PORTA_ACCESS_TOKEN'] +if ($accessToken.Length -lt 32) { + throw 'PORTA_ACCESS_TOKEN must contain at least 32 characters.' +} + +$configuredWebPort = [string] $envValues['PORTA_WEB_PORT'] +if ($configuredWebPort -ne [string] $OriginPort) { + throw "PORTA_WEB_PORT must be $OriginPort to match the watchdog and tunnel." +} + +$configuredProxyPort = [string] $envValues['PORTA_PORT'] +if ($configuredProxyPort -ne [string] $ProxyPort) { + throw "PORTA_PORT must be $ProxyPort to match the watchdog health check." +} + +$configuredHost = [string] $envValues['PORTA_HOST'] +if ($configuredHost -ne '127.0.0.1') { + throw 'This tunnel recipe requires PORTA_HOST=127.0.0.1.' +} + +$allowedHosts = @(([string] $envValues['PORTA_ALLOWED_HOSTS']).Split(',') | + ForEach-Object { $_.Trim() } | Where-Object { $_ }) +if ($allowedHosts -contains '*' -or $allowedHosts -contains 'true' -or $allowedHosts -contains 'all') { + throw 'PORTA_ALLOWED_HOSTS must list the exact public hostname; wildcards are not allowed by this recipe.' +} +if ($allowedHosts -notcontains $PublicHost) { + throw "PORTA_ALLOWED_HOSTS must contain the exact hostname '$PublicHost'." +} + +$publicOrigin = "https://$PublicHost" +$corsOrigins = @(([string] $envValues['PORTA_CORS_ORIGINS']).Split(',') | + ForEach-Object { $_.Trim() } | Where-Object { $_ }) +if (-not @($corsOrigins | Where-Object { $_ -ceq $publicOrigin })) { + throw "PORTA_CORS_ORIGINS must contain '$publicOrigin'." +} + +Assert-PortaCloudflaredIngress -CloudflaredConfig $CloudflaredConfig ` + -PublicOrigin $publicOrigin -OriginPort $OriginPort + +$preflightProblems = New-Object 'System.Collections.Generic.List[string]' +$existingTask = Get-ScheduledTask -TaskPath '\' -TaskName $TaskName ` + -ErrorAction SilentlyContinue +if ($null -ne $existingTask) { + $taskOwnership = Get-PortaScheduledTaskOwnership -Task $existingTask ` + -TaskName $TaskName -WatchdogScript $WatchdogScript ` + -LegacyWatchdogScript $LegacyWatchdogScript + if ($taskOwnership -eq 'current') { + $preflightProblems.Add( + "The current checkout already owns scheduled task '\$TaskName'. " + + 'Run stop-watchdog.ps1 before reinstalling it.' + ) + } + elseif ($taskOwnership -eq 'legacy') { + $preflightProblems.Add( + "The legacy watchdog task '\$TaskName' still exists for this checkout. " + + 'Run stop-watchdog.ps1, then stop any legacy-compatible launchers it reports.' + ) + } + else { + $preflightProblems.Add( + "Scheduled task '\$TaskName' is not owned by this checkout. " + + 'Choose another -TaskName or inspect the existing task manually.' + ) + } +} + +$untrackedLaunchers = @(Get-PortaUntrackedBatchLaunchers -ScriptDirectory $ScriptDir ` + -StateDirectory $StateDirectory -InstanceId $InstanceId -RunnerScript $RunnerScript) +foreach ($launcher in $untrackedLaunchers) { + $preflightProblems.Add( + "Untracked $($launcher.Component) launcher PID $($launcher.ProcessId) is using " + + "this checkout's run-$($launcher.Component).bat." + ) +} + +$portaRunner = Get-PortaManagedRunner -StateDirectory $StateDirectory -Component porta ` + -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript (Join-Path $ScriptDir 'run-porta.bat') +foreach ($port in @($OriginPort, $ProxyPort)) { + $listenerPids = @(Get-NetTCPConnection -LocalPort $port -State Listen ` + -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique) + foreach ($listenerPid in $listenerPids) { + $isManagedListener = $null -ne $portaRunner -and + (Test-PortaProcessDescendant -ProcessId ([int] $listenerPid) ` + -AncestorProcessId ([int] $portaRunner.runnerPid)) + if (-not $isManagedListener) { + $preflightProblems.Add( + "Untracked process PID $listenerPid is already listening on required port $port." + ) + } + } +} + +$tunnelRunner = Get-PortaManagedRunner -StateDirectory $StateDirectory -Component tunnel ` + -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript (Join-Path $ScriptDir 'run-tunnel.bat') +$cloudflaredProcesses = @(Get-CimInstance Win32_Process ` + -Filter "Name = 'cloudflared.exe'" -ErrorAction SilentlyContinue) +foreach ($process in $cloudflaredProcesses) { + if (-not (Test-PortaCommandLinePathOption -CommandLine ([string] $process.CommandLine) ` + -Option '--config' -Path $CloudflaredConfig)) { + continue + } + + $isManagedTunnel = $null -ne $tunnelRunner -and + (Test-PortaProcessDescendant -ProcessId ([int] $process.ProcessId) ` + -AncestorProcessId ([int] $tunnelRunner.runnerPid)) + if (-not $isManagedTunnel) { + $preflightProblems.Add( + "Untracked cloudflared PID $($process.ProcessId) is already using this config file." + ) + } +} + +if ($preflightProblems.Count -gt 0) { + $details = ($preflightProblems | ForEach-Object { " - $_" }) -join [Environment]::NewLine + throw ( + "Cannot install without risking another deployment or an unmanaged public process." + + [Environment]::NewLine + $details + [Environment]::NewLine + + 'No task or process was changed. Resolve the listed item(s), then rerun the installer.' + ) +} + +Write-Host "==> 1/4 Ensuring '$BinDir' exists and is on your user PATH" +if (-not (Test-Path -LiteralPath $BinDir)) { + New-Item -ItemType Directory -Path $BinDir | Out-Null +} +$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') +if (($userPath -split ';') -notcontains $BinDir) { + $newUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) { + $BinDir + } + else { + $userPath.TrimEnd(';') + ';' + $BinDir + } + [Environment]::SetEnvironmentVariable('PATH', $newUserPath, 'User') + Write-Host " added $BinDir to user PATH (restart shells to pick it up)" +} +$env:PATH = "$BinDir;$env:PATH" + +Write-Host '==> 2/4 Installing pnpm via corepack without an interactive download prompt' +$env:COREPACK_ENABLE_DOWNLOAD_PROMPT = '0' +[Environment]::SetEnvironmentVariable('COREPACK_ENABLE_DOWNLOAD_PROMPT', '0', 'User') +$corepack = Get-Command corepack -CommandType Application -ErrorAction Stop +$previousErrorActionPreference = $ErrorActionPreference +try { + $ErrorActionPreference = 'Continue' + & $corepack.Source enable --install-directory $BinDir pnpm + $corepackEnableExitCode = $LASTEXITCODE +} +finally { + $ErrorActionPreference = $previousErrorActionPreference +} +if ($corepackEnableExitCode -ne 0) { + throw "corepack enable failed with exit code $corepackEnableExitCode." +} + +try { + $ErrorActionPreference = 'Continue' + & $corepack.Source prepare "pnpm@$PnpmVersion" --activate + $corepackPrepareExitCode = $LASTEXITCODE +} +finally { + $ErrorActionPreference = $previousErrorActionPreference +} +if ($corepackPrepareExitCode -ne 0) { + throw "corepack prepare failed with exit code $corepackPrepareExitCode." +} + +$pnpmShim = Join-Path $BinDir 'pnpm.cmd' +if (-not (Test-Path -LiteralPath $pnpmShim -PathType Leaf)) { + throw "Corepack did not create the Windows pnpm shim: $pnpmShim" +} +try { + $ErrorActionPreference = 'Continue' + $pnpmOutput = & $pnpmShim --version 2>&1 + $pnpmExitCode = $LASTEXITCODE +} +finally { + $ErrorActionPreference = $previousErrorActionPreference +} +$pnpmVer = ([string] (($pnpmOutput | Select-Object -Last 1))).Trim() +if ($pnpmExitCode -ne 0 -or $pnpmVer -ne $PnpmVersion) { + throw "The pnpm.cmd shim reported '$pnpmVer' (exit $pnpmExitCode); expected '$PnpmVersion'." +} +Write-Host " pnpm ready: $pnpmVer" + +Write-Host "==> 3/4 Registering '$TaskName' (every $IntervalMinutes minutes and at logon)" +$watchdogArguments = @( + '-NoProfile', + '-WindowStyle', 'Hidden', + '-ExecutionPolicy', 'Bypass', + '-File', "`"$WatchdogScript`"", + '-OriginPort', [string] $OriginPort, + '-ProxyPort', [string] $ProxyPort, + '-TunnelMetricsPort', [string] $TunnelMetricsPort, + '-PublicHost', "`"$PublicHost`"", + '-CloudflaredConfig', "`"$CloudflaredConfig`"", + '-PnpmBinDir', "`"$BinDir`"", + '-TaskName', "`"$TaskName`"" +) -join ' ' + +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $watchdogArguments ` + -WorkingDirectory $RepoRoot +$firstRun = (Get-Date).AddMinutes(1) +$periodicTrigger = New-ScheduledTaskTrigger -Once -At $firstRun ` + -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes) +$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -StartWhenAvailable ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 15) +$taskUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$logonTrigger = New-ScheduledTaskTrigger -AtLogOn -User $taskUser +$principal = New-ScheduledTaskPrincipal -UserId $taskUser -LogonType Interactive -RunLevel Limited +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $periodicTrigger, $logonTrigger ` + -Settings $settings -Principal $principal | Out-Null + +Write-Host '==> 4/4 Running the watchdog once to bring the pipeline up' +Start-ScheduledTask -TaskName $TaskName + +Write-Host '' +Write-Host 'Done. Automatic recovery is active while this user is signed in.' +Write-Host "Watchdog log: $ScriptDir\watchdog-$InstanceId.log" +Write-Host "Porta logs: $RepoRoot\logs" +Write-Host "Tunnel log: $(Join-Path $StateDirectory 'tunnel.log')" +Write-Host "Stop safely: $ScriptDir\stop-watchdog.ps1 -TaskName `"$TaskName`"" diff --git a/deploy/windows/managed-runner.ps1 b/deploy/windows/managed-runner.ps1 new file mode 100644 index 00000000..7e265cb8 --- /dev/null +++ b/deploy/windows/managed-runner.ps1 @@ -0,0 +1,401 @@ +# Owns one managed component process tree and records enough identity information +# for the watchdog and stop script to distinguish it from unrelated processes. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('porta', 'tunnel')] + [string] $Component, + + [Parameter(Mandatory = $true)] + [string] $InstanceId, + + [Parameter(Mandatory = $true)] + [string] $StateDirectory, + + [Parameter(Mandatory = $true)] + [string] $CommandScript, + + [string] $PortaRuntimeConfigHash = '', + [string] $CloudflaredConfig = '', + [string] $CloudflaredConfigHash = '', + [int] $TunnelMetricsPort = 20241 +) + +$ErrorActionPreference = 'Stop' +$ScriptDir = $PSScriptRoot +. (Join-Path $ScriptDir 'watchdog-common.ps1') + +if (-not ('Porta.ManagedJob.NativeMethods' -as [type])) { + $jobInteropSource = @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +namespace Porta.ManagedJob +{ + public static class NativeMethods + { + private const uint CREATE_SUSPENDED = 0x00000004; + private const uint CREATE_NO_WINDOW = 0x08000000; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private const int JobObjectExtendedLimitInformation = 9; + private const uint INFINITE = 0xFFFFFFFF; + private const uint WAIT_FAILED = 0xFFFFFFFF; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct STARTUPINFO + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObject( + IntPtr lpJobAttributes, + string lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + IntPtr hJob, + int JobObjectInformationClass, + ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION lpJobObjectInformation, + uint cbJobObjectInformationLength); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcess( + string lpApplicationName, + StringBuilder lpCommandLine, + IntPtr lpProcessAttributes, + IntPtr lpThreadAttributes, + bool bInheritHandles, + uint dwCreationFlags, + IntPtr lpEnvironment, + string lpCurrentDirectory, + ref STARTUPINFO lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject( + IntPtr hJob, + IntPtr hProcess); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr hThread); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject( + IntPtr hHandle, + uint dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess( + IntPtr hProcess, + out uint lpExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateProcess( + IntPtr hProcess, + uint uExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + private static Win32Exception LastWin32Exception(string operation) + { + return new Win32Exception( + Marshal.GetLastWin32Error(), + operation + " failed"); + } + + public static IntPtr CreateKillOnCloseJob() + { + IntPtr job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) + { + throw LastWin32Exception("CreateJobObject"); + } + + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = + new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + uint size = (uint)Marshal.SizeOf( + typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + if (!SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + ref limits, + size)) + { + int error = Marshal.GetLastWin32Error(); + CloseHandle(job); + throw new Win32Exception( + error, + "SetInformationJobObject failed"); + } + + return job; + } + + public static PROCESS_INFORMATION CreateSuspendedProcess( + string applicationName, + string commandLine, + string currentDirectory) + { + STARTUPINFO startupInfo = new STARTUPINFO(); + startupInfo.cb = Marshal.SizeOf(typeof(STARTUPINFO)); + + PROCESS_INFORMATION processInformation; + if (!CreateProcess( + applicationName, + new StringBuilder(commandLine), + IntPtr.Zero, + IntPtr.Zero, + false, + CREATE_SUSPENDED | CREATE_NO_WINDOW, + IntPtr.Zero, + currentDirectory, + ref startupInfo, + out processInformation)) + { + throw LastWin32Exception("CreateProcess"); + } + + return processInformation; + } + + public static void AssignToJob( + IntPtr job, + IntPtr process) + { + if (!AssignProcessToJobObject(job, process)) + { + throw LastWin32Exception("AssignProcessToJobObject"); + } + } + + public static void ResumeProcessThread(IntPtr thread) + { + if (ResumeThread(thread) == uint.MaxValue) + { + throw LastWin32Exception("ResumeThread"); + } + } + + public static int WaitForProcessExit(IntPtr process) + { + if (WaitForSingleObject(process, INFINITE) == WAIT_FAILED) + { + throw LastWin32Exception("WaitForSingleObject"); + } + + uint exitCode; + if (!GetExitCodeProcess(process, out exitCode)) + { + throw LastWin32Exception("GetExitCodeProcess"); + } + + return unchecked((int)exitCode); + } + + public static void TerminateProcessBestEffort( + IntPtr process, + uint exitCode) + { + if (process != IntPtr.Zero) + { + TerminateProcess(process, exitCode); + } + } + + public static void CloseHandleBestEffort(IntPtr handle) + { + if (handle != IntPtr.Zero && handle != new IntPtr(-1)) + { + CloseHandle(handle); + } + } + } +} +'@ + + Add-Type -TypeDefinition $jobInteropSource -Language CSharp +} + +if (-not (Test-Path -LiteralPath $CommandScript -PathType Leaf)) { + throw "Managed command script does not exist: $CommandScript" +} + +if ($Component -eq 'porta') { + if ($PortaRuntimeConfigHash -notmatch '^[A-Fa-f0-9]{64}$') { + throw 'Expected pinned Porta environment SHA256 is missing or malformed.' + } + + $actualRuntimeConfigHash = Get-PortaPinnedEnvironmentHash ` + -RequireAuth ([string] $env:PORTA_REQUIRE_AUTH) ` + -AccessToken ([string] $env:PORTA_ACCESS_TOKEN) ` + -HostName ([string] $env:PORTA_HOST) ` + -ProxyPort ([string] $env:PORTA_PORT) ` + -WebPort ([string] $env:PORTA_WEB_PORT) ` + -AllowedHosts ([string] $env:PORTA_ALLOWED_HOSTS) ` + -CorsOrigins ([string] $env:PORTA_CORS_ORIGINS) + if (-not [string]::Equals( + $PortaRuntimeConfigHash, + $actualRuntimeConfigHash, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'Pinned Porta environment changed before the managed app could start.' + } +} + +if ($Component -eq 'tunnel') { + if (-not (Test-Path -LiteralPath $CloudflaredConfig -PathType Leaf)) { + throw "Cloudflared config does not exist: $CloudflaredConfig" + } + if ($CloudflaredConfigHash -notmatch '^[A-Fa-f0-9]{64}$') { + throw 'Expected Cloudflared config SHA256 is missing or malformed.' + } + + $actualConfigHash = (Get-FileHash -LiteralPath $CloudflaredConfig -Algorithm SHA256).Hash + if (-not [string]::Equals( + $CloudflaredConfigHash, + $actualConfigHash, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'Cloudflared config changed before the managed tunnel could start.' + } + + $env:CLOUDFLARED_CONFIG = $CloudflaredConfig + $env:PORTA_TUNNEL_METRICS_PORT = [string] $TunnelMetricsPort + $env:PORTA_WATCHDOG_STATE_DIR = $StateDirectory +} + +$state = [pscustomobject] @{ + component = $Component + instanceId = $InstanceId + runnerPid = $PID + startedAtUtc = (Get-Process -Id $PID).StartTime.ToUniversalTime().ToString('O') + runnerScript = [System.IO.Path]::GetFullPath($PSCommandPath) + commandScript = [System.IO.Path]::GetFullPath($CommandScript) + portaRuntimeConfigHash = $PortaRuntimeConfigHash + cloudflaredConfig = $CloudflaredConfig + cloudflaredConfigHash = $CloudflaredConfigHash + tunnelMetricsPort = $TunnelMetricsPort +} +Write-PortaComponentState -StateDirectory $StateDirectory -Component $Component -State $state + +$exitCode = 1 +$jobHandle = [IntPtr]::Zero +$processHandle = [IntPtr]::Zero +$threadHandle = [IntPtr]::Zero +$assignedToJob = $false +try { + $command = 'call "' + $CommandScript.Replace('"', '""') + '"' + $commandLine = '"' + $env:ComSpec + '" /d /s /c ' + $command + + # Create cmd.exe suspended so it cannot start descendants before becoming a + # member of the kill-on-close job. If this runner exits unexpectedly, Windows + # closes its last job handle and terminates the complete managed process tree. + $jobHandle = [Porta.ManagedJob.NativeMethods]::CreateKillOnCloseJob() + $processInfo = [Porta.ManagedJob.NativeMethods]::CreateSuspendedProcess( + $env:ComSpec, + $commandLine, + [Environment]::CurrentDirectory + ) + $processHandle = $processInfo.hProcess + $threadHandle = $processInfo.hThread + + [Porta.ManagedJob.NativeMethods]::AssignToJob($jobHandle, $processHandle) + $assignedToJob = $true + [Porta.ManagedJob.NativeMethods]::ResumeProcessThread($threadHandle) + [Porta.ManagedJob.NativeMethods]::CloseHandleBestEffort($threadHandle) + $threadHandle = [IntPtr]::Zero + + $exitCode = [Porta.ManagedJob.NativeMethods]::WaitForProcessExit( + $processHandle + ) +} +finally { + if (-not $assignedToJob) { + [Porta.ManagedJob.NativeMethods]::TerminateProcessBestEffort( + $processHandle, + 1 + ) + } + [Porta.ManagedJob.NativeMethods]::CloseHandleBestEffort($threadHandle) + [Porta.ManagedJob.NativeMethods]::CloseHandleBestEffort($processHandle) + # Close the job last. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE then cleans up any + # descendants still alive after cmd.exe exits, as well as on error paths. + [Porta.ManagedJob.NativeMethods]::CloseHandleBestEffort($jobHandle) + + $current = Read-PortaComponentState -StateDirectory $StateDirectory -Component $Component + if ($null -ne $current -and [int] $current.runnerPid -eq $PID) { + $statePath = Get-PortaComponentStatePath -StateDirectory $StateDirectory -Component $Component + Remove-Item -LiteralPath $statePath -Force -ErrorAction SilentlyContinue + } +} + +exit $exitCode diff --git a/deploy/windows/porta-watchdog.ps1 b/deploy/windows/porta-watchdog.ps1 new file mode 100644 index 00000000..49b88ea4 --- /dev/null +++ b/deploy/windows/porta-watchdog.ps1 @@ -0,0 +1,454 @@ +# Keeps one identity-scoped Porta app and Cloudflare tunnel healthy. +[CmdletBinding()] +param( + [ValidateRange(1, 65535)] + [int] $OriginPort = 3070, + + [ValidateRange(1, 65535)] + [int] $ProxyPort = 3170, + + [ValidateRange(1, 65535)] + [int] $TunnelMetricsPort = 20241, + + [Parameter(Mandatory = $true)] + [string] $PublicHost, + + [string] $CloudflaredConfig = (Join-Path $env:USERPROFILE '.cloudflared\config.yml'), + + [string] $PnpmBinDir = (Join-Path $env:USERPROFILE 'bin'), + + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string] $TaskName = 'PortaWatchdog', + + [ValidateRange(30, 3600)] + [int] $CooldownSeconds = 180, + + [ValidateRange(5, 300)] + [int] $StartupGraceSeconds = 45 +) + +$ErrorActionPreference = 'Stop' +$ScriptDir = $PSScriptRoot +$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir '..\..')) +$RunnerScript = Join-Path $ScriptDir 'managed-runner.ps1' +$PortaCommandScript = Join-Path $ScriptDir 'run-porta.bat' +$TunnelCommandScript = Join-Path $ScriptDir 'run-tunnel.bat' +$EnvFile = Join-Path $RepoRoot '.env' +$CloudflaredConfig = [System.IO.Path]::GetFullPath($CloudflaredConfig) +$PnpmBinDir = [System.IO.Path]::GetFullPath($PnpmBinDir) +$PortaRuntimeConfigHash = '' +$CloudflaredConfigHash = '' + +. (Join-Path $ScriptDir 'watchdog-common.ps1') + +$distinctPorts = @($OriginPort, $ProxyPort, $TunnelMetricsPort) | Select-Object -Unique +if (@($distinctPorts).Count -ne 3) { + throw 'OriginPort, ProxyPort, and TunnelMetricsPort must use three different ports.' +} +if ($PublicHost.Contains('://') -or $PublicHost.Contains('/') -or $PublicHost.Contains('\') -or + $PublicHost.Contains(':') -or $PublicHost -notmatch '^[A-Za-z0-9.-]+$') { + throw 'PublicHost must be one hostname only, without a scheme, port, path, or wildcard.' +} + +$InstanceId = Get-PortaInstanceId -RepoRoot $RepoRoot -TaskName $TaskName +$StateDirectory = Get-PortaStateDirectory -InstanceId $InstanceId +if (-not (Test-Path -LiteralPath $StateDirectory)) { + New-Item -ItemType Directory -Path $StateDirectory -Force | Out-Null +} + +$Log = Join-Path $ScriptDir "watchdog-$InstanceId.log" + +function Write-WatchdogLog { + param([string] $Message) + + "{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message | + Out-File -LiteralPath $Log -Append -Encoding UTF8 +} + +if ((Test-Path -LiteralPath $Log) -and ((Get-Item -LiteralPath $Log).Length -gt 1MB)) { + Get-Content -LiteralPath $Log -Tail 500 | + Set-Content -LiteralPath $Log -Encoding UTF8 +} + +function Test-Cooldown { + param( + [ValidateSet('porta', 'tunnel')] + [string] $Component + ) + + $marker = Join-Path $StateDirectory "$Component.last-start" + if (Test-Path -LiteralPath $marker) { + $age = ((Get-Date) - (Get-Item -LiteralPath $marker).LastWriteTime).TotalSeconds + if ($age -lt $CooldownSeconds) { + return $false + } + } + + New-Item -ItemType File -Path $marker -Force | Out-Null + return $true +} + +function Start-ManagedComponent { + param( + [ValidateSet('porta', 'tunnel')] + [string] $Component + ) + + $commandScript = if ($Component -eq 'porta') { + $PortaCommandScript + } + else { + $TunnelCommandScript + } + $arguments = @( + '-NoProfile', + '-WindowStyle', 'Hidden', + '-ExecutionPolicy', 'Bypass', + '-File', "`"$RunnerScript`"", + '-Component', $Component, + '-InstanceId', $InstanceId, + '-StateDirectory', "`"$StateDirectory`"", + '-CommandScript', "`"$commandScript`"" + ) + if ($Component -eq 'tunnel') { + $arguments += @( + '-CloudflaredConfig', "`"$CloudflaredConfig`"", + '-CloudflaredConfigHash', $CloudflaredConfigHash, + '-TunnelMetricsPort', [string] $TunnelMetricsPort + ) + } + else { + $arguments += @( + '-PortaRuntimeConfigHash', $PortaRuntimeConfigHash + ) + } + + Start-Process -FilePath 'powershell.exe' -ArgumentList $arguments -WindowStyle Hidden | Out-Null +} + +function Get-ValidatedRuntimeConfiguration { + if (-not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) { + throw "Missing runtime configuration: $EnvFile" + } + if (-not (Test-Path -LiteralPath (Join-Path $PnpmBinDir 'pnpm.cmd') -PathType Leaf)) { + throw "Managed pnpm.cmd is missing from $PnpmBinDir." + } + + $envValues = Read-PortaEnvFile -Path $EnvFile + if ([string] $envValues['PORTA_REQUIRE_AUTH'] -notmatch '^(?i:1|true|yes|on)$') { + throw 'PORTA_REQUIRE_AUTH is no longer enabled.' + } + + $token = [string] $envValues['PORTA_ACCESS_TOKEN'] + if ($token.Length -lt 32) { + throw 'PORTA_ACCESS_TOKEN is missing or shorter than 32 characters.' + } + + if ([string] $envValues['PORTA_WEB_PORT'] -ne [string] $OriginPort) { + throw "PORTA_WEB_PORT no longer matches watchdog port $OriginPort." + } + if ([string] $envValues['PORTA_PORT'] -ne [string] $ProxyPort) { + throw "PORTA_PORT no longer matches watchdog proxy port $ProxyPort." + } + if ([string] $envValues['PORTA_HOST'] -ne '127.0.0.1') { + throw 'PORTA_HOST must remain 127.0.0.1 for this public tunnel recipe.' + } + + $allowedHosts = @(([string] $envValues['PORTA_ALLOWED_HOSTS']).Split(',') | + ForEach-Object { $_.Trim() } | Where-Object { $_ }) + if ($allowedHosts -contains '*' -or $allowedHosts -contains 'true' -or + $allowedHosts -contains 'all' -or $allowedHosts -notcontains $PublicHost) { + throw "PORTA_ALLOWED_HOSTS must contain only explicit hosts including '$PublicHost'." + } + + $publicOrigin = "https://$PublicHost" + $corsOrigins = @(([string] $envValues['PORTA_CORS_ORIGINS']).Split(',') | + ForEach-Object { $_.Trim() } | Where-Object { $_ }) + if (-not @($corsOrigins | Where-Object { $_ -ceq $publicOrigin })) { + throw "PORTA_CORS_ORIGINS must contain the exact origin '$publicOrigin'." + } + + if (-not (Test-Path -LiteralPath $CloudflaredConfig -PathType Leaf)) { + throw "Cloudflared config is missing: $CloudflaredConfig" + } + $configHashBeforeValidation = ( + Get-FileHash -LiteralPath $CloudflaredConfig -Algorithm SHA256 + ).Hash.ToLowerInvariant() + Assert-PortaCloudflaredIngress -CloudflaredConfig $CloudflaredConfig ` + -PublicOrigin $publicOrigin -OriginPort $OriginPort + $configHashAfterValidation = ( + Get-FileHash -LiteralPath $CloudflaredConfig -Algorithm SHA256 + ).Hash.ToLowerInvariant() + if ($configHashBeforeValidation -ne $configHashAfterValidation) { + throw 'Cloudflared config changed while it was being validated; retry on the next tick.' + } + + $runtimeConfigHash = Get-PortaPinnedEnvironmentHash ` + -RequireAuth '1' -AccessToken $token -HostName '127.0.0.1' ` + -ProxyPort ([string] $ProxyPort) -WebPort ([string] $OriginPort) ` + -AllowedHosts $PublicHost -CorsOrigins $publicOrigin + + return [pscustomobject] @{ + AccessToken = $token + PublicOrigin = $publicOrigin + PortaRuntimeConfigHash = $runtimeConfigHash + CloudflaredConfigHash = $configHashAfterValidation + } +} + +function Test-WebHealth { + param( + [Parameter(Mandatory = $true)] + [string] $AccessToken + ) + + try { + $unauthorizedStatus = Get-PortaHttpStatus -Port $OriginPort ` + -HostHeader $PublicHost + $cookieToken = [Uri]::EscapeDataString($AccessToken) + $authorizedStatus = Get-PortaHttpStatus -Port $OriginPort ` + -HostHeader $PublicHost -Cookie "porta_access=$cookieToken" + return $unauthorizedStatus -eq 401 -and $authorizedStatus -eq 200 + } + catch { + return $false + } +} + +function Test-ProxyHealth { + try { + $response = Invoke-WebRequest -Uri "http://127.0.0.1:$ProxyPort/api/health" ` + -UseBasicParsing -TimeoutSec 5 + if ($response.StatusCode -ne 200) { + return $false + } + + $body = $response.Content | ConvertFrom-Json + return [string] $body.status -eq 'ok' + } + catch { + return $false + } +} + +function Test-TunnelHealth { + try { + $response = Invoke-WebRequest -Uri "http://127.0.0.1:$TunnelMetricsPort/ready" ` + -UseBasicParsing -TimeoutSec 5 + return $response.StatusCode -eq 200 + } + catch { + return $false + } +} + +function Stop-TrackedComponent { + param( + [ValidateSet('porta', 'tunnel')] + [string] $Component + ) + + $commandScript = if ($Component -eq 'porta') { + $PortaCommandScript + } + else { + $TunnelCommandScript + } + $runner = Get-PortaManagedRunner -StateDirectory $StateDirectory -Component $Component ` + -InstanceId $InstanceId -RunnerScript $RunnerScript -CommandScript $commandScript + if ($null -ne $runner) { + Stop-PortaManagedRunner -StateDirectory $StateDirectory -Component $Component ` + -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript $commandScript | Out-Null + Write-WatchdogLog "Stopped managed $Component runner PID $($runner.runnerPid)." + } +} + +function Wait-PortaHealth { + param( + [Parameter(Mandatory = $true)] + [string] $AccessToken, + + [ValidateRange(0, 300)] + [int] $TimeoutSeconds + ) + + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ($true) { + $candidate = Get-PortaManagedRunner -StateDirectory $StateDirectory ` + -Component porta -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript $PortaCommandScript + $matchesConfig = $null -ne $candidate -and + (Test-PortaManagedRunner -State $candidate -Component porta ` + -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript $PortaCommandScript ` + -PortaRuntimeConfigHash $PortaRuntimeConfigHash) + if ($matchesConfig -and + (Test-WebHealth -AccessToken $AccessToken) -and + (Test-ProxyHealth)) { + return $candidate + } + + if ([DateTime]::UtcNow -ge $deadline) { + return $null + } + Start-Sleep -Seconds 1 + } +} + +$lockPath = Join-Path $StateDirectory 'watchdog.lock' +$lock = $null +try { + $lock = [System.IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None') +} +catch { + Write-WatchdogLog 'Another watchdog instance is active; exiting.' + return +} + +try { + try { + $runtimeConfiguration = Get-ValidatedRuntimeConfiguration + $PortaRuntimeConfigHash = $runtimeConfiguration.PortaRuntimeConfigHash + $CloudflaredConfigHash = $runtimeConfiguration.CloudflaredConfigHash + $env:PATH = "$PnpmBinDir;$env:PATH" + # Pin the values validated from .env. Vite and Node otherwise permit + # .env.local, mode-specific files, or inherited process variables to + # override the public deployment's security and network settings. + $env:PORTA_REQUIRE_AUTH = '1' + $env:PORTA_ACCESS_TOKEN = $runtimeConfiguration.AccessToken + $env:PORTA_HOST = '127.0.0.1' + $env:PORTA_PORT = [string] $ProxyPort + $env:PORTA_WEB_PORT = [string] $OriginPort + $env:PORTA_ALLOWED_HOSTS = $PublicHost + $env:PORTA_CORS_ORIGINS = $runtimeConfiguration.PublicOrigin + } + catch { + Write-WatchdogLog "SECURITY: $($_.Exception.Message) Stopping all managed public-deployment processes." + foreach ($component in @('tunnel', 'porta')) { + try { + Stop-TrackedComponent -Component $component + } + catch { + Write-WatchdogLog "ERROR: could not stop managed $component after configuration failure: $($_.Exception.Message)" + } + } + return + } + + $portaRunner = Get-PortaManagedRunner -StateDirectory $StateDirectory -Component porta ` + -InstanceId $InstanceId -RunnerScript $RunnerScript -CommandScript $PortaCommandScript + $portaMatchesConfig = $null -ne $portaRunner -and + (Test-PortaManagedRunner -State $portaRunner -Component porta ` + -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript $PortaCommandScript ` + -PortaRuntimeConfigHash $PortaRuntimeConfigHash) + $portaHealthy = $portaMatchesConfig -and + (Test-WebHealth -AccessToken $runtimeConfiguration.AccessToken) -and + (Test-ProxyHealth) + + if (-not $portaHealthy) { + Write-WatchdogLog 'Porta has not passed both health checks; stopping the managed tunnel before recovery.' + Stop-TrackedComponent -Component tunnel + } + + if (-not $portaHealthy -and $portaMatchesConfig) { + try { + $runnerStarted = [DateTimeOffset]::Parse( + [string] $portaRunner.startedAtUtc + ).UtcDateTime + $runnerAgeSeconds = ( + [DateTime]::UtcNow - $runnerStarted + ).TotalSeconds + $remainingGrace = [Math]::Max( + 0, + [Math]::Ceiling($StartupGraceSeconds - $runnerAgeSeconds) + ) + } + catch { + $remainingGrace = 0 + } + + if ($remainingGrace -gt 0) { + Write-WatchdogLog "Porta is still starting; waiting up to $remainingGrace seconds." + $readyRunner = Wait-PortaHealth ` + -AccessToken $runtimeConfiguration.AccessToken ` + -TimeoutSeconds ([int] $remainingGrace) + if ($null -ne $readyRunner) { + $portaRunner = $readyRunner + $portaHealthy = $true + } + } + } + + if (-not $portaHealthy) { + if ($null -ne $portaRunner) { + Write-WatchdogLog "Porta runner PID $($portaRunner.runnerPid) is unhealthy; stopping its managed tree." + Stop-PortaManagedRunner -StateDirectory $StateDirectory -Component porta ` + -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript $PortaCommandScript | Out-Null + } + + if (Test-Cooldown -Component porta) { + Write-WatchdogLog 'Porta is down or unhealthy; starting its managed runner.' + Start-ManagedComponent -Component porta + $readyRunner = Wait-PortaHealth ` + -AccessToken $runtimeConfiguration.AccessToken ` + -TimeoutSeconds $StartupGraceSeconds + if ($null -ne $readyRunner) { + $portaRunner = $readyRunner + $portaHealthy = $true + Write-WatchdogLog 'Porta passed startup health checks.' + } + } + else { + Write-WatchdogLog 'Porta is unhealthy but still within its restart cooldown.' + } + } + + if (-not $portaHealthy) { + Write-WatchdogLog 'Tunnel remains stopped until Porta passes authenticated and unauthenticated health checks.' + return + } + + $tunnelRunner = Get-PortaManagedRunner -StateDirectory $StateDirectory -Component tunnel ` + -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript $TunnelCommandScript + $tunnelMatchesConfig = $null -ne $tunnelRunner -and + (Test-PortaManagedRunner -State $tunnelRunner -Component tunnel ` + -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript $TunnelCommandScript -CloudflaredConfig $CloudflaredConfig ` + -CloudflaredConfigHash $CloudflaredConfigHash -TunnelMetricsPort $TunnelMetricsPort) + $tunnelHealthy = $tunnelMatchesConfig -and (Test-TunnelHealth) + + if (-not $tunnelHealthy) { + if ($null -ne $tunnelRunner) { + Write-WatchdogLog "Tunnel runner PID $($tunnelRunner.runnerPid) is unhealthy or uses different settings; stopping its managed tree." + Stop-PortaManagedRunner -StateDirectory $StateDirectory -Component tunnel ` + -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript $TunnelCommandScript | Out-Null + } + + if (Test-Cooldown -Component tunnel) { + Write-WatchdogLog 'Configured Cloudflare tunnel is down or unhealthy; starting its managed runner.' + Start-ManagedComponent -Component tunnel + } + else { + Write-WatchdogLog 'Tunnel is unhealthy but still within its restart cooldown.' + } + } + + if ($portaHealthy -and $tunnelHealthy) { + Write-WatchdogLog "OK: managed Porta on :$OriginPort and configured tunnel are healthy." + } +} +catch { + Write-WatchdogLog "ERROR: $($_.Exception.Message)" + throw +} +finally { + if ($null -ne $lock) { + $lock.Close() + } + Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue +} diff --git a/deploy/windows/run-porta.bat b/deploy/windows/run-porta.bat new file mode 100644 index 00000000..4c924273 --- /dev/null +++ b/deploy/windows/run-porta.bat @@ -0,0 +1,17 @@ +@echo off +REM Starts the Porta app (proxy on 127.0.0.1:3170 + web on 127.0.0.1:3070) in the +REM foreground, so this process owns the app's lifetime. The repo root is resolved +REM two levels up from this script (deploy\windows\ -> repo root), so it works from +REM any checkout location without hard-coded paths. +setlocal +pushd "%~dp0..\.." +REM pnpm is expected on PATH via corepack (see install-watchdog.ps1). Never let corepack +REM block on its interactive download prompt when we are running hidden. +set "COREPACK_ENABLE_DOWNLOAD_PROMPT=0" +if not exist "logs" mkdir "logs" +echo [%date% %time%] Starting Porta (proxy + web)...>> "logs\porta.log" +REM "< NUL" hands stdin an immediate EOF, so nothing can ever hang waiting for input. +call pnpm.cmd dev < NUL >> "logs\porta.log" 2>&1 +echo [%date% %time%] Porta process exited with code %errorlevel%.>> "logs\porta.log" +popd +endlocal diff --git a/deploy/windows/run-tunnel.bat b/deploy/windows/run-tunnel.bat new file mode 100644 index 00000000..6773a9aa --- /dev/null +++ b/deploy/windows/run-tunnel.bat @@ -0,0 +1,20 @@ +@echo off +REM Runs the Cloudflare tunnel that serves your public hostname. +REM The tunnel id and ingress rules (hostname -> http://127.0.0.1:3070) live in the +REM cloudflared config file, so we run by --config rather than by tunnel name. Running +REM by name is fragile: the name in the config can differ from any name you type here. +setlocal +if "%CLOUDFLARED_EXE%"=="" set "CLOUDFLARED_EXE=cloudflared" +if "%CLOUDFLARED_CONFIG%"=="" set "CLOUDFLARED_CONFIG=%USERPROFILE%\.cloudflared\config.yml" +if "%PORTA_TUNNEL_METRICS_PORT%"=="" set "PORTA_TUNNEL_METRICS_PORT=20241" +if "%PORTA_WATCHDOG_STATE_DIR%"=="" ( + set "LOG_DIR=%USERPROFILE%\.cloudflared" +) else ( + set "LOG_DIR=%PORTA_WATCHDOG_STATE_DIR%" +) +if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" +set "LOG=%LOG_DIR%\tunnel.log" +echo [%date% %time%] Starting Cloudflare tunnel...>> "%LOG%" +"%CLOUDFLARED_EXE%" --config "%CLOUDFLARED_CONFIG%" tunnel --metrics "127.0.0.1:%PORTA_TUNNEL_METRICS_PORT%" run >> "%LOG%" 2>&1 +echo [%date% %time%] Tunnel process exited with code %errorlevel%.>> "%LOG%" +endlocal diff --git a/deploy/windows/stop-watchdog.ps1 b/deploy/windows/stop-watchdog.ps1 new file mode 100644 index 00000000..dc505774 --- /dev/null +++ b/deploy/windows/stop-watchdog.ps1 @@ -0,0 +1,118 @@ +# Unregisters one watchdog task and stops only its identity-validated process trees. +[CmdletBinding()] +param( + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string] $TaskName = 'PortaWatchdog' +) + +$ErrorActionPreference = 'Stop' +$ScriptDir = $PSScriptRoot +$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir '..\..')) +$RunnerScript = Join-Path $ScriptDir 'managed-runner.ps1' +$WatchdogScript = Join-Path $ScriptDir 'porta-watchdog.ps1' +$LegacyWatchdogScript = Join-Path $ScriptDir 'silent-watchdog.vbs' + +. (Join-Path $ScriptDir 'watchdog-common.ps1') + +$InstanceId = Get-PortaInstanceId -RepoRoot $RepoRoot -TaskName $TaskName +$StateDirectory = Get-PortaStateDirectory -InstanceId $InstanceId +if (-not (Test-Path -LiteralPath $StateDirectory)) { + New-Item -ItemType Directory -Path $StateDirectory -Force | Out-Null +} + +$task = Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue +if ($null -ne $task) { + $ownership = Get-PortaScheduledTaskOwnership -Task $task -TaskName $TaskName ` + -WatchdogScript $WatchdogScript -LegacyWatchdogScript $LegacyWatchdogScript + if ($ownership -eq 'unowned') { + throw ( + "Scheduled task '\$TaskName' is not owned by this checkout. " + + 'No task or process was changed.' + ) + } + + Stop-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction Stop +} +else { + Write-Host "Scheduled task '$TaskName' was not registered." +} + +$lockPath = Join-Path $StateDirectory 'watchdog.lock' +$lock = $null +$deadline = [DateTime]::UtcNow.AddSeconds(15) +while ($null -eq $lock -and [DateTime]::UtcNow -lt $deadline) { + try { + $lock = [System.IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None') + } + catch { + Start-Sleep -Milliseconds 250 + } +} + +if ($null -eq $lock) { + $taskState = if ($null -ne $task) { + "Task '$TaskName' was left registered" + } + else { + 'No task was registered' + } + throw ( + "Could not acquire the watchdog lock within 15 seconds. $taskState and " + + 'managed processes were not changed.' + ) +} + +$stopErrors = New-Object 'System.Collections.Generic.List[string]' +try { + if ($null -ne $task) { + Unregister-ScheduledTask -TaskPath '\' -TaskName $TaskName -Confirm:$false ` + -ErrorAction Stop + Write-Host "Unregistered scheduled task '$TaskName'." + } + + foreach ($component in @('tunnel', 'porta')) { + try { + $commandScript = Join-Path $ScriptDir "run-$component.bat" + $stopped = Stop-PortaManagedRunner -StateDirectory $StateDirectory ` + -Component $component -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript $commandScript + if ($stopped) { + Write-Host "Stopped the tracked $component process tree." + } + else { + Write-Host "No identity-validated $component process was running." + } + } + catch { + $stopErrors.Add( + "Could not stop the tracked $component process tree: $($_.Exception.Message)" + ) + } + } + + $untrackedLaunchers = @(Get-PortaUntrackedBatchLaunchers -ScriptDirectory $ScriptDir ` + -StateDirectory $StateDirectory -InstanceId $InstanceId -RunnerScript $RunnerScript) + foreach ($launcher in $untrackedLaunchers) { + $stopErrors.Add( + "Untracked $($launcher.Component) launcher PID $($launcher.ProcessId) remains. " + + 'It was not killed because its ownership could not be validated.' + ) + } + + foreach ($name in @('porta.last-start', 'tunnel.last-start')) { + $path = Join-Path $StateDirectory $name + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } +} +finally { + $lock.Close() + Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue +} + +if ($stopErrors.Count -gt 0) { + $details = ($stopErrors | ForEach-Object { " - $_" }) -join [Environment]::NewLine + throw ( + 'The scheduled task was removed, but the stop was incomplete:' + + [Environment]::NewLine + $details + ) +} diff --git a/deploy/windows/watchdog-common.ps1 b/deploy/windows/watchdog-common.ps1 new file mode 100644 index 00000000..f4614ad9 --- /dev/null +++ b/deploy/windows/watchdog-common.ps1 @@ -0,0 +1,778 @@ +# Shared helpers for the Windows watchdog, runner, and stop scripts. + +function Get-PortaInstanceId { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $RepoRoot, + + [Parameter(Mandatory = $true)] + [string] $TaskName + ) + + $normalizedRoot = [System.IO.Path]::GetFullPath($RepoRoot).TrimEnd('\').ToLowerInvariant() + $normalizedTaskName = $TaskName.ToLowerInvariant() + $bytes = [System.Text.Encoding]::UTF8.GetBytes("$normalizedRoot`n$normalizedTaskName") + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha.ComputeHash($bytes) + } + finally { + $sha.Dispose() + } + + return ([System.BitConverter]::ToString($hash).Replace('-', '').Substring(0, 16).ToLowerInvariant()) +} + +function Get-PortaPinnedEnvironmentHash { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $RequireAuth, + + [Parameter(Mandatory = $true)] + [string] $AccessToken, + + [Parameter(Mandatory = $true)] + [string] $HostName, + + [Parameter(Mandatory = $true)] + [string] $ProxyPort, + + [Parameter(Mandatory = $true)] + [string] $WebPort, + + [Parameter(Mandatory = $true)] + [string] $AllowedHosts, + + [Parameter(Mandatory = $true)] + [string] $CorsOrigins + ) + + $values = @( + $RequireAuth, + $AccessToken, + $HostName, + $ProxyPort, + $WebPort, + $AllowedHosts, + $CorsOrigins + ) + $payload = ($values | ForEach-Object { + $value = [string] $_ + "$($value.Length):$value" + }) -join '|' + $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha.ComputeHash($bytes) + } + finally { + $sha.Dispose() + } + + return [System.BitConverter]::ToString($hash).Replace('-', '').ToLowerInvariant() +} + +function Get-PortaStateDirectory { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $InstanceId + ) + + $localData = [Environment]::GetFolderPath('LocalApplicationData') + if ([string]::IsNullOrWhiteSpace($localData)) { + throw 'Could not resolve the current user LocalApplicationData directory.' + } + + return Join-Path $localData (Join-Path 'Porta\watchdog' $InstanceId) +} + +function Test-PortaProcessDescendant { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [int] $ProcessId, + + [Parameter(Mandatory = $true)] + [int] $AncestorProcessId + ) + + if ($ProcessId -le 0 -or $AncestorProcessId -le 0) { + return $false + } + + $currentId = $ProcessId + $visited = New-Object 'System.Collections.Generic.HashSet[int]' + for ($depth = 0; $depth -lt 64; $depth++) { + if ($currentId -eq $AncestorProcessId) { + return $true + } + if (-not $visited.Add($currentId)) { + return $false + } + + $process = Get-CimInstance Win32_Process -Filter "ProcessId = $currentId" ` + -ErrorAction SilentlyContinue + if ($null -eq $process) { + return $false + } + + $currentId = [int] $process.ParentProcessId + if ($currentId -le 0) { + return $false + } + } + + return $false +} + +function Get-PortaScheduledTaskOwnership { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [psobject] $Task, + + [Parameter(Mandatory = $true)] + [string] $TaskName, + + [Parameter(Mandatory = $true)] + [string] $WatchdogScript, + + [Parameter(Mandatory = $true)] + [string] $LegacyWatchdogScript + ) + + $actions = @($Task.Actions) + if ($actions.Count -ne 1) { + return 'unowned' + } + + $action = $actions[0] + $execute = [Environment]::ExpandEnvironmentVariables([string] $action.Execute) + $executeName = [System.IO.Path]::GetFileName($execute) + $arguments = ([string] $action.Arguments).Trim() + + if ($executeName -ieq 'powershell.exe') { + $watchdogPath = [Regex]::Escape( + [System.IO.Path]::GetFullPath($WatchdogScript) + ) + $taskNamePattern = [Regex]::Escape($TaskName) + $fileArgument = '(?i)(?:^|\s)-File\s+"' + $watchdogPath + '"(?=\s|$)' + $nameArgument = '(?i)(?:^|\s)-TaskName\s+"' + $taskNamePattern + '"(?=\s|$)' + if ($arguments -match $fileArgument -and $arguments -match $nameArgument) { + return 'current' + } + } + + if ($executeName -ieq 'wscript.exe') { + $expectedArguments = '"' + + [System.IO.Path]::GetFullPath($LegacyWatchdogScript) + '"' + if ($arguments -ieq $expectedArguments) { + return 'legacy' + } + } + + return 'unowned' +} + +function Test-PortaCommandLinePathToken { + [CmdletBinding()] + param( + [string] $CommandLine, + + [Parameter(Mandatory = $true)] + [string] $Path + ) + + if ([string]::IsNullOrWhiteSpace($CommandLine)) { + return $false + } + + $pattern = '(?i)(?:^|[\s"''])' + + [Regex]::Escape([System.IO.Path]::GetFullPath($Path)) + + '(?=$|[\s"''])' + return $CommandLine -match $pattern +} + +function Test-PortaCommandLinePathOption { + [CmdletBinding()] + param( + [string] $CommandLine, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^-{1,2}[A-Za-z0-9-]+$')] + [string] $Option, + + [Parameter(Mandatory = $true)] + [string] $Path + ) + + if ([string]::IsNullOrWhiteSpace($CommandLine)) { + return $false + } + + $pattern = '(?i)(?:^|\s)' + [Regex]::Escape($Option) + + '(?:=|\s+)"?' + + [Regex]::Escape([System.IO.Path]::GetFullPath($Path)) + + '"?(?=\s|$)' + return $CommandLine -match $pattern +} + +function Test-PortaCommandLineValueOption { + [CmdletBinding()] + param( + [string] $CommandLine, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^-{1,2}[A-Za-z0-9-]+$')] + [string] $Option, + + [Parameter(Mandatory = $true)] + [string] $Value + ) + + if ([string]::IsNullOrWhiteSpace($CommandLine)) { + return $false + } + + $pattern = '(?i)(?:^|\s)' + [Regex]::Escape($Option) + + '(?:=|\s+)"?' + [Regex]::Escape($Value) + '"?(?=\s|$)' + return $CommandLine -match $pattern +} + +function Get-PortaHttpStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateRange(1, 65535)] + [int] $Port, + + [Parameter(Mandatory = $true)] + [string] $HostHeader, + + [string] $Path = '/', + [string] $Cookie = '', + + [ValidateRange(1, 60)] + [int] $TimeoutSeconds = 5 + ) + + $client = New-Object System.Net.Sockets.TcpClient + $stream = $null + try { + $client.SendTimeout = $TimeoutSeconds * 1000 + $client.ReceiveTimeout = $TimeoutSeconds * 1000 + $client.Connect('127.0.0.1', $Port) + $stream = $client.GetStream() + $stream.ReadTimeout = $TimeoutSeconds * 1000 + $stream.WriteTimeout = $TimeoutSeconds * 1000 + + $cookieHeader = if ([string]::IsNullOrWhiteSpace($Cookie)) { + '' + } + else { + "Cookie: $Cookie`r`n" + } + $request = ( + "GET $Path HTTP/1.1`r`n" + + "Host: $HostHeader`r`n" + + $cookieHeader + + "Connection: close`r`n`r`n" + ) + $bytes = [System.Text.Encoding]::ASCII.GetBytes($request) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush() + + $reader = New-Object System.IO.StreamReader( + $stream, + [System.Text.Encoding]::ASCII, + $false, + 1024, + $true + ) + try { + $statusLine = $reader.ReadLine() + } + finally { + $reader.Dispose() + } + + if ($statusLine -notmatch '^HTTP/\d+(?:\.\d+)?\s+(\d{3})(?:\s|$)') { + throw "Unexpected HTTP status line: $statusLine" + } + return [int] $Matches[1] + } + finally { + if ($null -ne $stream) { + $stream.Dispose() + } + $client.Dispose() + } +} + +function Get-PortaComponentStatePath { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $StateDirectory, + + [Parameter(Mandatory = $true)] + [ValidateSet('porta', 'tunnel')] + [string] $Component + ) + + return Join-Path $StateDirectory "$Component.json" +} + +function Read-PortaComponentState { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $StateDirectory, + + [Parameter(Mandatory = $true)] + [ValidateSet('porta', 'tunnel')] + [string] $Component + ) + + $path = Get-PortaComponentStatePath -StateDirectory $StateDirectory -Component $Component + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + return $null + } + + try { + return Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json + } + catch { + return $null + } +} + +function Write-PortaComponentState { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $StateDirectory, + + [Parameter(Mandatory = $true)] + [ValidateSet('porta', 'tunnel')] + [string] $Component, + + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + if (-not (Test-Path -LiteralPath $StateDirectory)) { + New-Item -ItemType Directory -Path $StateDirectory -Force | Out-Null + } + + $path = Get-PortaComponentStatePath -StateDirectory $StateDirectory -Component $Component + $temporaryPath = "$path.$PID.tmp" + $State | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporaryPath -Encoding UTF8 + Move-Item -LiteralPath $temporaryPath -Destination $path -Force +} + +function Test-PortaManagedRunner { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [psobject] $State, + + [Parameter(Mandatory = $true)] + [ValidateSet('porta', 'tunnel')] + [string] $Component, + + [Parameter(Mandatory = $true)] + [string] $InstanceId, + + [Parameter(Mandatory = $true)] + [string] $RunnerScript, + + [Parameter(Mandatory = $true)] + [string] $CommandScript, + + [string] $PortaRuntimeConfigHash = '', + [string] $CloudflaredConfig = '', + [string] $CloudflaredConfigHash = '', + [int] $TunnelMetricsPort = 0 + ) + + try { + $runnerPid = [int] $State.runnerPid + if ($runnerPid -le 0) { + return $false + } + + if ([string] $State.component -ne $Component -or + [string] $State.instanceId -ne $InstanceId -or + [System.IO.Path]::GetFullPath([string] $State.runnerScript) -ne + [System.IO.Path]::GetFullPath($RunnerScript) -or + [System.IO.Path]::GetFullPath([string] $State.commandScript) -ne + [System.IO.Path]::GetFullPath($CommandScript)) { + return $false + } + + if ($Component -eq 'porta' -and + -not [string]::IsNullOrWhiteSpace($PortaRuntimeConfigHash) -and + [string] $State.portaRuntimeConfigHash -ne $PortaRuntimeConfigHash) { + return $false + } + + if ($Component -eq 'tunnel' -and -not [string]::IsNullOrWhiteSpace($CloudflaredConfig)) { + if ([System.IO.Path]::GetFullPath([string] $State.cloudflaredConfig) -ne + [System.IO.Path]::GetFullPath($CloudflaredConfig) -or + [string] $State.cloudflaredConfigHash -ne $CloudflaredConfigHash -or + [int] $State.tunnelMetricsPort -ne $TunnelMetricsPort) { + return $false + } + } + + $process = Get-Process -Id $runnerPid -ErrorAction Stop + $expectedStart = [DateTimeOffset]::Parse([string] $State.startedAtUtc).UtcDateTime + $actualStart = $process.StartTime.ToUniversalTime() + if ([Math]::Abs(($actualStart - $expectedStart).TotalSeconds) -gt 2) { + return $false + } + + $cim = Get-CimInstance Win32_Process -Filter "ProcessId = $runnerPid" -ErrorAction Stop + $commandLine = [string] $cim.CommandLine + $matchesExpectedRunner = ( + (Test-PortaCommandLinePathOption -CommandLine $commandLine ` + -Option '-File' -Path $RunnerScript) -and + (Test-PortaCommandLinePathOption -CommandLine $commandLine ` + -Option '-CommandScript' -Path $CommandScript) -and + (Test-PortaCommandLineValueOption -CommandLine $commandLine ` + -Option '-InstanceId' -Value $InstanceId) -and + (Test-PortaCommandLineValueOption -CommandLine $commandLine ` + -Option '-Component' -Value $Component) + ) + if (-not $matchesExpectedRunner) { + return $false + } + + if ($Component -eq 'porta' -and + -not [string]::IsNullOrWhiteSpace($PortaRuntimeConfigHash)) { + return Test-PortaCommandLineValueOption -CommandLine $commandLine ` + -Option '-PortaRuntimeConfigHash' -Value $PortaRuntimeConfigHash + } + + if ($Component -eq 'tunnel' -and -not [string]::IsNullOrWhiteSpace($CloudflaredConfig)) { + return ( + (Test-PortaCommandLinePathOption -CommandLine $commandLine ` + -Option '-CloudflaredConfig' -Path $CloudflaredConfig) -and + (Test-PortaCommandLineValueOption -CommandLine $commandLine ` + -Option '-CloudflaredConfigHash' -Value $CloudflaredConfigHash) -and + (Test-PortaCommandLineValueOption -CommandLine $commandLine ` + -Option '-TunnelMetricsPort' -Value ([string] $TunnelMetricsPort)) + ) + } + + return $true + } + catch { + return $false + } +} + +function Find-PortaManagedRunner { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('porta', 'tunnel')] + [string] $Component, + + [Parameter(Mandatory = $true)] + [string] $InstanceId, + + [Parameter(Mandatory = $true)] + [string] $RunnerScript, + + [Parameter(Mandatory = $true)] + [string] $CommandScript, + + [string] $PortaRuntimeConfigHash = '', + [string] $CloudflaredConfig = '', + [string] $CloudflaredConfigHash = '', + [int] $TunnelMetricsPort = 0 + ) + + $candidates = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe'" -ErrorAction SilentlyContinue + foreach ($candidate in $candidates) { + $commandLine = [string] $candidate.CommandLine + if (-not (Test-PortaCommandLinePathOption -CommandLine $commandLine ` + -Option '-File' -Path $RunnerScript) -or + -not (Test-PortaCommandLinePathOption -CommandLine $commandLine ` + -Option '-CommandScript' -Path $CommandScript) -or + -not (Test-PortaCommandLineValueOption -CommandLine $commandLine ` + -Option '-InstanceId' -Value $InstanceId) -or + -not (Test-PortaCommandLineValueOption -CommandLine $commandLine ` + -Option '-Component' -Value $Component)) { + continue + } + if ($Component -eq 'tunnel' -and -not [string]::IsNullOrWhiteSpace($CloudflaredConfig) -and + (-not (Test-PortaCommandLinePathOption -CommandLine $commandLine ` + -Option '-CloudflaredConfig' -Path $CloudflaredConfig) -or + -not (Test-PortaCommandLineValueOption -CommandLine $commandLine ` + -Option '-CloudflaredConfigHash' -Value $CloudflaredConfigHash) -or + -not (Test-PortaCommandLineValueOption -CommandLine $commandLine ` + -Option '-TunnelMetricsPort' -Value ([string] $TunnelMetricsPort)))) { + continue + } + if ($Component -eq 'porta' -and + -not [string]::IsNullOrWhiteSpace($PortaRuntimeConfigHash) -and + -not (Test-PortaCommandLineValueOption -CommandLine $commandLine ` + -Option '-PortaRuntimeConfigHash' -Value $PortaRuntimeConfigHash)) { + continue + } + + try { + $process = Get-Process -Id ([int] $candidate.ProcessId) -ErrorAction Stop + return [pscustomobject] @{ + component = $Component + instanceId = $InstanceId + runnerPid = [int] $candidate.ProcessId + startedAtUtc = $process.StartTime.ToUniversalTime().ToString('O') + runnerScript = [System.IO.Path]::GetFullPath($RunnerScript) + commandScript = [System.IO.Path]::GetFullPath($CommandScript) + portaRuntimeConfigHash = $PortaRuntimeConfigHash + cloudflaredConfig = $CloudflaredConfig + cloudflaredConfigHash = $CloudflaredConfigHash + tunnelMetricsPort = $TunnelMetricsPort + } + } + catch { + continue + } + } + + return $null +} + +function Get-PortaManagedRunner { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $StateDirectory, + + [Parameter(Mandatory = $true)] + [ValidateSet('porta', 'tunnel')] + [string] $Component, + + [Parameter(Mandatory = $true)] + [string] $InstanceId, + + [Parameter(Mandatory = $true)] + [string] $RunnerScript, + + [Parameter(Mandatory = $true)] + [string] $CommandScript, + + [string] $PortaRuntimeConfigHash = '', + [string] $CloudflaredConfig = '', + [string] $CloudflaredConfigHash = '', + [int] $TunnelMetricsPort = 0 + ) + + $state = Read-PortaComponentState -StateDirectory $StateDirectory -Component $Component + if ($null -ne $state -and + (Test-PortaManagedRunner -State $state -Component $Component -InstanceId $InstanceId ` + -RunnerScript $RunnerScript -CommandScript $CommandScript ` + -PortaRuntimeConfigHash $PortaRuntimeConfigHash ` + -CloudflaredConfig $CloudflaredConfig -CloudflaredConfigHash $CloudflaredConfigHash ` + -TunnelMetricsPort $TunnelMetricsPort)) { + return $state + } + + $discovered = Find-PortaManagedRunner -Component $Component -InstanceId $InstanceId ` + -RunnerScript $RunnerScript -CommandScript $CommandScript ` + -PortaRuntimeConfigHash $PortaRuntimeConfigHash ` + -CloudflaredConfig $CloudflaredConfig -CloudflaredConfigHash $CloudflaredConfigHash ` + -TunnelMetricsPort $TunnelMetricsPort + if ($null -ne $discovered) { + Write-PortaComponentState -StateDirectory $StateDirectory -Component $Component -State $discovered + return $discovered + } + + return $null +} + +function Get-PortaUntrackedBatchLaunchers { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $ScriptDirectory, + + [Parameter(Mandatory = $true)] + [string] $StateDirectory, + + [Parameter(Mandatory = $true)] + [string] $InstanceId, + + [Parameter(Mandatory = $true)] + [string] $RunnerScript + ) + + $cmdProcesses = @(Get-CimInstance Win32_Process -Filter "Name = 'cmd.exe'" ` + -ErrorAction SilentlyContinue) + foreach ($component in @('porta', 'tunnel')) { + $commandScript = Join-Path $ScriptDirectory "run-$component.bat" + $runner = Get-PortaManagedRunner -StateDirectory $StateDirectory ` + -Component $component -InstanceId $InstanceId -RunnerScript $RunnerScript ` + -CommandScript $commandScript + + foreach ($candidate in $cmdProcesses) { + $commandLine = [string] $candidate.CommandLine + if (-not (Test-PortaCommandLinePathToken -CommandLine $commandLine ` + -Path $commandScript)) { + continue + } + + $isManaged = $null -ne $runner -and + (Test-PortaProcessDescendant -ProcessId ([int] $candidate.ProcessId) ` + -AncestorProcessId ([int] $runner.runnerPid)) + if (-not $isManaged) { + [pscustomobject] @{ + Component = $component + ProcessId = [int] $candidate.ProcessId + Name = [string] $candidate.Name + CommandLine = $commandLine + } + } + } + } +} + +function Stop-PortaManagedRunner { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $StateDirectory, + + [Parameter(Mandatory = $true)] + [ValidateSet('porta', 'tunnel')] + [string] $Component, + + [Parameter(Mandatory = $true)] + [string] $InstanceId, + + [Parameter(Mandatory = $true)] + [string] $RunnerScript, + + [Parameter(Mandatory = $true)] + [string] $CommandScript, + + [string] $PortaRuntimeConfigHash = '', + [string] $CloudflaredConfig = '', + [string] $CloudflaredConfigHash = '', + [int] $TunnelMetricsPort = 0 + ) + + $state = Get-PortaManagedRunner -StateDirectory $StateDirectory -Component $Component ` + -InstanceId $InstanceId -RunnerScript $RunnerScript -CommandScript $CommandScript ` + -PortaRuntimeConfigHash $PortaRuntimeConfigHash ` + -CloudflaredConfig $CloudflaredConfig -CloudflaredConfigHash $CloudflaredConfigHash ` + -TunnelMetricsPort $TunnelMetricsPort + if ($null -eq $state) { + return $false + } + + $runnerPid = [int] $state.runnerPid + & taskkill.exe /PID $runnerPid /T /F 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Could not stop the managed $Component process tree (PID $runnerPid)." + } + + $statePath = Get-PortaComponentStatePath -StateDirectory $StateDirectory -Component $Component + Remove-Item -LiteralPath $statePath -Force -ErrorAction SilentlyContinue + return $true +} + +function Assert-PortaCloudflaredIngress { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $CloudflaredConfig, + + [Parameter(Mandatory = $true)] + [string] $PublicOrigin, + + [Parameter(Mandatory = $true)] + [ValidateRange(1, 65535)] + [int] $OriginPort + ) + + if (-not (Test-Path -LiteralPath $CloudflaredConfig -PathType Leaf)) { + throw "Cloudflared config does not exist: $CloudflaredConfig" + } + + $cloudflared = Get-Command cloudflared -CommandType Application -ErrorAction Stop + $previousErrorActionPreference = $ErrorActionPreference + try { + # Windows PowerShell 5.1 turns native stderr into ErrorRecord objects. + # Capture warnings without letting a benign stderr line terminate the script. + $ErrorActionPreference = 'Continue' + $validationOutput = & $cloudflared.Source --config $CloudflaredConfig ` + tunnel ingress validate 2>&1 + $validationExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($validationExitCode -ne 0) { + throw "Cloudflared rejected the ingress configuration: $($validationOutput -join [Environment]::NewLine)" + } + + try { + $ErrorActionPreference = 'Continue' + $ruleOutput = & $cloudflared.Source --config $CloudflaredConfig ` + tunnel ingress rule $PublicOrigin 2>&1 + $ruleExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + $expectedServicePattern = '(?im)^\s*service:\s*http://127\.0\.0\.1:' + + $OriginPort + '\s*$' + if ($ruleExitCode -ne 0 -or + ($ruleOutput -join [Environment]::NewLine) -notmatch $expectedServicePattern) { + throw "The first matching ingress rule for '$PublicOrigin' must route to http://127.0.0.1:$OriginPort." + } +} + +function Read-PortaEnvFile { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + $values = @{} + foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) { + if ($line -notmatch '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$') { + continue + } + + $name = $Matches[1] + $value = $Matches[2].Trim() + if ($value.StartsWith('"')) { + $closingQuote = $value.IndexOf('"', 1) + if ($closingQuote -gt 0) { + $value = $value.Substring(1, $closingQuote - 1) + } + } + elseif ($value.StartsWith("'")) { + $closingQuote = $value.IndexOf("'", 1) + if ($closingQuote -gt 0) { + $value = $value.Substring(1, $closingQuote - 1) + } + } + else { + $value = [Regex]::Replace($value, '\s+#.*$', '').Trim() + } + $values[$name] = $value + } + + return $values +} diff --git a/deploy/windows/watchdog-common.tests.ps1 b/deploy/windows/watchdog-common.tests.ps1 new file mode 100644 index 00000000..65ad0851 --- /dev/null +++ b/deploy/windows/watchdog-common.tests.ps1 @@ -0,0 +1,134 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'watchdog-common.ps1') + +function Assert-Equal { + param( + [Parameter(Mandatory = $true)] + $Actual, + + [Parameter(Mandatory = $true)] + $Expected, + + [Parameter(Mandatory = $true)] + [string] $Message + ) + + if ($Actual -ne $Expected) { + throw "$Message Expected '$Expected', got '$Actual'." + } +} + +function Assert-True { + param( + [Parameter(Mandatory = $true)] + [bool] $Condition, + + [Parameter(Mandatory = $true)] + [string] $Message + ) + + if (-not $Condition) { + throw $Message + } +} + +$instanceLower = Get-PortaInstanceId -RepoRoot 'C:\repo\porta' ` + -TaskName 'portawatchdog' +$instanceMixed = Get-PortaInstanceId -RepoRoot 'C:\Repo\Porta' ` + -TaskName 'PortaWatchdog' +Assert-Equal $instanceMixed $instanceLower ` + 'Instance identity must follow Windows path and task-name case semantics.' + +$environmentHash = Get-PortaPinnedEnvironmentHash ` + -RequireAuth '1' -AccessToken 'token-a' -HostName '127.0.0.1' ` + -ProxyPort '3170' -WebPort '3070' -AllowedHosts 'porta.example.com' ` + -CorsOrigins 'https://porta.example.com' +$changedTokenHash = Get-PortaPinnedEnvironmentHash ` + -RequireAuth '1' -AccessToken 'token-b' -HostName '127.0.0.1' ` + -ProxyPort '3170' -WebPort '3070' -AllowedHosts 'porta.example.com' ` + -CorsOrigins 'https://porta.example.com' +Assert-True ($environmentHash -match '^[a-f0-9]{64}$') ` + 'Pinned environment identity must be a lowercase SHA256.' +Assert-True ($environmentHash -ne $changedTokenHash) ` + 'Changing a pinned security value must change the environment identity.' + +$runnerScript = Join-Path $PSScriptRoot 'managed-runner.ps1' +$commandScript = Join-Path $PSScriptRoot 'run-porta.bat' +$runnerLine = ( + '"powershell.exe" -NoProfile -File "' + $runnerScript + + '" -Component porta -InstanceId abc123 -CommandScript "' + + $commandScript + '" -PortaRuntimeConfigHash ' + $environmentHash +) +Assert-True (Test-PortaCommandLinePathOption -CommandLine $runnerLine ` + -Option '-File' -Path $runnerScript) ` + 'The exact managed runner -File argument must match.' +Assert-True (Test-PortaCommandLinePathOption -CommandLine $runnerLine ` + -Option '-CommandScript' -Path $commandScript) ` + 'The exact managed command argument must match.' +Assert-True (Test-PortaCommandLineValueOption -CommandLine $runnerLine ` + -Option '-InstanceId' -Value 'abc123') ` + 'The exact instance argument must match.' +Assert-True (-not (Test-PortaCommandLineValueOption -CommandLine $runnerLine ` + -Option '-InstanceId' -Value 'abc12')) ` + 'A prefix of the instance id must not match.' + +$diagnosticLine = ( + 'powershell.exe -Command "Find-PortaManagedRunner -RunnerScript ' + + $runnerScript + ' -Component porta -InstanceId abc123"' +) +Assert-True (-not (Test-PortaCommandLinePathOption -CommandLine $diagnosticLine ` + -Option '-File' -Path $runnerScript)) ` + 'A diagnostic command mentioning the runner must not look like the runner.' + +$configPath = 'C:\Users\Example User\.cloudflared\config.yml' +$cloudflaredLine = ( + 'cloudflared.exe --config="' + $configPath + + '" tunnel --metrics 127.0.0.1:20241 run' +) +Assert-True (Test-PortaCommandLinePathOption -CommandLine $cloudflaredLine ` + -Option '--config' -Path $configPath) ` + 'The equals form of the cloudflared config argument must match.' + +$watchdogScript = Join-Path $PSScriptRoot 'porta-watchdog.ps1' +$legacyScript = Join-Path $PSScriptRoot 'silent-watchdog.vbs' +$currentTask = [pscustomobject] @{ + Actions = @([pscustomobject] @{ + Execute = 'powershell.exe' + Arguments = ( + '-NoProfile -File "' + $watchdogScript + + '" -TaskName "PortaWatchdog"' + ) + }) +} +$legacyTask = [pscustomobject] @{ + Actions = @([pscustomobject] @{ + Execute = 'wscript.exe' + Arguments = '"' + $legacyScript + '"' + }) +} +$foreignTask = [pscustomobject] @{ + Actions = @([pscustomobject] @{ + Execute = 'powershell.exe' + Arguments = ( + '-NoProfile -File "C:\other\porta-watchdog.ps1" ' + + '-TaskName "PortaWatchdog"' + ) + }) +} +Assert-Equal ( + Get-PortaScheduledTaskOwnership -Task $currentTask ` + -TaskName PortaWatchdog -WatchdogScript $watchdogScript ` + -LegacyWatchdogScript $legacyScript +) 'current' 'The current checkout task must be recognized.' +Assert-Equal ( + Get-PortaScheduledTaskOwnership -Task $legacyTask ` + -TaskName PortaWatchdog -WatchdogScript $watchdogScript ` + -LegacyWatchdogScript $legacyScript +) 'legacy' 'The exact legacy task must be recognized for safe migration.' +Assert-Equal ( + Get-PortaScheduledTaskOwnership -Task $foreignTask ` + -TaskName PortaWatchdog -WatchdogScript $watchdogScript ` + -LegacyWatchdogScript $legacyScript +) 'unowned' 'A task from another checkout must never be claimed.' + +Write-Host 'watchdog-common tests passed.' diff --git a/package.json b/package.json index f804230f..3088802e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "porta", - "version": "0.13.0", + "version": "0.14.0", "private": true, "scripts": { "dev": "node scripts/dev.mjs", diff --git a/packages/proxy/package.json b/packages/proxy/package.json index ab116488..684b8400 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -11,8 +11,8 @@ "test:watch": "vitest" }, "dependencies": { - "@hono/node-server": "^1.19.10", - "hono": "^4.12.25", + "@hono/node-server": "^2.0.10", + "hono": "^4.12.27", "ws": "^8.20.1" }, "devDependencies": { diff --git a/packages/web/package-lock.json b/packages/web/package-lock.json index e2dcc5d7..f72bf790 100644 --- a/packages/web/package-lock.json +++ b/packages/web/package-lock.json @@ -29,7 +29,7 @@ "globals": "^16.5.0", "happy-dom": "^20.8.9", "jsdom": "^28.1.0", - "serialize-javascript": "^7.0.6", + "serialize-javascript": "^7.0.7", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^8.0.16", @@ -2685,33 +2685,36 @@ "license": "MIT" }, "node_modules/@rollup/plugin-babel": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", - "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.10.4", - "@rollup/pluginutils": "^3.1.0" + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" }, "engines": { - "node": ">= 10.0.0" + "node": ">=14.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0", "@types/babel__core": "^7.1.9", - "rollup": "^1.20.0||^2.0.0" + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "@types/babel__core": { "optional": true + }, + "rollup": { + "optional": true } } }, "node_modules/@rollup/plugin-node-resolve": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", - "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", "dev": true, "license": "MIT", "dependencies": { @@ -2733,16 +2736,15 @@ } } }, - "node_modules/@rollup/plugin-node-resolve/node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" }, "engines": { "node": ">=14.0.0" @@ -2756,47 +2758,19 @@ } } }, - "node_modules/@rollup/plugin-node-resolve/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/plugin-node-resolve/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/plugin-replace": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", - "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "magic-string": "^0.25.7" - }, - "peerDependencies": { - "rollup": "^1.20.0 || ^2.0.0" - } - }, "node_modules/@rollup/plugin-terser": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", "dev": true, "license": "MIT", "dependencies": { - "serialize-javascript": "^6.0.1", + "serialize-javascript": "^7.0.3", "smob": "^1.0.0", "terser": "^5.17.4" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" @@ -2807,46 +2781,417 @@ } } }, - "node_modules/@rollup/plugin-terser/node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" }, "engines": { - "node": ">= 8.0.0" + "node": ">=14.0.0" }, "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } } }, - "node_modules/@rollup/pluginutils/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==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -2855,19 +3200,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@surma/rollup-plugin-off-main-thread": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", - "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "ejs": "^3.1.6", - "json5": "^2.2.0", - "magic-string": "^0.25.0", - "string.prototype.matchall": "^4.0.6" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2957,6 +3289,22 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { + "version": "3.0.0-pre1", + "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", + "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2994,9 +3342,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "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" }, @@ -3092,13 +3440,6 @@ } } }, - "node_modules/@vitest/mocker/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/@vitest/mocker/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3109,16 +3450,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/@vitest/pretty-format": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", @@ -3162,16 +3493,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/@vitest/spy": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", @@ -3438,9 +3759,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3492,15 +3813,15 @@ "license": "MIT" }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -3948,9 +4269,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -4016,6 +4337,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4044,9 +4384,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -4073,15 +4413,18 @@ } }, "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", "dev": true, "license": "MIT", "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -4113,9 +4456,9 @@ "link": true }, "node_modules/estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, "license": "MIT" }, @@ -4129,6 +4472,19 @@ "node": ">=0.10.0" } }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4154,9 +4510,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -4206,9 +4562,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -4303,18 +4659,21 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -4573,9 +4932,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -4793,6 +5152,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-finalizationregistry": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", @@ -5554,13 +5929,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -5596,13 +5964,13 @@ } }, "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "sourcemap-codec": "^1.4.8" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/marked": { @@ -5949,16 +6317,6 @@ "node": ">=6" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react": { "resolved": "../node_modules/.pnpm/react@19.2.4/node_modules/react", "link": true @@ -6160,31 +6518,60 @@ } }, "node_modules/rollup": { - "version": "2.80.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", - "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=10.0.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" } }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -6195,27 +6582,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6275,9 +6641,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.6.tgz", - "integrity": "sha512-ATTK5Q4gFVg0YDp1my2vqygyvhcklD/UV5GIlYHooGTn/NogJqIzpetkD6E5kmuVULqz/S9inUL25XcAgDRJQg==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz", + "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6357,15 +6723,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -6377,14 +6743,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -6453,9 +6819,9 @@ } }, "node_modules/smob": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", - "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", "dev": true, "license": "MIT", "engines": { @@ -6507,14 +6873,6 @@ "node": ">=0.10.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "dev": true, - "license": "MIT" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -6572,19 +6930,20 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -6594,16 +6953,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -6902,18 +7261,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -7259,16 +7618,6 @@ } } }, - "node_modules/vitest/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -7395,14 +7744,14 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -7434,30 +7783,30 @@ } }, "node_modules/workbox-background-sync": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", - "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", + "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==", "dev": true, "license": "MIT", "dependencies": { "idb": "^7.0.1", - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-broadcast-update": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz", - "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", + "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-build": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz", - "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz", + "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==", "dev": true, "license": "MIT", "dependencies": { @@ -7465,39 +7814,39 @@ "@babel/core": "^7.24.4", "@babel/preset-env": "^7.11.0", "@babel/runtime": "^7.11.2", - "@rollup/plugin-babel": "^5.2.0", - "@rollup/plugin-node-resolve": "^15.2.3", - "@rollup/plugin-replace": "^2.4.1", - "@rollup/plugin-terser": "^0.4.3", - "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", "ajv": "^8.6.0", "common-tags": "^1.8.0", + "eta": "^4.5.1", "fast-json-stable-stringify": "^2.1.0", "fs-extra": "^9.0.1", "glob": "^11.0.1", - "lodash": "^4.17.20", "pretty-bytes": "^5.3.0", - "rollup": "^2.79.2", + "rollup": "^4.53.3", "source-map": "^0.8.0-beta.0", "stringify-object": "^3.3.0", "strip-comments": "^2.0.1", "tempy": "^0.6.0", "upath": "^1.2.0", - "workbox-background-sync": "7.4.0", - "workbox-broadcast-update": "7.4.0", - "workbox-cacheable-response": "7.4.0", - "workbox-core": "7.4.0", - "workbox-expiration": "7.4.0", - "workbox-google-analytics": "7.4.0", - "workbox-navigation-preload": "7.4.0", - "workbox-precaching": "7.4.0", - "workbox-range-requests": "7.4.0", - "workbox-recipes": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0", - "workbox-streams": "7.4.0", - "workbox-sw": "7.4.0", - "workbox-window": "7.4.0" + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" }, "engines": { "node": ">=20.0.0" @@ -7517,140 +7866,140 @@ } }, "node_modules/workbox-cacheable-response": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", - "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", + "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-core": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", - "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", + "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", "dev": true, "license": "MIT" }, "node_modules/workbox-expiration": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", - "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz", + "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==", "dev": true, "license": "MIT", "dependencies": { "idb": "^7.0.1", - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-google-analytics": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz", - "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", + "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-background-sync": "7.4.0", - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" } }, "node_modules/workbox-navigation-preload": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz", - "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", + "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-precaching": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", - "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" } }, "node_modules/workbox-range-requests": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz", - "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", + "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-recipes": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz", - "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz", + "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==", "dev": true, "license": "MIT", "dependencies": { - "workbox-cacheable-response": "7.4.0", - "workbox-core": "7.4.0", - "workbox-expiration": "7.4.0", - "workbox-precaching": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" } }, "node_modules/workbox-routing": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", - "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-strategies": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", - "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", + "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-streams": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz", - "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz", + "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0" + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" } }, "node_modules/workbox-sw": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz", - "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", + "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==", "dev": true, "license": "MIT" }, "node_modules/workbox-window": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", - "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz", + "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==", "dev": true, "license": "MIT", "dependencies": { "@types/trusted-types": "^2.0.2", - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/ws": { diff --git a/packages/web/package.json b/packages/web/package.json index 77d42d99..fa149e6f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -34,7 +34,7 @@ "globals": "^16.5.0", "happy-dom": "^20.8.9", "jsdom": "^28.1.0", - "serialize-javascript": "^7.0.6", + "serialize-javascript": "^7.0.7", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^8.0.16", diff --git a/packages/web/tsconfig.node.json b/packages/web/tsconfig.node.json index 6c6df301..185a554c 100644 --- a/packages/web/tsconfig.node.json +++ b/packages/web/tsconfig.node.json @@ -24,6 +24,7 @@ }, "include": [ "vite.config.ts", + "vite-access-gate.test.ts", "vitest.config.ts", "vitest.e2e.config.ts", "e2e/**/*.ts" diff --git a/packages/web/vite-access-gate.test.ts b/packages/web/vite-access-gate.test.ts new file mode 100644 index 00000000..8fd2fd50 --- /dev/null +++ b/packages/web/vite-access-gate.test.ts @@ -0,0 +1,259 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Duplex } from "node:stream"; +import type { Connect, ViteDevServer } from "vite"; +import { describe, expect, it, vi } from "vitest"; +import { accessGate } from "./vite-access-gate"; + +type UpgradeListener = (request: IncomingMessage, socket: Duplex) => void; + +function configureGate(options: { enabled: boolean; token: string }) { + let middleware: Connect.NextHandleFunction | undefined; + const httpServer = new EventEmitter(); + const server = { + middlewares: { + use: vi.fn((handler: Connect.NextHandleFunction) => { + middleware = handler; + }), + }, + httpServer, + } as unknown as ViteDevServer; + const configureServer = accessGate(options).configureServer; + + if (typeof configureServer !== "function") { + throw new Error("access gate does not expose a configureServer hook"); + } + configureServer.call( + {} as ThisParameterType, + server, + ); + + return { + httpServer, + getMiddleware() { + if (!middleware) throw new Error("access gate middleware was not installed"); + return middleware; + }, + }; +} + +function request(url = "/", cookie?: string): IncomingMessage { + return { + headers: cookie === undefined ? {} : { cookie }, + url, + } as IncomingMessage; +} + +function response() { + const headers = new Map(); + const end = vi.fn(); + const res = { + statusCode: 200, + setHeader(name: string, value: number | string | readonly string[]) { + headers.set(name.toLowerCase(), value); + return this; + }, + end, + } as unknown as ServerResponse; + + return { res, end, headers }; +} + +function socket() { + const write = vi.fn(); + const destroy = vi.fn(); + return { + value: { write, destroy } as unknown as Duplex, + write, + destroy, + }; +} + +describe("accessGate", () => { + it("does not install access control when disabled", () => { + const { httpServer, getMiddleware } = configureGate({ + enabled: false, + token: "secret", + }); + + expect(httpServer.listenerCount("upgrade")).toBe(0); + expect(getMiddleware).toThrow("access gate middleware was not installed"); + }); + + it("fails closed when enabled without a configured token", () => { + const { httpServer, getMiddleware } = configureGate({ + enabled: true, + token: "", + }); + const { res, end } = response(); + const next = vi.fn(); + + getMiddleware()(request(), res, next); + + expect(res.statusCode).toBe(503); + expect(end).toHaveBeenCalledWith( + "Access control is enabled (PORTA_REQUIRE_AUTH) but PORTA_ACCESS_TOKEN is not set.", + ); + expect(next).not.toHaveBeenCalled(); + + const ws = socket(); + httpServer.emit("upgrade", request(), ws.value); + expect(ws.write).toHaveBeenCalledWith( + "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n", + ); + expect(ws.destroy).toHaveBeenCalledOnce(); + }); + + it("rejects an HTTP request without a token", () => { + const { getMiddleware } = configureGate({ + enabled: true, + token: "secret", + }); + const { res, end, headers } = response(); + const next = vi.fn(); + + getMiddleware()(request("/api/health"), res, next); + + expect(res.statusCode).toBe(401); + expect(headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(headers.get("cache-control")).toBe("no-store"); + expect(end).toHaveBeenCalledWith(expect.stringContaining("Unauthorized")); + expect(next).not.toHaveBeenCalled(); + }); + + it("sets a cookie for a valid query token and accepts it afterward", () => { + const { getMiddleware } = configureGate({ + enabled: true, + token: "a token/with=symbols", + }); + const middleware = getMiddleware(); + const bootstrap = response(); + + middleware( + request("/chat?access_token=a+token%2Fwith%3Dsymbols&other=value"), + bootstrap.res, + vi.fn(), + ); + + expect(bootstrap.res.statusCode).toBe(302); + expect(bootstrap.headers.get("location")).toBe("/chat"); + expect(bootstrap.headers.get("set-cookie")).toBe( + "porta_access=a%20token%2Fwith%3Dsymbols; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000", + ); + + const authenticated = response(); + const next = vi.fn(); + middleware( + request("/api/health", "other=x; porta_access=a%20token%2Fwith%3Dsymbols"), + authenticated.res, + next, + ); + + expect(next).toHaveBeenCalledOnce(); + expect(authenticated.end).not.toHaveBeenCalled(); + }); + + it("treats a malformed cookie as unauthenticated without crashing HTTP", () => { + const { getMiddleware } = configureGate({ + enabled: true, + token: "secret", + }); + const { res, end } = response(); + const next = vi.fn(); + + expect(() => + getMiddleware()( + request("/", "porta_access=%E0%A4%A"), + res, + next, + ), + ).not.toThrow(); + + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith(expect.stringContaining("Unauthorized")); + expect(next).not.toHaveBeenCalled(); + }); + + it("still accepts a valid query token when the cookie is malformed", () => { + const { getMiddleware } = configureGate({ + enabled: true, + token: "secret", + }); + const { res, headers } = response(); + + expect(() => + getMiddleware()( + request("/?access_token=secret", "porta_access=%E0%A4%A"), + res, + vi.fn(), + ), + ).not.toThrow(); + + expect(res.statusCode).toBe(302); + expect(headers.get("location")).toBe("/"); + }); + + it("authorizes valid WebSocket upgrades and blocks missing credentials before downstream listeners", () => { + const { httpServer } = configureGate({ + enabled: true, + token: "secret", + }); + const downstream = vi.fn(); + httpServer.on("upgrade", downstream); + + const querySocket = socket(); + httpServer.emit( + "upgrade", + request("/api/stream?access_token=secret"), + querySocket.value, + ); + expect(querySocket.write).not.toHaveBeenCalled(); + expect(querySocket.destroy).not.toHaveBeenCalled(); + expect(downstream).toHaveBeenCalledOnce(); + + downstream.mockClear(); + const cookieSocket = socket(); + httpServer.emit( + "upgrade", + request("/api/stream", "porta_access=secret"), + cookieSocket.value, + ); + expect(cookieSocket.write).not.toHaveBeenCalled(); + expect(cookieSocket.destroy).not.toHaveBeenCalled(); + expect(downstream).toHaveBeenCalledOnce(); + + downstream.mockClear(); + const unauthorizedSocket = socket(); + httpServer.emit( + "upgrade", + request("/api/stream"), + unauthorizedSocket.value, + ); + expect(unauthorizedSocket.write).toHaveBeenCalledWith( + "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n", + ); + expect(unauthorizedSocket.destroy).toHaveBeenCalledOnce(); + expect(downstream).not.toHaveBeenCalled(); + }); + + it("rejects a malformed cookie during a raw WebSocket upgrade without throwing", () => { + const { httpServer } = configureGate({ + enabled: true, + token: "secret", + }); + const ws = socket(); + + expect(() => + httpServer.emit( + "upgrade", + request("/api/stream", "porta_access=%E0%A4%A"), + ws.value, + ), + ).not.toThrow(); + + expect(ws.write).toHaveBeenCalledWith( + "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n", + ); + expect(ws.destroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/web/vite-access-gate.ts b/packages/web/vite-access-gate.ts new file mode 100644 index 00000000..b40228c1 --- /dev/null +++ b/packages/web/vite-access-gate.ts @@ -0,0 +1,147 @@ +import type { Plugin } from "vite"; +import { timingSafeEqual } from "node:crypto"; +import type { IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; + +/** + * Cookie-based access gate for the Porta dev server. + * + * When enabled, EVERY request (page, /api, and WebSocket upgrades) must present a + * valid access token — otherwise it is rejected. This is the first line of defence + * for a Porta instance exposed to the public internet via a tunnel. It is designed + * to compose with Cloudflare Access (edge auth); it is not a replacement for it. + * + * Flow for the legitimate user (once): + * 1. Visit https:///?access_token= + * 2. The gate validates it, sets an HttpOnly cookie, and redirects to a clean URL. + * 3. The cookie authenticates all later requests, including WebSockets (browsers + * send cookies on same-origin WS handshakes, but NOT Basic-Auth headers — which + * is why this uses a cookie rather than HTTP Basic Auth). + * + * Fail-closed: if `enabled` is true but no token is configured, everything is denied. + */ + +const COOKIE_NAME = "porta_access"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year + +function safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) return false; + return timingSafeEqual(ab, bb); +} + +function readCookie(header: string | undefined, name: string): string | null { + if (!header) return null; + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + if (part.slice(0, eq).trim() === name) { + try { + return decodeURIComponent(part.slice(eq + 1).trim()); + } catch { + // A cookie is untrusted input. Invalid percent-encoding must fail closed + // instead of escaping the HTTP middleware or WebSocket upgrade handler. + return null; + } + } + } + return null; +} + +function tokenFromQuery(url: string | undefined): string | null { + if (!url) return null; + const q = url.indexOf("?"); + if (q < 0) return null; + const params = new URLSearchParams(url.slice(q + 1)); + return params.get("access_token"); +} + +function isAuthorized(req: IncomingMessage, token: string): boolean { + if (!token) return false; + const cookie = readCookie(req.headers.cookie, COOKIE_NAME); + if (cookie && safeEqual(cookie, token)) return true; + const q = tokenFromQuery(req.url); + return !!q && safeEqual(q, token); +} + +export function accessGate(opts: { enabled: boolean; token: string }): Plugin { + return { + name: "porta-access-gate", + configureServer(server) { + if (!opts.enabled) return; // local dev with no gate configured + + // --- HTTP (page + /api) --- + server.middlewares.use((req, res, next) => { + if (!opts.token) { + res.statusCode = 503; + res.end( + "Access control is enabled (PORTA_REQUIRE_AUTH) but PORTA_ACCESS_TOKEN is not set.", + ); + return; + } + + const cookieVal = readCookie(req.headers.cookie, COOKIE_NAME); + if (cookieVal && safeEqual(cookieVal, opts.token)) return next(); + + // Bootstrap: a valid ?access_token= sets the cookie and redirects to a clean URL. + const q = tokenFromQuery(req.url); + if (q && safeEqual(q, opts.token)) { + const path = (req.url ?? "/").split("?")[0] || "/"; + res.statusCode = 302; + res.setHeader( + "Set-Cookie", + `${COOKIE_NAME}=${encodeURIComponent(opts.token)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}`, + ); + res.setHeader("Location", path); + res.end(); + return; + } + + res.statusCode = 401; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end( + "Unauthorized" + + "" + + "

401 — Access restricted

" + + "

This endpoint is private. Append ?access_token=YOUR_TOKEN to the URL once to sign in.

", + ); + }); + + // --- WebSocket upgrades (/api streaming, Vite HMR) --- + // EventEmitter listeners cannot stop propagation. Guard emit itself so an + // unauthorized upgrade is rejected before Vite or its proxy sees the request. + const httpServer = server.httpServer; + if (httpServer) { + const originalEmit = httpServer.emit; + const guardedEmit = function ( + this: typeof httpServer, + eventName: string | symbol, + ...args: unknown[] + ) { + if (eventName === "upgrade") { + const req = args[0] as IncomingMessage; + const socket = args[1] as Duplex; + if (!isAuthorized(req, opts.token)) { + socket.write( + "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n", + ); + socket.destroy(); + return true; + } + } + + return Reflect.apply(originalEmit, this, [eventName, ...args]); + } as typeof httpServer.emit; + + httpServer.emit = guardedEmit; + httpServer.once("close", () => { + if (httpServer.emit === guardedEmit) { + httpServer.emit = originalEmit; + } + }); + } + }, + }; +} diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index e7de5ddc..8aaaf7a8 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react-swc"; import { VitePWA } from "vite-plugin-pwa"; import { normalizeBasePath } from "./src/basePath.shared"; +import { accessGate } from "./vite-access-gate"; const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); @@ -21,9 +22,30 @@ export default defineConfig(({ mode }) => { const rawBasePath = env.PORTA_BASE_PATH || process.env.PORTA_BASE_PATH || "/"; const basePath = normalizeBasePath(rawBasePath); const allowedHostsRaw = env.PORTA_ALLOWED_HOSTS || process.env.PORTA_ALLOWED_HOSTS; - const allowedHosts = allowedHostsRaw === "true" || allowedHostsRaw === "all" || allowedHostsRaw === "*" + const allowedHostsValue = allowedHostsRaw?.trim(); + const allowedHosts = allowedHostsValue === "true" || allowedHostsValue === "all" || allowedHostsValue === "*" ? true - : (allowedHostsRaw ? allowedHostsRaw.split(",") : undefined); + : (allowedHostsValue + ? allowedHostsValue.split(",").map((host) => host.trim()).filter(Boolean) + : undefined); + + // Access gate: when PORTA_REQUIRE_AUTH is set, every request (page, /api, WebSocket) + // must carry a valid PORTA_ACCESS_TOKEN. Essential when the dev server is exposed + // publicly via a tunnel. Fail-closed: enabled but no token => everything denied. + const requireAuthRaw = ( + env.PORTA_REQUIRE_AUTH || process.env.PORTA_REQUIRE_AUTH || "" + ).trim(); + const requireAuth = /^(1|true|yes|on)$/i.test(requireAuthRaw); + if ( + requireAuthRaw && + !requireAuth && + !/^(0|false|no|off)$/i.test(requireAuthRaw) + ) { + throw new Error( + "PORTA_REQUIRE_AUTH must be one of 1/true/yes/on or 0/false/no/off.", + ); + } + const accessToken = env.PORTA_ACCESS_TOKEN || process.env.PORTA_ACCESS_TOKEN || ""; return { base: basePath, @@ -31,6 +53,8 @@ export default defineConfig(({ mode }) => { "import.meta.env.PORTA_BASE_PATH": JSON.stringify(basePath), }, plugins: [ + // Must be first so it gates requests before any other middleware. + accessGate({ enabled: requireAuth, token: accessToken }), react(), VitePWA({ registerType: "autoUpdate", @@ -55,6 +79,9 @@ export default defineConfig(({ mode }) => { server: { host: env.PORTA_HOST || process.env.PORTA_HOST || "127.0.0.1", port: Number(env.PORTA_WEB_PORT || process.env.PORTA_WEB_PORT || 3070), + // A tunnel and its watchdog target one fixed origin port. Do not silently + // move Vite to the next port if that origin is already occupied. + strictPort: true, ...(allowedHosts !== undefined ? { allowedHosts } : {}), proxy: { "/api": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c89372f..6c0d766d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,11 +11,11 @@ importers: packages/proxy: dependencies: '@hono/node-server': - specifier: ^1.19.10 - version: 1.19.10(hono@4.12.25) + specifier: ^2.0.10 + version: 2.0.10(hono@4.12.27) hono: - specifier: ^4.12.25 - version: 4.12.25 + specifier: ^4.12.27 + version: 4.12.27 ws: specifier: ^8.20.1 version: 8.21.0 @@ -34,7 +34,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@22.19.13)(happy-dom@20.8.9)(jsdom@28.1.0)(vite@8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)) + version: 4.1.0(@types/node@22.19.13)(happy-dom@20.8.9)(jsdom@28.1.0)(vite@8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0)) packages/web: dependencies: @@ -77,7 +77,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react-swc': specifier: ^4.2.2 - version: 4.2.3(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)) + version: 4.2.3(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0)) eslint: specifier: ^9.39.1 version: 9.39.3 @@ -97,8 +97,8 @@ importers: specifier: ^28.1.0 version: 28.1.0 serialize-javascript: - specifier: ^7.0.6 - version: 7.0.6 + specifier: ^7.0.7 + version: 7.0.7 typescript: specifier: ~5.9.3 version: 5.9.3 @@ -107,13 +107,13 @@ importers: version: 8.56.1(eslint@9.39.3)(typescript@5.9.3) vite: specifier: ^8.0.16 - version: 8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0) + version: 8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0) vite-plugin-pwa: specifier: ^1.2.0 - version: 1.2.0(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0))(workbox-build@7.4.0)(workbox-window@7.4.0) + version: 1.2.0(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0))(workbox-build@7.4.0)(workbox-window@7.4.0) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@24.11.0)(happy-dom@20.8.9)(jsdom@28.1.0)(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)) + version: 4.1.0(@types/node@24.11.0)(happy-dom@20.8.9)(jsdom@28.1.0)(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0)) packages: @@ -1114,9 +1114,9 @@ packages: '@noble/hashes': optional: true - '@hono/node-server@1.19.10': - resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.10': + resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==} + engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -1677,8 +1677,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - baseline-browser-mapping@2.10.38: - resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} engines: {node: '>=6.0.0'} hasBin: true @@ -1688,20 +1688,24 @@ packages: brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.1.1: - resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1727,8 +1731,8 @@ packages: caniuse-lite@1.0.30001775: resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==} - caniuse-lite@1.0.30001799: - resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -1856,8 +1860,8 @@ packages: electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} - electron-to-chromium@1.5.376: - resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + electron-to-chromium@1.5.395: + resolution: {integrity: sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==} entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} @@ -1894,8 +1898,8 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-to-primitive@1.3.1: - resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} esbuild@0.27.3: @@ -1995,8 +1999,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -2144,8 +2148,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} html-encoding-sniffer@6.0.0: @@ -2460,9 +2464,6 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.sortby@4.7.0: - resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -2470,8 +2471,8 @@ packages: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} - lru-cache@11.5.1: - resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -2536,8 +2537,8 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - node-releases@2.0.48: - resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} object-inspect@1.13.4: @@ -2559,8 +2560,8 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} p-limit@3.1.0: @@ -2610,6 +2611,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2760,8 +2765,8 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - serialize-javascript@7.0.6: - resolution: {integrity: sha512-ATTK5Q4gFVg0YDp1my2vqygyvhcklD/UV5GIlYHooGTn/NogJqIzpetkD6E5kmuVULqz/S9inUL25XcAgDRJQg==} + serialize-javascript@7.0.7: + resolution: {integrity: sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==} engines: {node: '>=20.0.0'} set-cookie-parser@2.7.2: @@ -2825,10 +2830,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - source-map@0.8.0-beta.0: - resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} - engines: {node: '>= 8'} - deprecated: The work that was done in this beta branch won't be included in future versions + source-map@0.8.0: + resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} + engines: {node: '>= 12'} sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} @@ -2895,8 +2899,8 @@ packages: resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} engines: {node: '>=10'} - terser@5.48.0: - resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + terser@5.49.0: + resolution: {integrity: sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==} engines: {node: '>=10'} hasBin: true @@ -2930,9 +2934,6 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} - tr46@1.0.1: - resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} - tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -3132,9 +3133,6 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - webidl-conversions@4.0.2: - resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} - webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -3151,9 +3149,6 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - whatwg-url@7.1.0: - resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3386,7 +3381,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 @@ -4309,9 +4304,9 @@ snapshots: '@exodus/bytes@1.15.0': {} - '@hono/node-server@1.19.10(hono@4.12.25)': + '@hono/node-server@2.0.10(hono@4.12.27)': dependencies: - hono: 4.12.25 + hono: 4.12.27 '@humanfs/core@0.19.1': {} @@ -4441,7 +4436,7 @@ snapshots: dependencies: serialize-javascript: 6.0.2 smob: 1.6.2 - terser: 5.48.0 + terser: 5.49.0 optionalDependencies: rollup: 2.80.0 @@ -4456,7 +4451,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: rollup: 2.80.0 @@ -4694,11 +4689,11 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react-swc@4.2.3(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0))': + '@vitejs/plugin-react-swc@4.2.3(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 '@swc/core': 1.15.18 - vite: 8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0) + vite: 8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0) transitivePeerDependencies: - '@swc/helpers' @@ -4711,21 +4706,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(vite@8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0))': + '@vitest/mocker@4.1.0(vite@8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0) + vite: 8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0) - '@vitest/mocker@4.1.0(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0))': + '@vitest/mocker@4.1.0(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0) + vite: 8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0) '@vitest/pretty-format@4.1.0': dependencies: @@ -4771,7 +4766,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4848,7 +4843,7 @@ snapshots: baseline-browser-mapping@2.10.0: {} - baseline-browser-mapping@2.10.38: {} + baseline-browser-mapping@2.11.1: {} bidi-js@1.0.3: dependencies: @@ -4859,7 +4854,7 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.1: + brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 @@ -4867,6 +4862,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 @@ -4875,13 +4874,13 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) - browserslist@4.28.2: + browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.10.38 - caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.376 - node-releases: 2.0.48 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.395 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) buffer-from@1.1.2: {} @@ -4906,7 +4905,7 @@ snapshots: caniuse-lite@1.0.30001775: {} - caniuse-lite@1.0.30001799: {} + caniuse-lite@1.0.30001806: {} chai@6.2.2: {} @@ -4933,7 +4932,7 @@ snapshots: core-js-compat@3.49.0: dependencies: - browserslist: 4.28.2 + browserslist: 4.28.7 cross-spawn@7.0.6: dependencies: @@ -5026,7 +5025,7 @@ snapshots: electron-to-chromium@1.5.302: {} - electron-to-chromium@1.5.376: {} + electron-to-chromium@1.5.395: {} entities@6.0.1: {} @@ -5053,7 +5052,7 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.1 + es-to-primitive: 1.3.4 function.prototype.name: 1.2.0 get-intrinsic: 1.3.0 get-proto: 1.0.1 @@ -5079,7 +5078,7 @@ snapshots: object-inspect: 1.13.4 object-keys: 1.1.1 object.assign: 4.1.7 - own-keys: 1.0.1 + own-keys: 1.0.2 regexp.prototype.flags: 1.5.4 safe-array-concat: 1.1.4 safe-push-apply: 1.0.0 @@ -5113,9 +5112,10 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - es-to-primitive@1.3.1: + es-to-primitive@1.3.4: dependencies: es-abstract-get: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 is-callable: 1.2.7 is-date-object: 1.1.0 @@ -5283,7 +5283,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.2: {} + fast-uri@3.1.4: {} fdir@6.5.0(picomatch@4.0.4): optionalDependencies: @@ -5444,7 +5444,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.25: {} + hono@4.12.27: {} html-encoding-sniffer@6.0.0: dependencies: @@ -5742,13 +5742,11 @@ snapshots: lodash.merge@4.6.2: {} - lodash.sortby@4.7.0: {} - lodash@4.18.1: {} lru-cache@11.2.6: {} - lru-cache@11.5.1: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: dependencies: @@ -5778,7 +5776,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: @@ -5786,7 +5784,7 @@ snapshots: minimatch@5.1.9: dependencies: - brace-expansion: 2.1.1 + brace-expansion: 2.1.2 minipass@7.1.3: {} @@ -5798,7 +5796,7 @@ snapshots: node-releases@2.0.27: {} - node-releases@2.0.48: {} + node-releases@2.0.51: {} object-inspect@1.13.4: {} @@ -5824,8 +5822,9 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - own-keys@1.0.1: + own-keys@1.0.2: dependencies: + call-bound: 1.0.4 get-intrinsic: 1.3.0 object-keys: 1.1.1 safe-push-apply: 1.0.0 @@ -5856,7 +5855,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.5.1 + lru-cache: 11.5.2 minipass: 7.1.3 pathe@2.0.3: {} @@ -5867,6 +5866,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.5: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.15: @@ -6035,7 +6036,7 @@ snapshots: dependencies: randombytes: 2.1.0 - serialize-javascript@7.0.6: {} + serialize-javascript@7.0.7: {} set-cookie-parser@2.7.2: {} @@ -6110,9 +6111,7 @@ snapshots: source-map@0.6.1: {} - source-map@0.8.0-beta.0: - dependencies: - whatwg-url: 7.1.0 + source-map@0.8.0: {} sourcemap-codec@1.4.8: {} @@ -6196,7 +6195,7 @@ snapshots: type-fest: 0.16.0 unique-string: 2.0.0 - terser@5.48.0: + terser@5.49.0: dependencies: '@jridgewell/source-map': 0.3.11 acorn: 8.17.0 @@ -6229,10 +6228,6 @@ snapshots: dependencies: tldts: 7.0.24 - tr46@1.0.1: - dependencies: - punycode: 2.3.1 - tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -6341,9 +6336,9 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.7 escalade: 3.2.0 picocolors: 1.1.1 @@ -6351,18 +6346,18 @@ snapshots: dependencies: punycode: 2.3.1 - vite-plugin-pwa@1.2.0(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0))(workbox-build@7.4.0)(workbox-window@7.4.0): + vite-plugin-pwa@1.2.0(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0))(workbox-build@7.4.0)(workbox-window@7.4.0): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.15 - vite: 8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0) + vite: 8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0) workbox-build: 7.4.0 workbox-window: 7.4.0 transitivePeerDependencies: - supports-color - vite@8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0): + vite@8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -6373,10 +6368,10 @@ snapshots: '@types/node': 22.19.13 esbuild: 0.27.7 fsevents: 2.3.3 - terser: 5.48.0 + terser: 5.49.0 tsx: 4.21.0 - vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0): + vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -6387,13 +6382,13 @@ snapshots: '@types/node': 24.11.0 esbuild: 0.27.7 fsevents: 2.3.3 - terser: 5.48.0 + terser: 5.49.0 tsx: 4.21.0 - vitest@4.1.0(@types/node@22.19.13)(happy-dom@20.8.9)(jsdom@28.1.0)(vite@8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)): + vitest@4.1.0(@types/node@22.19.13)(happy-dom@20.8.9)(jsdom@28.1.0)(vite@8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)) + '@vitest/mocker': 4.1.0(vite@8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -6410,7 +6405,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0) + vite: 8.0.16(@types/node@22.19.13)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.13 @@ -6419,10 +6414,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.0(@types/node@24.11.0)(happy-dom@20.8.9)(jsdom@28.1.0)(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)): + vitest@4.1.0(@types/node@24.11.0)(happy-dom@20.8.9)(jsdom@28.1.0)(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0)) + '@vitest/mocker': 4.1.0(vite@8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -6439,7 +6434,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.48.0)(tsx@4.21.0) + vite: 8.0.16(@types/node@24.11.0)(esbuild@0.27.7)(terser@5.49.0)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.11.0 @@ -6452,8 +6447,6 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - webidl-conversions@4.0.2: {} - webidl-conversions@8.0.1: {} whatwg-mimetype@3.0.0: {} @@ -6468,12 +6461,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - whatwg-url@7.1.0: - dependencies: - lodash.sortby: 4.7.0 - tr46: 1.0.1 - webidl-conversions: 4.0.2 - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -6554,7 +6541,7 @@ snapshots: lodash: 4.18.1 pretty-bytes: 5.6.0 rollup: 2.80.0 - source-map: 0.8.0-beta.0 + source-map: 0.8.0 stringify-object: 3.3.0 strip-comments: 2.0.1 tempy: 0.6.0