diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index f69e8d68..10f85b8b 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -40,7 +40,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: - node-version: '20' + node-version: '22.13.0' cache: 'npm' - name: Install dependencies diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index d82dd23b..458d3aa9 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -25,10 +25,10 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: Setup Node 22 + - name: Setup Node 22.13 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: - node-version: 22 + node-version: 22.13.0 - name: Install run: npm ci - name: Unit tests (EVM · CPM · baseline · workload) @@ -45,10 +45,10 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: Setup Node 22 + - name: Setup Node 22.13 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: - node-version: 22 + node-version: 22.13.0 - name: Install run: npm ci - name: Install Playwright (chromium) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef2d159..e84f41f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,11 +20,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 workflows stay inherited from `ContextualWisdomLab/.github`, not copied into this repository. +### Security + +- Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or + unexpanded placeholder values so production deployments fail closed. +- Neutralized audit-log CSV formulas even when executable prefixes are hidden + behind leading whitespace. +- Replaced dynamic and lazy-regex MS Project XML block extraction with bounded + linear scans to prevent pathological backtracking on malformed imports. +- Rejected non-string password candidates at the authentication boundary. +- Added regression coverage that prevents array-valued passwords from being + coerced into valid credentials. +- Updated Hono runtime dependencies to patched supported releases. + ### Changed - 프로젝트 이름 입력 필드에 입력 예시(placeholder)를 추가하여 사용자 편의성을 개선했습니다. - 데이터 테이블의 반복되는 액션 버튼에 컨텍스트 정보(작업명)를 포함한 명시적인 ARIA 레이블을 추가하고, 유효성 검사 에러를 폼 필드에 연결하여 접근성을 개선했습니다. - `createGanttBarElement`, `renderGantt`, `buildWeekdayTimeline`에서 반복적으로 호출되던 `compareDateStrings`를 직접적인 문자열 비교 연산(`>=`, `<=`)으로 교체하여 O(N*D) 복잡도의 캐시 스레싱과 정규식 검사를 방지했습니다. +- Treat fields added only to an editor draft as unsaved changes so unload and + cancel safeguards cannot silently discard newly introduced data. - Centralized OpenCode Review, Strix Security Scan, PR Review Merge Scheduler, failed-check explanation, and coverage evidence ownership in `ContextualWisdomLab/.github`, removing repository-local workflow, @@ -46,4 +61,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b3596e61..b1f11c4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ CPM critical path, and a weekly Gantt overlay. Two modes: # Standalone client (no install needed) python3 -m http.server 4173 # open http://127.0.0.1:4173 -# Cloud server (Node >= 22 — uses node:sqlite) +# Cloud server (Node ^22.13.0 || >=23.4.0 — uses node:sqlite; matches package.json engines) npm install npm run server # API + static client on :8787 @@ -40,7 +40,11 @@ python3 -m pytest tests/config # workflow-ownership / governance checks node tests/unit/cpm.test.mjs npx playwright test tests/e2e/scopeweave.spec.js -# Full stack via Docker (needs SCOPEWEAVE_JWT_SECRET in prod) +# Full stack via Docker (needs SCOPEWEAVE_JWT_SECRET — persist across restarts) +# Generate once and store outside git (e.g. shell profile / secrets manager). +# Re-running openssl each start mints a new key and invalidates existing JWTs. +: "${SCOPEWEAVE_JWT_SECRET:?Set a persistent ≥32-char secret before starting}" +# First-time only: export SCOPEWEAVE_JWT_SECRET="$(openssl rand -base64 32)" docker compose up --build # Dockerfile.server → :8787 ``` diff --git a/Dockerfile.server b/Dockerfile.server index b83f2c65..579360f1 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -16,7 +16,7 @@ COPY index.html 404.html app.js cloud-sync.js analytics.js styles.css wbs.json . ENV PORT=8787 ENV SCOPEWEAVE_DB=/data/scopeweave.db -# SCOPEWEAVE_JWT_SECRET MUST be supplied at runtime — the app warns on the dev default. +# SCOPEWEAVE_JWT_SECRET MUST be supplied at runtime; startup fails when it is weak or absent. RUN mkdir -p /data && chown -R node:node /data USER node diff --git a/README.md b/README.md index 3011a929..ea8bb77a 100644 --- a/README.md +++ b/README.md @@ -79,20 +79,23 @@ Standalone: python3 -m http.server 4173 # open http://127.0.0.1:4173 ``` -Cloud (Node ≥ 22): +Cloud (Node 22.13+ or 23.4+): ```bash npm install +# Persist this across restarts (do not re-mint every boot — that invalidates JWTs). +export SCOPEWEAVE_JWT_SECRET="${SCOPEWEAVE_JWT_SECRET:-$(openssl rand -base64 32)}" npm run server # serves the API + the static client on :8787 ``` -Docker: `docker compose up` (see `Dockerfile.server` / `docs/deploy.md`). +Docker: set a **persistent** `SCOPEWEAVE_JWT_SECRET` first, then run `docker compose up` +(see `Dockerfile.server` / `docs/deploy.md`). ### Environment | Var | Purpose | | --- | --- | -| `SCOPEWEAVE_JWT_SECRET` | **Required in prod** — JWT signing secret | +| `SCOPEWEAVE_JWT_SECRET` | **Required** — JWT signing secret (at least 32 non-whitespace characters; startup fails closed otherwise) | | `SCOPEWEAVE_DB` | SQLite path (default `data.db`; `:memory:` for tests) | | `PORT` | API port (default 8787) | | `OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI` | Real SSO IdP (mock when unset) | diff --git a/app.js b/app.js index b8c62279..a04aae71 100644 --- a/app.js +++ b/app.js @@ -347,7 +347,28 @@ function bindModalEvents() { }); } +function editorHasUnsavedChanges() { + if (!state.editor.mode || !state.editor.draft || !state.editor.initialDraft) { + return false; + } + const draftKeys = new Set([ + ...Object.keys(state.editor.initialDraft), + ...Object.keys(state.editor.draft), + ]); + return Array.from(draftKeys).some( + (key) => state.editor.draft[key] !== state.editor.initialDraft[key] + ); +} + function bindGlobalEvents() { + // Warn on tab close/refresh when the inline editor has a dirty draft. + // Browsers only show a generic leave-site dialog; returnValue is required. + window.addEventListener('beforeunload', (event) => { + if (!editorHasUnsavedChanges()) return; + event.preventDefault(); + event.returnValue = ''; + }); + document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { if (!elements.ganttModal.classList.contains('hidden')) { @@ -1196,11 +1217,8 @@ function openEditor({ mode, targetId = null, parentId = null, depth = 1, insertA } function closeEditor(force = false) { - if (!force && state.editor.draft && state.editor.initialDraft) { - const hasChanges = Object.keys(state.editor.initialDraft).some( - key => state.editor.draft[key] !== state.editor.initialDraft[key] - ); - if (hasChanges && !window.confirm('저장하지 않은 변경 사항이 있습니다. 편집을 취소하시겠습니까?')) { + if (!force && editorHasUnsavedChanges()) { + if (!window.confirm('저장하지 않은 변경 사항이 있습니다. 편집을 취소하시겠습니까?')) { return; } } diff --git a/cloud-sync.js b/cloud-sync.js index 7e44932b..9016cfbf 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -739,9 +739,39 @@ function openReportModal() { // no DOMParser needed → node-testable); swap for a real XML parser if // hand-edited files ever matter. export function parseMsProjectXml(xml) { + // Fully linear extract (indexOf/slice) — no dynamic RegExp and no lazy + // [\s\S]*? block collectors (those can quadratic-backtrack on truncated input). const tag = (block, name) => { - const m = block.match(new RegExp(`<${name}>([^<]*)`)); - return m ? m[1].trim() : ''; + const openingTag = `<${name}>`; + const closingTag = ``; + const valueStart = block.indexOf(openingTag); + if (valueStart === -1) return ''; + const contentStart = valueStart + openingTag.length; + const valueEnd = block.indexOf(closingTag, contentStart); + return valueEnd === -1 ? '' : block.slice(contentStart, valueEnd).trim(); + }; + const collectBlocks = (source, openTag, closeTag) => { + const out = []; + let from = 0; + for (;;) { + const start = source.indexOf(openTag, from); + if (start === -1) break; + const contentStart = start + openTag.length; + const end = source.indexOf(closeTag, contentStart); + // Incomplete open tag: stop linearly (do not rescan the remainder). + if (end === -1) break; + out.push(source.slice(start, end + closeTag.length)); + from = end + closeTag.length; + } + return out; + }; + const predecessorIds = (block) => { + const ids = []; + for (const link of collectBlocks(block, '', '')) { + const uid = tag(link, 'PredecessorUID'); + if (/^\d+$/.test(uid)) ids.push(`msp-${uid}`); + } + return ids; }; const unescape = (s) => s .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') @@ -749,15 +779,14 @@ export function parseMsProjectXml(xml) { const day = (s) => (/^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : ''); const tasks = []; const parents = {}; // depth -> last task id at that depth - const blocks = xml.match(/[\s\S]*?<\/Task>/g) || []; + const blocks = collectBlocks(String(xml || ''), '', ''); for (const block of blocks) { const uid = tag(block, 'UID'); const name = unescape(tag(block, 'Name')); if (!uid || uid === '0' || !name) continue; // project-summary row / blanks const level = Math.max(1, Number(tag(block, 'OutlineLevel')) || 1); const depth = Math.min(level, 3); // deeper levels flatten to task level - const preds = [...block.matchAll(/[\s\S]*?(\d+)<\/PredecessorUID>[\s\S]*?<\/PredecessorLink>/g)] - .map((m) => `msp-${m[1]}`); + const preds = predecessorIds(block); const pct = Number(tag(block, 'PercentComplete')) || 0; const t = { id: `msp-${uid}`, diff --git a/docker-compose.yml b/docker-compose.yml index 52b434c9..4d50d0d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ # Local / single-node full-stack ScopeWeave SaaS. # Usage: -# export SCOPEWEAVE_JWT_SECRET=$(openssl rand -base64 32) +# # First-time only: generate a secret, then store it outside Git. +# # Restore the same value from a shell profile or secrets manager on every start. +# export SCOPEWEAVE_JWT_SECRET="$(openssl rand -base64 32)" +# : "${SCOPEWEAVE_JWT_SECRET:?Restore the persistent JWT secret before starting}" # docker compose up --build # Then open http://localhost:8787 services: @@ -11,9 +14,9 @@ services: ports: - "8787:8787" environment: - # Default keeps `docker compose config/build` runnable (CI evidence gate); - # the server logs a loud warning if this insecure default reaches runtime. - SCOPEWEAVE_JWT_SECRET: ${SCOPEWEAVE_JWT_SECRET:-insecure-dev-secret-CHANGE-ME} + # Required at runtime (≥32 non-whitespace chars). No insecure default — + # server/auth.mjs fails closed when unset or weak. + SCOPEWEAVE_JWT_SECRET: ${SCOPEWEAVE_JWT_SECRET:-} volumes: - scopeweave-data:/data restart: unless-stopped diff --git a/docs/deploy.md b/docs/deploy.md index de8ade7f..db9d4c56 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -5,12 +5,25 @@ one origin (so the browser's `default-src 'self'` CSP allows the API calls). ## Quick start (Docker Compose) +Create the signing key once in a user-only file outside the repository, then +reload that same key for every restart. Replacing it invalidates all existing +session JWTs. + ```bash -export SCOPEWEAVE_JWT_SECRET=$(openssl rand -base64 32) # required +install -d -m 700 "$HOME/.config/scopeweave" +if [ ! -s "$HOME/.config/scopeweave/jwt-secret" ]; then + umask 077 + openssl rand -base64 32 > "$HOME/.config/scopeweave/jwt-secret" +fi +export SCOPEWEAVE_JWT_SECRET="$(cat "$HOME/.config/scopeweave/jwt-secret")" docker compose up --build # open http://localhost:8787 ``` +For managed deployments, store the same value in the platform's secrets +manager instead of a local file. Rotate it only as an intentional global +session-revocation operation. + That builds `Dockerfile.server`, runs the Node backend as a non-root user, and persists the database in the `scopeweave-data` volume. @@ -18,7 +31,7 @@ persists the database in the `scopeweave-data` volume. | Var | Required | Purpose | | --- | --- | --- | -| `SCOPEWEAVE_JWT_SECRET` | **yes** | Signs session JWTs. Use a long random value. The app warns loudly if the dev default is used. | +| `SCOPEWEAVE_JWT_SECRET` | **yes** | Signs session JWTs. Startup fails unless it contains at least 32 non-whitespace characters. | | `PORT` | no (default 8787) | Listen port | | `SCOPEWEAVE_DB` | no (default `/data/scopeweave.db`) | SQLite file path (on the volume) | | `SCOPEWEAVE_DEV` | no | Must be `1` to enable the dev `activate-pro` endpoint. **Never set in production.** | diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..5b21a5e6 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,33 @@ +# ScopeWeave security invariants + +ScopeWeave treats the following controls as release-blocking invariants. A change that weakens one of these controls must include an explicit threat-model update and regression coverage. + +## Authentication and signing keys + +- `SCOPEWEAVE_JWT_SECRET` is mandatory at process startup. +- The secret must contain at least 32 non-whitespace characters and must not be an unexpanded environment placeholder. +- Production deployments must restore the same secret across restarts. Rotating it is an intentional operation because all existing JWTs become invalid. +- Password verification rejects non-string candidate values before hashing or comparison. + +## Session revocation + +Bearer-token middleware and every endpoint that accepts a JWT through another transport must compare the token's `tv` claim with the user's current database `token_version`. + +## Spreadsheet exports + +Every user-controlled CSV cell is neutralized when, after optional leading whitespace, it begins with `=`, `+`, `-`, `@`, or `|`. Export code must not rely on callers to sanitize values. + +## XML imports + +Microsoft Project XML extraction uses bounded `indexOf`/`slice` loops. Dynamic regular expressions and lazy whole-document block collectors are prohibited because truncated or adversarial input can cause catastrophic backtracking. + +## Release verification + +Before merging security-sensitive changes, the current head must pass: + +- unit and API tests; +- cloud UI end-to-end tests; +- property fuzzing; +- dependency and OSV review; +- Semgrep and repository security scans; +- required independent review gates, including coverage evidence. diff --git a/package-lock.json b/package-lock.json index 21575e82..079e2031 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,26 +8,78 @@ "name": "scopeweave", "version": "1.0.0", "dependencies": { - "@hono/node-server": "^1.19.14", - "hono": "^4.12.27" + "@hono/node-server": "^2.0.12", + "hono": "^4.12.32" }, "devDependencies": { "@playwright/test": "1.61.1", + "c8": "12.0.0", "fast-check": "4.9.0" + }, + "engines": { + "node": "^22.13.0 || >=23.4.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/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/@playwright/test": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", @@ -44,6 +96,168 @@ "node": ">=18" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-check": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", @@ -67,6 +281,40 @@ "node": ">=12.17.0" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -82,15 +330,256 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/hono": { - "version": "4.12.27", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", - "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "license": "MIT", "engines": { "node": ">=16.9.0" } }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "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/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/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/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/playwright": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", @@ -139,6 +628,244 @@ } ], "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/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/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 9ae8b292..7790e678 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,18 @@ "version": "1.0.0", "private": true, "type": "module", + "packageManager": "npm@10.9.2", "description": "Production-grade pure HTML/CSS/JS WBS planner", + "engines": { + "node": "^22.13.0 || >=23.4.0" + }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "node scripts/ci/static_coverage_evidence.mjs coverage && npm run test:fuzz", + "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", + "test:coverage": "node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js", @@ -17,11 +22,12 @@ "fuzz": "node --test tests/fuzz/*.mjs" }, "dependencies": { - "@hono/node-server": "^1.19.14", - "hono": "^4.12.27" + "@hono/node-server": "^2.0.12", + "hono": "^4.12.32" }, "devDependencies": { "@playwright/test": "1.61.1", + "c8": "12.0.0", "fast-check": "4.9.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index bffabf92..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,91 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@hono/node-server': - specifier: ^1.19.14 - version: 1.19.14(hono@4.12.28) - hono: - specifier: ^4.12.27 - version: 4.12.28 - devDependencies: - '@playwright/test': - specifier: 1.61.1 - version: 1.61.1 - fast-check: - specifier: 4.9.0 - version: 4.9.0 - -packages: - - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - - '@playwright/test@1.61.1': - resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} - engines: {node: '>=18'} - hasBin: true - - fast-check@4.9.0: - resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} - engines: {node: '>=12.17.0'} - - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - hono@4.12.28: - resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} - engines: {node: '>=16.9.0'} - - playwright-core@1.61.1: - resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.61.1: - resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} - engines: {node: '>=18'} - hasBin: true - - pure-rand@8.4.1: - resolution: {integrity: sha512-c58R2+SPFcSIPXoU834QN/KPDDOSd8sXcSrqf6e83Me6Rrp1EYkxukkjXMVrKvKaADs1SOyNkWdfvLf6zY8qLQ==} - -snapshots: - - '@hono/node-server@1.19.14(hono@4.12.28)': - dependencies: - hono: 4.12.28 - - '@playwright/test@1.61.1': - dependencies: - playwright: 1.61.1 - - fast-check@4.9.0: - dependencies: - pure-rand: 8.4.1 - - fsevents@2.3.2: - optional: true - - hono@4.12.28: {} - - playwright-core@1.61.1: {} - - playwright@1.61.1: - dependencies: - playwright-core: 1.61.1 - optionalDependencies: - fsevents: 2.3.2 - - pure-rand@8.4.1: {} diff --git a/scripts/ci/static_coverage_evidence.mjs b/scripts/ci/static_coverage_evidence.mjs index b54ebf6b..b0cc5185 100644 --- a/scripts/ci/static_coverage_evidence.mjs +++ b/scripts/ci/static_coverage_evidence.mjs @@ -1,16 +1,16 @@ #!/usr/bin/env node import { execFileSync } from 'node:child_process'; -import { mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { resolve } from 'node:path'; const mode = process.argv[2]; -function run(command, args) { - execFileSync(command, args, { stdio: 'inherit' }); -} - function gitFiles(pathspec) { - return execFileSync('git', ['ls-files', pathspec], { encoding: 'utf8' }) + const repoRoot = resolve('.'); + return execFileSync( + 'git', + ['-c', `safe.directory=${repoRoot}`, '-C', repoRoot, 'ls-files', pathspec], + { encoding: 'utf8' }, + ) .split('\n') .filter(Boolean); } @@ -27,29 +27,9 @@ function checkDocstringScope() { console.log('Python files are CI helpers or tests; runtime docstring coverage is not applicable.'); } -function writeStaticCoverageSummary() { - run(process.execPath, ['--check', 'app.js']); - mkdirSync('coverage', { recursive: true }); - const metric = { total: 1, covered: 1, skipped: 0, pct: 100 }; - writeFileSync( - join('coverage', 'coverage-summary.json'), - JSON.stringify({ - total: { - lines: metric, - statements: metric, - functions: metric, - branches: metric - } - }, null, 2) - ); - console.log('Wrote static app coverage gate evidence to coverage/coverage-summary.json.'); -} - if (mode === 'docstrings') { checkDocstringScope(); -} else if (mode === 'coverage') { - writeStaticCoverageSummary(); } else { - console.error('Usage: static_coverage_evidence.mjs '); + console.error('Usage: static_coverage_evidence.mjs docstrings'); process.exit(2); } diff --git a/server/app.mjs b/server/app.mjs index 926d528d..13d95e5d 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -154,7 +154,7 @@ if (RL_MAX > 0) { app.post('/api/auth/signup', async (c) => { const { email, password, name } = await c.req.json().catch(() => ({})); - if (!email || !password || String(password).length < 8) { + if (!email || typeof password !== 'string' || password.length < 8) { return c.json({ error: 'email and password (min 8 chars) required' }, 400); } if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) { @@ -178,7 +178,9 @@ app.post('/api/auth/signup', async (c) => { app.post('/api/auth/login', async (c) => { const { email, password } = await c.req.json().catch(() => ({})); const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email || ''); - if (!u || !verifyPassword(password || '', u.password_hash)) { + // Pass password through only when it is a string — verifyPassword rejects + // non-strings (objects/arrays) so they never match an empty-password hash. + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { return c.json({ error: 'invalid credentials' }, 401); } return c.json({ token: signToken({ sub: u.id, email: u.email, tv: u.token_version }) }); @@ -649,11 +651,13 @@ app.get('/api/orgs/:id/audit', requireAuth, (c) => { ).all(orgId, limit); const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); if (c.req.query('format') === 'csv') { - // Compliance deliverable. Formula-injection-safe: values starting with - // = + - @ | are prefixed with ' so spreadsheets treat them as text. + // Compliance deliverable. Formula-injection-safe: values that (after optional + // leading whitespace) start with = + - @ | are prefixed with ' so + // spreadsheets treat them as text. Leading whitespace alone used to bypass + // /^[=+\-@|]/ — match the client-side CSV_FORMULA_PREFIX_PATTERN. const csvCell = (v) => { let s = v == null ? '' : String(v); - if (/^[=+\-@|]/.test(s)) s = `'${s}`; + if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; @@ -1308,9 +1312,11 @@ app.post('/api/auth/logout-all', requireAuth, (c) => { app.post('/api/auth/change-password', requireAuth, async (c) => { const uid = c.get('user').sub; const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); - if (!newPassword || String(newPassword).length < 8) return c.json({ error: 'new password (min 8) required' }, 400); + if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); - if (!u || !verifyPassword(oldPassword || '', u.password_hash)) return c.json({ error: 'current password incorrect' }, 403); + if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { + return c.json({ error: 'current password incorrect' }, 403); + } db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); return c.json({ ok: true }); }); @@ -1321,7 +1327,9 @@ app.delete('/api/account', requireAuth, async (c) => { const uid = c.get('user').sub; const { password } = await c.req.json().catch(() => ({})); const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); - if (!u || !verifyPassword(password || '', u.password_hash)) return c.json({ error: 'password required to delete account' }, 403); + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'password required to delete account' }, 403); + } db.exec('BEGIN'); try { db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit diff --git a/server/auth.mjs b/server/auth.mjs index 3d0b171f..a16a7281 100644 --- a/server/auth.mjs +++ b/server/auth.mjs @@ -13,18 +13,31 @@ export function hashApiToken(full) { return createHash('sha256').update(String(full)).digest('hex'); } -const SECRET = process.env.SCOPEWEAVE_JWT_SECRET || 'dev-insecure-secret-change-me'; -if (SECRET === 'dev-insecure-secret-change-me') { - console.warn('[auth] INSECURE dev JWT secret in use — set SCOPEWEAVE_JWT_SECRET in production'); +// Fail closed: never mint or verify tokens with a missing/weak/placeholder secret. +// Require ≥32 non-whitespace characters so compose-unexpanded literals and short +// defaults cannot silently ship. +const SECRET = process.env.SCOPEWEAVE_JWT_SECRET; +if ( + typeof SECRET !== 'string' + || SECRET.replace(/\s/g, '').length < 32 + || SECRET.includes('${SCOPEWEAVE_JWT_SECRET') +) { + throw new Error('SCOPEWEAVE_JWT_SECRET must be set to at least 32 non-whitespace characters'); } +// scryptSync requires string|ArrayBufferView — untyped JSON bodies must not +// throw TypeError (request-level DoS). hashPassword coerces non-strings to '' +// for a stable hash path; verifyPassword rejects non-strings with false so a +// malicious `{}` body never authenticates even if an empty-password hash exists. export function hashPassword(pw) { + const password = typeof pw === 'string' ? pw : ''; const salt = randomBytes(16).toString('hex'); - const hash = scryptSync(pw, salt, 64).toString('hex'); + const hash = scryptSync(password, salt, 64).toString('hex'); return `${salt}:${hash}`; } export function verifyPassword(pw, stored) { + if (typeof pw !== 'string') return false; const [salt, hash] = String(stored || '').split(':'); if (!salt || !hash) return false; const test = scryptSync(pw, salt, 64); diff --git a/tests/api/auth-secret.test.mjs b/tests/api/auth-secret.test.mjs new file mode 100644 index 00000000..0094d6c8 --- /dev/null +++ b/tests/api/auth-secret.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const importAuth = (secret) => { + const env = { ...process.env }; + if (secret === undefined) delete env.SCOPEWEAVE_JWT_SECRET; + else env.SCOPEWEAVE_JWT_SECRET = secret; + + return spawnSync( + process.execPath, + ['--input-type=module', '--eval', "await import('./server/auth.mjs')"], + { cwd: process.cwd(), env, encoding: 'utf8' }, + ); +}; + +for (const secret of [ + undefined, + '', + 'x'.repeat(31), + ' '.repeat(32), + `${'x'.repeat(31)} `, + '${SCOPEWEAVE_JWT_SECRET:-}', + // Unexpanded Compose placeholder that is already ≥32 non-whitespace chars — + // length alone must not admit placeholders (includes '${SCOPEWEAVE_JWT_SECRET'). + `\${SCOPEWEAVE_JWT_SECRET:-${'x'.repeat(32)}}`, +]) { + const result = importAuth(secret); + assert.notEqual(result.status, 0, 'missing or weak JWT secrets must fail startup'); + assert.match(result.stderr, /SCOPEWEAVE_JWT_SECRET must be set/); +} + +assert.equal( + importAuth('0123456789abcdef0123456789abcdef').status, + 0, + 'a 32-character JWT secret permits startup', +); + +const compose = readFileSync('docker-compose.yml', 'utf8'); +const jwtEnvLine = compose.match(/^[ \t]+SCOPEWEAVE_JWT_SECRET:[ \t]*([^\r\n]*)$/m); +assert.ok(jwtEnvLine, 'active JWT environment mapping'); +assert.equal(jwtEnvLine[1].trim(), '${SCOPEWEAVE_JWT_SECRET:-}'); + +console.log('✓ JWT secret startup contract tests passed'); diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index 1d4cd826..7157e523 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; -process.env.SCOPEWEAVE_JWT_SECRET = 'test-secret'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const { app } = await import('../../server/app.mjs'); const req = (path, opts = {}) => diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index f50830f5..84809c69 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -5,7 +5,7 @@ import assert from 'node:assert'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_DEV = '1'; // enables the dev-activate-pro endpoint for this test -process.env.SCOPEWEAVE_JWT_SECRET = 'test-secret'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const { app } = await import('../../server/app.mjs'); const req = (path, opts = {}) => @@ -27,10 +27,24 @@ assert.equal(r.status, 409, 'duplicate email → 409'); r = await req('/api/auth/signup', { method: 'POST', body: body({ email: 'x@y.com', password: 'short' }) }); assert.equal(r.status, 400, 'weak password → 400'); +// non-string password rejected at the API boundary (objects must not coerce) +r = await req('/api/auth/signup', { method: 'POST', body: body({ email: 'obj@y.com', password: { x: 1 } }) }); +assert.equal(r.status, 400, 'object password signup → 400'); +r = await req('/api/auth/signup', { method: 'POST', body: body({ email: 'arr@y.com', password: ['password123'] }) }); +assert.equal(r.status, 400, 'array password signup → 400'); + // wrong password rejected r = await req('/api/auth/login', { method: 'POST', body: body({ email: 'a@b.com', password: 'nope' }) }); assert.equal(r.status, 401, 'bad login → 401'); +// non-string login password never authenticates +r = await req('/api/auth/login', { method: 'POST', body: body({ email: 'a@b.com', password: { length: 12 } }) }); +assert.equal(r.status, 401, 'object password login → 401'); +r = await req('/api/auth/login', { method: 'POST', body: body({ email: 'a@b.com', password: null }) }); +assert.equal(r.status, 401, 'null password login → 401'); +r = await req('/api/auth/login', { method: 'POST', body: body({ email: 'a@b.com', password: ['password123'] }) }); +assert.equal(r.status, 401, 'array password login → 401'); + // me — has an owner workspace r = await req('/api/me', { headers: auth }); assert.equal(r.status, 200); @@ -479,6 +493,13 @@ r = await req('/api/auth/signup', { method: 'POST', body: body({ email: 'pw@x.co const pwAuth = { authorization: `Bearer ${(await r.json()).token}` }; r = await req('/api/auth/change-password', { method: 'POST', headers: pwAuth, body: body({ oldPassword: 'wrong', newPassword: 'newpass123' }) }); assert.equal(r.status, 403, 'wrong current password → 403'); +// non-string new/old passwords rejected at boundary +r = await req('/api/auth/change-password', { method: 'POST', headers: pwAuth, body: body({ oldPassword: 'password123', newPassword: { p: 1 } }) }); +assert.equal(r.status, 400, 'object newPassword → 400'); +r = await req('/api/auth/change-password', { method: 'POST', headers: pwAuth, body: body({ oldPassword: { p: 1 }, newPassword: 'newpass123' }) }); +assert.equal(r.status, 403, 'object oldPassword → 403'); +r = await req('/api/auth/change-password', { method: 'POST', headers: pwAuth, body: body({ oldPassword: ['password123'], newPassword: 'newpass123' }) }); +assert.equal(r.status, 403, 'array oldPassword → 403'); r = await req('/api/auth/change-password', { method: 'POST', headers: pwAuth, body: body({ oldPassword: 'password123', newPassword: 'newpass123' }) }); assert.equal(r.status, 200, 'password changed'); assert.equal((await req('/api/auth/login', { method: 'POST', body: body({ email: 'pw@x.com', password: 'newpass123' }) })).status, 200, 'login with new password'); @@ -487,17 +508,39 @@ assert.equal((await req('/api/auth/login', { method: 'POST', body: body({ email: r = await req('/api/auth/signup', { method: 'POST', body: body({ email: 'gone@x.com', password: 'password123' }) }); const goneAuth = { authorization: `Bearer ${(await r.json()).token}` }; assert.equal((await req('/api/account', { method: 'DELETE', headers: goneAuth, body: body({ password: 'wrong' }) })).status, 403, 'account delete needs password'); +assert.equal((await req('/api/account', { method: 'DELETE', headers: goneAuth, body: body({ password: { x: 1 } }) })).status, 403, 'object password delete → 403'); +assert.equal((await req('/api/account', { method: 'DELETE', headers: goneAuth, body: body({ password: ['password123'] }) })).status, 403, 'array password delete → 403'); assert.equal((await req('/api/account', { method: 'DELETE', headers: goneAuth, body: body({ password: 'password123' }) })).status, 200, 'account deleted'); assert.equal((await req('/api/auth/login', { method: 'POST', body: body({ email: 'gone@x.com', password: 'password123' }) })).status, 401, 'deleted account cannot login'); // ---- Audit CSV export ---- +// Plant a formula-injection payload with leading whitespace (the historic bypass +// of /^[=+\-@|]/). action is free text in the audit log; the CSV cell guard +// must neutralize it. +{ + const { db } = await import('../../server/db.mjs'); + db.prepare( + `INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) + VALUES(?,?,?,?,?,?)` + ).run(orgAId, null, ' =cmd|"/c calc"', 'probe', 'csv-inject', null); +} r = await req(`/api/orgs/${orgAId}/audit?format=csv`, { headers: auth }); assert.equal(r.status, 200, 'audit csv 200'); assert.ok((r.headers.get('content-type') || '').startsWith('text/csv'), 'text/csv'); const auditCsv = await r.text(); assert.ok(auditCsv.startsWith('id,createdAt,actorEmail,action'), 'csv header'); assert.ok(auditCsv.includes('project.create'), 'contains audited actions'); -assert.ok(!/^[=+\-@]/m.test(auditCsv.split('\r\n')[1] || ''), 'formula-injection guarded'); +assert.ok(!/^[=+\-@|]/.test(auditCsv.split('\r\n')[1] || ''), 'formula-injection guarded'); +// Leading-whitespace formula payloads must be quote-prefixed so Excel/LibreOffice +// treat the cell as text (not a DDE/formula). +assert.ok( + auditCsv.split('\r\n').some((line) => line.includes(`'=cmd|"/c calc"`) || line.includes(`' =cmd|`)), + 'whitespace-prefixed formula neutralized with leading single-quote' +); +assert.ok( + !auditCsv.split('\r\n').some((line) => /(^|,)\s*=cmd\|/.test(line)), + 'raw whitespace formula must not appear unquoted in CSV' +); r = await req(`/api/orgs/${orgAId}/audit?format=csv`, { headers: oauth }); assert.equal(r.status, 403, 'non-manager audit csv → 403'); diff --git a/tests/e2e/beforeunload.spec.js b/tests/e2e/beforeunload.spec.js new file mode 100644 index 00000000..c94c44ae --- /dev/null +++ b/tests/e2e/beforeunload.spec.js @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Inline editor unsaved-change guards', () => { + test('Escape on dirty editor prompts before discard', async ({ page }) => { + await page.goto('./'); + + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + await expect(page.locator('.editor-panel')).toBeVisible(); + + await page.locator('[data-testid="editor-phase"]').fill('Phase dirty'); + + page.once('dialog', async (dialog) => { + expect(dialog.message()).toContain('저장하지 않은 변경 사항'); + await dialog.dismiss(); + }); + await page.keyboard.press('Escape'); + + // Dismiss keeps the editor open with the draft. + await expect(page.locator('.editor-panel')).toBeVisible(); + await expect(page.locator('[data-testid="editor-phase"]')).toHaveValue('Phase dirty'); + }); + + test('beforeunload fires when draft is dirty', async ({ page }) => { + await page.goto('./'); + + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + await page.locator('[data-testid="editor-phase"]').fill('Phase leave'); + + // Wait on the dialog event before navigating — fixed sleeps flake under CI load. + const dialogPromise = page.waitForEvent('dialog', { + predicate: (dialog) => dialog.type() === 'beforeunload', + timeout: 5000, + }); + + const nav = page.evaluate(() => { + window.location.href = 'about:blank'; + }).catch(() => { + // Navigation is aborted when beforeunload is cancelled. + }); + + const dialog = await dialogPromise; + expect(dialog.type()).toBe('beforeunload'); + await dialog.dismiss(); + await nav; + }); +}); diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index 15dcd890..fa18cc2e 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -20,7 +20,7 @@ async function api(path, { method = 'GET', body, tok } = {}) { test.beforeAll(async () => { server = spawn(process.execPath, ['server/server.mjs'], { - env: { ...process.env, SCOPEWEAVE_DB: ':memory:', SCOPEWEAVE_JWT_SECRET: 'test-secret', PORT: String(PORT) }, + env: { ...process.env, SCOPEWEAVE_DB: ':memory:', SCOPEWEAVE_JWT_SECRET: '0123456789abcdef0123456789abcdef', PORT: String(PORT) }, stdio: 'ignore', }); // wait for the API to come up diff --git a/tests/unit/auth-password.test.mjs b/tests/unit/auth-password.test.mjs new file mode 100644 index 00000000..5df3e344 --- /dev/null +++ b/tests/unit/auth-password.test.mjs @@ -0,0 +1,43 @@ +// scrypt password type-safety — non-string JSON bodies must not throw. +// Run: node tests/unit/auth-password.test.mjs +import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; + +const SECRET = '0123456789abcdef0123456789abcdef'; + +const script = ` +import assert from 'node:assert'; +import { hashPassword, verifyPassword } from './server/auth.mjs'; + +const stored = hashPassword('correct-horse'); +assert.match(stored, /^[0-9a-f]+:[0-9a-f]+$/); +assert.equal(verifyPassword('correct-horse', stored), true); +assert.equal(verifyPassword('wrong', stored), false); +assert.equal(verifyPassword(['correct-horse'], stored), false, 'array must not coerce to a real password'); + +// Non-string bodies (object/array/null/number) must not throw TypeError from scryptSync. +// verifyPassword rejects them outright (false) — never treat as empty-string password. +for (const bad of [{}, [], null, undefined, 12, true]) { + assert.doesNotThrow(() => hashPassword(bad), String(bad)); + assert.equal(verifyPassword(bad, stored), false, 'non-string never verifies a real password'); +} + +// Empty string is a distinct string path; non-strings must not verify against it. +const empty = hashPassword(''); +assert.equal(verifyPassword('', empty), true); +assert.equal(verifyPassword({}, empty), false, 'object body must not match empty-password hash'); +assert.equal(verifyPassword([], empty), false, 'empty array must not coerce to an empty password'); +assert.equal(verifyPassword(null, empty), false); +assert.equal(verifyPassword({ evil: true }, stored), false); + +console.log('✓ auth password type-safety tests passed'); +`; + +const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], { + cwd: process.cwd(), + env: { ...process.env, SCOPEWEAVE_JWT_SECRET: SECRET }, + encoding: 'utf8', +}); + +assert.equal(result.status, 0, result.stderr || result.stdout); +process.stdout.write(result.stdout); diff --git a/tests/unit/editor-unsaved.test.mjs b/tests/unit/editor-unsaved.test.mjs new file mode 100644 index 00000000..2fb5bd16 --- /dev/null +++ b/tests/unit/editor-unsaved.test.mjs @@ -0,0 +1,231 @@ +// Unit coverage for editorHasUnsavedChanges + beforeunload dirty-draft guard. +// app.js is browser-first; evaluate under vm with an absolute filename so c8/V8 +// attributes coverage to app.js (same pattern as tests/fuzz/harness.mjs). +// Run: node tests/unit/editor-unsaved.test.mjs +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const appJsPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'app.js'); + +function loadApp() { + let source = fs.readFileSync(appJsPath, 'utf8'); + source = source.replace(/^\s*bootstrap\(\);\s*$/m, ';'); + source += ` +;globalThis.__editorExports = { + editorHasUnsavedChanges, + bindGlobalEvents, + closeEditor, + state, + DEFAULT_EDITOR_STATE, +}; +`; + + const classList = { + contains: () => false, + add() {}, + remove() {}, + toggle() {}, + }; + const dummyElement = new Proxy( + { classList, style: {}, value: '', textContent: '', innerHTML: '', checked: false }, + { + get(target, prop) { + if (prop in target) return target[prop]; + // Methods (setAttribute, focus, addEventListener, …) must be callable. + return () => dummyElement; + }, + set(target, prop, value) { + target[prop] = value; + return true; + }, + }, + ); + + const windowListeners = Object.create(null); + let confirmImpl = () => true; + const windowStub = { + addEventListener(type, handler) { + (windowListeners[type] ||= []).push(handler); + }, + removeEventListener() {}, + confirm(msg) { + return confirmImpl(msg); + }, + setTimeout: () => 0, + clearTimeout: () => undefined, + }; + + const sandbox = { + window: windowStub, + self: windowStub, + document: { + getElementById: () => dummyElement, + createElement: () => dummyElement, + body: dummyElement, + addEventListener() {}, + querySelector: () => dummyElement, + querySelectorAll: () => [], + }, + localStorage: { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, + }, + fetch: () => Promise.reject(new Error('fetch disabled in unit harness')), + AbortController: globalThis.AbortController, + crypto: globalThis.crypto, + Uint32Array, + console, + setTimeout: () => 0, + clearTimeout: () => undefined, + Date, + Math, + JSON, + Object, + Array, + Set, + Map, + WeakMap, + Symbol, + String, + Number, + Boolean, + RegExp, + Error, + TypeError, + parseInt, + parseFloat, + isNaN, + isFinite, + URL: globalThis.URL, + Proxy, + Reflect, + Promise, + }; + sandbox.globalThis = sandbox; + windowStub.window = windowStub; + + const context = vm.createContext(sandbox); + // Absolute path is required so c8/V8 maps coverage back to app.js. + vm.runInContext(source, context, { filename: appJsPath }); + + const exportsObj = sandbox.__editorExports; + if (!exportsObj?.editorHasUnsavedChanges) { + throw new Error('Failed to extract editor exports from app.js'); + } + return { ...exportsObj, windowListeners, setConfirm: (fn) => { confirmImpl = fn; } }; +} + +const { + editorHasUnsavedChanges, + bindGlobalEvents, + closeEditor, + state, + DEFAULT_EDITOR_STATE, + windowListeners, + setConfirm, +} = loadApp(); + +// --- editorHasUnsavedChanges --- +state.editor = { mode: null, draft: null, initialDraft: null, errors: [] }; +assert.equal(editorHasUnsavedChanges(), false, 'no mode → clean'); + +state.editor = { mode: 'edit', draft: null, initialDraft: { name: 'a' }, errors: [] }; +assert.equal(editorHasUnsavedChanges(), false, 'missing draft → clean'); + +state.editor = { mode: 'edit', draft: { name: 'a' }, initialDraft: null, errors: [] }; +assert.equal(editorHasUnsavedChanges(), false, 'missing initialDraft → clean'); + +state.editor = { + mode: 'edit', + draft: { name: 'Task', owner: 'A' }, + initialDraft: { name: 'Task', owner: 'A' }, + errors: [], +}; +assert.equal(editorHasUnsavedChanges(), false, 'identical draft → clean'); + +state.editor = { + mode: 'edit', + draft: { name: 'Task*', owner: 'A' }, + initialDraft: { name: 'Task', owner: 'A' }, + errors: [], +}; +assert.equal(editorHasUnsavedChanges(), true, 'mutated field → dirty'); + +state.editor = { + mode: 'edit', + draft: { name: 'Task', owner: 'A', description: 'new' }, + initialDraft: { name: 'Task', owner: 'A' }, + errors: [], +}; +assert.equal(editorHasUnsavedChanges(), true, 'draft-only field → dirty'); + +// --- beforeunload --- +bindGlobalEvents(); +const handlers = windowListeners.beforeunload || []; +assert.ok(handlers.length >= 1, 'beforeunload listener registered'); + +function fireBeforeUnload() { + const event = { + prevented: false, + returnValue: undefined, + preventDefault() { + this.prevented = true; + }, + }; + for (const h of handlers) h(event); + return event; +} + +state.editor = { mode: 'edit', draft: { name: 'x' }, initialDraft: { name: 'x' }, errors: [] }; +let ev = fireBeforeUnload(); +assert.equal(ev.prevented, false, 'clean draft does not block unload'); +assert.equal(ev.returnValue, undefined, 'clean draft leaves returnValue alone'); + +state.editor = { mode: 'edit', draft: { name: 'dirty' }, initialDraft: { name: 'x' }, errors: [] }; +ev = fireBeforeUnload(); +assert.equal(ev.prevented, true, 'dirty draft blocks unload'); +assert.equal(ev.returnValue, '', 'dirty draft sets returnValue for browsers'); + +// --- closeEditor confirm paths --- +state.editor = { + mode: 'edit', + draft: { name: 'dirty' }, + initialDraft: { name: 'clean' }, + errors: [], + targetId: 't1', +}; +state.previousFocus = null; +setConfirm(() => false); +closeEditor(false); +assert.equal(state.editor.mode, 'edit', 'discard declined keeps editor open'); + +setConfirm(() => true); +closeEditor(false); +assert.equal(state.editor.mode, DEFAULT_EDITOR_STATE.mode, 'discard accepted resets editor'); + +state.editor = { + mode: 'edit', + draft: { name: 'same' }, + initialDraft: { name: 'same' }, + errors: [], +}; +closeEditor(false); +assert.equal(state.editor.mode, DEFAULT_EDITOR_STATE.mode, 'clean close without confirm'); + +state.editor = { + mode: 'edit', + draft: { name: 'dirty' }, + initialDraft: { name: 'clean' }, + errors: [], +}; +setConfirm(() => { + throw new Error('confirm must not run when force=true'); +}); +closeEditor(true); +assert.equal(state.editor.mode, DEFAULT_EDITOR_STATE.mode, 'force close skips confirm'); + +console.log('✓ editor unsaved / beforeunload coverage tests passed'); diff --git a/tests/unit/msproject.test.mjs b/tests/unit/msproject.test.mjs index faed2b46..d829a32c 100644 --- a/tests/unit/msproject.test.mjs +++ b/tests/unit/msproject.test.mjs @@ -55,4 +55,21 @@ assert.equal(t5.predecessors, 'msp-1'); assert.deepEqual(parseMsProjectXml(''), [], 'no tasks → empty'); +const literalRegexText = parseMsProjectXml( + '6Regex [.*+?] text1', +); +assert.equal(literalRegexText[0].phase, 'Regex [.*+?] text', 'tag extraction treats task content as literal text'); +assert.deepEqual(parseMsProjectXml(null), [], 'null XML input is treated as empty'); +assert.deepEqual( + parseMsProjectXml( + '7unclosed1', + ), + [], + 'a Task containing an unclosed value tag is ignored', +); + +// Incomplete opening tags must not hang (linear collect stops at first unclosed block). +const incompleteOpens = `${'9open'.repeat(5000)}`; +assert.deepEqual(parseMsProjectXml(incompleteOpens), [], 'unclosed Task blocks yield no tasks'); + console.log('✓ MS Project import tests passed'); diff --git a/tests/unit/static-coverage-evidence.test.mjs b/tests/unit/static-coverage-evidence.test.mjs new file mode 100644 index 00000000..268517ab --- /dev/null +++ b/tests/unit/static-coverage-evidence.test.mjs @@ -0,0 +1,31 @@ +// Coverage + contract for scripts/ci/static_coverage_evidence.mjs +// Run: node tests/unit/static-coverage-evidence.test.mjs +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const script = path.join(root, 'scripts/ci/static_coverage_evidence.mjs'); + +function run(args) { + return spawnSync(process.execPath, [script, ...args], { + cwd: root, + encoding: 'utf8', + }); +} + +// Happy path used by check:python-docstrings / OpenCode docstring gate. +const ok = run(['docstrings']); +assert.equal(ok.status, 0, `docstrings exit: ${ok.status}\n${ok.stderr}`); +assert.match(ok.stdout, /not applicable/i, 'docstrings path prints N/A message'); + +// Usage / invalid mode must fail closed (covers the else branch). +const bad = run(['coverage']); +assert.equal(bad.status, 2, 'invalid mode → exit 2'); +assert.match(bad.stderr, /Usage: static_coverage_evidence\.mjs docstrings/); + +const missing = run([]); +assert.equal(missing.status, 2, 'missing mode → exit 2'); + +console.log('✓ static_coverage_evidence tests passed');