From 675de771c70803f1ad1047687a3a21e73e4bc9da Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 19 Jul 2026 19:47:51 +0200 Subject: [PATCH] feat(control-panel): web admin UI for the zero-touch tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time, signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy that mints a fresh 60s manage capability token per call to the control-plane admin API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang. --- control-panel/.gitignore | 4 + control-panel/build.mjs | 37 + control-panel/package-lock.json | 2902 +++++++++++++++++++ control-panel/package.json | 35 + control-panel/public/api.ts | 77 + control-panel/public/app.ts | 114 + control-panel/public/dom.ts | 60 + control-panel/public/index.html | 17 + control-panel/public/styles.css | 134 + control-panel/public/views.ts | 161 + control-panel/src/app.ts | 85 + control-panel/src/config.ts | 97 + control-panel/src/cp-client.ts | 127 + control-panel/src/manage-token.ts | 106 + control-panel/src/pairing.ts | 37 + control-panel/src/routes/api-routes.ts | 94 + control-panel/src/routes/auth-routes.ts | 72 + control-panel/src/security/compare.ts | 23 + control-panel/src/security/cookies.ts | 53 + control-panel/src/security/rate-limit.ts | 32 + control-panel/src/security/request.ts | 26 + control-panel/src/security/session.ts | 54 + control-panel/src/server.ts | 45 + control-panel/src/static.ts | 63 + control-panel/test/api-routes.test.ts | 99 + control-panel/test/auth-routes.test.ts | 102 + control-panel/test/compare.test.ts | 31 + control-panel/test/config.test.ts | 74 + control-panel/test/cp-client.test.ts | 94 + control-panel/test/helpers.ts | 91 + control-panel/test/manage-token.test.ts | 59 + control-panel/test/pairing.test.ts | 27 + control-panel/test/rate-limit.test.ts | 32 + control-panel/test/security-headers.test.ts | 40 + control-panel/test/session.test.ts | 48 + control-panel/tsconfig.json | 22 + control-panel/tsconfig.web.json | 19 + control-panel/vitest.config.ts | 14 + 38 files changed, 5207 insertions(+) create mode 100644 control-panel/.gitignore create mode 100644 control-panel/build.mjs create mode 100644 control-panel/package-lock.json create mode 100644 control-panel/package.json create mode 100644 control-panel/public/api.ts create mode 100644 control-panel/public/app.ts create mode 100644 control-panel/public/dom.ts create mode 100644 control-panel/public/index.html create mode 100644 control-panel/public/styles.css create mode 100644 control-panel/public/views.ts create mode 100644 control-panel/src/app.ts create mode 100644 control-panel/src/config.ts create mode 100644 control-panel/src/cp-client.ts create mode 100644 control-panel/src/manage-token.ts create mode 100644 control-panel/src/pairing.ts create mode 100644 control-panel/src/routes/api-routes.ts create mode 100644 control-panel/src/routes/auth-routes.ts create mode 100644 control-panel/src/security/compare.ts create mode 100644 control-panel/src/security/cookies.ts create mode 100644 control-panel/src/security/rate-limit.ts create mode 100644 control-panel/src/security/request.ts create mode 100644 control-panel/src/security/session.ts create mode 100644 control-panel/src/server.ts create mode 100644 control-panel/src/static.ts create mode 100644 control-panel/test/api-routes.test.ts create mode 100644 control-panel/test/auth-routes.test.ts create mode 100644 control-panel/test/compare.test.ts create mode 100644 control-panel/test/config.test.ts create mode 100644 control-panel/test/cp-client.test.ts create mode 100644 control-panel/test/helpers.ts create mode 100644 control-panel/test/manage-token.test.ts create mode 100644 control-panel/test/pairing.test.ts create mode 100644 control-panel/test/rate-limit.test.ts create mode 100644 control-panel/test/security-headers.test.ts create mode 100644 control-panel/test/session.test.ts create mode 100644 control-panel/tsconfig.json create mode 100644 control-panel/tsconfig.web.json create mode 100644 control-panel/vitest.config.ts diff --git a/control-panel/.gitignore b/control-panel/.gitignore new file mode 100644 index 0000000..e4a6a17 --- /dev/null +++ b/control-panel/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +public/build/ +coverage/ +*.log diff --git a/control-panel/build.mjs b/control-panel/build.mjs new file mode 100644 index 0000000..7256912 --- /dev/null +++ b/control-panel/build.mjs @@ -0,0 +1,37 @@ +/** + * Frontend build — mirrors the base app's esbuild style (vanilla TS → ESM bundle). Emits + * public/build/{app.js, index.html, styles.css}. index.html + styles.css are copied verbatim + * (index.html already references ./app.js and ./styles.css, both siblings in the build dir). + */ +import { build } from 'esbuild' +import { mkdir, copyFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const pub = resolve(here, 'public') +const out = resolve(pub, 'build') + +async function main() { + await mkdir(out, { recursive: true }) + + await build({ + entryPoints: [resolve(pub, 'app.ts')], + bundle: true, + format: 'esm', + target: 'es2022', + minify: true, + sourcemap: true, + outfile: resolve(out, 'app.js'), + logLevel: 'info', + }) + + await copyFile(resolve(pub, 'index.html'), resolve(out, 'index.html')) + await copyFile(resolve(pub, 'styles.css'), resolve(out, 'styles.css')) + process.stdout.write('[control-panel] frontend build → public/build\n') +} + +main().catch((err) => { + process.stderr.write(`[control-panel] build failed: ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) +}) diff --git a/control-panel/package-lock.json b/control-panel/package-lock.json new file mode 100644 index 0000000..2f35aa1 --- /dev/null +++ b/control-panel/package-lock.json @@ -0,0 +1,2902 @@ +{ + "name": "control-panel", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "control-panel", + "version": "0.0.0", + "dependencies": { + "fastify": "^4.28.1", + "qrcode": "^1.5.4", + "relay-auth": "file:../relay-auth", + "relay-contracts": "file:../relay-contracts", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@types/qrcode": "^1.5.6", + "@vitest/coverage-v8": "^4.1.9", + "esbuild": "^0.28.1", + "tsx": "^4.19.2", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, + "../relay-auth": { + "version": "0.0.0", + "dependencies": { + "relay-contracts": "file:../relay-contracts", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@vitest/coverage-v8": "^4.1.9", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, + "../relay-contracts": { + "version": "0.0.0", + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^5.7.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.3.0", + "fastq": "^1.17.1" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.1.0", + "ajv": "^8.10.0", + "ajv-formats": "^3.0.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^2.1.0", + "json-schema-ref-resolver": "^1.0.1", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.3.tgz", + "integrity": "sha512-8V8UrSDUkYpi4AXM7Na0G6hctXSaRHBGMuANOotuFdHEFtTdqDTRNfcDczA9WkKODI17o7o10iQvzdMIxXb8eA==", + "license": "MIT" + }, + "node_modules/fastify": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", + "abstract-logging": "^2.0.1", + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pino/node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/relay-auth": { + "resolved": "../relay-auth", + "link": true + }, + "node_modules/relay-contracts": { + "resolved": "../relay-contracts", + "link": true + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/ret": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/safe-regex2": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "license": "MIT", + "dependencies": { + "ret": "~0.4.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/control-panel/package.json b/control-panel/package.json new file mode 100644 index 0000000..af5bd4f --- /dev/null +++ b/control-panel/package.json @@ -0,0 +1,35 @@ +{ + "name": "control-panel", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Web control panel for the zero-touch tunnel system. Loopback-only Fastify backend that gates an operator session behind a password, then proxies the control-plane admin API (list hosts / issue pairing codes / revoke hosts), minting a fresh short-TTL `manage` capability token per call. Vanilla-TS + esbuild SPA. NEVER logs secrets/tokens/passwords.", + "engines": { + "node": ">=18" + }, + "main": "src/server.ts", + "scripts": { + "start": "tsx src/server.ts", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "build": "node build.mjs" + }, + "dependencies": { + "fastify": "^4.28.1", + "qrcode": "^1.5.4", + "relay-auth": "file:../relay-auth", + "relay-contracts": "file:../relay-contracts", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@types/qrcode": "^1.5.6", + "@vitest/coverage-v8": "^4.1.9", + "esbuild": "^0.28.1", + "tsx": "^4.19.2", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/control-panel/public/api.ts b/control-panel/public/api.ts new file mode 100644 index 0000000..d3ae5bf --- /dev/null +++ b/control-panel/public/api.ts @@ -0,0 +1,77 @@ +/** + * Typed fetch client for the panel backend. Same-origin; the session cookie rides automatically + * (HttpOnly — JS never reads it). Every call narrows the response and throws `ApiError` on failure + * so the UI can react (e.g. bounce to the login screen on 401). + */ +export interface HostView { + readonly hostId: string + readonly subdomain: string + readonly status: string + readonly lastSeen?: string + readonly createdAt?: string + readonly revokedAt?: string | null + readonly notAfter?: string +} + +export interface PairingArtifacts { + readonly code: string + readonly expiresAt: string + readonly pairCommand: string + readonly qrDataUrl: string +} + +export class ApiError extends Error { + readonly status: number + constructor(status: number, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +async function request(method: string, url: string, body?: unknown): Promise { + const init: RequestInit = { method, credentials: 'same-origin', headers: {} } + if (body !== undefined) { + init.headers = { 'content-type': 'application/json' } + init.body = JSON.stringify(body) + } + return fetch(url, init) +} + +async function json(res: Response): Promise { + if (!res.ok) throw new ApiError(res.status, `request failed (${res.status})`) + return (await res.json()) as T +} + +export async function getSession(): Promise { + const res = await request('GET', '/api/session') + const data = await json<{ authenticated: boolean }>(res) + return data.authenticated === true +} + +/** Attempt login. Returns true on success; throws ApiError (status) otherwise so callers can message. */ +export async function login(password: string): Promise { + const res = await request('POST', '/login', { password }) + if (res.ok) return true + throw new ApiError(res.status, `login failed (${res.status})`) +} + +export async function logout(): Promise { + await request('POST', '/logout') +} + +export async function getHosts(): Promise { + const res = await request('GET', '/api/hosts') + const data = await json<{ hosts: HostView[] }>(res) + return data.hosts ?? [] +} + +export async function createPairingCode(): Promise { + const res = await request('POST', '/api/pairing-codes') + return json(res) +} + +export async function revokeHost(hostId: string): Promise { + const res = await request('DELETE', `/api/hosts/${encodeURIComponent(hostId)}`) + if (!res.ok && res.status !== 204) throw new ApiError(res.status, `revoke failed (${res.status})`) +} diff --git a/control-panel/public/app.ts b/control-panel/public/app.ts new file mode 100644 index 0000000..5687df0 --- /dev/null +++ b/control-panel/public/app.ts @@ -0,0 +1,114 @@ +/** + * SPA bootstrap + state machine. On load: check the session → render login or dashboard. The + * dashboard polls the host list on an interval and refreshes after mutations. A 401 from any proxy + * call bounces the operator back to the login screen (session expired). + */ +import { mount } from './dom.js' +import * as api from './api.js' +import { ApiError } from './api.js' +import { renderLogin, renderDashboard, pairingModal, confirmDialog, toast, type DashboardHandlers } from './views.js' +import type { HostView } from './api.js' + +const POLL_INTERVAL_MS = 15_000 + +function rootEl(): HTMLElement { + const root = document.getElementById('app') + if (root === null) throw new Error('#app root not found') + return root +} + +let pollTimer: number | undefined + +function stopPolling(): void { + if (pollTimer !== undefined) { + clearInterval(pollTimer) + pollTimer = undefined + } +} + +/** True if an error is an auth failure that should bounce to login. */ +function isUnauthorized(err: unknown): boolean { + return err instanceof ApiError && err.status === 401 +} + +async function showLogin(): Promise { + stopPolling() + const root = rootEl() + const view = renderLogin(async (password) => { + const errorNode = (view as HTMLElement & { errorNode?: HTMLElement }).errorNode + try { + await api.login(password) + await showDashboard() + } catch (err) { + const msg = err instanceof ApiError && err.status === 429 ? 'Too many attempts. Wait and retry.' : err instanceof ApiError && err.status === 503 ? 'Panel not configured (no password set).' : 'Incorrect password.' + if (errorNode) errorNode.textContent = msg + } + }) + mount(root, view) +} + +async function loadHosts(): Promise { + return api.getHosts() +} + +async function refreshDashboard(root: HTMLElement, handlers: DashboardHandlers): Promise { + try { + const hosts = await loadHosts() + mount(root, renderDashboard(hosts, handlers)) + } catch (err) { + if (isUnauthorized(err)) { + await showLogin() + return + } + toast(root, 'Failed to load hosts.', 'error') + } +} + +async function showDashboard(): Promise { + const root = rootEl() + + const handlers: DashboardHandlers = { + onRefresh: () => void refreshDashboard(root, handlers), + onLogout: async () => { + stopPolling() + await api.logout() + await showLogin() + }, + onNewCode: async () => { + try { + const artifacts = await api.createPairingCode() + document.body.append(pairingModal(artifacts)) + } catch (err) { + if (isUnauthorized(err)) return void showLogin() + toast(root, 'Failed to create pairing code.', 'error') + } + }, + onRevoke: async (host) => { + const ok = await confirmDialog(`Revoke host "${host.subdomain}"? This removes its tunnel access.`) + if (!ok) return + try { + await api.revokeHost(host.hostId) + toast(root, `Revoked ${host.subdomain}.`, 'info') + await refreshDashboard(root, handlers) + } catch (err) { + if (isUnauthorized(err)) return void showLogin() + toast(root, 'Failed to revoke host.', 'error') + } + }, + } + + await refreshDashboard(root, handlers) + stopPolling() + pollTimer = window.setInterval(() => void refreshDashboard(root, handlers), POLL_INTERVAL_MS) +} + +async function boot(): Promise { + try { + const authed = await api.getSession() + await (authed ? showDashboard() : showLogin()) + } catch { + await showLogin() + } +} + +void boot() diff --git a/control-panel/public/dom.ts b/control-panel/public/dom.ts new file mode 100644 index 0000000..c8e6a29 --- /dev/null +++ b/control-panel/public/dom.ts @@ -0,0 +1,60 @@ +/** + * Tiny DOM builder. ALL text and attribute values are set via `textContent` / `setAttribute` — this + * module NEVER assigns `innerHTML`, so untrusted server/host data (host subdomains, etc.) can never + * inject markup. Children passed as strings become text nodes (also safe). + */ +export type Child = Node | string + +export interface ElProps { + class?: string + text?: string + type?: string + name?: string + placeholder?: string + value?: string + disabled?: boolean + autocomplete?: string + title?: string + src?: string + alt?: string + role?: string + ariaLabel?: string + onClick?: (e: MouseEvent) => void + onSubmit?: (e: SubmitEvent) => void +} + +export function el( + tag: K, + props: ElProps = {}, + children: Child[] = [], +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + if (props.class !== undefined) node.className = props.class + if (props.text !== undefined) node.textContent = props.text + if (props.type !== undefined) node.setAttribute('type', props.type) + if (props.name !== undefined) node.setAttribute('name', props.name) + if (props.placeholder !== undefined) node.setAttribute('placeholder', props.placeholder) + if (props.value !== undefined) (node as HTMLInputElement).value = props.value + if (props.disabled) node.setAttribute('disabled', 'true') + if (props.autocomplete !== undefined) node.setAttribute('autocomplete', props.autocomplete) + if (props.title !== undefined) node.setAttribute('title', props.title) + if (props.src !== undefined) node.setAttribute('src', props.src) + if (props.alt !== undefined) node.setAttribute('alt', props.alt) + if (props.role !== undefined) node.setAttribute('role', props.role) + if (props.ariaLabel !== undefined) node.setAttribute('aria-label', props.ariaLabel) + if (props.onClick !== undefined) node.addEventListener('click', props.onClick as EventListener) + if (props.onSubmit !== undefined) node.addEventListener('submit', props.onSubmit as EventListener) + for (const child of children) node.append(child) + return node +} + +/** Remove all children of a node. */ +export function clear(node: Element): void { + while (node.firstChild) node.removeChild(node.firstChild) +} + +/** Replace a node's content with a single new child. */ +export function mount(root: Element, child: Node): void { + clear(root) + root.append(child) +} diff --git a/control-panel/public/index.html b/control-panel/public/index.html new file mode 100644 index 0000000..c9285a8 --- /dev/null +++ b/control-panel/public/index.html @@ -0,0 +1,17 @@ + + + + + + + + Tunnel Control Panel + + + +
+

Loading…

+
+ + + diff --git a/control-panel/public/styles.css b/control-panel/public/styles.css new file mode 100644 index 0000000..ac63afa --- /dev/null +++ b/control-panel/public/styles.css @@ -0,0 +1,134 @@ +/* Tunnel Control Panel — themed (light/dark via prefers-color-scheme), responsive. */ +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #f0f2f5; + --text: #1b1f24; + --muted: #5b6572; + --border: #dfe3e8; + --primary: #2563eb; + --primary-text: #ffffff; + --danger: #dc2626; + --ok: #16a34a; + --warn: #d97706; + --shadow: 0 6px 24px rgba(20, 24, 33, 0.12); + --radius: 12px; + color-scheme: light dark; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1319; + --surface: #171c24; + --surface-2: #1f2630; + --text: #e7ebf0; + --muted: #9aa5b3; + --border: #2a323d; + --primary: #3b82f6; + --danger: #ef4444; + --ok: #22c55e; + --warn: #f59e0b; + --shadow: 0 6px 24px rgba(0, 0, 0, 0.5); + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--text); + font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} +.mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; } +.muted { color: var(--muted); } +.small { font-size: 13px; } +h1 { font-size: 22px; margin: 0; } +h2 { font-size: 17px; margin: 0 0 12px; } + +.app { min-height: 100vh; } +.centered { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; } + +/* Buttons */ +.btn { + appearance: none; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + padding: 9px 14px; + border-radius: 9px; + font-size: 14px; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, transform 0.02s ease; +} +.btn:hover { border-color: var(--primary); } +.btn:active { transform: translateY(1px); } +.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-text); } +.btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); } +.btn.danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); } +.btn.ghost { background: transparent; } +.btn.small { padding: 6px 10px; font-size: 13px; } +.icon-btn { background: none; border: none; color: var(--muted); font-size: 18px; cursor: pointer; line-height: 1; } + +/* Login */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 28px; +} +.login { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; } +.field-label { font-size: 13px; color: var(--muted); } +.login input { + width: 100%; + padding: 11px 12px; + border-radius: 9px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-size: 15px; +} +.login input:focus { outline: 2px solid var(--primary); outline-offset: 1px; } +.error { color: var(--danger); font-size: 13px; min-height: 18px; margin: 0; } + +/* Dashboard */ +.dashboard { max-width: 960px; margin: 0 auto; padding: 24px 20px 48px; } +.topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; } +.topbar .actions { display: flex; gap: 8px; flex-wrap: wrap; } +.dashboard .card { padding: 20px; } + +.table-scroll { overflow-x: auto; } +table.hosts { width: 100%; border-collapse: collapse; font-size: 14px; } +table.hosts th, table.hosts td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; } +table.hosts th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); } +table.hosts tr:last-child td { border-bottom: none; } +.empty { color: var(--muted); padding: 24px 4px; text-align: center; } + +/* Status pills */ +.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--border); } +.pill-online, .pill-active { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); background: color-mix(in srgb, var(--ok) 12%, transparent); } +.pill-offline { color: var(--muted); } +.pill-revoked, .pill-suspended { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent); } +.pill-unknown { color: var(--warn); } + +/* Modal */ +.overlay { position: fixed; inset: 0; background: rgba(6, 9, 14, 0.55); display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 50; } +.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); width: 100%; max-width: 420px; } +.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); } +.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; align-items: center; text-align: center; } +.code-big { font-size: 26px; font-weight: 700; letter-spacing: 0.08em; padding: 8px 12px; background: var(--surface-2); border-radius: 9px; } +.qr { width: 200px; height: 200px; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 9px; } +.command-row { display: flex; gap: 8px; align-items: center; width: 100%; } +.command-row.end { justify-content: flex-end; } +.command { flex: 1; text-align: left; background: var(--surface-2); padding: 9px 11px; border-radius: 8px; font-size: 13px; overflow-x: auto; white-space: nowrap; } + +/* Toasts */ +.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); padding: 10px 16px; border-radius: 9px; box-shadow: var(--shadow); z-index: 60; font-size: 14px; } +.toast-info { background: var(--surface); border: 1px solid var(--border); color: var(--text); } +.toast-error { background: var(--danger); color: #fff; } + +@media (max-width: 560px) { + .topbar { flex-direction: column; align-items: stretch; } + .topbar .actions { justify-content: stretch; } + .topbar .actions .btn { flex: 1; } +} diff --git a/control-panel/public/views.ts b/control-panel/public/views.ts new file mode 100644 index 0000000..a16e372 --- /dev/null +++ b/control-panel/public/views.ts @@ -0,0 +1,161 @@ +/** + * View builders. Every dynamic value (host subdomain/status/dates, pairing code, command) is placed + * with `textContent` via the `el` helper — NEVER innerHTML — so host data (treated as untrusted for + * XSS even though it comes from an authenticated CP) can't inject markup. The QR is an whose + * src is a data: URL our own backend generated with the `qrcode` lib. + */ +import { el, clear, type Child } from './dom.js' +import type { HostView, PairingArtifacts } from './api.js' + +export interface DashboardHandlers { + onNewCode: () => void + onRevoke: (host: HostView) => void + onLogout: () => void + onRefresh: () => void +} + +/** Login card with a password field; `onSubmit(password)` fires on submit. */ +export function renderLogin(onSubmit: (password: string) => void): HTMLElement { + const input = el('input', { type: 'password', name: 'password', placeholder: 'Panel password', autocomplete: 'current-password' }) + const error = el('p', { class: 'error', role: 'alert' }) + const form = el( + 'form', + { + class: 'card login', + onSubmit: (e) => { + e.preventDefault() + error.textContent = '' + onSubmit(input.value) + }, + }, + [ + el('h1', { text: 'Tunnel Control Panel' }), + el('label', { text: 'Password', class: 'field-label' }), + input, + el('button', { type: 'submit', class: 'btn primary', text: 'Sign in' }), + error, + ], + ) + const wrap = el('div', { class: 'centered' }, [form]) + // Expose the error node so the app can show login failures. + ;(wrap as HTMLElement & { errorNode?: HTMLElement }).errorNode = error + queueMicrotask(() => input.focus()) + return wrap +} + +function statusPill(status: string): HTMLElement { + const known = ['online', 'offline', 'revoked', 'suspended', 'active'].includes(status) + return el('span', { class: `pill pill-${known ? status : 'unknown'}`, text: status }) +} + +function fmtDate(value: string | undefined | null): string { + if (value === undefined || value === null || value === '') return '—' + const t = Date.parse(value) + return Number.isNaN(t) ? value : new Date(t).toLocaleString() +} + +function hostRow(host: HostView, handlers: DashboardHandlers): HTMLElement { + const revoke = el('button', { + class: 'btn danger small', + text: 'Revoke', + title: `Revoke ${host.subdomain}`, + onClick: () => handlers.onRevoke(host), + }) + return el('tr', {}, [ + el('td', { text: host.subdomain, class: 'mono' }), + el('td', {}, [statusPill(host.status)]), + el('td', { text: fmtDate(host.notAfter), class: 'muted' }), + el('td', { text: fmtDate(host.lastSeen), class: 'muted' }), + el('td', {}, [revoke]), + ]) +} + +function hostsTable(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement { + if (hosts.length === 0) { + return el('div', { class: 'empty', text: 'No hosts enrolled yet. Create a pairing code to add one.' }) + } + const head = el('thead', {}, [ + el('tr', {}, [ + el('th', { text: 'Subdomain' }), + el('th', { text: 'Status' }), + el('th', { text: 'Cert expiry' }), + el('th', { text: 'Last seen' }), + el('th', { text: '' }), + ]), + ]) + const body = el('tbody', {}, hosts.map((h) => hostRow(h, handlers))) + return el('div', { class: 'table-scroll' }, [el('table', { class: 'hosts' }, [head, body])]) +} + +/** Full dashboard: header (refresh / new-code / logout) + hosts table. */ +export function renderDashboard(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement { + const header = el('header', { class: 'topbar' }, [ + el('h1', { text: 'Tunnel Control Panel' }), + el('div', { class: 'actions' }, [ + el('button', { class: 'btn', text: 'Refresh', onClick: () => handlers.onRefresh() }), + el('button', { class: 'btn primary', text: 'New pairing code', onClick: () => handlers.onNewCode() }), + el('button', { class: 'btn ghost', text: 'Log out', onClick: () => handlers.onLogout() }), + ]), + ]) + return el('div', { class: 'dashboard' }, [ + header, + el('section', { class: 'card' }, [el('h2', { text: 'Hosts' }), hostsTable(hosts, handlers)]), + ]) +} + +/** Generic modal overlay; returns { overlay, close }. Clicking the backdrop or ✕ closes it. */ +function modal(title: string, children: Child[]): { overlay: HTMLElement; close: () => void } { + const close = (): void => overlay.remove() + const dialog = el('div', { class: 'modal', role: 'dialog' }, [ + el('div', { class: 'modal-head' }, [el('h2', { text: title }), el('button', { class: 'icon-btn', text: '✕', ariaLabel: 'Close', onClick: close })]), + el('div', { class: 'modal-body' }, children), + ]) + const overlay = el('div', { class: 'overlay', onClick: (e) => { if (e.target === overlay) close() } }, [dialog]) + return { overlay, close } +} + +/** Pairing-code modal: the code, its QR, the copyable command, and the expiry. */ +export function pairingModal(artifacts: PairingArtifacts): HTMLElement { + const command = el('code', { class: 'command', text: artifacts.pairCommand }) + const copyBtn = el('button', { + class: 'btn small', + text: 'Copy command', + onClick: () => { + void navigator.clipboard?.writeText(artifacts.pairCommand).then( + () => { copyBtn.textContent = 'Copied!' }, + () => { copyBtn.textContent = 'Copy failed' }, + ) + }, + }) + const { overlay } = modal('Pairing code', [ + el('p', { class: 'muted', text: 'Run this on the new host, or scan the QR from the phone client. Single-use.' }), + el('div', { class: 'code-big mono', text: artifacts.code }), + el('img', { class: 'qr', src: artifacts.qrDataUrl, alt: 'Pairing code QR' }), + el('div', { class: 'command-row' }, [command, copyBtn]), + el('p', { class: 'muted small', text: `Expires: ${fmtDate(artifacts.expiresAt)}` }), + ]) + return overlay +} + +/** Confirm dialog → resolves true (confirmed) / false (cancelled). */ +export function confirmDialog(message: string): Promise { + return new Promise((resolve) => { + const { overlay, close } = modal('Please confirm', [ + el('p', { text: message }), + el('div', { class: 'command-row end' }, [ + el('button', { class: 'btn ghost', text: 'Cancel', onClick: () => { close(); resolve(false) } }), + el('button', { class: 'btn danger', text: 'Revoke', onClick: () => { close(); resolve(true) } }), + ]), + ]) + document.body.append(overlay) + }) +} + +/** A transient toast for errors/success (textContent only). */ +export function toast(root: HTMLElement, message: string, kind: 'error' | 'info' = 'info'): void { + const t = el('div', { class: `toast toast-${kind}`, text: message, role: 'status' }) + root.append(t) + setTimeout(() => t.remove(), 4000) +} + +export { clear } diff --git a/control-panel/src/app.ts b/control-panel/src/app.ts new file mode 100644 index 0000000..72f8e01 --- /dev/null +++ b/control-panel/src/app.ts @@ -0,0 +1,85 @@ +/** + * Fastify app assembly. All collaborators are injected (config, CP client, token minter, static + * root, clock) so the whole surface is unit-testable via `app.inject()` with fakes and NO network. + * Route order matters: auth + api plugins register BEFORE the static wildcard so specific routes win. + */ +import Fastify, { type FastifyInstance } from 'fastify' +import type { PanelConfig } from './config.js' +import type { CpClient } from './cp-client.js' +import type { ManageTokenMinter } from './manage-token.js' +import type { RateLimiter } from './security/rate-limit.js' +import { buildAuthRoutes } from './routes/auth-routes.js' +import { buildApiRoutes } from './routes/api-routes.js' +import { buildStaticRoutes } from './static.js' + +/** + * Baseline security response headers applied to EVERY response (via an onRequest hook, so they are + * present on 404/401/error paths and on streamed static files too). The CSP is tuned for this + * self-contained SPA: it loads /build/app.js (script) and /build/styles.css (stylesheet) same-origin + * and renders the pairing QR as a `data:` image — hence `img-src 'self' data:`. `style-src` allows + * inline styles defensively; everything else is locked to 'self', framing is forbidden, and + * object-src/base-uri are neutralised. + */ +const SECURITY_HEADERS: Readonly> = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'no-referrer', + 'Content-Security-Policy': + "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", +} + +export interface AppDeps { + readonly config: PanelConfig + readonly cpClient: CpClient + readonly minter: ManageTokenMinter + /** Clock (ms) — injectable for tests. */ + readonly now?: () => number + /** Per-IP login limiter — injectable for tests. */ + readonly rateLimiter?: RateLimiter + /** Absolute path to the built SPA (public/build). `null` ⇒ skip static serving (tests). */ + readonly staticRoot?: string | null + /** Server-side error log (no secrets). Defaults to a no-op. */ + readonly logError?: (message: string) => void +} + +export async function buildApp(deps: AppDeps): Promise { + // Fastify's own logger is disabled: we control what is logged so no secret/token/password leaks. + // + // trustProxy: the panel sits behind nginx on loopback. The nginx vhost MUST set + // `X-Forwarded-For $remote_addr` (a clean, non-client-controllable value — NOT + // `$proxy_add_x_forwarded_for`, which would append attacker-supplied hops). Trusting the proxy makes + // `req.ip` reflect the REAL client address; without it every request looks like 127.0.0.1 and the + // /login rate limiter collapses into a single global bucket → a trivial unauthenticated lockout DoS. + const app = Fastify({ logger: false, bodyLimit: 64 * 1024, trustProxy: true }) + + // Defense-in-depth: stamp security headers on every response before routing (so 404/401/error + // responses and streamed static files all carry them). Registered first, applies to all child plugins. + app.addHook('onRequest', async (_req, reply) => { + for (const [name, value] of Object.entries(SECURITY_HEADERS)) reply.header(name, value) + }) + + // Conditional spreads keep `exactOptionalPropertyTypes` happy (never pass an explicit `undefined`). + const nowPart = deps.now !== undefined ? { now: deps.now } : {} + await app.register( + buildAuthRoutes({ + config: deps.config, + ...nowPart, + ...(deps.rateLimiter !== undefined ? { rateLimiter: deps.rateLimiter } : {}), + }), + ) + await app.register( + buildApiRoutes({ + config: deps.config, + cpClient: deps.cpClient, + minter: deps.minter, + ...nowPart, + ...(deps.logError !== undefined ? { logError: deps.logError } : {}), + }), + ) + + if (deps.staticRoot != null) { + await app.register(buildStaticRoutes(deps.staticRoot)) + } + + return app +} diff --git a/control-panel/src/config.ts b/control-panel/src/config.ts new file mode 100644 index 0000000..54ead27 --- /dev/null +++ b/control-panel/src/config.ts @@ -0,0 +1,97 @@ +/** + * Boot configuration — zod-validated at the process boundary, FAIL-CLOSED. + * + * Every field is read from the environment ONCE at startup and returned as an immutable + * `PanelConfig`. Structural problems (missing SESSION_SECRET, a non-numeric port, an empty + * OPERATOR_ACCOUNT_ID) throw here so the server never boots half-configured. + * + * `PANEL_PASSWORD` is intentionally OPTIONAL at this layer: an unset password is a valid (if + * useless) deployment state that the /login route turns into a fail-closed 503 — the panel must + * still start so an operator can see the error, rather than crash-looping. Every other secret is + * required. Secrets are NEVER logged; this module only ever returns them inside the config object. + */ +import { z } from 'zod' + +/** Default control-plane admin API base (loopback — the CP admin surface must never be public). */ +export const DEFAULT_CP_URL = 'http://127.0.0.1:8080' +/** Default PKCS#8 Ed25519 capability signing key (matches relay-run/mint-manage-token.ts). */ +export const DEFAULT_CAPABILITY_SIGN_KEY_PATH = '/etc/relay/capability/capability-sign.key.pem' +/** Default tunnel zone used to render the `pair` command shown to the operator. */ +export const DEFAULT_TUNNEL_ZONE = 'terminal.yaojia.wang' +/** Default loopback bind port for the panel HTTP server. */ +export const DEFAULT_PANEL_BIND_PORT = 8090 +/** SESSION_SECRET must have enough entropy to make cookie forgery infeasible. */ +export const MIN_SESSION_SECRET_LEN = 16 + +/** + * True iff `hostname` is a loopback literal: `localhost`, `::1`, or anything in 127.0.0.0/8. + * The panel's CP admin client must ONLY ever reach the local control-plane — enforcing this at the + * config boundary is anti-SSRF (a misconfigured CP_URL pointing at a remote/internal host is refused). + */ +export function isLoopbackHost(hostname: string): boolean { + const h = hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase() // strip IPv6 brackets + if (h === 'localhost' || h === '::1') return true + const m = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(h) + return m !== null && Number(m[1]) === 127 +} + +/** True iff `url` parses AND its host is a loopback literal. Malformed URLs fail closed (false). */ +function cpUrlHostIsLoopback(url: string): boolean { + try { + return isLoopbackHost(new URL(url).hostname) + } catch { + return false + } +} + +const EnvSchema = z.object({ + // Optional: unset ⇒ /login returns 503 (fail-closed at the route, not at boot). + PANEL_PASSWORD: z.string().min(1).optional(), + // Required: HMAC key that signs the session cookie. No default — a predictable secret is a bug. + SESSION_SECRET: z.string().min(MIN_SESSION_SECRET_LEN, `SESSION_SECRET must be >= ${MIN_SESSION_SECRET_LEN} chars`), + // Must be loopback (127.0.0.0/8, ::1, localhost): the CP admin surface is local-only. Fail closed otherwise. + CP_URL: z + .string() + .url() + .refine(cpUrlHostIsLoopback, 'CP_URL host must be loopback (127.0.0.0/8, ::1, or localhost)') + .default(DEFAULT_CP_URL), + // `aud` of every minted manage token — MUST equal the control-plane's expectedAud (its BASE_DOMAIN). + BASE_DOMAIN: z.string().min(1), + // `sub`/accountId scoped into every manage token and admin path. + OPERATOR_ACCOUNT_ID: z.string().min(1), + CAPABILITY_SIGN_KEY_PATH: z.string().min(1).default(DEFAULT_CAPABILITY_SIGN_KEY_PATH), + TUNNEL_ZONE: z.string().min(1).default(DEFAULT_TUNNEL_ZONE), + PANEL_BIND_PORT: z.coerce.number().int().min(1).max(65535).default(DEFAULT_PANEL_BIND_PORT), +}) + +export interface PanelConfig { + readonly panelPassword: string | undefined + readonly sessionSecret: string + readonly cpUrl: string + readonly baseDomain: string + readonly operatorAccountId: string + readonly capabilitySignKeyPath: string + readonly tunnelZone: string + readonly panelBindPort: number +} + +/** Loopback-only bind host — the admin panel must never listen on a public interface. */ +export const PANEL_BIND_HOST = '127.0.0.1' + +/** + * Parse + validate the environment into an immutable config. Throws a `ZodError` on any structural + * problem (fail-closed). Never logs the values it reads. + */ +export function loadConfig(env: NodeJS.ProcessEnv): PanelConfig { + const parsed = EnvSchema.parse(env) + return { + panelPassword: parsed.PANEL_PASSWORD, + sessionSecret: parsed.SESSION_SECRET, + cpUrl: parsed.CP_URL.replace(/\/+$/, ''), // normalise: strip trailing slashes for clean joins + baseDomain: parsed.BASE_DOMAIN, + operatorAccountId: parsed.OPERATOR_ACCOUNT_ID, + capabilitySignKeyPath: parsed.CAPABILITY_SIGN_KEY_PATH, + tunnelZone: parsed.TUNNEL_ZONE, + panelBindPort: parsed.PANEL_BIND_PORT, + } +} diff --git a/control-panel/src/cp-client.ts b/control-panel/src/cp-client.ts new file mode 100644 index 0000000..544644f --- /dev/null +++ b/control-panel/src/cp-client.ts @@ -0,0 +1,127 @@ +/** + * Control-plane ADMIN API client. Each method takes a freshly-minted `manage` bearer token and calls + * the loopback CP admin surface. Responses are Zod-validated at the boundary (the CP is trusted for + * auth but its payloads are still external data → validate, never `as`). Non-2xx ⇒ `CpClientError`. + * + * CP contract (control-plane/src/api/provision.ts, mounted at root, `Authorization: Bearer `): + * - GET /accounts/:id/hosts → 200 [HostRecord...] (agentPubkey base64) + * - POST /accounts/:id/pairing-codes → 201 { code, expiresAt } + * - DELETE /hosts/:hostId → 204 + * + * We expose only a curated host view to the SPA (never leak agentPubkey/enrollFpr). + */ +import { z } from 'zod' + +export interface HostView { + readonly hostId: string + readonly subdomain: string + readonly status: string + readonly lastSeen: string | undefined + readonly createdAt: string | undefined + readonly revokedAt: string | null | undefined + /** Cert expiry, if the CP ever includes one (not in today's HostRecord) — surfaced when present. */ + readonly notAfter: string | undefined +} + +export interface IssuedPairing { + readonly code: string + readonly expiresAt: string +} + +export interface CpClient { + listHosts(accountId: string, token: string): Promise + createPairingCode(accountId: string, token: string): Promise + deleteHost(hostId: string, token: string): Promise +} + +/** Upstream failure — carries an HTTP-ish status for the route layer to map (kept generic; no CP body leaked). */ +export class CpClientError extends Error { + readonly status: number + constructor(status: number, message: string) { + super(message) + this.name = 'CpClientError' + this.status = status + } +} + +// Lenient: require the fields we render; passthrough-tolerate everything else the CP adds/removes. +const HostRecordSchema = z + .object({ + hostId: z.string(), + subdomain: z.string(), + status: z.string(), + lastSeen: z.string().optional(), + createdAt: z.string().optional(), + revokedAt: z.string().nullable().optional(), + notAfter: z.string().optional(), + }) + .passthrough() + +const HostListSchema = z.array(HostRecordSchema) +const IssuedPairingSchema = z.object({ code: z.string().min(1), expiresAt: z.string().min(1) }) + +function toHostView(rec: z.infer): HostView { + return { + hostId: rec.hostId, + subdomain: rec.subdomain, + status: rec.status, + lastSeen: rec.lastSeen, + createdAt: rec.createdAt, + revokedAt: rec.revokedAt, + notAfter: rec.notAfter, + } +} + +type FetchFn = typeof fetch + +interface CpClientDeps { + readonly cpUrl: string + readonly fetchFn?: FetchFn +} + +async function readJson(res: Response): Promise { + try { + return await res.json() + } catch { + throw new CpClientError(502, 'control-plane returned a non-JSON response') + } +} + +export function createCpClient(deps: CpClientDeps): CpClient { + const doFetch: FetchFn = deps.fetchFn ?? fetch + const authHeaders = (token: string): Record => ({ authorization: `Bearer ${token}`, accept: 'application/json' }) + + const call = async (path: string, init: RequestInit): Promise => { + try { + return await doFetch(`${deps.cpUrl}${path}`, init) + } catch { + // Network/DNS/connection failure — the CP is unreachable. + throw new CpClientError(502, 'control-plane unreachable') + } + } + + return { + async listHosts(accountId, token) { + const res = await call(`/accounts/${encodeURIComponent(accountId)}/hosts`, { headers: authHeaders(token) }) + if (!res.ok) throw new CpClientError(res.status, `list hosts failed (${res.status})`) + const parsed = HostListSchema.parse(await readJson(res)) + return parsed.map(toHostView) + }, + + async createPairingCode(accountId, token) { + const res = await call(`/accounts/${encodeURIComponent(accountId)}/pairing-codes`, { + method: 'POST', + headers: { ...authHeaders(token), 'content-type': 'application/json' }, + body: '{}', + }) + if (!res.ok) throw new CpClientError(res.status, `issue pairing code failed (${res.status})`) + const parsed = IssuedPairingSchema.parse(await readJson(res)) + return { code: parsed.code, expiresAt: parsed.expiresAt } + }, + + async deleteHost(hostId, token) { + const res = await call(`/hosts/${encodeURIComponent(hostId)}`, { method: 'DELETE', headers: authHeaders(token) }) + if (!res.ok && res.status !== 204) throw new CpClientError(res.status, `revoke host failed (${res.status})`) + }, + } +} diff --git a/control-panel/src/manage-token.ts b/control-panel/src/manage-token.ts new file mode 100644 index 0000000..19ef28d --- /dev/null +++ b/control-panel/src/manage-token.ts @@ -0,0 +1,106 @@ +/** + * In-process `manage` capability-token minter — the in-code equivalent of + * relay-run/scripts/mint-manage-token.ts. Every proxied admin call mints a FRESH short-TTL token so + * a leaked token's blast radius is ~60s. + * + * The token is a §4.3 PASETO v4.public capability token signed by the P5 PRIVATE Ed25519 key + * (PKCS#8 PEM at CAPABILITY_SIGN_KEY_PATH): `aud = BASE_DOMAIN`, `sub = OPERATOR_ACCOUNT_ID`, + * `rights = ['manage']`, `ttl = 60s`. `issueCapabilityToken` mandates a well-formed DPoP `cnf.jkt`, + * so we stamp one from a throwaway ephemeral key (the CP admin API verifies signature+aud+rights but + * does NOT require a live DPoP proof — see the mint script's header note). + * + * SECURITY: the signing-key material and the minted token are NEVER logged. + */ +import { readFile } from 'node:fs/promises' +import { issueCapabilityToken } from 'relay-auth' +import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js' +import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js' + +/** Manage tokens are `manage`-scoped, single-account, 60s TTL. `host` is a placeholder (issue() forbids '*'/''). */ +const MANAGE_TOKEN_TTL_SEC = 60 +const MANAGE_HOST_PLACEHOLDER = '_manage_' + +/** Raised when the signing key cannot be loaded/imported. Message is safe (no key material). */ +export class ManageTokenError extends Error { + constructor(message: string) { + super(message) + this.name = 'ManageTokenError' + } +} + +export interface ManageTokenMinter { + /** Mint a fresh manage token for the configured operator account. */ + mint(): Promise +} + +export interface ManageTokenConfig { + readonly capabilitySignKeyPath: string + readonly baseDomain: string + readonly operatorAccountId: string +} + +/** Import a PKCS#8 PEM Ed25519 private key into a non-extractable signing CryptoKey. */ +async function importSigningKeyFromPem(pemPath: string): Promise { + let pem: string + try { + pem = await readFile(pemPath, 'utf8') + } catch { + throw new ManageTokenError(`capability signing key not readable at ${pemPath}`) + } + const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '') + if (b64.length === 0) throw new ManageTokenError('capability signing key PEM is empty') + let der: Uint8Array + try { + // Copy into a fresh ArrayBuffer-backed view: WebCrypto's BufferSource requires Uint8Array, + // which Buffer (ArrayBufferLike) does not satisfy under strict lib types. + const raw = Buffer.from(b64, 'base64') + der = new Uint8Array(new ArrayBuffer(raw.byteLength)) + der.set(raw) + } catch { + throw new ManageTokenError('capability signing key PEM is not valid base64') + } + try { + return await globalThis.crypto.subtle.importKey('pkcs8', der, { name: 'Ed25519' }, false, ['sign']) + } catch { + throw new ManageTokenError('capability signing key is not a valid PKCS#8 Ed25519 key') + } +} + +/** + * Build a minter that lazily loads + memoizes the signing key (so the panel boots even if the key + * file is briefly unavailable — a missing key surfaces as an upstream error on the first proxy call, + * not a boot crash). Clock is injectable for tests. + */ +export function createManageTokenMinter(config: ManageTokenConfig, now: () => number = () => Date.now()): ManageTokenMinter { + let keyPromise: Promise | undefined + + const signingKey = (): Promise => { + if (keyPromise === undefined) { + keyPromise = importSigningKeyFromPem(config.capabilitySignKeyPath).catch((err: unknown) => { + keyPromise = undefined // allow a later retry after the operator fixes the key + throw err + }) + } + return keyPromise + } + + return { + async mint(): Promise { + const key = await signingKey() + const eph = await generateEd25519KeyPair() + const cnfJkt = await jwkThumbprint(await exportEd25519PublicRaw(eph.publicKey)) + return issueCapabilityToken( + { + principal: { accountId: config.operatorAccountId } as never, // runtime reads only accountId + aud: config.baseDomain, + host: MANAGE_HOST_PLACEHOLDER, + rights: ['manage'], + ttlSeconds: MANAGE_TOKEN_TTL_SEC, + cnfJkt, + }, + key, + Math.floor(now() / 1000), + ) + }, + } +} diff --git a/control-panel/src/pairing.ts b/control-panel/src/pairing.ts new file mode 100644 index 0000000..e3e8251 --- /dev/null +++ b/control-panel/src/pairing.ts @@ -0,0 +1,37 @@ +/** + * Operator-facing pairing artifacts derived from a freshly-issued pairing code: + * - the ready-to-run `web-terminal-agent pair --install --zone ` command line, and + * - a QR PNG data: URL of the code (rendered with the `qrcode` dep) for phone scanning. + * Pure/deterministic given the code + zone (except the QR, which is a stable render of the code). + */ +import QRCode from 'qrcode' + +/** Build the exact command an operator runs on the new host to enroll it into the tunnel. */ +export function buildPairCommand(code: string, zone: string): string { + return `web-terminal-agent pair ${code} --install --zone ${zone}` +} + +/** Render a QR PNG data: URL encoding the pairing code (for scanning on the phone client). */ +export async function buildQrDataUrl(code: string): Promise { + return QRCode.toDataURL(code, { errorCorrectionLevel: 'M', margin: 1, width: 256 }) +} + +export interface PairingArtifacts { + readonly code: string + readonly expiresAt: string + readonly pairCommand: string + readonly qrDataUrl: string +} + +/** Combine an issued code with its operator-facing command + QR into the /api/pairing-codes payload. */ +export async function buildPairingArtifacts( + issued: { readonly code: string; readonly expiresAt: string }, + zone: string, +): Promise { + return { + code: issued.code, + expiresAt: issued.expiresAt, + pairCommand: buildPairCommand(issued.code, zone), + qrDataUrl: await buildQrDataUrl(issued.code), + } +} diff --git a/control-panel/src/routes/api-routes.ts b/control-panel/src/routes/api-routes.ts new file mode 100644 index 0000000..b1f9851 --- /dev/null +++ b/control-panel/src/routes/api-routes.ts @@ -0,0 +1,94 @@ +/** + * Session-gated proxy routes. A `preHandler` rejects any request without a valid session cookie + * (401) — nothing downstream runs unauthenticated. For EACH call we mint a FRESH `manage` token and + * hand it to the CP admin client: + * - GET /api/hosts → CP GET /accounts/{op}/hosts → curated host list + * - POST /api/pairing-codes → CP POST /accounts/{op}/pairing-codes → { code, expiresAt, pairCommand, qrDataUrl } + * - DELETE /api/hosts/:hostId → CP DELETE /hosts/:hostId → 204 + * + * Upstream/mint failures map to a generic 502 (never leak CP internals or token/key material). + */ +import { z } from 'zod' +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' +import type { PanelConfig } from '../config.js' +import type { CpClient } from '../cp-client.js' +import { CpClientError } from '../cp-client.js' +import type { ManageTokenMinter } from '../manage-token.js' +import { buildPairingArtifacts } from '../pairing.js' +import { requestIsAuthed } from '../security/request.js' + +// hostId is a server-issued UUIDv4 — accept only a conservative id charset (defence-in-depth). +// A bare `.` or `..` passes the charset but would reshape the outbound CP admin URL +// (`${cpUrl}/hosts/${hostId}`) via dot-segment normalization, so reject those two exact values. +export const HostIdSchema = z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9._-]+$/) + .refine((v) => v !== '.' && v !== '..', { message: 'host id must not be a dot-segment' }) + +export interface ApiRoutesDeps { + readonly config: PanelConfig + readonly cpClient: CpClient + readonly minter: ManageTokenMinter + readonly now?: () => number + /** Server-side logger for upstream failures (no secrets). Defaults to a no-op. */ + readonly logError?: (message: string) => void +} + +function mapUpstreamError(reply: FastifyReply, err: unknown, log: (m: string) => void): FastifyReply { + if (err instanceof CpClientError) { + log(`upstream control-plane error: ${err.message}`) + return reply.code(502).send({ error: 'upstream_error' }) + } + log(`proxy error: ${err instanceof Error ? err.name : 'unknown'}`) + return reply.code(502).send({ error: 'upstream_error' }) +} + +export function buildApiRoutes(deps: ApiRoutesDeps): FastifyPluginAsync { + const now = deps.now ?? (() => Date.now()) + const log = deps.logError ?? (() => {}) + const accountId = deps.config.operatorAccountId + + return async (app) => { + // Deny-by-default session gate for every route in this plugin. + app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => { + if (!requestIsAuthed(req, deps.config.sessionSecret, now())) { + await reply.code(401).send({ error: 'unauthenticated' }) + } + }) + + app.get('/api/hosts', async (_req, reply) => { + try { + const token = await deps.minter.mint() + const hosts = await deps.cpClient.listHosts(accountId, token) + return reply.code(200).send({ hosts }) + } catch (err) { + return mapUpstreamError(reply, err, log) + } + }) + + app.post('/api/pairing-codes', async (_req, reply) => { + try { + const token = await deps.minter.mint() + const issued = await deps.cpClient.createPairingCode(accountId, token) + const artifacts = await buildPairingArtifacts(issued, deps.config.tunnelZone) + return reply.code(201).send(artifacts) + } catch (err) { + return mapUpstreamError(reply, err, log) + } + }) + + app.delete('/api/hosts/:hostId', async (req, reply) => { + const parsed = HostIdSchema.safeParse((req.params as { hostId?: unknown }).hostId) + if (!parsed.success) return reply.code(400).send({ error: 'invalid host id' }) + try { + const token = await deps.minter.mint() + await deps.cpClient.deleteHost(parsed.data, token) + return reply.code(204).send() + } catch (err) { + return mapUpstreamError(reply, err, log) + } + }) + } +} diff --git a/control-panel/src/routes/auth-routes.ts b/control-panel/src/routes/auth-routes.ts new file mode 100644 index 0000000..f53a5a3 --- /dev/null +++ b/control-panel/src/routes/auth-routes.ts @@ -0,0 +1,72 @@ +/** + * Auth routes: POST /login, POST /logout, GET /api/session. + * + * SECURITY: + * - /login is RATE-LIMITED per client IP BEFORE any credential work (brute-force throttle → 429). + * - FAIL-CLOSED: unset PANEL_PASSWORD ⇒ 503 (never authenticates); wrong password ⇒ 401. + * - credential compare is CONSTANT-TIME (compare.ts SHA-256 fixed-length). + * - on success, set a signed HttpOnly; SameSite=Strict; Secure-when-https session cookie (12h). + * - the password and session token are NEVER logged; input is Zod-validated at the boundary. + */ +import { z } from 'zod' +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' +import type { PanelConfig } from '../config.js' +import { constantTimeEqual } from '../security/compare.js' +import { buildClearCookie, buildSetCookie } from '../security/cookies.js' +import { createSessionToken, SESSION_TTL_SEC } from '../security/session.js' +import { createSlidingWindowLimiter, DEFAULT_LOGIN_RATE_MAX, DEFAULT_LOGIN_RATE_WINDOW_MS, type RateLimiter } from '../security/rate-limit.js' +import { isSecureRequest, requestIsAuthed } from '../security/request.js' + +const LoginBodySchema = z.object({ password: z.string().min(1).max(512) }).strict() + +export interface AuthRoutesDeps { + readonly config: PanelConfig + readonly now?: () => number + readonly rateLimiter?: RateLimiter + readonly clientKey?: (req: FastifyRequest) => string +} + +function setCookie(reply: FastifyReply, value: string): void { + reply.header('set-cookie', value) +} + +export function buildAuthRoutes(deps: AuthRoutesDeps): FastifyPluginAsync { + const now = deps.now ?? (() => Date.now()) + const clientKey = deps.clientKey ?? ((req: FastifyRequest) => req.ip || 'unknown') + const limiter = deps.rateLimiter ?? createSlidingWindowLimiter(DEFAULT_LOGIN_RATE_MAX, DEFAULT_LOGIN_RATE_WINDOW_MS, now) + + return async (app) => { + app.post('/login', async (req, reply) => { + // Throttle FIRST so brute force is limited regardless of outcome. + if (!limiter.allow(clientKey(req))) { + return reply.code(429).send({ error: 'rate_limited' }) + } + + const parsed = LoginBodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: 'invalid request' }) + + // Fail-closed: an unconfigured panel authenticates no one. + const configured = deps.config.panelPassword + if (typeof configured !== 'string' || configured.length === 0) { + return reply.code(503).send({ error: 'login unavailable' }) + } + + if (!constantTimeEqual(parsed.data.password, configured)) { + return reply.code(401).send({ error: 'rejected' }) + } + + const token = createSessionToken(deps.config.sessionSecret, now()) + setCookie(reply, buildSetCookie({ value: token, maxAgeSec: SESSION_TTL_SEC, secure: isSecureRequest(req) })) + return reply.code(200).send({ authenticated: true }) + }) + + app.post('/logout', async (req, reply) => { + setCookie(reply, buildClearCookie({ secure: isSecureRequest(req) })) + return reply.code(200).send({ authenticated: false }) + }) + + app.get('/api/session', async (req, reply) => { + return reply.code(200).send({ authenticated: requestIsAuthed(req, deps.config.sessionSecret, now()) }) + }) + } +} diff --git a/control-panel/src/security/compare.ts b/control-panel/src/security/compare.ts new file mode 100644 index 0000000..e9fcbd9 --- /dev/null +++ b/control-panel/src/security/compare.ts @@ -0,0 +1,23 @@ +/** + * Constant-time string comparison (SECURITY-CRITICAL — never use `===` for secrets). + * + * Mirrors src/http/auth.ts in the base app: both inputs are hashed with SHA-256 to a FIXED 32 + * bytes, then compared with `crypto.timingSafeEqual`. Hashing-to-fixed-length removes the length + * side-channel and sidesteps `timingSafeEqual`'s throw-on-length-mismatch. A missing/empty + * candidate short-circuits to `false` before the comparator (it is not a secret-compare oracle). + */ +import { createHash, timingSafeEqual } from 'node:crypto' + +export function constantTimeEqual(a: string | undefined, b: string | undefined): boolean { + if (typeof a !== 'string' || typeof b !== 'string') return false + if (a.length === 0 || b.length === 0) return false + const ha = createHash('sha256').update(a, 'utf8').digest() + const hb = createHash('sha256').update(b, 'utf8').digest() + return timingSafeEqual(ha, hb) // both are exactly 32 bytes → never throws +} + +/** Constant-time byte comparison of two equal-length buffers (false on length mismatch). */ +export function constantTimeEqualBytes(a: Buffer, b: Buffer): boolean { + if (a.length !== b.length) return false + return timingSafeEqual(a, b) +} diff --git a/control-panel/src/security/cookies.ts b/control-panel/src/security/cookies.ts new file mode 100644 index 0000000..96f3026 --- /dev/null +++ b/control-panel/src/security/cookies.ts @@ -0,0 +1,53 @@ +/** + * Cookie parse + Set-Cookie serialization — dependency-light (no @fastify/cookie), mirroring the + * base app's src/http/auth.ts discipline. Values are returned verbatim (NOT URL-decoded); the + * session token uses a cookie-safe charset (base64url + '.') so there is nothing to decode. + */ + +/** The panel session cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate it). */ +export const SESSION_COOKIE_NAME = 'panel_session' + +/** + * Parse a raw `Cookie:` header into a name→value map. Malformed pairs (no `=`, empty name) are + * ignored; last write wins on duplicate names. + */ +export function parseCookieHeader(header: string | undefined): Record { + const out: Record = {} + if (header === undefined || header === '') return out + for (const part of header.split(';')) { + const eq = part.indexOf('=') + if (eq <= 0) continue + const name = part.slice(0, eq).trim() + if (name === '') continue + out[name] = part.slice(eq + 1).trim() + } + return out +} + +interface SetCookieOptions { + readonly value: string + readonly maxAgeSec: number + readonly secure: boolean +} + +/** + * Build a `Set-Cookie` value. Flags: HttpOnly (no JS read), SameSite=Strict (cross-site pages can't + * ride the cookie — CSRF/CSWSH defence), Path=/, Max-Age, and Secure ONLY when `secure` (a Secure + * cookie is never sent over http:// — forcing it would break a loopback/http operator session). + */ +export function buildSetCookie(opts: SetCookieOptions): string { + const parts = [ + `${SESSION_COOKIE_NAME}=${opts.value}`, + 'Path=/', + `Max-Age=${opts.maxAgeSec}`, + 'HttpOnly', + 'SameSite=Strict', + ] + if (opts.secure) parts.push('Secure') + return parts.join('; ') +} + +/** Build a `Set-Cookie` that immediately expires the session cookie (logout). */ +export function buildClearCookie(opts: { secure: boolean }): string { + return buildSetCookie({ value: '', maxAgeSec: 0, secure: opts.secure }) +} diff --git a/control-panel/src/security/rate-limit.ts b/control-panel/src/security/rate-limit.ts new file mode 100644 index 0000000..a52ab5a --- /dev/null +++ b/control-panel/src/security/rate-limit.ts @@ -0,0 +1,32 @@ +/** + * In-process sliding-window rate limiter keyed by an arbitrary client bucket (per-IP for /login). + * Mirrors the base app's control-plane auth-login limiter: a rejected attempt is NOT recorded, so a + * throttled client can't push its own window forward. Pure/injectable clock for tests. + */ +export interface RateLimiter { + /** Returns true and records the hit if under the limit; false (no record) when the window is full. */ + allow(key: string): boolean +} + +/** Default per-IP login attempts within the window. */ +export const DEFAULT_LOGIN_RATE_MAX = 10 +/** Default login rate window (ms): 15 minutes. */ +export const DEFAULT_LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000 + +export function createSlidingWindowLimiter(max: number, windowMs: number, now: () => number): RateLimiter { + const hits = new Map() + return { + allow(key: string): boolean { + const ts = now() + const cutoff = ts - windowMs + const kept = (hits.get(key) ?? []).filter((t) => t > cutoff) + if (kept.length >= max) { + hits.set(key, kept) // persist the pruned window; do NOT record this rejected attempt + return false + } + kept.push(ts) + hits.set(key, kept) + return true + }, + } +} diff --git a/control-panel/src/security/request.ts b/control-panel/src/security/request.ts new file mode 100644 index 0000000..3467c3c --- /dev/null +++ b/control-panel/src/security/request.ts @@ -0,0 +1,26 @@ +/** + * Request-scoped security helpers shared by the route plugins: detect HTTPS (drives the Secure + * cookie flag) and read/verify the session cookie off an incoming Fastify request. + */ +import type { FastifyRequest } from 'fastify' +import { parseCookieHeader, SESSION_COOKIE_NAME } from './cookies.js' +import { verifySessionToken } from './session.js' + +/** + * True iff the request arrived over HTTPS — directly (`socket.encrypted`) or via a TLS-terminating + * edge that set `x-forwarded-proto: https`. Even though the panel binds loopback, a reverse proxy + * may front it; honour XFP so the Secure flag is correct on the tunnel path. + */ +export function isSecureRequest(req: FastifyRequest): boolean { + const xfp = req.headers['x-forwarded-proto'] + const proto = Array.isArray(xfp) ? xfp[0] : xfp + if (typeof proto === 'string' && proto.split(',')[0]?.trim().toLowerCase() === 'https') return true + const socket = req.raw.socket as { encrypted?: boolean } | undefined + return socket?.encrypted === true +} + +/** True iff the request carries a valid, unexpired session cookie signed with `sessionSecret`. */ +export function requestIsAuthed(req: FastifyRequest, sessionSecret: string, nowMs: number): boolean { + const cookies = parseCookieHeader(req.headers.cookie) + return verifySessionToken(sessionSecret, cookies[SESSION_COOKIE_NAME], nowMs) +} diff --git a/control-panel/src/security/session.ts b/control-panel/src/security/session.ts new file mode 100644 index 0000000..f84368e --- /dev/null +++ b/control-panel/src/security/session.ts @@ -0,0 +1,54 @@ +/** + * Signed session token — an HMAC-SHA256 MAC over a short-TTL expiry claim. The cookie value is + * `.`; the MAC covers the expiry so a client cannot extend its own + * session. Verification is constant-time and rejects tampering, wrong-secret, and past-expiry + * tokens. Self-contained (node:crypto only) — no external signing dependency. + */ +import { createHmac } from 'node:crypto' +import { constantTimeEqualBytes } from './compare.js' + +/** Session lifetime (seconds): 12h — long enough to avoid constant re-auth, short enough to bound replay. */ +export const SESSION_TTL_SEC = 12 * 60 * 60 + +function macFor(secret: string, payload: string): Buffer { + return createHmac('sha256', secret).update(payload, 'utf8').digest() +} + +function b64url(buf: Buffer): string { + return buf.toString('base64url') +} + +/** + * Mint a session token that expires `ttlSec` after `nowMs`. The expiry is embedded and signed. + */ +export function createSessionToken(secret: string, nowMs: number, ttlSec: number = SESSION_TTL_SEC): string { + const expSec = Math.floor(nowMs / 1000) + ttlSec + const payload = String(expSec) + return `${payload}.${b64url(macFor(secret, payload))}` +} + +/** + * True iff `token` is a well-formed, correctly-signed, unexpired session token. + * Any structural problem, MAC mismatch, or past-expiry ⇒ false (deny-by-default). + */ +export function verifySessionToken(secret: string, token: string | undefined, nowMs: number): boolean { + if (typeof token !== 'string' || token.length === 0) return false + const dot = token.indexOf('.') + if (dot <= 0 || dot === token.length - 1) return false + const payload = token.slice(0, dot) + const presentedMacB64 = token.slice(dot + 1) + // Expiry must be a positive integer string; reject anything else before touching crypto. + if (!/^\d+$/.test(payload)) return false + + let presentedMac: Buffer + try { + presentedMac = Buffer.from(presentedMacB64, 'base64url') + } catch { + return false + } + const expectedMac = macFor(secret, payload) + if (!constantTimeEqualBytes(presentedMac, expectedMac)) return false + + const expSec = Number(payload) + return Number.isSafeInteger(expSec) && expSec * 1000 > nowMs +} diff --git a/control-panel/src/server.ts b/control-panel/src/server.ts new file mode 100644 index 0000000..8dc9163 --- /dev/null +++ b/control-panel/src/server.ts @@ -0,0 +1,45 @@ +/** + * Process entrypoint. Loads + validates config (fail-closed), wires the REAL collaborators (CP + * client over CP_URL, lazy manage-token minter reading the PKCS#8 capability key), and binds the + * app to LOOPBACK ONLY (127.0.0.1) — the admin panel must never listen on a public interface. + * + * Logging here is deliberately minimal and secret-free: only the bind address/port and startup + * errors (never the password, session secret, key material, or any minted token) are printed. + */ +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' +import { loadConfig, PANEL_BIND_HOST } from './config.js' +import { createCpClient } from './cp-client.js' +import { createManageTokenMinter } from './manage-token.js' +import { buildApp } from './app.js' + +const HERE = dirname(fileURLToPath(import.meta.url)) +/** Built SPA lives at control-panel/public/build (sibling of src/). */ +const STATIC_ROOT = resolve(HERE, '..', 'public', 'build') + +async function main(): Promise { + const config = loadConfig(process.env) + const cpClient = createCpClient({ cpUrl: config.cpUrl }) + const minter = createManageTokenMinter({ + capabilitySignKeyPath: config.capabilitySignKeyPath, + baseDomain: config.baseDomain, + operatorAccountId: config.operatorAccountId, + }) + + const app = await buildApp({ + config, + cpClient, + minter, + staticRoot: STATIC_ROOT, + // Structured, secret-free server log for upstream failures. + logError: (message: string) => process.stderr.write(`[control-panel] ${message}\n`), + }) + + await app.listen({ host: PANEL_BIND_HOST, port: config.panelBindPort }) + process.stdout.write(`[control-panel] listening on http://${PANEL_BIND_HOST}:${config.panelBindPort}\n`) +} + +main().catch((err: unknown) => { + process.stderr.write(`[control-panel] fatal: ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) +}) diff --git a/control-panel/src/static.ts b/control-panel/src/static.ts new file mode 100644 index 0000000..bd1be87 --- /dev/null +++ b/control-panel/src/static.ts @@ -0,0 +1,63 @@ +/** + * Minimal self-contained static SPA server (no @fastify/static dependency). Serves the esbuild + * output from `root` (control-panel/public/build) with a small content-type map, path-traversal + * guard, and an index.html fallback for extension-less GETs (SPA routing). Registered LAST so the + * specific /login and /api/* routes always win in Fastify's router. + */ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { resolve, sep, extname } from 'node:path' +import type { FastifyPluginAsync, FastifyReply } from 'fastify' + +const CONTENT_TYPES: Readonly> = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.webmanifest': 'application/manifest+json', +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch { + return false + } +} + +async function sendFile(reply: FastifyReply, filePath: string): Promise { + const type = CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream' + await reply.type(type).send(createReadStream(filePath)) +} + +export function buildStaticRoutes(root: string): FastifyPluginAsync { + const rootResolved = resolve(root) + const indexHtml = resolve(rootResolved, 'index.html') + + return async (app) => { + app.get('/*', async (req, reply) => { + // Only GET/HEAD reach here (Fastify method routing); resolve the URL path safely. + const urlPath = decodeURIComponent(req.url.split('?')[0] ?? '/') + const candidate = resolve(rootResolved, '.' + urlPath) + + // Path-traversal guard: the resolved target must stay within root. + if (candidate !== rootResolved && !candidate.startsWith(rootResolved + sep)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + if (urlPath !== '/' && (await isFile(candidate))) { + return sendFile(reply, candidate) + } + // SPA fallback: serve index.html for '/' and any unknown extension-less route. + if (await isFile(indexHtml)) { + return sendFile(reply, indexHtml) + } + return reply.code(404).send({ error: 'not found' }) + }) + } +} diff --git a/control-panel/test/api-routes.test.ts b/control-panel/test/api-routes.test.ts new file mode 100644 index 0000000..f096a00 --- /dev/null +++ b/control-panel/test/api-routes.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { buildApp } from '../src/app.js' +import { CpClientError } from '../src/cp-client.js' +import { HostIdSchema } from '../src/routes/api-routes.js' +import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader } from './helpers.js' + +const AUTH = { cookie: authCookieHeader() } + +describe('session gating', () => { + it('rejects every proxy route without a valid session cookie (401)', async () => { + const app = await buildApp({ config: makeConfig(), cpClient: fakeCpClient(), minter: fakeMinter(), staticRoot: null }) + for (const r of [ + { method: 'GET' as const, url: '/api/hosts' }, + { method: 'POST' as const, url: '/api/pairing-codes' }, + { method: 'DELETE' as const, url: '/api/hosts/abc' }, + ]) { + const res = await app.inject(r) + expect(res.statusCode).toBe(401) + } + await app.close() + }) +}) + +describe('GET /api/hosts', () => { + it('mints a token and returns the CP host list for the operator account', async () => { + const cpClient = fakeCpClient({ + hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }], + }) + const minter = fakeMinter('MINTED') + const app = await buildApp({ config: makeConfig({ operatorAccountId: 'acct-XYZ' }), cpClient, minter, staticRoot: null }) + + const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }] }) + expect(minter.calls()).toBe(1) + expect(cpClient.calls[0]).toMatchObject({ method: 'listHosts', accountIdOrHostId: 'acct-XYZ', token: 'MINTED' }) + await app.close() + }) + + it('maps an upstream CP failure to 502', async () => { + const cpClient = fakeCpClient({ throwErr: new CpClientError(403, 'denied') }) + const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null }) + const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH }) + expect(res.statusCode).toBe(502) + expect(res.json()).toEqual({ error: 'upstream_error' }) + await app.close() + }) +}) + +describe('POST /api/pairing-codes', () => { + it('returns the code, pair command, and a QR data URL', async () => { + const cpClient = fakeCpClient({ pairing: { code: 'ABCD-EFGH', expiresAt: '2026-03-01T00:00:00.000Z' } }) + const app = await buildApp({ config: makeConfig({ tunnelZone: 'z.test' }), cpClient, minter: fakeMinter(), staticRoot: null }) + + const res = await app.inject({ method: 'POST', url: '/api/pairing-codes', headers: AUTH }) + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.code).toBe('ABCD-EFGH') + expect(body.expiresAt).toBe('2026-03-01T00:00:00.000Z') + expect(body.pairCommand).toBe('web-terminal-agent pair ABCD-EFGH --install --zone z.test') + expect(String(body.qrDataUrl).startsWith('data:image/png;base64,')).toBe(true) + await app.close() + }) +}) + +describe('DELETE /api/hosts/:hostId', () => { + it('revokes the host and returns 204', async () => { + const cpClient = fakeCpClient() + const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter('T'), staticRoot: null }) + const res = await app.inject({ method: 'DELETE', url: '/api/hosts/host-42', headers: AUTH }) + expect(res.statusCode).toBe(204) + expect(cpClient.calls[0]).toMatchObject({ method: 'deleteHost', accountIdOrHostId: 'host-42', token: 'T' }) + await app.close() + }) + + it('rejects an invalid host id with 400', async () => { + const cpClient = fakeCpClient() + const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null }) + const res = await app.inject({ method: 'DELETE', url: '/api/hosts/' + encodeURIComponent('bad id!'), headers: AUTH }) + expect(res.statusCode).toBe(400) + expect(cpClient.calls).toHaveLength(0) + await app.close() + }) +}) + +describe('HostIdSchema dot-segment rejection', () => { + // The HTTP router already normalizes literal `.`/`..` path segments, but the outbound CP admin URL is + // built as `${cpUrl}/hosts/${hostId}` — so the schema itself must refuse dot-segments (defense-in-depth). + it('rejects a bare "." and ".."', () => { + expect(HostIdSchema.safeParse('.').success).toBe(false) + expect(HostIdSchema.safeParse('..').success).toBe(false) + }) + + it('accepts a UUID host id, a hyphenated id, and "..." (not a dot-segment)', () => { + expect(HostIdSchema.safeParse('550e8400-e29b-41d4-a716-446655440000').success).toBe(true) + expect(HostIdSchema.safeParse('host-42').success).toBe(true) + expect(HostIdSchema.safeParse('...').success).toBe(true) + }) +}) diff --git a/control-panel/test/auth-routes.test.ts b/control-panel/test/auth-routes.test.ts new file mode 100644 index 0000000..97a653a --- /dev/null +++ b/control-panel/test/auth-routes.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest' +import { buildApp } from '../src/app.js' +import { SESSION_COOKIE_NAME } from '../src/security/cookies.js' +import { createSlidingWindowLimiter, type RateLimiter } from '../src/security/rate-limit.js' +import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader, TEST_SESSION_SECRET } from './helpers.js' + +async function makeApp(overrides: Parameters[0] extends infer T ? Partial : never = {}) { + return buildApp({ + config: makeConfig(), + cpClient: fakeCpClient(), + minter: fakeMinter(), + staticRoot: null, + ...overrides, + }) +} + +describe('POST /login', () => { + it('returns 503 when PANEL_PASSWORD is unset (fail-closed)', async () => { + const app = await makeApp({ config: makeConfig({ panelPassword: undefined }) }) + const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'anything' } }) + expect(res.statusCode).toBe(503) + await app.close() + }) + + it('returns 401 on the wrong password (no cookie set)', async () => { + const app = await makeApp() + const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'wrong' } }) + expect(res.statusCode).toBe(401) + expect(res.headers['set-cookie']).toBeUndefined() + await app.close() + }) + + it('returns 200 and sets an HttpOnly SameSite=Strict session cookie on success', async () => { + const app = await makeApp({ config: makeConfig({ panelPassword: 'secret-pw' }) }) + const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'secret-pw' } }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ authenticated: true }) + const cookie = String(res.headers['set-cookie']) + expect(cookie).toContain(`${SESSION_COOKIE_NAME}=`) + expect(cookie).toContain('HttpOnly') + expect(cookie).toContain('SameSite=Strict') + await app.close() + }) + + it('returns 429 when rate-limited (before credential work)', async () => { + const denyAll: RateLimiter = { allow: () => false } + const app = await makeApp({ rateLimiter: denyAll }) + const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'whatever' } }) + expect(res.statusCode).toBe(429) + await app.close() + }) + + it('returns 400 on a malformed body', async () => { + const app = await makeApp() + const res = await app.inject({ method: 'POST', url: '/login', payload: { notpassword: 1 } }) + expect(res.statusCode).toBe(400) + await app.close() + }) + + // trustProxy: true (app.ts) makes req.ip read X-Forwarded-For, so the limiter buckets by REAL client + // IP. Without it every request would share the single 127.0.0.1 bucket (a global lockout DoS). + it('rate-limits per forwarded client IP: same X-Forwarded-For shares a budget, a different one is independent', async () => { + const limiter = createSlidingWindowLimiter(1, 60_000, () => 0) // one attempt per window per key + const app = await makeApp({ rateLimiter: limiter }) + const login = (xff: string) => + app.inject({ method: 'POST', url: '/login', headers: { 'x-forwarded-for': xff }, payload: { password: 'wrong' } }) + + // IP A, 1st attempt: budget available → reaches credential check → 401 (not throttled). + expect((await login('203.0.113.10')).statusCode).toBe(401) + // IP A, 2nd attempt: same bucket exhausted → 429. + expect((await login('203.0.113.10')).statusCode).toBe(429) + // IP B: its own bucket → 401, proving req.ip reflects the forwarded address (else it would be 429). + expect((await login('198.51.100.20')).statusCode).toBe(401) + await app.close() + }) +}) + +describe('POST /logout', () => { + it('clears the session cookie', async () => { + const app = await makeApp() + const res = await app.inject({ method: 'POST', url: '/logout' }) + expect(res.statusCode).toBe(200) + expect(String(res.headers['set-cookie'])).toContain('Max-Age=0') + await app.close() + }) +}) + +describe('GET /api/session', () => { + it('reports authenticated:false without a cookie', async () => { + const app = await makeApp() + const res = await app.inject({ method: 'GET', url: '/api/session' }) + expect(res.json()).toEqual({ authenticated: false }) + await app.close() + }) + + it('reports authenticated:true with a valid session cookie', async () => { + const app = await makeApp() + const res = await app.inject({ method: 'GET', url: '/api/session', headers: { cookie: authCookieHeader(TEST_SESSION_SECRET) } }) + expect(res.json()).toEqual({ authenticated: true }) + await app.close() + }) +}) diff --git a/control-panel/test/compare.test.ts b/control-panel/test/compare.test.ts new file mode 100644 index 0000000..117b074 --- /dev/null +++ b/control-panel/test/compare.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { constantTimeEqual, constantTimeEqualBytes } from '../src/security/compare.js' + +describe('constantTimeEqual', () => { + it('returns true for identical strings', () => { + expect(constantTimeEqual('correct-horse', 'correct-horse')).toBe(true) + }) + + it('returns false for different strings', () => { + expect(constantTimeEqual('correct-horse', 'battery-staple')).toBe(false) + }) + + it('returns false for different-length strings (no length oracle)', () => { + expect(constantTimeEqual('abc', 'abcdef')).toBe(false) + }) + + it('returns false when either side is empty or undefined', () => { + expect(constantTimeEqual('', 'x')).toBe(false) + expect(constantTimeEqual('x', '')).toBe(false) + expect(constantTimeEqual(undefined, 'x')).toBe(false) + expect(constantTimeEqual('x', undefined)).toBe(false) + }) +}) + +describe('constantTimeEqualBytes', () => { + it('true for equal buffers, false for differing or mismatched length', () => { + expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('aa'))).toBe(true) + expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('ab'))).toBe(false) + expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('aaa'))).toBe(false) + }) +}) diff --git a/control-panel/test/config.test.ts b/control-panel/test/config.test.ts new file mode 100644 index 0000000..c664812 --- /dev/null +++ b/control-panel/test/config.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { + loadConfig, + DEFAULT_CP_URL, + DEFAULT_TUNNEL_ZONE, + DEFAULT_PANEL_BIND_PORT, + DEFAULT_CAPABILITY_SIGN_KEY_PATH, +} from '../src/config.js' + +const base = { + SESSION_SECRET: 'a-sufficiently-long-secret-value', + BASE_DOMAIN: 'terminal.yaojia.wang', + OPERATOR_ACCOUNT_ID: 'acct-1', +} + +describe('loadConfig', () => { + it('applies defaults for optional fields', () => { + const cfg = loadConfig({ ...base } as NodeJS.ProcessEnv) + expect(cfg.cpUrl).toBe(DEFAULT_CP_URL) + expect(cfg.tunnelZone).toBe(DEFAULT_TUNNEL_ZONE) + expect(cfg.panelBindPort).toBe(DEFAULT_PANEL_BIND_PORT) + expect(cfg.capabilitySignKeyPath).toBe(DEFAULT_CAPABILITY_SIGN_KEY_PATH) + expect(cfg.panelPassword).toBeUndefined() + }) + + it('reads all provided values and strips trailing slash from CP_URL', () => { + const cfg = loadConfig({ + ...base, + PANEL_PASSWORD: 'pw', + CP_URL: 'http://127.0.0.1:9000/', + TUNNEL_ZONE: 'z.example', + PANEL_BIND_PORT: '9999', + } as NodeJS.ProcessEnv) + expect(cfg.panelPassword).toBe('pw') + expect(cfg.cpUrl).toBe('http://127.0.0.1:9000') + expect(cfg.tunnelZone).toBe('z.example') + expect(cfg.panelBindPort).toBe(9999) + }) + + it('throws when SESSION_SECRET is missing (fail-closed)', () => { + const { SESSION_SECRET: _omit, ...rest } = base + expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow() + }) + + it('throws when SESSION_SECRET is too short', () => { + expect(() => loadConfig({ ...base, SESSION_SECRET: 'short' } as NodeJS.ProcessEnv)).toThrow() + }) + + it('throws when BASE_DOMAIN is missing', () => { + const { BASE_DOMAIN: _omit, ...rest } = base + expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow() + }) + + it('throws when OPERATOR_ACCOUNT_ID is missing', () => { + const { OPERATOR_ACCOUNT_ID: _omit, ...rest } = base + expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow() + }) + + it('throws when PANEL_BIND_PORT is out of range', () => { + expect(() => loadConfig({ ...base, PANEL_BIND_PORT: '70000' } as NodeJS.ProcessEnv)).toThrow() + }) + + it('accepts loopback CP_URL hosts (127.0.0.0/8, ::1, localhost)', () => { + for (const url of ['http://127.0.0.1:8080', 'http://127.9.9.9:1', 'http://[::1]:8080', 'http://localhost:8080']) { + expect(loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv).cpUrl).toBe(url) + } + }) + + it('throws when CP_URL host is not loopback (anti-SSRF, fail-closed)', () => { + for (const url of ['http://evil.example.com:8080', 'http://169.254.169.254/', 'http://10.0.0.5:8080', 'http://8.8.8.8']) { + expect(() => loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv)).toThrow() + } + }) +}) diff --git a/control-panel/test/cp-client.test.ts b/control-panel/test/cp-client.test.ts new file mode 100644 index 0000000..dd195d1 --- /dev/null +++ b/control-panel/test/cp-client.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest' +import { createCpClient, CpClientError } from '../src/cp-client.js' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} + +describe('createCpClient.listHosts', () => { + it('maps the CP host records to a curated view and strips agentPubkey/enrollFpr', async () => { + const captured: { url?: string; init?: RequestInit | undefined } = {} + const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => { + captured.url = String(url) + captured.init = init + return jsonResponse([ + { + hostId: 'h1', + accountId: 'acct-1', + subdomain: 'alpha', + agentPubkey: 'BASE64PUBKEY', + enrollFpr: 'fpr', + status: 'online', + lastSeen: '2026-01-02T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + revokedAt: null, + }, + ]) + }) as typeof fetch + + const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn }) + const hosts = await client.listHosts('acct-1', 'TOKEN123') + + expect(captured.url).toBe('http://127.0.0.1:8080/accounts/acct-1/hosts') + expect((captured.init?.headers as Record).authorization).toBe('Bearer TOKEN123') + expect(hosts).toHaveLength(1) + expect(hosts[0]).toMatchObject({ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: '2026-01-02T00:00:00.000Z' }) + expect(hosts[0]).not.toHaveProperty('agentPubkey') + expect(hosts[0]).not.toHaveProperty('enrollFpr') + }) + + it('throws CpClientError carrying the upstream status on non-2xx', async () => { + const fetchFn = (async () => new Response('nope', { status: 403 })) as typeof fetch + const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn }) + await expect(client.listHosts('acct-1', 't')).rejects.toMatchObject({ name: 'CpClientError', status: 403 }) + }) + + it('maps a network failure to a 502 CpClientError', async () => { + const fetchFn = (async () => { + throw new Error('ECONNREFUSED') + }) as typeof fetch + const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn }) + await expect(client.listHosts('acct-1', 't')).rejects.toMatchObject({ status: 502 }) + }) +}) + +describe('createCpClient.createPairingCode', () => { + it('POSTs and maps { code, expiresAt }', async () => { + const captured: { init?: RequestInit | undefined } = {} + const fetchFn = (async (_url: string | URL | Request, init?: RequestInit) => { + captured.init = init + return jsonResponse({ code: 'ABCD-EFGH', expiresAt: '2026-02-01T00:00:00.000Z' }, 201) + }) as typeof fetch + const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn }) + const issued = await client.createPairingCode('acct-1', 'TOK') + expect(captured.init?.method).toBe('POST') + expect(issued).toEqual({ code: 'ABCD-EFGH', expiresAt: '2026-02-01T00:00:00.000Z' }) + }) + + it('rejects a malformed CP pairing response', async () => { + const fetchFn = (async () => jsonResponse({ nope: true }, 201)) as typeof fetch + const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn }) + await expect(client.createPairingCode('acct-1', 't')).rejects.toBeInstanceOf(Error) + }) +}) + +describe('createCpClient.deleteHost', () => { + it('DELETEs and resolves on 204', async () => { + const captured: { url?: string; init?: RequestInit | undefined } = {} + const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => { + captured.url = String(url) + captured.init = init + return new Response(null, { status: 204 }) + }) as typeof fetch + const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn }) + await client.deleteHost('host-42', 'TOK') + expect(captured.url).toBe('http://127.0.0.1:8080/hosts/host-42') + expect(captured.init?.method).toBe('DELETE') + }) + + it('throws CpClientError on a non-2xx delete', async () => { + const fetchFn = (async () => new Response('no', { status: 404 })) as typeof fetch + const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn }) + await expect(client.deleteHost('h', 't')).rejects.toMatchObject({ status: 404 }) + }) +}) diff --git a/control-panel/test/helpers.ts b/control-panel/test/helpers.ts new file mode 100644 index 0000000..b6141ba --- /dev/null +++ b/control-panel/test/helpers.ts @@ -0,0 +1,91 @@ +/** + * Shared test helpers: a valid PanelConfig factory, an authed session cookie, an Ed25519 PKCS#8 PEM + * generator (for the manage-token test), and simple fakes for the CP client + token minter. + */ +import { SESSION_COOKIE_NAME } from '../src/security/cookies.js' +import { createSessionToken } from '../src/security/session.js' +import type { PanelConfig } from '../src/config.js' +import type { CpClient, HostView, IssuedPairing } from '../src/cp-client.js' +import type { ManageTokenMinter } from '../src/manage-token.js' + +export const TEST_SESSION_SECRET = 'test-session-secret-0123456789' + +export function makeConfig(overrides: Partial = {}): PanelConfig { + return { + panelPassword: 'hunter2-correct-horse', + sessionSecret: TEST_SESSION_SECRET, + cpUrl: 'http://127.0.0.1:8080', + baseDomain: 'terminal.yaojia.wang', + operatorAccountId: 'acct-operator-001', + capabilitySignKeyPath: '/nonexistent/key.pem', + tunnelZone: 'terminal.yaojia.wang', + panelBindPort: 8090, + ...overrides, + } +} + +/** A valid session cookie header value for injection (`cookie` header). */ +export function authCookieHeader(secret: string = TEST_SESSION_SECRET, nowMs: number = Date.now()): string { + return `${SESSION_COOKIE_NAME}=${createSessionToken(secret, nowMs)}` +} + +/** A minter that returns a fixed opaque token and records how many times it was called. */ +export function fakeMinter(token = 'fake.manage.token'): ManageTokenMinter & { readonly calls: () => number } { + let n = 0 + return { + async mint() { + n += 1 + return token + }, + calls: () => n, + } +} + +export interface FakeCpCall { + readonly method: string + readonly accountIdOrHostId: string + readonly token: string +} + +/** A CP client fake that records calls and returns canned data (or throws an injected error). */ +export function fakeCpClient(opts: { + hosts?: readonly HostView[] + pairing?: IssuedPairing + throwErr?: Error +} = {}): CpClient & { readonly calls: readonly FakeCpCall[] } { + const calls: FakeCpCall[] = [] + const guard = (): void => { + if (opts.throwErr) throw opts.throwErr + } + return { + calls, + async listHosts(accountId, token) { + calls.push({ method: 'listHosts', accountIdOrHostId: accountId, token }) + guard() + return opts.hosts ?? [] + }, + async createPairingCode(accountId, token) { + calls.push({ method: 'createPairingCode', accountIdOrHostId: accountId, token }) + guard() + return opts.pairing ?? { code: 'ABCD-EFGH', expiresAt: '2026-01-01T00:00:00.000Z' } + }, + async deleteHost(hostId, token) { + calls.push({ method: 'deleteHost', accountIdOrHostId: hostId, token }) + guard() + }, + } +} + +/** Generate an Ed25519 PKCS#8 PEM (private key) for signing-key tests. */ +export async function generatePkcs8Pem(): Promise<{ pem: string; publicRaw: Uint8Array }> { + const pair = (await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as { + publicKey: CryptoKey + privateKey: CryptoKey + } + const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey('pkcs8', pair.privateKey)) + const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey)) + const b64 = Buffer.from(pkcs8).toString('base64') + const lines = b64.match(/.{1,64}/g) ?? [b64] + const pem = `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n` + return { pem, publicRaw: raw } +} diff --git a/control-panel/test/manage-token.test.ts b/control-panel/test/manage-token.test.ts new file mode 100644 index 0000000..e01e3f6 --- /dev/null +++ b/control-panel/test/manage-token.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { writeFile, mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { peekPasetoClaims } from 'relay-auth/src/crypto/paseto.js' +import { createManageTokenMinter, ManageTokenError } from '../src/manage-token.js' +import { generatePkcs8Pem } from './helpers.js' + +async function writeKey(): Promise { + const { pem } = await generatePkcs8Pem() + const dir = await mkdtemp(join(tmpdir(), 'cp-key-')) + const path = join(dir, 'capability-sign.key.pem') + await writeFile(path, pem, 'utf8') + return path +} + +describe('createManageTokenMinter', () => { + it('mints a manage token with the correct aud, sub, rights, and TTL', async () => { + const keyPath = await writeKey() + const minter = createManageTokenMinter({ + capabilitySignKeyPath: keyPath, + baseDomain: 'terminal.yaojia.wang', + operatorAccountId: 'acct-op-1', + }) + + const token = await minter.mint() + expect(token.startsWith('v4.public.')).toBe(true) + + const claims = peekPasetoClaims(token) as Record + expect(claims.aud).toBe('terminal.yaojia.wang') + expect(claims.sub).toBe('acct-op-1') + expect(claims.rights).toEqual(['manage']) + expect(typeof claims.host).toBe('string') + expect((claims.exp as number) - (claims.iat as number)).toBe(60) + // The additive DPoP proof-of-possession binding must be present. + expect((claims.cnf as { jkt?: string })?.jkt).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + + it('mints a fresh token each call (distinct jti)', async () => { + const keyPath = await writeKey() + const minter = createManageTokenMinter({ + capabilitySignKeyPath: keyPath, + baseDomain: 'd', + operatorAccountId: 'a', + }) + const a = peekPasetoClaims(await minter.mint()) as Record + const b = peekPasetoClaims(await minter.mint()) as Record + expect(a.jti).not.toBe(b.jti) + }) + + it('throws ManageTokenError when the key file is missing', async () => { + const minter = createManageTokenMinter({ + capabilitySignKeyPath: '/nonexistent/does-not-exist.pem', + baseDomain: 'd', + operatorAccountId: 'a', + }) + await expect(minter.mint()).rejects.toBeInstanceOf(ManageTokenError) + }) +}) diff --git a/control-panel/test/pairing.test.ts b/control-panel/test/pairing.test.ts new file mode 100644 index 0000000..36cb5cc --- /dev/null +++ b/control-panel/test/pairing.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { buildPairCommand, buildQrDataUrl, buildPairingArtifacts } from '../src/pairing.js' + +describe('pairing artifacts', () => { + it('builds the ready-to-run pair command with the code and zone', () => { + const cmd = buildPairCommand('ABCD-EFGH', 'terminal.yaojia.wang') + expect(cmd).toBe('web-terminal-agent pair ABCD-EFGH --install --zone terminal.yaojia.wang') + }) + + it('renders a PNG data URL for the code', async () => { + const url = await buildQrDataUrl('ABCD-EFGH') + expect(url.startsWith('data:image/png;base64,')).toBe(true) + expect(url.length).toBeGreaterThan(100) + }) + + it('combines an issued code into the full artifacts payload', async () => { + const artifacts = await buildPairingArtifacts( + { code: 'WXYZ-1234', expiresAt: '2026-05-01T00:00:00.000Z' }, + 'z.example', + ) + expect(artifacts.code).toBe('WXYZ-1234') + expect(artifacts.expiresAt).toBe('2026-05-01T00:00:00.000Z') + expect(artifacts.pairCommand).toContain('WXYZ-1234') + expect(artifacts.pairCommand).toContain('z.example') + expect(artifacts.qrDataUrl.startsWith('data:image/png;base64,')).toBe(true) + }) +}) diff --git a/control-panel/test/rate-limit.test.ts b/control-panel/test/rate-limit.test.ts new file mode 100644 index 0000000..a1260df --- /dev/null +++ b/control-panel/test/rate-limit.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { createSlidingWindowLimiter } from '../src/security/rate-limit.js' + +describe('sliding-window rate limiter', () => { + it('allows up to max, then blocks within the window', () => { + let t = 0 + const limiter = createSlidingWindowLimiter(3, 1000, () => t) + expect(limiter.allow('ip')).toBe(true) + expect(limiter.allow('ip')).toBe(true) + expect(limiter.allow('ip')).toBe(true) + expect(limiter.allow('ip')).toBe(false) + }) + + it('does not record a rejected attempt (window frees up after it elapses)', () => { + let t = 0 + const limiter = createSlidingWindowLimiter(2, 1000, () => t) + expect(limiter.allow('ip')).toBe(true) + expect(limiter.allow('ip')).toBe(true) + expect(limiter.allow('ip')).toBe(false) // blocked, NOT recorded + t = 1001 // original two hits now outside the window + expect(limiter.allow('ip')).toBe(true) + expect(limiter.allow('ip')).toBe(true) + }) + + it('tracks buckets independently per key', () => { + let t = 0 + const limiter = createSlidingWindowLimiter(1, 1000, () => t) + expect(limiter.allow('a')).toBe(true) + expect(limiter.allow('a')).toBe(false) + expect(limiter.allow('b')).toBe(true) + }) +}) diff --git a/control-panel/test/security-headers.test.ts b/control-panel/test/security-headers.test.ts new file mode 100644 index 0000000..aa3e867 --- /dev/null +++ b/control-panel/test/security-headers.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { buildApp } from '../src/app.js' +import { makeConfig, fakeCpClient, fakeMinter } from './helpers.js' + +async function makeApp() { + return buildApp({ config: makeConfig(), cpClient: fakeCpClient(), minter: fakeMinter(), staticRoot: null }) +} + +const HARDENING: Readonly> = { + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + 'referrer-policy': 'no-referrer', +} + +describe('security response headers', () => { + it('stamps CSP + hardening headers on a matched route', async () => { + const app = await makeApp() + const res = await app.inject({ method: 'GET', url: '/api/session' }) + expect(res.statusCode).toBe(200) + for (const [name, value] of Object.entries(HARDENING)) expect(res.headers[name]).toBe(value) + + const csp = String(res.headers['content-security-policy']) + expect(csp).toContain("default-src 'self'") + expect(csp).toContain("img-src 'self' data:") // pairing QR is a data: image + expect(csp).toContain("style-src 'self' 'unsafe-inline'") + expect(csp).toContain("object-src 'none'") + expect(csp).toContain("base-uri 'none'") + expect(csp).toContain("frame-ancestors 'none'") + await app.close() + }) + + it('stamps the headers even on an unmatched (404) response', async () => { + const app = await makeApp() + const res = await app.inject({ method: 'GET', url: '/definitely-not-a-route' }) + expect(res.statusCode).toBe(404) + expect(res.headers['x-frame-options']).toBe('DENY') + expect(String(res.headers['content-security-policy'])).toContain("default-src 'self'") + await app.close() + }) +}) diff --git a/control-panel/test/session.test.ts b/control-panel/test/session.test.ts new file mode 100644 index 0000000..d950369 --- /dev/null +++ b/control-panel/test/session.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { createSessionToken, verifySessionToken, SESSION_TTL_SEC } from '../src/security/session.js' + +const SECRET = 'session-secret-value-1234567890' + +describe('session token', () => { + it('round-trips: a freshly minted token verifies', () => { + const now = 1_000_000_000_000 + const token = createSessionToken(SECRET, now) + expect(verifySessionToken(SECRET, token, now)).toBe(true) + }) + + it('rejects a token signed with a different secret', () => { + const now = Date.now() + const token = createSessionToken(SECRET, now) + expect(verifySessionToken('another-secret-value-000000000', token, now)).toBe(false) + }) + + it('rejects a tampered MAC', () => { + const now = Date.now() + const token = createSessionToken(SECRET, now) + const [payload] = token.split('.') + expect(verifySessionToken(SECRET, `${payload}.deadbeef`, now)).toBe(false) + }) + + it('rejects a tampered (extended) expiry', () => { + const now = Date.now() + const token = createSessionToken(SECRET, now) + const mac = token.split('.')[1] + const farFuture = Math.floor(now / 1000) + 999999 + expect(verifySessionToken(SECRET, `${farFuture}.${mac}`, now)).toBe(false) + }) + + it('rejects an expired token', () => { + const now = 1_000_000_000_000 + const token = createSessionToken(SECRET, now) + const afterExpiry = now + (SESSION_TTL_SEC + 1) * 1000 + expect(verifySessionToken(SECRET, token, afterExpiry)).toBe(false) + }) + + it('rejects malformed tokens', () => { + const now = Date.now() + expect(verifySessionToken(SECRET, undefined, now)).toBe(false) + expect(verifySessionToken(SECRET, '', now)).toBe(false) + expect(verifySessionToken(SECRET, 'no-dot', now)).toBe(false) + expect(verifySessionToken(SECRET, 'notanumber.abcd', now)).toBe(false) + }) +}) diff --git a/control-panel/tsconfig.json b/control-panel/tsconfig.json new file mode 100644 index 0000000..68d14a5 --- /dev/null +++ b/control-panel/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "noImplicitAny": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "public"] +} diff --git a/control-panel/tsconfig.web.json b/control-panel/tsconfig.web.json new file mode 100644 index 0000000..6d1ed3c --- /dev/null +++ b/control-panel/tsconfig.web.json @@ -0,0 +1,19 @@ +{ + "// note": "Frontend (browser) config for public/*.ts — TYPE-CHECK ONLY (noEmit). The browser bundle is produced by esbuild (build.mjs), which emits public/build/app.js loaded by index.html.", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["public/**/*.ts"], + "exclude": ["public/build"] +} diff --git a/control-panel/vitest.config.ts b/control-panel/vitest.config.ts new file mode 100644 index 0000000..bf8e4ea --- /dev/null +++ b/control-panel/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + environment: 'node', + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + // server.ts is the process entrypoint (binds a socket) — an integration seam, not a unit. + exclude: ['**/*.d.ts', 'src/server.ts'], + }, + }, +})