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}>([^<]*)${name}>`));
- return m ? m[1].trim() : '';
+ const openingTag = `<${name}>`;
+ const closingTag = `${name}>`;
+ 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');