diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..79ba22f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI +on: + pull_request: + push: + branches: [main, develop] +concurrency: + group: policyweave-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + services: + postgres: + image: postgres@sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2 # 18.6-alpine3.24 + env: + POSTGRES_DB: policyweave_test + POSTGRES_USER: policyweave_ci + POSTGRES_PASSWORD: policyweave_ci_password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U policyweave_ci -d policyweave_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Generate exact-head dependency SBOM + run: | + mkdir -p dependency-evidence + npm sbom --sbom-format cyclonedx > dependency-evidence/policyweave-dependency-sbom.cdx.json + - run: npm run lint + - run: npm test + - run: npm run build + - name: Verify PostgreSQL policy revision migration + env: + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGUSER: policyweave_ci + PGDATABASE: policyweave_test + PGPASSWORD: policyweave_ci_password + run: sh db/tests/policy_revision_runtime.sh + - name: Verify PostgreSQL concurrent policy writers + env: + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGUSER: policyweave_ci + PGDATABASE: policyweave_test + PGPASSWORD: policyweave_ci_password + run: sh db/tests/policy_revision_concurrency.sh + - name: Verify PostgreSQL policy revision restore + env: + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGUSER: policyweave_ci + PGDATABASE: policyweave_test + PGPASSWORD: policyweave_ci_password + run: sh db/tests/policy_revision_restore.sh + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + - name: Upload exact-head browser evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: policyweave-browser-evidence + path: test-results + if-no-files-found: warn + - name: Upload exact-head dependency SBOM + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: policyweave-dependency-sbom + path: dependency-evidence/policyweave-dependency-sbom.cdx.json + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b224117 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +.env +coverage +playwright-report +test-results/ +*.tsbuildinfo +.codegraph/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e4f5bde --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md + +## Product responsibility +PolicyWeave is a local-first privacy-policy fact-authoring workspace. It structures facts supplied by a service operator, detects missing or contradictory inputs, and renders a review draft. It does not provide legal advice, certify compliance, or infer facts that the operator has not established. + +## Development contract +- Treat structured policy facts as the source of truth; rendered policy prose is a deterministic projection. +- Preserve the seven authoring steps in `docs/PRD.md`: service information, collection items, purposes, retention, third-party transfer, international transfer, and privacy contact. +- Behavior changes require regression or edge-case tests first. Do not resolve a review finding until the exact current head proves the finding obsolete or fixed. +- Re-fetch the PR head before every commit/push. Never force-push or weaken branch/ruleset protections to merge. +- Keep `ARCHITECTURE.md`, `docs/TRD.md`, ADRs, `docs/research-traceability.md`, `CHANGELOG.md`, and `docs/product-technical-gap-baseline.md` aligned with implementation. +- Do not encode legal conclusions from memory. Every legal/rule/template decision needs an authoritative source, effective date, source revision, and implementation/test trace. +- Do not commit identifying customer, individual, or real operational-organization data in tests, examples, fixtures, or product documentation. Publicly documented legal authorities, official document titles, standards bodies, source publishers, and the repository owner may be named when required for accurate provenance and citation. Production must not consume synthetic demo data. +- Keep persistence objects semantically named with at least two words and `snake_case` unless a framework contract requires another convention. Avoid generic named persistence objects such as a standalone `id` table/collection. +- Hosted persistence/publication must be introduced only behind explicit tenant, authorization, audit, encryption, immutable revision, and supersession contracts. CI PostgreSQL restart and dump/restore evidence lives in `db/tests/policy_revision_restore.sh` and does not enable a hosted adapter. Seed `retention_status = applies` only in the same transaction as its `retention_rule`; autocommit fails the deferred fact contract. +- GitHub Actions dependencies stay SHA pinned and checkout credentials must not persist. +- Direct npm dependencies stay pinned to their reviewed lock resolutions. Compiler and bundler packages belong in `devDependencies`, and exact-head CI publishes a CycloneDX dependency SBOM without treating license metadata as legal approval. + +## Verification +The minimum exact-head gate is `npm run lint`, `npm test`, and `npm run build`, plus every live organization-required workflow, independent approval, and resolved review thread. Queued, skipped, predecessor-head, or stale results are not passing evidence. + +## Know-how +- Draft PRs cannot merge through the API: mark ready (`gh pr ready`), re-confirm exact-head CLEAN/MERGEABLE/verify GREEN with zero unresolved threads, then ordinary `--merge` without branch deletion or force-push. +- Slow local executors flake Vitest's default 5s per-test timeout on full-workflow UI tests while exact-head CI stays GREEN. Use `npx vitest run --testTimeout=60000` for the local signal; CI `verify` is authoritative. Detail: `CLAUDE.md`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..981aa1f --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,46 @@ +# Architecture + +## Product boundary +PolicyWeave owns the authoring and review of structured privacy-processing facts and the deterministic generation of a review draft. It does not own legal advice, identity-provider data, payment processing, or a customer's source systems. Hosted publication and durable storage are future supporting capabilities, not authority to invent or reinterpret processing facts. + +## Domain-driven design +The core subdomain is **Policy Fact Authoring**. Supporting subdomains are **Review & Publication** and **Legal Source Registry**. Browser/storage frameworks, authentication infrastructure, observability, and deployment are generic subdomains. + +### Bounded contexts +- **Policy Fact Authoring**: captures `service_profile`, collection presence/no-collection attestation, `collection_item`, `processing_purpose`, `retention_rule`, `third_party_transfer`, `international_transfer`, and `privacy_contact` facts. +- **Review & Publication**: derives `review_finding`, controls `policy_revision` review state, and will create immutable `publication_revision` artifacts only after explicit authorization. +- **Legal Source Registry**: versions authoritative sources, effective dates, rule/template revisions, and citations. It is an anti-corruption layer between changing external law/guidance and already-published revisions. + +### Context map +`Policy Fact Authoring -> Review & Publication` is a customer/supplier relationship through a versioned policy-fact contract. `Legal Source Registry -> Review & Publication` supplies versioned rule evidence; source updates cannot silently mutate historical policy revisions. External customer systems remain behind adapters and must not be queried or mutated through hidden coupling. + +## Ubiquitous language and model +A future `policy_revision` is the minimal aggregate root and transaction boundary. `service_profile`, collection presence/no-collection attestation, `collection_item`, `processing_purpose`, `retention_rule`, `third_party_transfer`, `international_transfer`, and `privacy_contact` are revision-owned facts/value objects unless later evidence requires independent lifecycles. `review_finding` is derived evidence. `publication_revision` is an immutable release receipt, not a mutable policy row. Rendered prose is a projection and never the source of truth. + +Core invariants: +1. A fresh workspace contains no inferred customer operational facts; blank means unresolved rather than `none`. +2. Collection presence is established by either at least one selected collection item or an explicit no-collection attestation. Empty selection alone remains unresolved. The no-collection attestation and selected items are mutually exclusive; contradictory state fails closed. +3. Turning on the no-collection attestation invalidates all selected-item collection mode, processing purpose, and collection-path evidence. Turning the attestation off cannot revive those stale facts. +4. Selecting a collection item without confirming its collection mode, processing purpose, or collection-path evidence creates independent blocking review findings. +5. Service identity, retention, third-party provision status, international-transfer status, and privacy contact remain blocking authoring responsibilities until explicitly established; service URL and contact-email syntax checks prove only usable field shape, not endpoint/mailbox ownership. +6. Third-party provision and international transfer distinguish unresolved, yes, and no. A `yes` status requires dependent facts; a transition away from yes invalidates dependent details so stale facts cannot revive silently. +7. Disabling a collection item invalidates its dependent collection-mode, processing-purpose, and collection-path evidence; re-enabling requires renewed confirmation. +8. Review findings navigate to the fact that caused them. +9. Publication must never upgrade an unreviewed or incomplete draft to a reviewed/authoritative state. +10. A published revision remains reproducible from its policy facts plus rule/template/source versions. +11. External legal-source updates produce explicit re-evaluation, not silent rewriting. + +## Current implementation +The active MVP is a React/Vite browser workspace. State is in memory and there is no production persistence or publication backend. The browser can download a deterministic versioned JSON draft containing normalized operator-authored facts and readiness finding codes; this local portability projection is not publication, persistence, or legal approval. The seven PRD steps are routed to distinct editing surfaces. The collection taxonomy is metadata only. `src/policy.ts` owns deterministic collection-selection/no-collection/mode/purpose/path and non-collection authoring-completeness findings; `src/App.tsx` owns browser orchestration, explicit no-collection and transfer-status capture, warning-to-source navigation, stale dependent-fact invalidation, and deterministic preview rendering. `src/AuthoringFocusController.tsx` is a browser interaction adapter: after explicit rail, previous/next, or review-warning navigation changes the active editing surface, it moves programmatic focus to that surface's heading without changing domain state, intercepting ordinary field interaction, or overriding the separate preview-focus shortcut. + +Authoring completeness is deliberately separate from legal sufficiency. Current readiness rules prove that product-defined fact responsibilities were explicitly addressed; they do not assert that a policy complies with law. Source/effective-date-bound legal validation belongs to the Legal Source Registry -> Review & Publication boundary. + +## Persistence boundary (Proposed schema; CI-only runtime) +ADR-0003 and `db/migrations/0001_policy_revision.sql` propose the first PostgreSQL contract. The 3NF write model uses `policy_revision` as aggregate root; `service_profile`, `collection_item`, `processing_purpose`, and `retention_rule` are revision-owned facts. `(tenant_account_id, revision_number)` identifies a version, while `(policy_revision_id, collection_item_key)` is the item-level UPSERT/idempotency key. Deferred database constraints lock the owning revision row, reject collection items under explicit no-collection, and reject retention-rule/status contradictions at transaction commit. + +Exact-head CI executes that migration against digest-pinned PostgreSQL 18, including rollback, two-session locks with NULL-safe complete-value assertions, process restart, and custom-format dump/restore of complete collection-item plus independent collection and retention facts. This is not an active datastore, released API, or hosted adapter. Hosted tenant authorization, immutable audit events, encryption, deletion, operational backup/restore, and production-scale contention remain open. Publication remains append-only/immutable with explicit supersession; writes across unrelated aggregates must not share a transaction merely for convenience. Named database/schema/persistence objects use at least two semantic words and `snake_case`, for example `policy_revision`, `collection_item`, `processing_purpose`, `review_finding`, `publication_revision`, and `legal_source_revision`. + +Separate write-side draft commands from read-side rendered/review projections once hosted traffic justifies it. Account for revision hot spots and optimistic/constrained writes before adding collaborative editing. Keep source/customer integrations behind ACLs; do not form a shared kernel with unrelated ContextualWisdomLab products without demonstrated reuse. + +## Deployment and operability direction +The browser-only MVP needs no service mesh. A hosted backend should be compose-deployable across Docker/Podman/Colima before Kubernetes migration, expose asynchronous/non-blocking request handling, and add realistic k6 evidence for network surfaces before latency claims. No code may depend on an optional `close_connection` instance attribute existing unless the adapter contract guarantees it. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..931da61 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +All notable product changes are recorded here. PolicyWeave is pre-release; entries describe the active commercialization branch and do not imply a published compliance product. + +## Unreleased + +### Added +- Executable npm manifest/lock/license contracts and an exact-head CycloneDX SBOM artifact. Every direct declaration must equal its reviewed lock resolution, the lock root must match the manifest, and every locked package must retain machine-readable license metadata. +- Deterministic local JSON draft export with a versioned `snake_case` contract, normalized operator-authored facts, explicit incomplete/review-ready state, readiness finding codes, and fail-closed rejection of service URLs containing credentials, query, or fragment components. Unresolved collection mode is serialized as `null`, not the UI empty-string sentinel, and object-URL cleanup is deferred until after download navigation starts. The browser download performs no network transfer and does not claim publication. +- PostgreSQL restart and custom-format dump/restore evidence that preserves NULL-safe complete service/collection-item values, a collecting-without-retention cross-state fixture, and independent no-collection and applies-retention facts, then re-executes no-collection plus both retention-status/rule contradictions against the restored database. +- PostgreSQL two-session concurrency evidence that observes real lock waits, rejects a collection-item writer racing with a no-collection update, and proves competing same-item UPSERTs converge to one row carrying the second writer's label, mode, and path with NULL-safe complete-value assertions and without timing-based transaction sleeps. +- PostgreSQL 18 runtime contract coverage for migration apply/down/apply cycles, item-key UPSERT idempotency, and deferred rejection of no-collection, missing-retention-rule, and revision-owner contradictions. The database remains CI-only and is not a hosted product backend. +- Proposed PostgreSQL `policy_revision` 3NF migration contract with tenant-scoped version identity, normalized revision-owned facts, owner-key immutability, parent-row-serialized deferred no-collection/retention consistency checks, and item-level natural-key UPSERT. It is source-validated only and does not claim a deployed database or hosted persistence. +- Seven-step authoring workflow with distinct editing surfaces for service information, collection items, processing purposes, retention, third-party transfer, international transfer, and privacy contact. +- Explicit `개인정보를 수집하지 않음` operator attestation so a genuine no-collection service can complete collection authoring without treating an empty item list as `none`. +- Independent explicit retention status (`확인 필요` / `보유함` / `보유하지 않음`) so collection absence cannot be misused as evidence that storage or retention is absent. +- Warning-to-source navigation for missing collection selection/no-collection confirmation, collection mode, collection-path evidence, processing purposes, service identity, retention status/period, transfer statuses/details, and privacy contact. +- Explicit unresolved/yes/no states for third-party provision and international transfer, with dependent detail capture only for confirmed `yes` cases. +- Regression coverage for all seven step routes, zero-inferred startup facts, first-responsibility startup state, explicit no-collection state and stale-item invalidation, independent retention authority and stale-period invalidation, collection-mode/path confirmation, seven-step readiness, explicit no-transfer attestations, transfer-dependent fact invalidation, whitespace normalization, service URL projection, warning navigation, collection-path/purpose separation, stale collection evidence invalidation, buyer-facing publication guidance, non-deceptive handling of unshipped affordances, authored focus-indicator contrast, and authoring-step focus transfer. +- Product/technical gap ledger, architecture, technical requirements, security baseline, and legal-source/accessibility traceability. +- Playwright/axe browser evidence harness covering desktop, tablet, and mobile rendering; horizontal overflow; keyboard activation and focus transfer; explicit no-collection progression; retention-status transitions and stale-period invalidation; effective 200% browser-zoom reflow from the desktop profile; serious/critical automated accessibility findings; real-browser JSON download events with mouse, keyboard, and touch activation; fixed filename; JSON MIME; byte-stable repeated exports; review-ready payload semantics; success and preparation/activation-error object-URL cleanup; and exact-head screenshot artifacts. + +### Changed +- All direct npm packages now use exact reviewed versions. React and Lucide remain runtime dependencies; TypeScript, Vite, and the React Vite plugin are correctly classified with the test/build toolchain in `devDependencies`, and npm regenerated the lock graph so transitive development scope is accurate. +- PostgreSQL negative-path evidence now matches each expected domain error message, so an unrelated SQL or connection failure cannot masquerade as a passing invariant check. +- Repository CI now starts one digest-pinned PostgreSQL 18 service inside the existing verification job and runs the migration contract before browser evidence, avoiding a second workflow or runner while producing real database evidence. +- Repository CI now groups runs by workflow plus pull-request number or branch ref and cancels superseded heads, preventing stale queued runs from consuming runner capacity without coupling unrelated PRs or refs. +- GitHub Actions checkout, setup/cache, and browser-evidence upload now use immutable Node 24-based `actions/checkout@v7.0.1`, `actions/setup-node@v7.0.0`, and `actions/upload-artifact@v7.0.1` releases instead of deprecated action runtimes. +- ADR readiness language now matches the executable retention contract: status is explicit, a period/end condition is required only for `applies`, and `none` needs no period; unmerged ADRs remain Proposed until protected-branch adoption. +- Browser-discovered muted text now uses a darker authored token, with a regression contract requiring at least 4.5:1 contrast on every current white, rail, conditional-field, and metadata surface. +- Fresh workspaces no longer preselect collection items or prefill collection modes/processing purposes; readiness fails closed until the operator establishes actual collection facts or explicitly attests that the service collects no personal data. +- Fresh workspaces now open at step 1, service information, rather than displaying step 2 while falsely rendering the untouched first responsibility as completed. +- Completed responsibilities retain their `done` state while active, and collection-flow tests now enter step 2 explicitly instead of depending on the retired step-2 startup state. +- Empty collection selection remains unresolved rather than being interpreted as `none`; no-collection and selected-item states are mutually exclusive and contradictory state fails closed. +- Turning on no-collection confirmation clears selected-item mode, purpose, and path evidence; later removing the confirmation does not silently restore stale customer facts. +- Collection and retention are now independent authority states. An intermediate no-collection→no-retention inference was removed after authoritative PIPC terminology showed that collection, storage, and retention are distinct included forms of personal-information processing. +- Retention readiness now requires an explicit status: `보유함` requires a period/end condition; `보유하지 않음` does not. Leaving `보유함` clears the previous period so stale facts cannot silently revive. +- Every selected collection item requires explicit collection mode, processing purpose, and nonblank collection-path evidence before readiness can pass; collection-path findings navigate back to the collection step. +- Public-readiness includes product-defined service name/URL, explicit retention status and any required period, transfer-status/detail, and privacy-contact completeness. +- Service URL and privacy-contact email are shape-validated as usability contracts without claiming endpoint reachability or mailbox ownership. +- Credential-bearing service URLs are rejected and withheld from the review projection so embedded usernames or passwords cannot leak into a generated draft. +- Query- or fragment-bearing service URLs are rejected consistently by readiness, preview, and export so a source fact cannot be silently rewritten to a different destination. +- Blank transfer state is no longer treated as an implicit `none`; explicit `없음` confirmation is required, while `있음` requires dependent recipient/purpose or country/recipient facts. +- Changing a transfer status away from `있음` clears its dependent details so stale customer facts cannot silently revive. +- Disabling a collection item clears its collection mode, processing purpose, and collection-path evidence so re-enabling cannot silently revive stale customer facts. +- Service URL entered in the service-information step is projected into the review draft. +- Collection-path editing remains in the collection step while processing-purpose editing stays in its dedicated purpose step. +- Review preview applies the same whitespace-normalized purpose-completeness contract as the blocking review rule. +- Step-rail, previous/next, and review-warning navigation now transfers programmatic focus to the newly active step heading; ordinary form controls and the dedicated preview shortcut are excluded from that transfer. +- Review-warning navigation now lets the browser scroll the focused owner heading into view; the previous `preventScroll` option could leave that heading hundreds of pixels above the desktop or mobile viewport. +- The publication-area CTA describes a readiness check and directs the operator to responsible review rather than exposing internal implementation boundaries. +- JSON export now downloads the current structured draft locally, contains download preparation and activation exceptions, reports a retry action through the existing live status output, and revokes the temporary object URL whenever allocation succeeded; the redundant no-op `검토본 생성` control remains removed, and the document title remains non-interactive status text. +- Authored generic and custom-checkbox keyboard focus outlines now use the high-contrast `--green` token; a CSS regression test computes and enforces at least 3:1 contrast against white instead of relying on a low-contrast focus color. +- Responsive review behavior and mobile publication feedback were repaired during PR review. +- Responsive CSS contract tests use literal media-query regular expressions, removing the Semgrep dynamic-RegExp finding without suppressing or weakening the scanner gate. +- Node types are declared for the stylesheet contract test's `node:fs` boundary so the production TypeScript build type-checks the executable test source. +- GitHub Actions checkout dependencies are SHA pinned and credentials are not persisted in the working tree. + +### Not yet shipped +- Authoritative legal-rule snapshots that can determine legal sufficiency beyond product-defined fact completeness. +- Manual zoom and screen-reader evidence beyond the automated desktop/tablet/mobile Chromium accessibility and focus checks. +- A product persistence adapter, durable hosted storage, tenant authorization, immutable audit history, encryption, operational backup/restore, and production-scale contention evidence. Bounded CI database execution, including process restart and dump/restore, does not constitute a hosted runtime. +- Authenticated immutable publication revisions and public URL lifecycle. +- Hosted tenant/security/operability evidence and endpoint load testing. +- Versioned DB-backed ko/en/ja/zh/vi/es/de/fr translation resources and localized export acceptance evidence. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fe1893f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,13 @@ +# CLAUDE.md + +Follow `AGENTS.md` as the repository-wide operating contract. + +Before changing code, read `docs/PRD.md`, `ARCHITECTURE.md`, the applicable ADRs, `docs/TRD.md`, `docs/research-traceability.md`, and `docs/product-technical-gap-baseline.md`. Preserve PolicyWeave's boundary: verified operator facts in, deterministic reviewable policy projection out; no legal-advice or compliance-guarantee claims. + +For behavior changes, add or strengthen tests before production code. Reconcile documentation and the product-gap ledger on the same branch. Use the exact current PR head for reviews, checks, and merge decisions, and never bypass governance to compensate for a failing or unassigned check. + +Deferred `policy_revision` fact triggers evaluate the final commit state. CI restore seeds `retention_status = applies` and `retention_rule` in one transaction; the owner runbook is `db/tests/policy_revision_restore.sh`. + +## Know-how (2026-09-09, exact-head `e1c588f` verified) +- Draft PRs cannot merge through the API (`Pull Request is still a draft`): run `gh pr ready ` first, re-confirm CLEAN/MERGEABLE plus exact-head verify GREEN, then `gh pr merge --merge`. Never delete stacked branches on merge; successors restack with ordinary non-force merge commits. +- Local Vitest defaults to a 5s per-test timeout and flakes on slow executors (full `App` workflow tests exceed it under load while CI verify stays GREEN). Use `npx vitest run --testTimeout=60000` for the local full-suite signal; do not retune production or timeouts to mask executor slowness. CI `verify` on the exact head remains the authoritative verdict. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..17c7167 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 ContextualWisdomLab + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index fd97f15..c7267ce 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,120 @@ # PolicyWeave -웹·앱 운영자가 실제 개인정보 처리 흐름을 입력하면, 누락과 모순을 표시하면서 개인정보처리방침 검토본을 만드는 로컬 우선 웹 앱입니다. +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ContextualWisdomLab/PolicyWeave) -> 생성 결과는 법률 자문이나 준법 보장이 아닙니다. 공개 전 개인정보보호책임자 또는 법률 전문가의 검토가 필요합니다. +**실제 개인정보 처리 사실을 구조화해, 누락과 모순을 찾고 검토 가능한 개인정보처리방침 초안을 만드는 로컬 우선 워크스페이스입니다.** -## 실행 +PolicyWeave는 범용 법률 문구를 임의로 채우는 생성기가 아닙니다. 운영자가 확인한 서비스 사실을 단계별로 입력하면 수집 항목, 처리 목적, 보유 기간, 제3자 제공, 국외 이전과 담당자 정보를 서로 연결하고, 공개 전에 다시 확인해야 할 지점을 보여 줍니다. + +> PolicyWeave의 출력은 법률 자문, 준법 보장 또는 인증이 아닙니다. 공개 전 개인정보보호책임자 또는 적절한 법률 전문가의 검토가 필요합니다. + +## 누구를 위한 제품인가 + +| 사용자 | PolicyWeave가 돕는 일 | +| --- | --- | +| 웹·앱 운영자 | 실제 서비스의 개인정보 처리 흐름을 빠짐없이 정리하고 검토본을 만든다. | +| 개인정보보호 책임자 | 입력 사실, 누락 경고와 검토 필요 항목을 한 흐름에서 확인한다. | +| 제품·개발 팀 | 정책 문구보다 먼저 구조화된 처리 사실과 책임 경계를 합의한다. | +| 통합·유지보수 담당자 | 정책 데이터 모델, 제품 요구사항과 결정 기록을 저장소에서 추적한다. | + +## 현재 할 수 있는 일 + +현재 소스는 다음 7단계 작성 흐름을 구현합니다. + +1. 서비스 정보 +2. 수집 항목 +3. 처리 목적 +4. 보유 기간 +5. 제3자 제공 +6. 국외 이전 +7. 개인정보 보호 담당자 + +선택한 수집 항목에는 수집 경로와 처리 목적을 별도로 기록할 수 있습니다. 필수 사실이 없거나 처리 목적이 비어 있으면 검토본이 이를 숨기지 않고 차단 또는 검토 경고로 드러내며, 경고에서 원인이 있는 입력 단계로 돌아갈 수 있습니다. 작성 내용은 실시간 검토본에 반영되고 모바일·키보드 사용도 고려합니다. + +## 빠른 시작 + +현재 제품은 저장소에서 실행하는 초기 개발 버전입니다. 패키지 메타데이터는 `0.1.0`이지만 GitHub에 게시된 릴리스는 아직 없습니다. ```bash -npm install +npm ci npm run dev ``` -검증은 `npm run lint`, `npm test`, `npm run build`로 수행합니다. +프로덕션 번들 및 검증: + +```bash +npm run lint +npm test +npm run build +npx playwright install chromium +npm run test:e2e +``` + +`npm ci`는 체크인된 `package-lock.json`을 사용합니다. 이 저장소는 `private: true` 패키지이므로 npm 배포물을 제품 릴리스로 간주하지 않습니다. + +## 제품 경계 + +PolicyWeave가 책임지는 것은 **운영자가 입력한 개인정보 처리 사실의 구조화, 검토 지원, 결정적인 검토본 생성**입니다. + +PolicyWeave는 다음을 권위 있게 판단하지 않습니다. + +- 실제 서비스가 어떤 SDK·쿠키·데이터를 사용하는지 자동 추측 +- 특정 처리의 법적 적법성에 대한 최종 판단 +- 동의가 필요한지 여부를 모든 상황에 동일하게 결정 +- 법률 자문, 규제기관 판단, 인증 또는 감사 의견 대체 +- 아직 구현되지 않은 정적 공개 URL·호스팅 백엔드를 현재 기능처럼 표시 + +향후 공개·버전 이력·영속화가 추가되더라도, 사람이 확인한 처리 사실과 책임자 검토가 자동 생성 문구보다 우선합니다. + +## 동작 방식 + +```text +운영자가 확인한 처리 사실 + │ + ▼ +7단계 구조화 편집 + │ + ├── 필수 사실/관계 검증 + ├── 차단 오류와 검토 경고 + └── 원인 단계로 이동 + │ + ▼ +실시간 개인정보처리방침 검토본 + │ + ▼ +책임자·법률 검토 후 별도 공개 결정 +``` + +현재 브라우저 애플리케이션은 작성과 검토 경험에 집중합니다. PRD에 기록된 정적 공개 URL, 버전 영속화와 배포 계약은 후속 제품 작업이며 현재 구현으로 과장하지 않습니다. + +## 개인정보와 보안 관점 + +PolicyWeave는 개인정보처리방침을 만들기 위해 불필요한 실제 개인정보를 수집하도록 설계하지 않습니다. 정책 작성에는 서비스의 **처리 범주와 운영 사실**을 입력하고, 사람·고객의 실제 민감 값을 데모나 문서에 복사하지 않는 것이 기본 원칙입니다. + +보안 및 신뢰 경계는 [`SECURITY.md`](SECURITY.md)와 기술 문서에서 관리합니다. 새 저장·호스팅·외부 연동 기능은 별도의 인증·권한·감사·tenant 경계를 갖추기 전까지 현재 기능으로 취급하지 않습니다. + +## 문서 + +- [제품 요구사항](docs/PRD.md) — 사용자 문제, MVP, 비목표와 성공 기준 +- [기술 요구사항](docs/TRD.md) — 구현 및 품질 계약 +- [아키텍처](ARCHITECTURE.md) — 제품 책임과 기술 경계 +- [ADR-0001: Policy as Data](docs/ADR-0001-policy-as-data.md) — 핵심 설계 결정 +- [제품·기술 Gap baseline](docs/product-technical-gap-baseline.md) — 아직 닫히지 않은 상용화 Gap과 완료 증거 +- [공개 문서 홈](docs/index.md) — 저장소 문서 탐색 시작점 +- [변경 이력](CHANGELOG.md) + +## 기여와 검증 + +동작을 바꾸는 변경은 해당 사용자 흐름의 회귀 테스트와 문서 계약을 함께 갱신해야 합니다. 작업 전 [`AGENTS.md`](AGENTS.md)와 [`CLAUDE.md`](CLAUDE.md)를 읽고, 최소한 다음 검증이 동일한 head에서 통과하는지 확인합니다. + +```bash +npm run lint +npm test +npm run build +``` -## 현재 범위 +통과한 로컬 명령이나 PR branch 자체는 배포·인증·규제 준수 또는 게시 릴리스의 증거가 아닙니다. -- 7단계 작성 흐름과 진행 상태 -- 개인정보 수집 항목 선택 및 필수/선택 구분 -- 조건부 상세 입력 -- 실시간 검토본과 법적 근거 표시 -- 차단 오류와 권장 검토 항목 구분 -- 반응형 작성/미리보기 전환 +## 라이선스 -제품 요구사항과 결정 기록은 [`docs/PRD.md`](docs/PRD.md), [`docs/ADR-0001-policy-as-data.md`](docs/ADR-0001-policy-as-data.md)에 있습니다. +PolicyWeave의 ContextualWisdomLab 원저작 소스와 문서는 [MIT License](LICENSE)로 제공됩니다. npm 의존성, 표준 문서, 외부 서비스·자산은 각각의 라이선스와 이용 조건을 유지하며 이 저장소의 MIT grant가 이를 재라이선스하지 않습니다. 상업적 사용과 배포에 적합하지 않은 inbound 소프트웨어·자산은 제품 의존성으로 반입하지 않습니다. diff --git a/db/migrations/0001_policy_revision.down.sql b/db/migrations/0001_policy_revision.down.sql new file mode 100644 index 0000000..3042322 --- /dev/null +++ b/db/migrations/0001_policy_revision.down.sql @@ -0,0 +1,9 @@ +begin; + +drop function upsert_collection_item(uuid, text, text, collection_fact_mode, text); +drop table processing_purpose, retention_rule, collection_item, service_profile, policy_revision; +drop function enforce_policy_revision_facts(); +drop type collection_fact_mode; +drop type retention_fact_status; + +commit; diff --git a/db/migrations/0001_policy_revision.sql b/db/migrations/0001_policy_revision.sql new file mode 100644 index 0000000..2962f4c --- /dev/null +++ b/db/migrations/0001_policy_revision.sql @@ -0,0 +1,151 @@ +begin; + +create type retention_fact_status as enum ('unresolved', 'applies', 'none'); +create type collection_fact_mode as enum ('required', 'optional'); + +create table policy_revision ( + policy_revision_id uuid primary key, + tenant_account_id uuid not null, + revision_number bigint not null check (revision_number > 0), + no_collection_confirmed boolean not null default false, + retention_status retention_fact_status not null default 'unresolved', + created_at timestamptz not null default clock_timestamp(), + unique (tenant_account_id, revision_number) +); + +create table service_profile ( + policy_revision_id uuid primary key references policy_revision (policy_revision_id) on delete cascade, + service_name text, + service_url text +); + +create table collection_item ( + policy_revision_id uuid not null references policy_revision (policy_revision_id) on delete cascade, + collection_item_key text not null check (btrim(collection_item_key) <> ''), + collection_item_label text not null check (btrim(collection_item_label) <> ''), + collection_mode collection_fact_mode, + collection_path text check (collection_path is null or btrim(collection_path) <> ''), + primary key (policy_revision_id, collection_item_key) +); + +create table processing_purpose ( + policy_revision_id uuid not null, + collection_item_key text not null, + purpose_text text not null check (btrim(purpose_text) <> ''), + primary key (policy_revision_id, collection_item_key), + foreign key (policy_revision_id, collection_item_key) + references collection_item (policy_revision_id, collection_item_key) on delete cascade +); + +create table retention_rule ( + policy_revision_id uuid primary key references policy_revision (policy_revision_id) on delete cascade, + retention_period text not null check (btrim(retention_period) <> '') +); + +create function enforce_policy_revision_facts() returns trigger +language plpgsql +as $function$ +declare + target_revision_id uuid; + no_collection_confirmed boolean; + retention_status retention_fact_status; +begin + if tg_op = 'UPDATE' and old.policy_revision_id is distinct from new.policy_revision_id then + raise exception using + errcode = '23514', + message = 'revision-owned facts cannot move between policy revisions'; + end if; + + target_revision_id := case when tg_op = 'DELETE' then old.policy_revision_id else new.policy_revision_id end; + + select revision.no_collection_confirmed, revision.retention_status + into no_collection_confirmed, retention_status + from policy_revision as revision + where revision.policy_revision_id = target_revision_id + for no key update; + + if not found then + return null; + end if; + + if no_collection_confirmed and exists ( + select 1 from collection_item as item where item.policy_revision_id = target_revision_id + ) then + raise exception using + errcode = '23514', + message = 'no-collection confirmation conflicts with collection items'; + end if; + + if retention_status = 'applies' and not exists ( + select 1 from retention_rule as rule where rule.policy_revision_id = target_revision_id + ) then + raise exception using + errcode = '23514', + message = 'retention status applies requires a retention rule'; + elsif retention_status <> 'applies' and exists ( + select 1 from retention_rule as rule where rule.policy_revision_id = target_revision_id + ) then + raise exception using + errcode = '23514', + message = 'retention rule requires retention status applies'; + end if; + + return null; +end; +$function$; + +create constraint trigger policy_revision_fact_contract +after insert or update on policy_revision +deferrable initially deferred +for each row execute function enforce_policy_revision_facts(); + +create constraint trigger service_profile_fact_contract +after insert or update or delete on service_profile +deferrable initially deferred +for each row execute function enforce_policy_revision_facts(); + +create constraint trigger collection_item_fact_contract +after insert or update or delete on collection_item +deferrable initially deferred +for each row execute function enforce_policy_revision_facts(); + +create constraint trigger processing_purpose_fact_contract +after insert or update or delete on processing_purpose +deferrable initially deferred +for each row execute function enforce_policy_revision_facts(); + +create constraint trigger retention_rule_fact_contract +after insert or update or delete on retention_rule +deferrable initially deferred +for each row execute function enforce_policy_revision_facts(); + +create function upsert_collection_item( + input_policy_revision_id uuid, + input_collection_item_key text, + input_collection_item_label text, + input_collection_mode collection_fact_mode default null, + input_collection_path text default null +) returns collection_item +language sql +as $function$ + insert into collection_item as stored ( + policy_revision_id, + collection_item_key, + collection_item_label, + collection_mode, + collection_path + ) values ( + input_policy_revision_id, + input_collection_item_key, + input_collection_item_label, + input_collection_mode, + input_collection_path + ) + on conflict (policy_revision_id, collection_item_key) do update + set collection_item_label = excluded.collection_item_label, + collection_mode = excluded.collection_mode, + collection_path = excluded.collection_path + returning stored.*; +$function$; + +commit; diff --git a/db/tests/policy_revision_concurrency.sh b/db/tests/policy_revision_concurrency.sh new file mode 100644 index 0000000..43573ae --- /dev/null +++ b/db/tests/policy_revision_concurrency.sh @@ -0,0 +1,195 @@ +#!/bin/sh +set -eu + +: "${PGHOST:=127.0.0.1}" +: "${PGPORT:=5432}" +: "${PGUSER:=policyweave_ci}" +: "${PGDATABASE:=policyweave_test}" +: "${PGPASSWORD:=policyweave_ci_password}" +export PGHOST PGPORT PGUSER PGDATABASE PGPASSWORD + +test_run_id=$$ +parent_release_fifo="/tmp/policyweave_parent_release_${test_run_id}" +parent_ready_file="/tmp/policyweave_parent_ready_${test_run_id}" +parent_writer_log="/tmp/policyweave_parent_writer_${test_run_id}.log" +item_writer_log="/tmp/policyweave_item_writer_${test_run_id}.log" +upsert_release_fifo="/tmp/policyweave_upsert_release_${test_run_id}" +upsert_ready_file="/tmp/policyweave_upsert_ready_${test_run_id}" +first_upsert_log="/tmp/policyweave_first_upsert_${test_run_id}.log" +second_upsert_log="/tmp/policyweave_second_upsert_${test_run_id}.log" +parent_writer_process_id= +item_writer_process_id= +first_upsert_process_id= +second_upsert_process_id= + +psql_command() { + psql --no-psqlrc --set ON_ERROR_STOP=1 "$@" +} + +cleanup_concurrency_test() { + for writer_process_id in \ + ${parent_writer_process_id:-} \ + ${item_writer_process_id:-} \ + ${first_upsert_process_id:-} \ + ${second_upsert_process_id:-}; do + kill "$writer_process_id" 2>/dev/null || true + wait "$writer_process_id" 2>/dev/null || true + done + rm -f \ + "$parent_release_fifo" "$parent_ready_file" "$parent_writer_log" "$item_writer_log" \ + "$upsert_release_fifo" "$upsert_ready_file" "$first_upsert_log" "$second_upsert_log" +} + +wait_for_file() { + expected_file=$1 + wait_name=$2 + attempt_count=0 + while [ ! -f "$expected_file" ]; do + attempt_count=$((attempt_count + 1)) + if [ "$attempt_count" -ge 200 ]; then + echo "timed out waiting for ${wait_name}" >&2 + return 1 + fi + sleep 0.05 + done +} + +wait_for_lock() { + writer_application_name=$1 + writer_log=$2 + attempt_count=0 + while :; do + waiting_count=$(psql_command --tuples-only --no-align --command \ + "select count(*) from pg_stat_activity where application_name = '${writer_application_name}' and wait_event_type = 'Lock'") + if [ "$waiting_count" -ge 1 ]; then + return 0 + fi + attempt_count=$((attempt_count + 1)) + if [ "$attempt_count" -ge 200 ]; then + cat "$writer_log" >&2 || true + echo "timed out waiting for PostgreSQL lock: ${writer_application_name}" >&2 + return 1 + fi + sleep 0.05 + done +} + +trap cleanup_concurrency_test 0 1 2 15 + +mkfifo "$parent_release_fifo" "$upsert_release_fifo" +psql_command --file db/migrations/0001_policy_revision.sql + +psql_command <<'SQL' +insert into policy_revision (policy_revision_id, tenant_account_id, revision_number) +values ('30000000-0000-4000-8000-000000000001', '40000000-0000-4000-8000-000000000001', 1); +SQL + +PGAPPNAME=policyweave_parent_writer PGOPTIONS='-c statement_timeout=15000' \ + psql --no-psqlrc --set ON_ERROR_STOP=1 >"$parent_writer_log" 2>&1 <"$item_writer_log" 2>&1 <<'SQL' & +begin; +insert into collection_item (policy_revision_id, collection_item_key, collection_item_label) +values ('30000000-0000-4000-8000-000000000001', 'contact_email', 'Contact email'); +commit; +SQL +item_writer_process_id=$! +wait_for_lock policyweave_item_writer "$item_writer_log" +printf 'release\n' >"$parent_release_fifo" +wait "$parent_writer_process_id" +parent_writer_process_id= + +set +e +wait "$item_writer_process_id" +item_writer_status=$? +set -e +item_writer_process_id= +if [ "$item_writer_status" -eq 0 ]; then + echo 'competing collection item unexpectedly committed' >&2 + exit 1 +fi +grep -F -- 'no-collection confirmation conflicts with collection items' "$item_writer_log" + +psql_command <<'SQL' +insert into policy_revision (policy_revision_id, tenant_account_id, revision_number) +values ('30000000-0000-4000-8000-000000000002', '40000000-0000-4000-8000-000000000001', 2); +SQL + +PGAPPNAME=policyweave_first_upsert_writer PGOPTIONS='-c statement_timeout=15000' \ + psql --no-psqlrc --set ON_ERROR_STOP=1 >"$first_upsert_log" 2>&1 <"$second_upsert_log" 2>&1 <<'SQL' & +begin; +select upsert_collection_item( + '30000000-0000-4000-8000-000000000002', + 'contact_email', + 'Concurrent contact email', + 'optional', + 'Account profile form' +); +commit; +SQL +second_upsert_process_id=$! +wait_for_lock policyweave_second_upsert_writer "$second_upsert_log" +printf 'release\n' >"$upsert_release_fifo" +wait "$first_upsert_process_id" +first_upsert_process_id= +wait "$second_upsert_process_id" +second_upsert_process_id= + +psql_command <<'SQL' +do $concurrency_assertion$ +declare + stored_count integer; + stored_label text; + stored_mode text; + stored_path text; +begin + select count(*), + max(collection_item_label), + max(collection_mode::text), + max(collection_path) + into stored_count, stored_label, stored_mode, stored_path + from collection_item + where policy_revision_id = '30000000-0000-4000-8000-000000000002' + and collection_item_key = 'contact_email'; + + if stored_count <> 1 + or stored_label <> 'Concurrent contact email' + or stored_mode is distinct from 'optional' + or stored_path is distinct from 'Account profile form' then + raise exception 'concurrent collection-item UPSERTs did not converge'; + end if; +end; +$concurrency_assertion$; +SQL + +psql_command --file db/migrations/0001_policy_revision.down.sql +cleanup_concurrency_test +trap - 0 1 2 15 diff --git a/db/tests/policy_revision_restore.sh b/db/tests/policy_revision_restore.sh new file mode 100644 index 0000000..ea8fadb --- /dev/null +++ b/db/tests/policy_revision_restore.sh @@ -0,0 +1,311 @@ +#!/bin/sh +set -eu + +: "${PGHOST:=127.0.0.1}" +: "${PGPORT:=5432}" +: "${PGUSER:=policyweave_ci}" +: "${PGDATABASE:=policyweave_test}" +: "${PGPASSWORD:=policyweave_ci_password}" +export PGHOST PGPORT PGUSER PGDATABASE PGPASSWORD + +test_run_id=$$ +dump_file="/tmp/policyweave_restore_${test_run_id}.dump" +list_file="/tmp/policyweave_restore_${test_run_id}.list" +container_dump_file="/tmp/policyweave_restore.dump" +postgres_container_id= + +psql_command() { + psql --no-psqlrc --set ON_ERROR_STOP=1 "$@" +} + +cleanup_restore_test() { + rm -f "$dump_file" "$list_file" + if [ -n "$postgres_container_id" ]; then + docker exec "$postgres_container_id" rm -f "$container_dump_file" >/dev/null 2>&1 || true + fi + psql --no-psqlrc --command "drop database if exists policyweave_restore" >/dev/null 2>&1 || true +} + +wait_for_postgres() { + attempt_count=0 + while :; do + if docker exec "$postgres_container_id" pg_isready -U "$PGUSER" -d "$PGDATABASE" >/dev/null 2>&1 \ + && psql_command --command 'select 1' >/dev/null 2>&1; then + return 0 + fi + attempt_count=$((attempt_count + 1)) + if [ "$attempt_count" -ge 60 ]; then + echo 'PostgreSQL did not accept connections after restart' >&2 + return 1 + fi + sleep 1 + done +} + +find_postgres_container() { + container_id=$(docker ps --filter name=postgres --format '{{.ID}}' | head -n 1) + if [ -n "$container_id" ]; then + printf '%s\n' "$container_id" + return 0 + fi + docker ps --format '{{.ID}} {{.Ports}}' | awk '/5432/ { print $1; exit }' +} + +expect_failure() { + failure_name=$1 + expected_message=$2 + failure_log="/tmp/${failure_name}.log" + if psql_command >"$failure_log" 2>&1; then + echo "expected PostgreSQL failure: ${failure_name}" >&2 + return 1 + fi + if ! grep -F -- "$expected_message" "$failure_log"; then + cat "$failure_log" >&2 + echo "unexpected PostgreSQL failure: ${failure_name}" >&2 + return 1 + fi +} + +assert_restored_facts() { + scene_name=$1 + psql_command < 1 + or service_profile_count <> 1 + or stored_service_name is distinct from 'Restore Probe Service' + or stored_service_url is distinct from 'https://restore.example.test' + or collection_item_count <> 1 + or stored_item_label <> 'Restore contact email' + or stored_item_mode is distinct from 'required' + or stored_item_path is distinct from 'Account registration form' + or collection_purpose_count <> 1 + or stored_purpose <> 'Account notices' + or collection_rule_count <> 1 + or stored_period <> '1 year after account closure' + or no_collection_revision_count <> 1 + or no_collection_item_count <> 0 + or no_collection_rule_count <> 0 + or collection_without_retention_count <> 1 then + raise exception '${scene_name} did not preserve independent collection and retention facts'; + end if; +end; +\$restore_assertion\$; +SQL +} + +if ! command -v docker >/dev/null 2>&1; then + echo 'docker is required for PostgreSQL restart evidence' >&2 + exit 1 +fi + +postgres_container_id=$(find_postgres_container) +if [ -z "$postgres_container_id" ]; then + echo 'postgres container not found for restart evidence' >&2 + docker ps >&2 || true + exit 1 +fi + +trap cleanup_restore_test 0 1 2 15 + +psql_command --file db/migrations/0001_policy_revision.sql + +psql_command <<'SQL' +begin; +insert into policy_revision ( + policy_revision_id, tenant_account_id, revision_number, retention_status +) values ( + '60000000-0000-4000-8000-000000000001', '50000000-0000-4000-8000-000000000001', 1, 'applies' +); +insert into service_profile (policy_revision_id, service_name, service_url) +values ( + '60000000-0000-4000-8000-000000000001', + 'Restore Probe Service', + 'https://restore.example.test' +); +select upsert_collection_item( + '60000000-0000-4000-8000-000000000001', + 'contact_email', + 'Restore contact email', + 'required', + 'Account registration form' +); +insert into processing_purpose (policy_revision_id, collection_item_key, purpose_text) +values ( + '60000000-0000-4000-8000-000000000001', + 'contact_email', + 'Account notices' +); +insert into retention_rule (policy_revision_id, retention_period) +values ( + '60000000-0000-4000-8000-000000000001', + '1 year after account closure' +); + +insert into policy_revision ( + policy_revision_id, tenant_account_id, revision_number, no_collection_confirmed, retention_status +) values ( + '60000000-0000-4000-8000-000000000002', '50000000-0000-4000-8000-000000000001', 2, true, 'none' +); + +insert into policy_revision ( + policy_revision_id, tenant_account_id, revision_number, retention_status +) values ( + '60000000-0000-4000-8000-000000000003', '50000000-0000-4000-8000-000000000001', 3, 'none' +); +select upsert_collection_item( + '60000000-0000-4000-8000-000000000003', + 'support_email', + 'Support email', + 'optional', + 'Support request form' +); +commit; +SQL + +psql_command --command 'checkpoint' + +docker restart "$postgres_container_id" >/dev/null +wait_for_postgres +assert_restored_facts restart + +docker exec "$postgres_container_id" pg_dump \ + -U "$PGUSER" \ + -d "$PGDATABASE" \ + --no-owner \ + --no-acl \ + --format=custom \ + --schema=public \ + -f "$container_dump_file" + +docker cp "$postgres_container_id:$container_dump_file" "$dump_file" +if [ ! -s "$dump_file" ]; then + echo 'pg_dump produced an empty custom archive' >&2 + exit 1 +fi + +psql_command --file db/migrations/0001_policy_revision.down.sql +psql_command <<'SQL' +do $rollback_assertion$ +begin + if to_regclass('public.policy_revision') is not null then + raise exception 'rollback left policy_revision behind before restore'; + end if; +end; +$rollback_assertion$; +SQL + +psql_command --command "drop database if exists policyweave_restore" +psql_command --command "create database policyweave_restore" + +docker exec "$postgres_container_id" pg_restore -l "$container_dump_file" \ + | grep -v 'SCHEMA - public' >"$list_file" +docker cp "$list_file" "$postgres_container_id:$container_dump_file.list" +docker exec "$postgres_container_id" pg_restore \ + -U "$PGUSER" \ + -d policyweave_restore \ + --no-owner \ + --no-acl \ + --exit-on-error \ + -L "$container_dump_file.list" \ + "$container_dump_file" + +PGDATABASE=policyweave_restore assert_restored_facts restore +PGDATABASE=policyweave_restore expect_failure restored_no_collection_conflict 'no-collection confirmation conflicts with collection items' <<'SQL' +begin; +insert into collection_item (policy_revision_id, collection_item_key, collection_item_label) +values ('60000000-0000-4000-8000-000000000002', 'contact_email', 'Contact email'); +commit; +SQL +PGDATABASE=policyweave_restore expect_failure restored_status_without_rule 'retention status applies requires a retention rule' <<'SQL' +begin; +update policy_revision + set retention_status = 'applies' + where policy_revision_id = '60000000-0000-4000-8000-000000000002'; +commit; +SQL +PGDATABASE=policyweave_restore expect_failure restored_rule_without_status 'retention rule requires retention status applies' <<'SQL' +begin; +insert into retention_rule (policy_revision_id, retention_period) +values ('60000000-0000-4000-8000-000000000002', '1 year'); +commit; +SQL + +psql_command --command "drop database policyweave_restore" +cleanup_restore_test +trap - 0 1 2 15 diff --git a/db/tests/policy_revision_runtime.sh b/db/tests/policy_revision_runtime.sh new file mode 100644 index 0000000..bfa1e73 --- /dev/null +++ b/db/tests/policy_revision_runtime.sh @@ -0,0 +1,138 @@ +#!/bin/sh +set -eu + +: "${PGHOST:=127.0.0.1}" +: "${PGPORT:=5432}" +: "${PGUSER:=policyweave_ci}" +: "${PGDATABASE:=policyweave_test}" +: "${PGPASSWORD:=policyweave_ci_password}" +export PGHOST PGPORT PGUSER PGDATABASE PGPASSWORD + +psql_command() { + psql --no-psqlrc --set ON_ERROR_STOP=1 "$@" +} + +expect_failure() { + failure_name=$1 + expected_message=$2 + failure_log="/tmp/${failure_name}.log" + if psql_command >"$failure_log" 2>&1; then + echo "expected PostgreSQL failure: ${failure_name}" >&2 + return 1 + fi + if ! grep -F -- "$expected_message" "$failure_log"; then + cat "$failure_log" >&2 + echo "unexpected PostgreSQL failure: ${failure_name}" >&2 + return 1 + fi +} + +# server_version_num must be >= 180000 and < 190000. +server_version_num=$(psql_command --tuples-only --no-align --command 'show server_version_num') +if [ "$server_version_num" -lt 180000 ] || [ "$server_version_num" -ge 190000 ]; then + echo "unsupported PostgreSQL server_version_num: ${server_version_num}" >&2 + exit 1 +fi + +psql_command --file db/migrations/0001_policy_revision.sql + +psql_command <<'SQL' +insert into policy_revision (policy_revision_id, tenant_account_id, revision_number) +values ('10000000-0000-4000-8000-000000000001', '20000000-0000-4000-8000-000000000001', 1); + +select upsert_collection_item( + '10000000-0000-4000-8000-000000000001', + 'contact_email', + 'Contact email', + 'required', + 'Account registration form' +); +select upsert_collection_item( + '10000000-0000-4000-8000-000000000001', + 'contact_email', + 'Account contact email', + 'optional', + 'Account profile form' +); + +do $runtime_assertion$ +declare + stored_count integer; + stored_label text; + stored_mode text; + stored_path text; +begin + select count(*), + max(collection_item_label), + max(collection_mode::text), + max(collection_path) + into stored_count, stored_label, stored_mode, stored_path + from collection_item + where policy_revision_id = '10000000-0000-4000-8000-000000000001' + and collection_item_key = 'contact_email'; + + if stored_count <> 1 + or stored_label <> 'Account contact email' + or stored_mode <> 'optional' + or stored_path <> 'Account profile form' then + raise exception 'collection-item UPSERT was not idempotent'; + end if; +end; +$runtime_assertion$; +SQL + +expect_failure no_collection_conflict 'no-collection confirmation conflicts with collection items' <<'SQL' +begin; +insert into policy_revision ( + policy_revision_id, tenant_account_id, revision_number, no_collection_confirmed +) values ( + '10000000-0000-4000-8000-000000000002', '20000000-0000-4000-8000-000000000001', 2, true +); +insert into collection_item (policy_revision_id, collection_item_key, collection_item_label) +values ('10000000-0000-4000-8000-000000000002', 'contact_email', 'Contact email'); +commit; +SQL + +expect_failure retention_rule_missing 'retention status applies requires a retention rule' <<'SQL' +begin; +insert into policy_revision ( + policy_revision_id, tenant_account_id, revision_number, retention_status +) values ( + '10000000-0000-4000-8000-000000000003', '20000000-0000-4000-8000-000000000001', 3, 'applies' +); +commit; +SQL + +psql_command <<'SQL' +insert into policy_revision (policy_revision_id, tenant_account_id, revision_number) +values + ('10000000-0000-4000-8000-000000000004', '20000000-0000-4000-8000-000000000001', 4), + ('10000000-0000-4000-8000-000000000005', '20000000-0000-4000-8000-000000000001', 5); +insert into service_profile (policy_revision_id, service_name) +values ('10000000-0000-4000-8000-000000000004', 'Example Service'); +SQL + +expect_failure revision_owner_change 'revision-owned facts cannot move between policy revisions' <<'SQL' +begin; +update service_profile + set policy_revision_id = '10000000-0000-4000-8000-000000000005' + where policy_revision_id = '10000000-0000-4000-8000-000000000004'; +commit; +SQL + +psql_command --file db/migrations/0001_policy_revision.down.sql +psql_command <<'SQL' +do $rollback_assertion$ +begin + if to_regclass('public.policy_revision') is not null + or exists (select 1 from pg_proc where proname in ('upsert_collection_item', 'enforce_policy_revision_facts')) + or exists (select 1 from pg_type where typname in ('collection_fact_mode', 'retention_fact_status')) then + raise exception 'rollback left migration-owned objects behind'; + end if; +end; +$rollback_assertion$; +SQL + +# A second apply/down cycle verifies that rollback restores a clean provisioning state. +psql_command --file db/migrations/0001_policy_revision.sql +psql_command --file db/migrations/0001_policy_revision.down.sql diff --git a/docs/ADR-0001-policy-as-data.md b/docs/ADR-0001-policy-as-data.md new file mode 100644 index 0000000..c816872 --- /dev/null +++ b/docs/ADR-0001-policy-as-data.md @@ -0,0 +1,17 @@ +# ADR-0001: 정책 문서보다 정책 데이터를 원본으로 둔다 + +상태: 제안됨 (Proposed) + +## 결정 + +수집 항목, 목적, 보유 기간, 제공 대상, 이전 국가와 연락처를 구조화된 정책 데이터로 저장한다. 화면의 문서는 이 데이터에서 결정적으로 렌더링한다. + +구조화된 사실 사이의 의존성도 데이터 계약의 일부로 취급한다. 수집 항목을 비활성화하면 그 항목에 종속된 처리 목적과 수집 경로 증거를 함께 무효화하고, 다시 활성화할 때는 최신 사실을 재입력·재검토한다. 미리보기와 검토 규칙은 공백 정규화를 포함해 같은 완전성 계약을 사용한다. + +## 이유 + +문자열 문서를 직접 편집하면 동일 항목이 표·본문·요약에서 불일치할 수 있다. 구조화된 원본은 검증 규칙, 변경 이력, 다국어 렌더러와 공개 API를 같은 계약 위에 올릴 수 있다. 비활성화된 사실에 종속 데이터를 그대로 보존해 자동 복원하면 사용자가 철회한 처리 흐름의 오래된 증거가 새 검토 없이 다시 유효해질 수 있다. + +## 결과 + +자유 문구가 필요한 예외에는 출처와 검토 상태를 함께 저장해야 한다. 법령 변경은 템플릿 버전과 규칙 세트 버전으로 추적한다. 향후 영속화 계층도 삭제·비활성화 명령에서 종속 사실의 무효화 또는 명시적 이력 보존을 구분하고, 현재 유효한 정책 사실과 감사 이력을 혼동하지 않아야 한다. diff --git a/docs/ADR-0002-seven-step-authoring-workflow.md b/docs/ADR-0002-seven-step-authoring-workflow.md new file mode 100644 index 0000000..ea33967 --- /dev/null +++ b/docs/ADR-0002-seven-step-authoring-workflow.md @@ -0,0 +1,36 @@ +# ADR-0002: Seven-step authoring is an explicit domain workflow + +Status: Proposed +Date: 2026-09-01 + +## Context +The PRD defines seven authoring stages, but an early implementation changed only the step rail while always rendering the collection-item editor. This made the buyer-facing workflow appear complete while most facts were not editable and review findings could not lead the operator to the responsible input. + +A later commercialization pass found a second workflow-integrity defect: the fresh workspace preselected collection items and prefilled service-specific collection modes and processing purposes. Those values looked like customer facts even though the operator had never established them, contradicting PolicyWeave's fact-authoring boundary and its prohibition on synthetic production data. + +A subsequent exact-head pass found that even after collection facts were repaired, the readiness CTA could enable while service identity, retention, transfer statuses, and privacy contact were still visibly unresolved in the review draft. Blank transfer fields were also ambiguous between `none` and `not yet checked`. + +The next pass found a separate completeness hole: collection-path evidence was editable but not required by readiness. After that was repaired, the remaining collection-state model still made a service that genuinely collects no personal data impossible to complete because an empty item selection was always unresolved. Treating empty selection as `none` would reintroduce inference, so the workflow needs an explicit negative attestation. + +## Decision +Each PRD stage is an explicit editable state of the Policy Fact Authoring context: service information, collection items, processing purposes, retention, third-party transfer, international transfer, and privacy contact. The UI router must render a corresponding editor for the selected stage. Review findings carry enough domain context to navigate to the responsible stage. + +The collection catalog is metadata, not an assertion about a customer's service. A fresh workspace starts with every collection item unselected and with collection mode, processing purpose, and collection path unresolved. Collection readiness is established by either selecting at least one actual collection item or explicitly attesting that the service collects no personal data. Empty selection alone remains unresolved. The no-collection attestation and selected items are mutually exclusive; contradictory state fails closed. Turning the attestation on clears selected items plus their mode, purpose, and path evidence, and later turning it off does not restore those stale facts. + +When collection items are present, each selected item requires the operator to establish collection mode, processing purpose, and collection-path evidence before readiness can clear. Disabling an item invalidates those dependent facts so re-enabling cannot silently restore stale evidence. + +Readiness also requires the product-defined facts owned by the other authoring stages: service name and URL, explicit retention status, third-party provision status, international-transfer status, and privacy-contact owner/email. When retention applies, a nonblank retention period or end condition is required; explicit `none` requires no period. Third-party provision and international transfer use explicit unresolved/yes/no states. `no` is an operator attestation, not an inference from a blank field. `yes` requires its dependent recipient/purpose or country/recipient facts. A transition away from `yes` clears those dependent values so stale operational facts do not silently revive. + +These readiness rules are authoring-completeness rules, not a legal state machine. Completion of a UI step or zero product-defined blockers does not mean legal sufficiency. Legal/rule completeness is determined separately by deterministic rules bound to versioned authoritative source evidence. + +## Consequences +- Navigation and review-to-source behavior are regression-tested. +- Production startup state contains taxonomy metadata only, not inferred customer operational facts. +- Empty collection selection cannot masquerade as `none`; an explicit no-collection attestation is required for a no-collection service. +- No-collection and selected-item states are mutually exclusive, and switching to no-collection invalidates stale item evidence. +- Selection/no-collection, collection mode, processing purpose, collection path, service identity, retention, transfer status/detail, and privacy contact findings fail closed and navigate to their owning step. +- Blank transfer state can no longer masquerade as an explicit `none` attestation. +- Stale collection and transfer-dependent facts are invalidated when their owning status changes. +- The preview remains a projection over structured facts and cannot become an independent source of truth. +- Future persistence stores facts and revision/review state rather than serialized page prose. +- Additional legal validations can grow without redefining the seven buyer-facing authoring responsibilities. diff --git a/docs/ADR-0003-policy-revision-persistence.md b/docs/ADR-0003-policy-revision-persistence.md new file mode 100644 index 0000000..bb5b3fc --- /dev/null +++ b/docs/ADR-0003-policy-revision-persistence.md @@ -0,0 +1,67 @@ +# ADR-0003: PostgreSQL policy revision persistence foundation + +Status: Proposed +Date: 2026-09-07 + +## Problem + +The browser workspace holds an operator's draft only in memory. A hosted product needs durable version identity and item-level retry behavior without collapsing unresolved facts into negative facts, coupling PolicyWeave to another product's database, or treating an open PR as a released persistence service. + +## Constraints + +- `policy_revision` remains the smallest transaction aggregate. +- Tenant identity and revision number must form a unique version identity. +- Explicit no-collection confirmation and retention status remain independent facts. +- A no-collection revision cannot own collection items. +- `retention_status = applies` requires exactly one current `retention_rule`; other statuses cannot retain one. +- Organization-owned persistence identifiers use two or more semantic words and `snake_case`. +- Retries update one collection item through its declared natural key; they do not replace an entire revision. +- Hosted authorization, audit, encryption, publication, and operational backup/restore remain mandatory before production use. CI restart and dump/restore evidence does not replace those hosted controls. + +## Decision + +Introduce a PostgreSQL migration contract with `policy_revision` as aggregate root. `service_profile`, `collection_item`, `processing_purpose`, and `retention_rule` are revision-owned normalized tables. The explicit no-collection boolean and the independent `retention_fact_status` live on the revision; absence of rows does not manufacture either fact. + +Use `(tenant_account_id, revision_number)` as revision version identity and `(policy_revision_id, collection_item_key)` as collection-item identity. `upsert_collection_item` uses PostgreSQL `ON CONFLICT` on that item key, so an identical retry addresses the same item rather than duplicating it or rewriting the aggregate wholesale. + +Deferred constraint triggers take a `FOR NO KEY UPDATE` lock on the owning revision row and evaluate the final transaction state. This mode serializes competing fact checks without conflicting with the `FOR KEY SHARE` lock used by foreign-key child writes. The triggers reject fact reparenting, a no-collection revision with collection items, an `applies` retention state without a rule, and a rule attached to any other retention state. A command may change a status and its dependent row in either statement order within one transaction while still failing closed at commit. + +## Alternatives + +### Store one JSON document per revision + +Rejected for the hosted write model. It makes item-level conflict behavior and relational integrity implicit, encourages whole-document overwrites, and weakens 3NF evidence. JSON remains suitable for versioned export or event payloads after those contracts exist. + +### Persist browser prose or component state + +Rejected. Rendered prose is a projection, and component state is not the Policy Fact Authoring ubiquitous language. Either choice would make a presentation format authoritative over operator-established facts. + +### Reuse another ContextualWisdomLab database or source branch + +Rejected. No released owner contract currently supplies PolicyWeave's product-domain persistence. Cross-service SQL, source copying, and temporary-branch dependencies would violate the product ownership boundary. + +## Evidence + +`src/persistence-schema.test.ts` fixes the stable schema markers for revision identity, normalized ownership, deferred fact consistency, and natural-key UPSERT. `db/tests/policy_revision_runtime.sh` applies the migration to a digest-pinned PostgreSQL 18 service, exercises natural-key retry, requires each negative scene to emit its expected domain error, executes the down migration, verifies object removal, and repeats the apply/down cycle. `db/tests/policy_revision_concurrency.sh` coordinates two real sessions with FIFOs, observes PostgreSQL lock waits, and verifies conflicting fact writers fail closed while same-item UPSERT writers converge to one row carrying the second writer's label, mode, and path; nullable mode/path comparisons use `IS DISTINCT FROM` so missing values cannot satisfy the evidence assertion. `db/tests/policy_revision_restore.sh` checkpoints, restarts the service container, dumps a custom-format archive, restores it into a fresh database, and proves a collecting revision may independently retain `retention_status = none`, NULL-safe complete service/collection-item values survive, and no-collection plus both retention status/rule deferred contradictions still fail against the restored schema; nullable restored values are checked with `IS DISTINCT FROM`. Applies retention and its required rule are seeded in one transaction because the deferred fact trigger evaluates the final commit state. This remains CI evidence rather than a deployed storage claim. + +## Risks and effects + +- The migration is not a production backend and grants no network access. +- Exact-head CI must prove PostgreSQL 18 execution for the covered single-session, two-session, process-restart, and dump/restore cases; it does not prove tenant authorization, encryption, deletion, or production-scale contention. +- The `tenant_account_id` is deliberately not linked to an identity table until a released Keyverse contract and PolicyWeave authorization design exist. +- Draft facts may remain nullable while unresolved; database constraints protect contradictions, while completeness remains the deterministic review responsibility. +- The collection mode enum uses locale-neutral values. UI labels are translated at the application boundary rather than stored as database truth. + +## Operational and failure scenes + +- A client repeats the same collection-item command after losing a response: the natural-key UPSERT addresses one row. +- A client tries to add an item to a revision confirmed as no-collection: commit fails with a constraint violation. +- A client changes retention from `applies` to `none` but forgets to remove the old rule: commit fails, so stale retention evidence cannot survive. +- A client attempts to move a retention rule between revisions: commit fails instead of leaving the original `applies` revision without its required rule. +- A no-collection writer holds the revision lock while a second client adds an item: the second client waits, observes the committed parent fact, and fails closed. +- Two clients UPSERT the same item key: the second waits for the first and updates the same row rather than creating a duplicate. +- Two clients claim the same tenant revision number: the unique constraint rejects one rather than creating ambiguous versions. + +## Follow-up + +Add tenant-purpose authorization and immutable audit events, measure production-scale contention, and only then connect a hosted asynchronous API. Immutable publication and supersession remain a separate Review & Publication decision. diff --git a/docs/ERD.md b/docs/ERD.md new file mode 100644 index 0000000..82118c6 --- /dev/null +++ b/docs/ERD.md @@ -0,0 +1,51 @@ +# Proposed policy revision ERD + +Status: Proposed source contract; no database runtime is deployed. + +```mermaid +erDiagram + policy_revision ||--o| service_profile : owns + policy_revision ||--o{ collection_item : owns + collection_item ||--o| processing_purpose : explains + policy_revision ||--o| retention_rule : governs + + policy_revision { + uuid policy_revision_id PK + uuid tenant_account_id + bigint revision_number + boolean no_collection_confirmed + retention_fact_status retention_status + timestamptz created_at + } + + service_profile { + uuid policy_revision_id PK, FK + text service_name + text service_url + } + + collection_item { + uuid policy_revision_id PK, FK + text collection_item_key PK + text collection_item_label + collection_fact_mode collection_mode + text collection_path + } + + processing_purpose { + uuid policy_revision_id PK, FK + text collection_item_key PK, FK + text purpose_text + } + + retention_rule { + uuid policy_revision_id PK, FK + text retention_period + } +``` + +`(tenant_account_id, revision_number)` is the revision version key. `(policy_revision_id, collection_item_key)` is the collection-item UPSERT/idempotency key. `tenant_account_id` is an external identity value without a database foreign key until PolicyWeave has an authorized released Keyverse boundary. + +Deferred constraint triggers take `FOR NO KEY UPDATE` on the owning revision, reject moving an owned fact between revisions, and check final transaction consistency. Explicit no-collection forbids collection items. `retention_status = applies` requires one `retention_rule`; other statuses forbid one. Absence of an item or rule never manufactures an operator attestation. + +This ERD reflects migration `db/migrations/0001_policy_revision.sql`. It does not include future audit, authorization, legal-source, review-finding, or publication tables because those contracts are not implemented in this migration. diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..0767c4c --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,50 @@ +# PolicyWeave 제품 요구사항 + +## 문제 + +소규모 웹·앱 운영자는 실제 데이터 처리 흐름을 법정 고지 문구로 옮기는 과정에서 누락을 만들기 쉽다. 범용 템플릿은 서비스가 수집하지 않는 항목까지 남기거나, 처리 목적·보유 기간·제3자 제공을 서로 모순되게 작성한다. + +## 제품 원칙 + +PolicyWeave는 법률 문장을 임의로 창작하는 도구가 아니다. 운영자가 입력한 사실을 구조화하고, 적용 근거와 확인이 필요한 부분을 분리해 검토본을 만든다. 자동 생성 결과는 변호사·개인정보보호책임자의 검토를 대체하지 않는다. 새 작업공간은 고객의 실제 수집 여부, 수집 구분, 처리 목적, 수집 경로, 보유 여부·기간, 제3자 제공 여부 또는 국외 이전 여부를 추정하지 않는다. 비어 있는 값은 `없음`이 아니라 `미확인`이다. 실제 개인정보를 수집하지 않는 서비스는 운영자가 이를 명시적으로 확인할 수 있어야 하며, 그 확인은 빈 항목 목록에서 추론되지 않는다. 개인정보의 `수집`과 `보유`는 서로 다른 처리 사실이므로 no-collection 확인만으로 no-retention을 추론하지 않는다. + +## MVP + +- 7단계 질문 흐름: 서비스 정보, 수집 항목, 처리 목적, 보유 기간, 제3자 제공, 국외 이전, 담당자 +- 입력과 동시에 갱신되는 개인정보처리방침 검토본 +- 제품 정의 필수 사실 누락과 권장 검토 항목의 구분 +- 처리 항목별 수집 구분·목적·수집 경로 연결 +- 개인정보를 수집하지 않는 경우의 명시적 운영자 확인과 수집 항목 사실의 상호배타성 +- 개인정보 보유 여부의 명시적 `보유함`/`보유하지 않음` 확인; `보유함`일 때만 보유 기간 요구 +- 제3자 제공과 국외 이전의 명시적 `있음`/`없음` 확인; `있음`일 때만 종속 상세 사실 요구 +- 공개 전 검토 요약과 버전 정보 +- 정적 공개 URL 발행 계약(후속 백엔드에서 구현) +- 버전이 명시된 JSON으로 현재 정책 사실과 검토 상태를 로컬 내보내기 + +## 비목표 + +- 법률 자문 또는 준법 보장 +- 사용자의 실제 서비스·SDK를 자동 추측 +- 동의가 필요 없는 처리에 무조건 동의를 요구하는 방식 +- 빈 입력을 자동으로 `없음` 또는 적법 상태로 해석하는 방식 +- 한 작성 단계의 부정 사실을 다른 처리 단계의 부정 사실로 자동 확장하는 방식 + +## 성공 기준 + +- 사용자는 일곱 작성 책임의 제품 정의 필수 사실을 누락 없이 검토할 수 있다. +- 새 작업공간은 고객 운영 사실을 미리 채우지 않는다. +- 새 작업공간은 첫 번째 미확인 책임인 `서비스 정보`에서 시작한다. +- 단계 이동 자체는 작성 책임의 완료를 의미하지 않는다. 진행률, 완료 체크와 `입력 확인됨` 표시는 해당 단계의 제품 정의 차단 사실이 실제로 해소된 경우에만 readiness와 동일한 fail-closed 사실 계약에서 파생한다. +- 수집 단계는 실제 수집 항목을 하나 이상 확인하거나 `개인정보를 수집하지 않음`을 명시적으로 확인해야 한다. 두 상태가 동시에 참인 데이터는 공개 준비를 통과하지 않는다. +- `개인정보를 수집하지 않음`으로 전환하면 이전 수집 항목의 수집 구분·처리 목적·수집 경로를 폐기하고, 확인을 해제해도 해당 사실을 자동 복원하지 않는다. +- no-collection 상태는 보유 여부를 자동 결정하지 않는다. 보유 단계는 별도의 명시적 `보유함`/`보유하지 않음` 상태를 요구하며, `보유함`은 비어 있지 않은 보유 기간 또는 종료 조건을 추가로 요구한다. +- `보유함`에서 벗어나면 이전 보유 기간을 폐기하고, 다시 `보유함`으로 전환해도 해당 값을 자동 복원하지 않는다. +- 실제 수집 항목이 있는 경우 각 항목의 수집 구분·처리 목적·수집 경로, 서비스 이름·URL, 명시적 보유 상태와 필요한 보유 기간, 제3자 제공 여부, 국외 이전 여부, 개인정보 보호 담당자·연락 이메일이 확인되기 전에는 공개 준비 상태를 통과하지 않는다. +- 개인정보를 수집하지 않는 경우에도 보유 상태는 독립적으로 확인되어야 한다. 이는 다른 경로로 생성·저장·보유하는 개인정보 가능성을 수집 부재만으로 배제하지 않기 위함이다. +- 서비스 URL은 절대 HTTP(S) 주소 형식을 만족해야 하고, 개인정보 보호 연락 이메일은 최소 주소 형식 검사를 통과해야 한다. 이 검사는 서비스 존재나 메일함 소유권을 주장하지 않는다. +- 제3자 제공 또는 국외 이전을 `있음`으로 확인한 경우 해당 수령자/목적 또는 국가/수령자 사실까지 확인되어야 한다. +- `없음` 확인은 명시적 운영자 사실이며, 이전에 입력한 종속 제공·이전 상세 사실은 상태 변경 시 폐기된다. +- 모든 경고는 해당 입력 단계로 이동할 수 있다. +- JSON 내보내기는 확인되지 않은 값을 임의 사실로 채우지 않고, 자격정보가 포함된 잘못된 서비스 URL을 파일에 기록하지 않으며, 네트워크 전송 없이 현재 작성 사실과 검토 상태를 재현한다. +- 모바일에서도 작성과 미리보기를 전환할 수 있다. +- 키보드만으로 모든 입력과 주요 동작을 수행할 수 있다. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..697447c --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,37 @@ +# Security and Privacy Baseline + +## Current exposure +PolicyWeave is currently a client-only, local-first authoring workspace. There is no production account system, server-side persistence, publication service, or secret-bearing provider integration in this repository. This limits current remote data exposure but does not make the product compliant or production-ready. + +## Assets and trust boundaries +Protected assets include policy facts, contact details, processing descriptions, legal/rule source receipts, review findings, audit events, and future publication artifacts. Browser state is trusted only for the active local editing session. Any future API, datastore, identity provider, legal-source feed, or customer system is an explicit external boundary and requires an ACL/adapter. + +## Threats to address before hosted launch +- Cross-tenant access or confused-deputy publication. +- Unauthorized draft mutation or publication. +- Loss of provenance/source-version evidence. +- Silent mutation of already-published revisions. +- Injection through user-entered policy text, imported source metadata, or rendered markup. +- Credential leakage in CI or runtime. +- Over-broad logging of PII or policy content. +- Retention/deletion behavior inconsistent with the operator's configured lifecycle. +- Supply-chain compromise in npm/GitHub Actions dependencies. + +## Required controls +1. Authenticate users and authorize every tenant/resource/purpose operation server-side before hosted persistence. +2. Encrypt PII and policy content in transit and at rest; use non-masking protections when masking would break legitimate work, while minimizing disclosure in logs/telemetry. +3. Keep immutable audit events for security- and publication-relevant actions with actor, tenant, revision, action, result, and timestamp. +4. Publication operates on an explicit reviewed revision and creates an immutable publication receipt with digest/version evidence. +5. Secrets never enter client bundles or repository content. CI checkout credentials remain non-persistent and Actions are SHA pinned. +6. Validate and encode user-entered content at output boundaries; do not treat imported HTML/Markdown/source material as executable instructions. +7. Define backup/restore, incident response, access review, retention/deletion, and evidence collection before claiming SOC 2 readiness. Map hosted controls toward CSAP and SOC 2 without describing an unassessed product as certified. +8. Tests/docs use fictionalized organizations and people; real personal/institutional names are not fixtures. +9. Service URLs containing credentials, query, or fragment components are invalid and withheld from the review projection; operators must provide a credential-free HTTP(S) location whose destination can be exported without lossy rewriting. +10. Direct npm declarations use exact reviewed lock resolutions, and compiler/bundler packages are development-only. Exact-head CI emits a CycloneDX SBOM from the installed lock graph; the package-lock license inventory supports review but does not itself approve license compatibility. + +## Verification +Security posture is head-specific. A successful predecessor scan, unresolved finding dismissal, or queued security workflow is not passing evidence. Merge/release decisions must reacquire the exact current head's organization-required security/SAST/review checks plus the dependency manifest contract and CycloneDX artifact. Vulnerability or license inventory is evidence for review, not a substitute for an explicit release decision. + + +## Local JSON export +The export path serializes only the current in-memory PolicyWeave draft and deterministic readiness codes into a browser Blob. It makes no network request, uses a fixed filename rather than customer-controlled path text, and defers object-URL revocation until the next task after initiating the download so browsers with deferred navigation can consume the Blob. If local download preparation or activation throws, the exception is contained and the existing live status output directs the operator to retry. A temporary object URL is revoked exactly when allocation succeeded; preparation failure before allocation has no fabricated cleanup target. A service URL containing username, password, query, or fragment components is omitted from the file and remains represented by the `service_url_format` finding. The same shared validator withholds it from preview and readiness, preventing token disclosure and destination-changing rewrites. The file is still customer-controlled sensitive data; operators remain responsible for its storage and transfer. This control is not encryption, persistence, publication, backup, authorization, or mid-transfer cancellation evidence. diff --git a/docs/TRD.md b/docs/TRD.md new file mode 100644 index 0000000..0d9e19b --- /dev/null +++ b/docs/TRD.md @@ -0,0 +1,57 @@ +# Technical Requirements Document + +## Scope +This TRD covers the pre-release PolicyWeave browser workspace and the contracts that must exist before hosted persistence/publication is added. + +## Current runtime +- React + TypeScript + Vite browser application. +- Structured authoring state is in browser memory; no production database or backend exists. +- Local JSON export projects the current draft through `createPolicyExport` into deterministic `schema_version = 1` data with normalized facts, explicit `incomplete`/`review_ready` state, and readiness finding codes. Service URLs containing credentials, query, or fragment components export as `null` and remain blocking rather than being rewritten to another destination; unresolved collection modes export as `null`. Download uses a browser Blob/object URL, defers URL revocation until the next task so deferred browser navigation can consume it, contains preparation and activation exceptions with a retry message in the existing live status output, and performs no network request. +- `src/policy.ts` owns deterministic review logic for collection selection/no-collection attestation/mode/purpose/path and the non-collection authoring-completeness findings for service identity, explicit retention status/period, transfer statuses/details, and privacy contact. +- `src/App.tsx` provides the seven-step authoring flow, review navigation, explicit collection/retention/transfer-status capture, stale dependent-fact invalidation, and deterministic preview projection. +- `src/AuthoringFocusController.tsx` keeps explicit step navigation and review-warning jumps aligned with the newly active step by moving programmatic focus to its heading after the React update and allowing the browser to reveal that target; ordinary form controls and the dedicated preview shortcut are outside this behavior. +- The current CI contract is lint, Vitest, TypeScript/Vite build, and Playwright Chromium browser evidence plus live organization-required security/review workflows. Browser cases cover desktop/tablet/mobile rendering, keyboard-triggered focus transfer, the explicit no-collection path, retention-status transitions with stale-period invalidation, effective 200% browser-zoom reflow from the desktop layout viewport, horizontal overflow, serious/critical axe findings, real download events with mouse, keyboard, and touch activation, fixed filename, JSON MIME, byte-stable repeated exports, review-ready payload semantics, object-URL cleanup on success and simulated activation failure, contained pre-allocation failure, failure announcement, and per-project screenshots retained as an exact-head artifact. +- Muted small text uses one authored color token whose contrast is regression-tested against every current surface background at a minimum 4.5:1 ratio; browser axe remains the integration authority for rendered combinations. +- Direct npm declarations equal the exact reviewed versions resolved by `package-lock.json`. `react`, `react-dom`, and `lucide-react` are the only runtime packages; the compiler, bundler, Vite React plugin, lint, test, browser, type, and DOM harnesses are development packages. CI generates a CycloneDX 1.5 SBOM from the exact installed lock graph and retains it as a head-bound artifact; license fields are inventory evidence, not a legal compatibility decision. + +## Functional contracts +1. Every PRD step must route to an editable surface; selecting a step cannot change only the rail indicator. +2. A fresh workspace contains no inferred customer operational facts. Blank authoring values mean unresolved, not `none`. It starts at the first unresolved responsibility (`서비스 정보`) and must not mark an untouched earlier responsibility as completed merely because a later editor was shown. +3. Collection readiness requires either at least one explicitly selected collection item or an explicit no-collection attestation. The no-collection attestation and selected items are mutually exclusive; contradictory state fails closed. Turning the attestation on clears selected items and their mode/purpose/path evidence, and later turning it off does not revive those stale facts. +4. Every selected collection item requires an explicit collection mode, nonblank processing purpose, and nonblank collection-path evidence. Each missing responsibility is counted independently and navigates to its owning step. +5. Retention is an independent operator fact, not a consequence of collection state. The retention step uses explicit unresolved/`applies`/`none` status. Unresolved blocks readiness. `applies` requires a nonblank retention period or end condition; `none` requires no period. Changing away from `applies` clears the previous period so stale retention evidence cannot revive. No-collection never auto-selects `none`. +6. Service name, service URL, third-party provision status, international-transfer status, privacy-contact owner, and privacy-contact email are product-defined readiness facts and block readiness while unresolved. Service URL must be an absolute HTTP(S) URL without credentials, query, or fragment components so preview and export preserve one destination; contact email must satisfy a minimal address-shape check. These syntax checks do not claim endpoint reachability or mailbox ownership. +7. Third-party provision and international transfer use explicit unresolved/yes/no status. `no` is an operator attestation; `yes` requires its dependent facts. Changing either status away from `yes` clears dependent details to prevent stale evidence revival. +8. Blank/whitespace authoring facts are normalized as unresolved where that fact is required by the explicit governing status. +9. Disabling a collection item invalidates dependent collection-mode, processing-purpose, and collection-path evidence; re-enabling requires renewed confirmation. +10. Every blocking finding links to the responsible editing step. +11. Explicit step navigation through the rail, previous/next controls, or a blocking-finding action transfers focus to the newly active step heading after that surface renders. The focused heading must remain fully inside the current viewport; navigation must not suppress the browser scroll needed to reveal it. This contract must not steal focus from ordinary editing controls or the dedicated preview shortcut. +12. Preview text is derived from current structured facts, including independent collection and retention statuses; it does not become an independently editable authority. +13. UI copy distinguishes a review draft from legal advice, certification, or a compliance guarantee. + +The separation between collection and retention follows the PIPC Standard Personal Information Protection Guidelines terminology: `처리` includes collection, storage, retention, and other acts, so absence of collection is insufficient evidence for absence of retention. These remain authoring-completeness contracts, not claims that the resulting policy is legally sufficient. Legal sufficiency remains a separate versioned-rule responsibility backed by authoritative source evidence. + +## Quality contracts +- Touched production behavior requires regression and edge-case tests first. +- Touched production functions carry descriptive JSDoc rather than relying on implicit behavior. +- Do not suppress deprecation warnings to pass CI. +- Dependency changes must update the manifest and npm-generated lock graph together, keep build-only tools out of production dependencies, retain machine-readable license inventory, and reacquire exact-head vulnerability, SBOM, and license evidence. +- Browser/accessibility validation must include keyboard/focus order, focus-target viewport visibility, WCAG 2.2 automated checks, responsive desktop/tablet/mobile interactions, and screenshot evidence before a buyer-facing accessibility claim. Deterministic jsdom focus-transition coverage is supporting evidence only and does not substitute for real-browser, zoom, screen-reader, or responsive verification. +- Hosted web endpoints, when introduced, use non-blocking/asynchronous handling and require realistic k6 tests before a p95 <=20 ms page/API claim is recorded. +- Production does not depend on synthetic demo data. + +## Local draft portability +The JSON file is a draft portability artifact, not a publication receipt, immutable revision, legal approval, or persistence backup. It may be exported while incomplete so operators can inspect and transfer their authored work without converting blanks into `none`. Contract changes require a new schema version and compatibility evidence; the current fixed filename avoids using customer-controlled text as a filesystem name. Preparation/activation-error recovery does not establish cancellation of an in-progress browser transfer. + +## Hosted persistence/publication entry criteria +Before network persistence lands, define a versioned policy-data schema, migration policy, 3NF relational model, per-item UPSERT/idempotency rules, immutable publication receipt, supersession/rollback semantics, tenant/purpose authorization, audit evidence, encryption/key management, retention/deletion behavior, and backup/restore testing. Use two-or-more-word semantic persistence object names in `snake_case` by default. The revision model must preserve explicit no-collection and explicit retention status independently; `none` must not be materialized from collection absence, and an inapplicable/non-retained state must not carry a live `retention_rule` value. + +ADR-0003 and migration `0001_policy_revision.sql` satisfy only the source-contract portion of that entry criterion: tenant-scoped revision identity, normalized service/collection/purpose/retention facts, parent-row-serialized deferred contradiction checks, and collection-item natural-key UPSERT. Current CI executes PostgreSQL 18 for apply/down/apply, exact domain-error, two-session lock with NULL-safe complete-value assertions, process-restart, and custom-format dump/restore scenes. Authorization, audit, encryption, deletion, production-scale contention, and a hosted adapter remain required before network persistence may be enabled. + +A publication command must operate on a specific reviewed `policy_revision` and create a new immutable `publication_revision`. It must fail closed when blocking findings, incompatible rule/template versions, missing source receipts, or missing authorization exist. Publication never mutates foreign customer databases. + +## External boundaries +Legal/rule data enters through a Legal Source Registry ACL with source identity, effective date, retrieval/version evidence, and an implementation mapping. Customer/application integrations require their own explicit adapters. If future LLM assistance is justified for drafting/explanation, calls must route through `ContextualWisdomLab/contextual-orchestrator`; deterministic validation and publication authority remain outside the model. + +## Verification state +Only exact-current-head checks count. Queued, cancelled, skipped-required, or predecessor-head runs are not evidence of passing. Normal integration requires the live ruleset's independent approval and thread-resolution requirements; no administrative bypass is part of this TRD. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f79e66d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,34 @@ +# PolicyWeave + +PolicyWeave is a local-first privacy-policy fact-authoring workspace for web and app operators. It structures facts the operator has established, highlights missing or contradictory inputs, and renders a deterministic privacy-policy review draft without presenting that draft as legal advice or a compliance certification. + +## Start here + +- [README](../README.md) — local setup, current product scope, and validation commands. +- [Product requirements](PRD.md) — the seven-step authoring workflow and buyer-facing acceptance criteria. +- [Technical requirements](TRD.md) — implementation and quality requirements for the product boundary. +- [Architecture](../ARCHITECTURE.md) — system responsibilities, dependency direction, and trust boundaries. +- [ADR 0001](ADR-0001-policy-as-data.md) — the policy-as-data decision that makes structured operator facts authoritative over rendered prose. +- [ADR 0002](ADR-0002-seven-step-authoring-workflow.md) — the Proposed explicit seven-step authoring and fail-closed fact workflow. +- [ADR 0003](ADR-0003-policy-revision-persistence.md) — the Proposed PostgreSQL revision identity, 3NF fact, consistency, and item-level UPSERT contract. +- [Proposed policy revision ERD](ERD.md) — the normalized persistence relationships and transaction invariants represented by migration `0001`. +- [Research and legal traceability](research-traceability.md) — authoritative-source, effective-date, and implementation/test traceability for legal and policy decisions. +- [Product and technical gap baseline](product-technical-gap-baseline.md) — current commercialization gaps and evidence status. +- [Security](../SECURITY.md) — security policy and reporting boundary. +- [Changelog](../CHANGELOG.md) — repository change history. +- [Repository releases](https://github.com/ContextualWisdomLab/PolicyWeave/releases) — published release records when they exist. +- [Ask DeepWiki](https://deepwiki.com/ContextualWisdomLab/PolicyWeave) — repository-oriented Q&A and navigation. + +## Product boundary + +PolicyWeave owns structured privacy-processing facts, completeness and contradiction review, and deterministic draft rendering. The operator remains responsible for establishing the underlying facts and obtaining the human or legal review appropriate to publication. The product must not infer unknown facts, turn template text into source-of-truth data, or represent generated prose as a compliance guarantee. + +The repository distinguishes protected-main behavior from active pull-request work and planned commercialization gaps. This landing page does not promote branch-local work, a successful build, or an unpublished artifact to shipped or released status. + +## Onboarding + +Start with the README and PRD, then use the architecture and ADR to understand why structured facts remain authoritative. Contributors should follow `AGENTS.md` and keep product, technical, research-traceability, changelog, and gap-baseline documents aligned with implementation and exact-head evidence. + +## Publication status + +This file is the reviewed source for a future repository documentation landing page. GitHub Pages is not considered published until repository settings, deployment state, and the live HTTPS content are independently verified. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 0000000..417a57e --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,115 @@ +# Product and technical gap baseline + +Last reconciled: 2026-09-09 + +This ledger records PolicyWeave's commercialization gap from PRD/ADR/TRD/architecture, implementation, authoritative legal-source evidence, review findings, and live GitHub state. Live Check conclusions are not committed because they change outside the repository; merge decisions must re-fetch the exact current PR head and live ruleset. + +## Protected integration truth + +The default branch remains `main@52f4fd6bb68f870d0519cf11dd471573a2f197c0`, whose tree contains only the historical README. The executable application, MIT license grant, PRD/TRD/ADRs, database contracts, tests, and evidence described below are proposed on the open PR #1–#10 stack; they are not yet protected-branch or released product truth. PR #11's original README-only diagnosis was therefore valid for `main`, but its separate main-based replacement tree would have competed with and contradicted the canonical product writer. PR #11 is non-force restacked on the current stack and preserves this integration-truth ledger together with its dependency-governance delta (exact-pinned manifest, regenerated lockfile, manifest/scope/license contracts, CI CycloneDX SBOM evidence, and workflow artifact isolation). Any parent movement requires fresh exact-head verification before ordinary merge. + +## Product responsibility +PolicyWeave is a local-first policy-fact workspace that helps a service operator structure actual personal-data processing facts, detect omissions or contradictions, and render a reviewable privacy-policy draft. It is not legal advice and does not claim compliance or certification. Structured facts are the source of truth; rendered prose is a deterministic projection over facts plus future versioned rule/template evidence. + +## Exact-head implementation evidence +Earlier PR review repaired seven-step routing/warning navigation, collection-path/purpose coupling, stale collection evidence, whitespace normalization, service-URL projection, misleading publication copy, unshipped/no-op action affordances, responsive preview behavior, checkout credential persistence, legal-source traceability, and the test-only dynamic-RegExp SAST finding. Those predecessor repairs remain covered by the regression suite but predecessor workflow conclusions are never merge evidence for a later head. + +A 2026-09-02 exact-head commercialization pass found that `initialItems` preselected `name`, `email`, and `usage` and prefilled their collection modes and purposes even though the operator had never established those service-specific facts. Regression tests were committed first at `37c5c490e8cc04b3378f828130d9261edb80cfc3`; production was repaired at `b12052b03646706a89016fbacca52a9d58359022`, then PRD/ADR/TRD/architecture/changelog/baseline were reconciled at `8e242842049c245c593b625f058468518318bdea`. The fresh workspace now has zero inferred collection selection/mode/purpose facts, selected items fail closed on missing mode/purpose, and disabling an item invalidates its mode/purpose/path evidence. + +The next exact-head pass found a second buyer-facing authority defect: after completing one selected collection item, the readiness CTA could enable while service identity, retention, third-party provision, international transfer, and privacy contact remained visibly unresolved in the review draft. Blank transfer fields also conflated `not checked` with `none`. Regression/edge-case tests were committed first at `1e77d4424f4b028e56f0453afe17f78dd5cebeca`. Production was repaired at `cbe3a743bd8ba09b3c63d4b79507975a6a77547a`: `getDraftReview` now derives product-defined findings for all non-collection authoring responsibilities; third-party provision and international transfer use explicit unresolved/yes/no states; confirmed `yes` requires dependent details; changing away from `yes` invalidates stale dependent details; total readiness and warning navigation include these findings. These rules establish authoring completeness only and introduce no new legal conclusion, heuristic weighting, or synthetic production facts. + +A concurrent writer then established the missing collection-path readiness contract first at `0b2c8a5b13851fa20c61d17e3758892f67858c10`. Production/UI repair `7b843ef8fdc049e7f24513557cd86c7c4bbbcca6` made nonblank collection-path evidence an independent blocker with direct warning navigation, and docs/baseline reconciliation followed at `aa3be87a37fa78d55627c9e65464e5cda8fbff60`. Existing service-URL and privacy-contact-email shape validation was revalidated on that implementation rather than left as a stale planned gap. + +The following commercialization pass identified a remaining buyer-flow dead end: a service that genuinely collects no personal data could never clear collection readiness because an empty item selection was always unresolved. Interpreting emptiness as `none` would have reintroduced inference. Test-first commit `97063fc34fbc0e6db2652922f78bc74c00e1ef93` specifies an explicit no-collection attestation, contradiction fail-closed behavior, and stale-item invalidation. Production commit `e73eb83a913597fb05ddea97519d549d58007faf` adds the operator attestation, makes it mutually exclusive with selected items, clears item mode/purpose/path facts when enabled, prevents their later silent revival, and projects the explicit negative fact into the review draft. This is a product authoring-completeness state, not a new legal conclusion. + +A subsequent pass challenged the relation between collection and retention rather than allowing the product to manufacture a downstream fact. An intermediate candidate (`01ff0548b2b89bd65d0478f2499dd7a9fb7a6f4d` → `5ad05c6a9f51aece33672bc9b1e1dc5dfbe3ccf3` → `fc35f8c1b597ad6b556e1fa3abfef466d80ac4e9`) tried to make retention automatically inapplicable under no-collection. Before merge evidence, current authoritative PIPC terminology was rechecked: the Standard Personal Information Protection Guidelines define `처리` as including collection, recording, storage, retention, use, provision and other acts. Therefore absence of collection alone cannot prove absence of storage/retention. The candidate inference was superseded on the same writer branch by test-first commits `734a3d603761fd851f7d8dfc9434d92626518de6`, `dc4f9ad1079f90efb27165099f5dce13ddb4f637`, and `e0f25c0a003df46b487ae564d8fc4995581fbfca`. Production commits `6207729b651f259f0c87cab3680bad207a60beda` and `25a75ad3ee5ded8b0e605c2fb551cc93b11ad568` introduce an independent explicit retention status (`unresolved` / `applies` / `none`), require a period only for `applies`, clear stale period data when leaving that state, and never derive retention status from no-collection. `docs/research-traceability.md` binds the correction to PIPC Notice No. 2025-4. This is a product fact-authority correction, not a legal-sufficiency claim. + +The next accessibility pass established a concrete authored-focus defect rather than claiming generic WCAG conformance: `styles.css` used `#8eb59e` for generic and custom-checkbox keyboard focus outlines, approximately 2.27:1 against white. W3C WCAG 2.2 guidance ties authored focus indication to the Level AA non-text contrast requirement. Test-first commit `e5b77f1897ab13dd4f27ccd8b7fa724ba3e74bb0` required the high-contrast product token; production commit `e9e7dcc5c4160a70a9c483574fb651125589392d` changed both focus paths to `--green` (`#174f35`, approximately 9.52:1 against white); regression commit `ee73bc24e80b32851dcc6f519a457ca9d5efed56` now computes the token contrast and enforces a minimum 3:1 ratio. + +The focus-order pass found that explicit step navigation replaced the visible editor but left focus on the triggering rail/action/warning control, weakening keyboard and assistive-technology context after the screen changed. Test-first commit `e9ff9cd49532560b28d970236d4f64fa03f21d25` specifies focus transfer for rail selection, sequential next navigation, and review-warning jumps. Production commits `960432e7557c55d13ab83106970f1e9bc93b415f` and `2ec1e10d44a04332d223e1be42a3fde9c28c7f40` add and activate a bounded focus controller that targets the newly rendered step heading and deliberately excludes ordinary editing controls and the existing preview shortcut. W3C WCAG 2.2 Focus Order evidence is traced, but jsdom does not establish real-browser traversal, focus-not-obscured, zoom, screen-reader, or responsive screenshot conformance; those remain open and no full-WCAG claim is made. + +A subsequent buyer-flow pass found that a fresh workspace still initialized `current` to step 2. That displayed `수집 항목` before the first service-identity responsibility had been addressed and, more seriously, rendered step 1 as `입력 확인됨` even though its facts were blank. Test-first commit `91ba89706bcaf040c9c1ef6b8e9a38c81bb77ea1` requires the first editor, `1/7 단계`, zero `done` rail entries, and unresolved copy on initial render. Production commit `a892bbd659bc26657a031b6e6e5eccf51fc08d1c` changes only the initial step from 2 to 1; its GitHub commit patch confirms the only semantic production change is `useState(2)` to `useState(1)`. PRD, TRD, and CHANGELOG were reconciled through `e98cfd03b032baf19203af16849d5d0e37aaad38`. This removes a false completion signal without inventing or modifying any customer fact. + +A concurrent writer then repaired completion/progress semantics so navigation cannot make untouched responsibilities appear complete. The branch advanced without rewriting predecessor history; completion is now derived from the same verified-fact contract used by readiness rather than from the currently visited step. This evidence is preserved on the canonical `develop` writer branch and must remain part of final exact-head verification. + +The 2026-09-07 exact-head CI run exposed nine regressions after the truthful step-1 startup repair. Eight tests still queried step-2 collection controls before navigating there; they now enter the collection responsibility explicitly. The remaining RED showed that the active class replaced the completed class, so a completed current responsibility lost its `done` state. `StepRail` now composes `active` and `done` independently, preserving both current-position and verified-completion semantics without changing readiness rules. Full verification then exposed a separate TypeScript build failure because the stylesheet contract test imported `node:fs` without Node type authority; the existing file-based test remains intact and now has an explicit Node type dependency. + +The next exact-head review found that ADR-0002 still described a retention period as universally required even though the executable contract and TRD correctly allow explicit `retention_status = none` without one. A documentation regression now binds ADR-0002 to explicit conditional retention semantics. ADR-0001 and ADR-0002 also remain Proposed while their defining PR is unmerged; protected-branch adoption, not implementation on an open branch, is the acceptance boundary. + +The following buyer-security pass found that the service URL validator accepted HTTP(S) authority credentials and the review projection rendered the raw value. Realistic RED coverage now proves both the readiness and projection leaks. The shared URL contract rejects nonempty username/password components, and the preview renders only a validated, trimmed web URL. This prevents embedded credentials from entering the generated draft without claiming endpoint ownership or reachability. + +The browser-evidence slice adds a bounded Playwright/axe harness to the existing product CI rather than creating a second workflow. Six cases span desktop, tablet, and mobile Chromium projects and exercise truthful startup, horizontal overflow, serious/critical automated accessibility findings, keyboard activation, focus visibility, and the explicit no-collection transition. Each viewport writes an exact-run screenshot beneath `test-results` for SHA-bound artifact upload. Local test discovery, lint, 45 Vitest cases, and production build are verified; the local executor could not download Chromium because the Playwright CDN timed out repeatedly, so actual browser GREEN and screenshots remain unproven until the exact-head CI run completes. + +Exact-head CI `34073373104` then supplied the missing real-browser evidence. Dependency installation, lint, 45 tests, production build, Chromium installation, all three keyboard/no-collection cases, and artifact upload succeeded; the three viewport accessibility cases consistently failed on the same muted token. Axe measured `#6d756f` at 4.17–4.45:1 against the authored rail, conditional-field, and metadata backgrounds. A RED stylesheet contract reproduces those exact background combinations and requires 4.5:1; the single shared token is darkened to `#656d67`, whose weakest current authored pairing is above the threshold. This is a bounded text-contrast repair, not a claim of WCAG conformance; its browser rerun is included in the later exact-head GREEN evidence below. + +The focus-not-obscured slice preserved the reviewed PR #1 head and stacked a browser contract above it. Its first hosted RED (`34079868713`) exposed an ambiguous test locator rather than product behavior, so the locator was narrowed to the specific service-name finding without changing production. The corrected exact-head RED (`34080045989`) then proved the product defect: keyboard activation focused the correct owner heading but left it at `y=-656` on desktop and `y=-581` on mobile; tablet passed and the matrix finished 7/9. `AuthoringFocusController` no longer suppresses focus scrolling, allowing the browser to reveal the focused heading. Exact-head CI `34080286158` then completed clean installation, lint, 46/46 Vitest, the production build, and 9/9 desktop/tablet/mobile Playwright/axe cases; artifact `10003466673` is bound by SHA-256 digest `014c4b0efc1b3da0cabf398553325c70c9f249e3ce3c92a775f8cea20d025880`. This is bounded responsive focus-target evidence; zoom and screen-reader evidence remain open. + +The next stacked browser slice verifies retention as an independent operator authority rather than adding another production mechanism. Its first hosted run (`34081110910`) exposed a test locator that matched both the rail and a warning action, so no product conclusion was drawn; the corrected test scopes navigation to the rail. Exact-head `53f109a2d676d4c6d9ef9e5d9ecf08308ee00af9` CI `34081250637` then completed clean installation, lint, 46/46 Vitest, the production build, and 12/12 desktop/tablet/mobile Playwright/axe cases. The browser evidence proves `applies + period`, `none`, and renewed `applies` transitions preserve progress semantics while invalidating and not reviving the stale period. Artifact `10003765825` is bound by SHA-256 digest `f315190d07fda7428697025f2a953b9300259c05f693ca9dd3a1563d69ea4d63`. + +The zoom/reflow slice first applied CSS `zoom: 2`; hosted CI `34082740777` reported repeatable 425px tablet and 247px mobile overflow. That method magnified minimum dimensions without shrinking the CSS layout viewport or re-evaluating media queries, so the result was classified as a test-method false positive rather than used to justify product CSS churn. The corrected contract halves the desktop CSS layout viewport to model the reflow condition exposed at effective 200% browser zoom. Exact-head `5842998b64ddc593b35ec51fe03bb3cc187bd0f7` CI `34083310517` completed clean installation, lint, 46/46 Vitest, the production build, 13 Playwright/axe cases, and two intentional non-desktop skips. It verifies no document-level overflow plus visible, keyboard-reachable step progression. Artifact `10004390548` is bound by SHA-256 digest `ae7dfc9362041866e01389239a751125cd5b1584c9e0674bae99070364ff39c6`. This is bounded automated reflow evidence, not native browser-UI automation, screen-reader evidence, manual WCAG conformance, or a full accessibility claim. + +Action-runtime cleanup used hosted CI `34088150412` as a real control-plane RED: setup/cache and artifact upload were already on Node 24-based action releases and the product suite passed, but checkout still emitted GitHub's Node 20 retirement warning. Immutable `actions/checkout@v7.0.1`, `actions/setup-node@v7.0.0`, and `actions/upload-artifact@v7.0.1` pins preserve `persist-credentials: false`. Pre-restack head `23b3a1c7429aca2ea39e153c6504beee9d507d39` CI `34179633214` passed the full suite without `DEP0040`, `DEP0169`, or Node 20 retirement warnings; artifact `10038466559` is bound by digest `sha256:83d8e177c78856a41eb58c3312fb8a671903989deb7b3bb2b8a747f8eea9091b`. After non-force restack, head `fba59b85c5ce33813147939f8124cb2d69b184a1` CI `34180350163` repeated that result with 53/53 regressions and browser evidence; artifact `10038696518` is bound by digest `sha256:f31fd4d0dee1164e76e0841f01cc2c5f6f9ce73c6b07fc08e98ac9bd4b333fa8`. These are immutable predecessor head/run pairs, not current merge evidence; merge decisions must re-fetch the exact current PR head and live ruleset. + +Queue RCA then observed distinct stale heads `cbfbdae4a4bb72433d7bdcc51afdbd8a29c102a1`, `3d576aa9e4079af0749cb03f6cc20e4cb30e6639`, and `7dd5c24eed855ef7acdfc0dcb217c86dd00d2df3` retaining separate queued CI runs because the repository workflow had no concurrency contract. Test-only head `bdaf5e8560289b7c4774d46fff2afa7489ad51c9` specifies PR-or-ref grouping and stale-head cancellation. Repository CI now groups by workflow plus pull-request number (or branch ref for push events) and sets `cancel-in-progress: true`; this prevents future superseded heads from consuming runner capacity while keeping unrelated PRs and refs independent. + +The restart/restore slice remains bounded CI evidence and does not enable a hosted adapter. After the initial atomic-seed repair, review found that paired collection/applies and no-collection/none fixtures could not prove collection and retention are independent, the restored database re-exercised only the no-collection trigger, and a nullable restored `service_name` could evade `<>` through SQL three-valued logic. Test-only head `5e54834873e125b3e3ce4f599e4037e017330638` added the missing cross-state and NULL-safe assertions; exact-head CI `34204279846` was RED only in the restore step with `restart did not preserve independent collection and retention facts`. The next commit seeds a valid collecting revision with `retention_status = none`, keeps authored service and collection-item assertions NULL-safe, and executes status-side missing-rule plus rule-side unexpected-rule transactions against the restored database. Pre-documentation head `202e69d95c94e4432365d6599016a371c0f2cbc3` CI `34204464388` then passed the complete suite. A later exact-head review found that the nullable authored service URL was not selected or asserted even though the evidence claim covered complete service values. Mutation-probe head `aaef3b5489493669cdb53c08a72b6a109fc0b687` deliberately nulled that URL after restart; CI `34205653966` passed every preceding step and failed only the new NULL-safe restore assertion. Commit `57732c6dbec872ad29e97a7f22096dbba9613e9a` removes the probe while retaining literal name/URL checks. These immutable runs establish the TDD transitions but are not substitutes for the final current-head verdict. PR #9 merged into the concurrent-writer stack at `5309a6141d8f3f0c89e9cfdaae04f4644a8dfcd4`. The TypeScript restore contract now binds restored service URL survival and the collecting-without-retention cross-state fixture. This is CI durability evidence, not operational backup, tenant authorization, or a released datastore. + +On 2026-09-09 the review-to-merge loop collapsed the writer stack through ordinary merge commits without force-push, branch deletion, or protection changes, following the PR #9 precedent for unprotected stacked branches. PRs #2, #3, #4, #5, #6, #7, and #8 merged bottom-up between 01:10:44Z and 01:14:13Z, each with exact-head `verify` GREEN, zero unresolved review threads, and MERGEABLE/CLEAN state re-fetched immediately before its merge. The local-only restore-contract commit `878d0ce` was pushed as a new branch and opened as successor PR #14 on the PR #8 head; its Codex P2 review correctly showed the URL and `collection_without_retention_count` markers could not detect a coupled revision-`...0003` seed-plus-query change from `none` to `applies`. Commit `d0da193` binds the `...0003`/`none` seed tuple, its `support_email` item seed, and the revision-specific query predicates together; a negative control proved the old markers pass that coupled mutation while the new predicates fail it, and the review thread was resolved with that evidence. CI `34298653319` passed on `d0da193`, and PR #14 merged at 01:25:12Z. PR #13 (dependency pin and toolchain classification for issue #12) merged at 01:19:32Z on its immutable GREEN pair of head `eff7ec19f511` and CI `34251045288`, advancing the docs readiness branch to `bbea5c9`, whose fresh `verify` (`34298735574`) is GREEN. No PR was closed: 9 merges leave drafts #1, #10, and #11 open, each retaining a valid delta. `develop` still declares 16 `latest` occurrences, which explains PR #1's `dependency-review` failure; the pinned manifest (zero `latest`) sits at the top of the side chain and reaches `develop` only through the PR #10 to #11 to down-chain successor path. PR #1's remaining failures are the two CodeQL compatibility analyses plus `dependency-review`; `verify`, Noema, OpenCode, Strix, Semgrep, Trivy, OSV, and Scorecard are GREEN on its current head. Central-workflow run logs live in the organization central repository, so a leaf-side run fetch returns 404; such incidents go through the central owner path rather than leaf-side churn or bypass. The chain tip carrying the full writer delta is `origin/codex/policyweave-concurrent-writer-evidence` (`df9eef5`), with sibling successors `codex/policyweave-json-export` (PR #10, draft; its buyer-visible export enablement needs product review plus a base update now that its base moved) and this ledger branch stacked above it. The repository has no scheduled workflow (only `ci.yml`, no `schedule` trigger), so the hourly cadence is served by the autoresearch loop's own status messages rather than an in-repo scheduler. This remains CI durability evidence with no hosted adapter, tenant authorization, audit, encryption, or released datastore. + +PR #16 landed the full writer-stack tip in `develop` at `7bf0fa9`. The side chain was then restacked without force: PR #10 head merged tip `111400a` via `cef8c0b` (auto-merged baseline/test regions verified coherent with no markers; merged-tree local gates GREEN with 71/71 tests), and PR #11 head merged the restacked export head via `e67c34c` with one Active-order conflict resolved by preserving both sides. Merged-tree local gates were GREEN (lint, 76/76 tests, build). Both restacks requested fresh CodeRabbit review; exact-head CI `34301731941` (PR #10) and `34302266597` (PR #11) are GREEN on the restacked heads. PR #11's scope sentence was then corrected to name its dependency-governance delta (exact-pinned manifest, regenerated lockfile, manifest/scope/license contracts, CI CycloneDX SBOM evidence, workflow artifact isolation) instead of claiming only an integration-truth gap; head `77bf3c2` CI `34311980712` is GREEN with zero unresolved threads. PR #10 merged at 04:32:57Z and PR #11 at 04:54:21Z through ordinary merge commits without force-push or branch deletion; only draft #1 remains open. This successor merges the export plus dependency-governance side-chain tip into `develop`, delivering zero-`latest` pins down-chain. PR #1 on the pre-successor `develop` head still fails only the two CodeQL compatibility analyses plus `dependency-review` (16 `latest` declarations remain in `develop` until this successor lands), while every other central check is GREEN. No PR was closed to reach this state; drafts #10 and #11 were merged, never closed. +The local-draft portability slice converts the previously disabled JSON affordance into a deterministic versioned export. Test-only head `553c1a62514286b0a2e57621072a192556594314` produced exact RED CI `34210028140`: the four new contracts failed while 62 predecessor tests passed. The minimal projection normalizes authored values, keeps unresolved states explicit, exports readiness finding codes, omits credential-bearing service URLs, and downloads through a fixed-name browser Blob without network transfer. This is a mutable draft artifact, not `publication_revision`, durable persistence, backup, authorization, or legal approval. Exact-head review then found two portability defects: immediate object-URL revocation could race deferred WebKit navigation, and an unresolved collection mode leaked the UI sentinel `""` into schema v1. Test-only head `e3861409e3d5f786a1a6756178db71912d1edec0` produced RED CI `34212535756` with precisely 65 passed/2 failed. The minimal repair defers revocation to the next task and narrows the exported mode to the selected enum or `null`; implementation head `eaf18cb4115c1419263b132b5693325e2be42a8d` passed lint, 67/67 tests, build, and all PostgreSQL evidence before final documentation sealing. A later CodeRabbit review found that direct `Object.defineProperty` URL mocks survived `restoreAllMocks()` and that accepted service URLs could copy query/fragment secrets into JSON. Test-first head `0f1a608428681aebfc9187f3d867c59f9905171b` added both regression contracts, but CI `34213620870` was cancelled before execution when the concurrent writer advanced the same branch, so it is not claimed as RED evidence. Concurrent child `5c803b1d2369d4149566b1bf30b83248a46c74c1` preserves that test head and initially canonicalized accepted URLs to credential-free origin/path; `90a3d6810ab213af7fb332df0e88f3475109488e` replaces the URL overrides with restorable Vitest spies. The later P1 correction below supersedes that lossy canonicalization. + +Design-assurance then identified that the local download itself had only jsdom/mocked-anchor evidence. Test-only head `7be6d366540ae87ee2504dba0020bf9d31ab811c` adds a Playwright contract that consumes the real Chromium download event in desktop, tablet, and mobile projects, checks the fixed filename, reads and parses the downloaded file, and verifies schema version, incomplete state, authored service name, explicit null, and finding codes. Immutable test-head run `34215521539` passed 69/69 Vitest contracts, the TypeScript/Vite build, PostgreSQL 18.6 migration/concurrency/restart/custom restore, and 16 Playwright/axe cases with two scoped reflow skips; artifact `10051741653` is bound by digest `sha256:9210de5928ff1ab16a0862c8f988b872517af9f28368288e074df187aa04a615`. No production change was needed; this closes the browser-download evidence gap without claiming import, publication, or hosted persistence. + +The next exact-head design review correctly limited that evidence to mouse activation and one incomplete Korean-language state. Test-only head `798f2c486d19968f7f858219670279cec636fd6b` retains the real download path and adds keyboard activation across desktop/tablet/mobile, touch activation in the touch-enabled mobile project, JSON MIME inspection, byte equality across repeated exports, complete `review_ready` state with no findings, and one-to-one object-URL revocation. CI `34220766193` passed 69/69 Vitest contracts, TypeScript/Vite build, PostgreSQL 18.6 migration/concurrency/restart/custom restore, and 21 Playwright/axe cases with six intentional project-scope skips. Artifact `10053675937` is bound by digest `sha256:0f600da884ed4a42e2839e05e1dec5c6deda2be6747fb6550a0161315c1e709a`. No production change was needed. + +The activation-failure pass then found that a browser exception from the generated download link still revoked its object URL but escaped as an unhandled page error and left the operator without a next action. Test-only head `bc4c7f1e16cf5cb2ae8606efe81d8201bbd578de` produced exact RED CI `34223146923`: 69/69 Vitest and every PostgreSQL step passed, while the new desktop Chromium case received an empty live output instead of the required retry guidance. Minimal implementation head `d8117e2a9a52a7c42941252d77e2327b18d903a2` catches only activation exceptions, reuses the existing live status output, and preserves deferred cleanup in `finally`. CI `34223403382` passed 69/69 Vitest, the production build, all PostgreSQL evidence, and 22 Playwright/axe cases with eight intentional project-scope skips. Artifact `10054717353` is bound by digest `sha256:9e58d8a112d4228128794cc35bcb9600e1715c1efb491d0ec9d382d2d2598a3d`. In-progress transfer cancellation and versioned ko/en/ja/zh/vi/es/de/fr resources remain open rather than being inferred from activation-error recovery. + +The preparation-failure pass then found that Blob/object-URL allocation still occurred before the existing error boundary. A browser allocation exception therefore escaped as a page error and left the live status output empty. Test-only head `83f189ebf78aab9124745bc4209a281cf6f11fa0` produced exact RED CI `34232255893`: 71/71 Vitest, build, and all PostgreSQL evidence passed, while the new desktop Chromium scene failed after receiving an empty output; the browser result was one failed, 22 passed, and ten intentional project-scope skips. Minimal implementation head `8d637f96ad22874645c25b4834b2ce37ae575ddd` extends the same `try` boundary across export preparation and activation, reuses the established retry message, and schedules revocation only when allocation returned a URL. CI `34232543645` passed 71/71 Vitest, build, PostgreSQL migration/concurrency/restart/restore, and 23 Playwright/axe cases with ten scoped skips; artifact `10058444165` is bound by digest `sha256:62b957f180d698b6714b46a89c2adcbb17bbd16152e6ae8820ee55eb84818958`. No retry loop, secondary error channel, or fabricated cleanup target was added. + +The subsequent Codex P1 review found that the validator and preview accepted query-dependent or hash-routed service URLs while export silently removed those components, allowing a `review_ready` artifact to name a different destination from the authored source fact. Test-only head `01ab876a228d8cf9caadce2a4760ca3a1ffeedb7` covered a normal query-dependent URL, a hash-routed URL, and a secret-bearing combination; exact RED CI `34224341164` failed that contract before concurrent writer `3f16e062e5d68dd9ec4b839514ddc2aa396a4358` supplied the shared root fix without rewriting history. Successor `c2f4887080d572d6c16347395b306905f18e6846` added the missing buyer-facing preview regression, but its first integration accidentally narrowed the export matrix to the secret-bearing combination. Canonical child `170a65830fddcdc688ef53cdc793a84dbc4d0025` restores every valid matrix case while retaining the preview contract: query/fragment URLs remain `service_url_format` blockers, are withheld from preview, and export as `null` rather than being rewritten. CI `34225294729` passed 70/70 Vitest, the production build, all PostgreSQL evidence, and 22 Playwright/axe cases with eight intentional project-scope skips; artifact `10055464028` is bound by digest `sha256:90e30f973dcf490e226412039c8b3e43d50f0655dd7f5dc901f8d8f93100d845`. This intentionally narrows admissible v1 URLs instead of manufacturing a different customer fact. + +The final URL-boundary follow-up found that WHATWG `URL.search` and `URL.hash` are empty for authored bare delimiters even though serialization retains `?` or `#`. Test-first head `0580f466837bc5b000a54e79732b08bf83a3fd2f` added both empty-delimiter cases to the existing query/hash/secret matrix; CI `34230043117` passed lint and failed in `npm test` before the source repair. Minimal child `9d92a9ea716c5427161450df34ef142caaaf64e6` checks the canonical serialization for actual delimiters, rejecting bare query/fragment markers through the one readiness/preview/export boundary. Positive edge coverage at `a6cf635fcfc082fafacff13b658108fd076fd474` separately proves encoded `%3F`/`%23` pathname data remains admissible and byte-preserved, preventing a later raw-input substring check from over-blocking legitimate path data. Exact source-fix CI `34230291396` passed 70/70 Vitest, build, PostgreSQL migration/concurrency/restart/restore, and 22 Playwright/axe cases with eight intentional project-scope skips; artifact `10057683181` is bound by digest `sha256:6d2e7fc1ddafefdbd55e9c13b716dca123ba148dc753d65efb26d1bb5e3c26f4`. This closes the reviewed delimiter bypass without widening URL parsing or adding another validator. + +The dependency-governance pass found that every direct npm declaration was either `latest` or a mutable caret range and that TypeScript, Vite, and the React Vite plugin were incorrectly classified as production dependencies. Test-only head `80b69d6a640194997906ec23ca0140f28fd95908` produced exact RED CI `34244619215`: clean install and lint passed, 73 predecessor tests passed, and only the two new pin/classification contracts failed. Minimal implementation `cd9144f4a8a78427457bb58ee4b0a5f75a1ca750` pins all 21 direct packages to their existing reviewed lock resolutions, moves the three build packages to `devDependencies`, and uses npm to regenerate development-scope metadata without changing any resolved package version. A second test-only head `f6bbf9d6e5621ac26e3d1905556c192c2c8613c7` produced exact RED CI `34245711353`: 75 tests passed and only the missing CycloneDX publication contract failed. Workflow repair `a3592da89c75be87564e1d4ec8d9e685e9e3093e` generates a CycloneDX 1.5 SBOM from the exact installed lock graph and uploads it through the existing SHA-pinned artifact action. A final storage review found that placing the SBOM below `test-results` duplicated it in both browser and dependency artifacts. Test-only head `3c8ac646c7ccd0140976f3fcefc914fc4b9a7d80` produced exact RED CI `34246822467` with 75 passing tests and only the new artifact-isolation contract failing; minimal repair `00b89fb6c986b22e2453a58f4b6dc61ba7ff1dff` writes the SBOM once below `dependency-evidence` and uploads only that path as the dependency artifact. Current review follow-up independently checks runtime and development declarations, asserts both artifact destinations, and names all three runtime packages consistently. The lock/license and SBOM evidence support security and legal review but do not themselves approve dependency licenses or replace organization vulnerability gates. + +The successor landed this side chain in `develop` order via PR #18 (successor head `828ecf1`, parents `8178ba6` + `83fb9f8`, verify `34313155294` GREEN, zero review threads, merged 05:06:01Z): the above export evidence plus the dependency-governance delta arrived together, preserving both histories without force-push. Predecessor merges in this turn: PR #10 (export, head `cef8c0b`, verify `34301731941` GREEN, 7/7 threads resolved, merged 04:32:57Z after `gh pr ready` lifted the draft merge block) and PR #11 (readiness plus pins, head `77bf3c2` after the scope-sentence correction, verify `34311980712` GREEN, 1/1 thread resolved, merged 04:54:21Z). `develop` is now `e1c588f` with zero `latest` declarations; merged-tree local gates are lint GREEN, build GREEN, and 76/76 Vitest GREEN. No PR was closed to reach this state. + +This turn landed two independent docs-only deltas without force-push or branch deletion: PR #20 added the 9 missing JSDoc comments in `src/policy.ts` (non-test src export docstring coverage 8/17 to 17/17; diff +9/-0; verify `34317973719` GREEN, merged 06:14:43Z), and PR #21 recorded the 2026-09-09 NLIC re-verification that Act No. 21445 still heads `[시행 2026. 9. 11.]` (verify `34318129844` GREEN, merged 06:18:15Z). PR #1 re-verification on current heads shows `verify`, Noema, OpenCode, Semgrep, Trivy, OSV, and Scorecard GREEN, while `strix` flipped to FAIL on a docs-only delta (central logs uninspectable from the leaf repo, routed via the central owner path), `dependency-review` still FAILs despite zero `latest` declarations, and both CodeQL compatibility analyses still FAIL; independent approval remains the structural blocker. A mid-turn edit briefly mangled a traceability header and was repaired before commit, leaving a +4/-0 diff. + +This turn added boundary evidence without production change: PR #23 (`src/policy-boundary.test.ts`, +48/-0) pins 6 edge contracts that all passed on first run — uppercase service-URL canonicalization, username-only URL rejection without username leakage, contradiction-plus-blocker counting, step 2/3 exclusion under contradiction, a 4-case email-format matrix, and null export of a whitespace retention period. Suite stands at 82/82 with 17/17 non-test src export docstrings; verify `34320952183` GREEN, merged 06:54:20Z. PR #1 tracking: on head `2d4928d`, `verify` stayed GREEN while `dependency-review` and both CodeQL compatibility analyses still FAIL; `strix` showed one FAIL on `632a08e` then a pending rerun, so its verdict on that head is inconclusive rather than confirmed. The PR #23 merge advanced `develop` to `82e7826`, re-queuing the full central matrix; that verdict is pending and is not claimed here. + +## Current baseline + +| Area | Evidence | Status | Commercialization gap | Owner/action | Next verification | +| --- | --- | --- | --- | --- | --- | +| Guided authoring | PRD, ADR-0002, seven routed editors, first-responsibility startup, `getReview`, explicit retention `getDraftReview`, no-collection/transfer states | Repaired foundation | Fresh state no longer skips or falsely completes step 1; collection, retention, transfer and other responsibilities fail closed independently; collection-path evidence remains unstructured free text and legal sufficiency is deliberately separate | Policy Fact Authoring: preserve deterministic completeness and independent authority; add structured path-evidence types only when a real integration/use case proves the need | Exact-head unit/UI edge tests, then browser E2E | +| Draft portability | Versioned `createPolicyExport`, fixed-name browser Blob download, explicit readiness state/finding codes, unsafe-URL rejection across preview/readiness/export, keyboard/touch activation, JSON MIME, repeat-byte checks, preparation/activation-error guidance, and allocation-aware success/error URL cleanup | Implemented foundation; bounded exact-head browser download GREEN | No import/migration contract, in-progress cancellation evidence, localized resource contract, or immutable publication semantics; exported files remain operator-controlled sensitive data | Policy Fact Authoring: preserve deterministic schema-versioned projection without network transfer, lossy URL rewriting, or inferred facts | Future version compatibility, cancellation, locale, and fail-closed import tests before schema evolution | +| Customer-fact authority | Zero-inferred startup facts; truthful initial rail state; explicit no-collection; independent explicit retention status; explicit transfer states; stale dependent-fact invalidation | Repaired | No known buyer-facing authority dead end remains in the in-memory seven-step fact model; hosted persistence must preserve these independent states without deriving one from another | Policy Fact Authoring: encode collection and retention as separate revision-owned facts; no automatic no-collection→no-retention rule | Persistence/schema invariant tests and exact-head UI tests | +| Review workspace | Live preview, total blocker count, warning-to-owner navigation, buyer-facing readiness guidance, deterministic step-heading focus transfer, Playwright viewport/screenshot harness | Implemented foundation; offscreen focus repaired and exact-head browser verified | Automated desktop/tablet/mobile focus-scroll evidence is GREEN; broader interaction coverage remains bounded | UX: retain exact-head artifacts, then extend interaction coverage | Exact-head screenshots, keyboard/focus and accessibility checks | +| Accessibility | Semantic controls, visible focus behavior, focus-token >=3:1 regression, muted-text >=4.5:1 authored-surface regression, jsdom focus transition, axe/browser, focused-heading viewport checks, responsive retention transitions, and effective 200% browser-zoom reflow | Partial; bounded exact-head browser GREEN | Native browser UI zoom automation, screen-reader, and manual WCAG evidence remain absent | UX/Test Engineering: add a manual interaction record and remaining cases without claiming conformance from automation alone | Exact-head WCAG/browser matrix plus screen-reader and manual evidence | +| Legal/rule traceability | `docs/research-traceability.md`; PIPA/PIPC source/effective-date model; PIPC Notice No. 2025-4 terminology bound to collection/retention separation | Source baseline established | Current readiness rules are product completeness only; no versioned article/section-level legal-rule engine | Legal Source Registry: map future legal validations to source/effective date/revision | Snapshot fixtures across effective dates | +| Policy model | ADRs, ARCHITECTURE, TRD, ADR-0003, Proposed ERD, up/down migration, schema, runtime, two-session concurrency, restart, and dump/restore contract tests | Proposed 3NF foundation; PostgreSQL 18 exact-head CI execution required; browser runtime remains memory-only | Apply/down/apply, exact negative errors, observed lock waits, conflicting-fact rejection, same-item UPSERT convergence with NULL-safe complete label/mode/path assertions, process restart, and custom-format restore with a collection/no-retention cross-state, NULL-safe complete service name/URL and item assertions, and restored no-collection plus both retention contradiction checks are implemented; authorization, audit, encryption, deletion, and production-scale contention remain unproved | Platform: retain exact-head PostgreSQL evidence, then add the hosted authorization/audit boundary while keeping the adapter disabled | Tenant authorization, immutable audit, and encryption tests | +| Publication | Readiness CTA truthfully does not pretend to publish; immutable `publication_revision` is designed | Planned | No authenticated approval, immutable publication, supersession, rollback, or public URL lifecycle | Review & Publication: implement after persistence/security entry criteria | Authorization, replay/digest, supersession tests | +| Security/privacy | `docs/SECURITY.md`; local-first runtime; SHA-pinned checkout | Baseline documented | Hosted tenant model, encryption/key handling, audit/incident/retention evidence absent | Platform/Security: threat-model hosted boundary before backend | Exact-head security tests and org scans | +| Dependency supply chain | Exact-pinned manifest, npm-regenerated lock graph, executable pin/scope/license contracts, and CycloneDX 1.5 CI artifact | Repaired foundation on open stack; exact-head release gate still applies | License compatibility approval and organization vulnerability verdict remain external review responsibilities | PolicyWeave owns direct dependency intent; CI/Security owners retain scanning and release gates | Exact-head clean install, 76 tests, build, SBOM artifact/digest, vulnerability checks, and explicit license review | +| Tests | 82 unit/UI/schema/workflow/dependency/boundary regressions plus PostgreSQL runtime/concurrency/restore scripts, 23 Playwright/axe passes, ten intentional project-scope skips, real-browser interaction/download/error contracts, screenshot/SBOM artifact contracts, and 17/17 non-test src export docstrings | Improved; bounded exact-head verification required after every head movement | Repository-wide 100% execution/branch/boundary coverage is not yet evidenced | Test Engineering: measure coverage and extend realistic edge cases | Exact-head coverage + browser/database/dependency evidence | +| Performance | Static Vite client | Unevidenced | No realistic buyer-flow browser performance baseline; no network backend exists for meaningful k6 endpoint evidence | Operability: record browser baseline now; add k6 only when hosted network surfaces exist | Real measurements before latency claims | +| CI/security merge gate | Repo CI plus active organization ruleset-required workflows; immutable Node 24-based checkout, setup/cache, and artifact-upload action releases | Live external gate; warning-free evidence is re-fetched for the exact merge candidate | Every branch movement invalidates predecessor evidence and stale approval; current hosted jobs may remain queued before runner assignment and dependency/reviewer control-plane failures can fail closed independently | Re-fetch exact-head workflows/reviews; use the central owner path for runner/dependency-review incidents rather than leaf-side churn or bypass | Terminal exact-head checks with no action-runtime deprecation warnings + independent approval + resolved threads | + +## DDD/context map +Core subdomain: **Policy Fact Authoring**. Supporting: **Review & Publication**, **Legal Source Registry**. Generic infrastructure remains outside domain authority. `Policy Fact Authoring -> Review & Publication` supplies a versioned fact contract. `Legal Source Registry -> Review & Publication` supplies versioned source/rule evidence through an ACL. Customer/source-system integrations remain adapters and may not mutate foreign systems through hidden coupling. + +Ubiquitous language: `policy_revision`, `service_profile`, explicit no-collection attestation, `retention_status`, `collection_item`, `processing_purpose`, `retention_rule`, `third_party_transfer`, `international_transfer`, `privacy_contact`, `review_finding`, `legal_source_revision`, `publication_revision`. + +The Proposed aggregate root is the minimal `policy_revision` boundary. The no-collection fact is revision-owned state rather than a synthetic `collection_item`. `retention_status` is a separate revision-owned fact; `retention_rule` exists only when that status is `applies`. Deferred database constraints reject contradictory final transaction state. When transitioning away from `applies`, the hosting adapter must delete the current `retention_rule` in the same transaction. A retained `retention_rule` causes the deferred constraint to reject the commit; immutable audit history remains separate. Rendered prose is a read projection. `publication_revision` remains a future immutable and explicitly superseded release receipt. Relational persistence is 3NF by default; named persistence objects use at least two semantic words and `snake_case`, and item-level UPSERT/idempotency semantics are explicit in the source migration. + +## Buyer-visible release gates +A publishable release requires complete fact-to-warning navigation, deterministic rendering of captured source facts, no inferred customer operational facts, truthful first-responsibility startup/progress, explicit unresolved-review blocking across the seven authoring responsibilities, an explicit no-collection path that does not infer unrelated processing facts, independent explicit retention status, immutable reviewed publication revisions, accessible responsive interaction, authoritative source/version traceability for legal rules, hosted security/privacy evidence, and exact-head CI/security/independent review. Buyer-facing wording describes assistance and reviewability, never a compliance guarantee or an internal implementation boundary. + +## Active commercialization order +1. Re-verify PR #1 on the post-successor `develop` head `e1c588f`: `verify` is GREEN on the exact head, but `dependency-review` still FAILs even with zero `latest` declarations in `develop`, and both CodeQL compatibility analyses still FAIL. Central-workflow logs live in the organization central repository (leaf-repo API returns 404), so the `dependency-review` reason is not inspectable from this repo; route both through the central owner path rather than leaf-side churn. Secure independent approval (sole-maintainer approval is the structural blocker); merge PR #1 only through ordinary protection. The full writer-stack tip landed in `develop` via PR #16 and the export plus dependency-governance side chain via PRs #10/#11/#18. +2. Extend the exact-head responsive/accessibility evidence with screen-reader checks, native browser-zoom automation when the harness supports it, and manual records; CSS focus contrast, logical step-focus transfer, focused-heading viewport visibility, retention transitions, effective 200% browser-zoom reflow, and current desktop/tablet/mobile screenshots are already verified. +3. Preserve exact-head PostgreSQL 18 migration, domain-error, two-session lock, restart, and dump/restore evidence, then prove tenant authorization, immutable audit, and encryption before enabling any hosted adapter. +4. Implement secure review/publication with immutable releases and explicit supersession. +5. Add hosted tenant isolation, audit/incident evidence, compose deployment, and realistic performance/load tests when network services exist. diff --git a/docs/research-traceability.md b/docs/research-traceability.md new file mode 100644 index 0000000..fefba7f --- /dev/null +++ b/docs/research-traceability.md @@ -0,0 +1,57 @@ +# Legal and research traceability + +Last reconciled: 2026-09-02 + +PolicyWeave treats legal and regulatory material as versioned evidence, not as implicit knowledge embedded in UI copy. The current MVP does not yet implement a legal/rule engine; this ledger therefore records source authority and the product capability that still needs to be derived and tested. + +## Authoritative source register + +| Source | Current evidence on 2026-09-02 | Product use | Implementation status | +| --- | --- | --- | --- | +| Republic of Korea, Personal Information Protection Act (개인정보 보호법), National Law Information Center | Current law: Act No. 20897, effective 2025-10-02. Enacted amendment: Act No. 21445, promulgated 2026-03-10. Its general amended provisions take effect 2026-09-11, while Article 32-2(1) proviso and Article 75(2)(15) take effect separately on 2027-07-01. Retrieved 2026-09-02 from the National Law Information Center; canonical amendment identifier: Act No. 21445 / legal-information sequence 283839. https://www.law.go.kr/LSW/lsInfoP.do?ancNo=21445&ancYd=20260310&efYd=20260911&lsiSeq=283839 | Statutory authority and provision-level effective-date anchor for rule derivation | Registry only; no article-level rules are encoded yet. Future rule snapshots must bind each provision to its own effective date. | +| Republic of Korea, Personal Information Protection Commission, Standard Personal Information Protection Guidelines (표준 개인정보 보호지침) | PIPC Notice No. 2025-4, effective 2025-04-11. Article 2 defines `처리` as including collection, generation, linkage, recording, storage, retention, use, provision, disclosure, destruction, and similar acts. Retrieved 2026-09-02 from the National Law Information Center; canonical administrative-rule sequence 2100000257592. https://www.law.go.kr/LSW/admRulInfoP.do?admRulSeq=2100000257592&chrClsCd=010201 | Terminology boundary: collection is one form of personal-information processing; absence of collection alone cannot establish absence of storage/retention or other processing | Bound to the retention-applicability repair: PolicyWeave requires a separate explicit retention status and does not infer it from no-collection. | +| Republic of Korea, Enforcement Decree of the Personal Information Protection Act (개인정보 보호법 시행령), National Law Information Center | Presidential Decree No. 36121, promulgated 2026-02-19 and effective 2026-08-20. Retrieved 2026-09-02; canonical National Law Information Center legal-information sequence 283503. https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joNo=0032&lsiSeq=283503 | Subordinate-rule authority/effective-date anchor | Registry only; no decree-level rules are encoded yet. Future mappings must record the exact decree article and effective-date snapshot. | +| Personal Information Protection Commission. (2026, April 23). 개인정보 처리방침 작성지침(2026.4. 개정) [Guideline for writing privacy policies, April 2026 revision]. | PIPC marks this as the current guide. Retrieved 2026-09-02. https://pipc.go.kr/np/cop/bbs/selectBoardList.do?bbsId=BS217&mCode=D010030000 | Authoring/review guidance and template requirement discovery | Source registered; requirement-by-requirement mapping still required | + +## Source re-verification (2026-09-09) + +The National Law Information Center page for the PIPA amendment (Act No. 21445) was re-fetched on 2026-09-09 and still heads the text `개인정보 보호법 [시행 2026. 9. 11.] [법률 제21445호, 2026. 3. 10., 일부개정]`. The register above is therefore unchanged: the general amended provisions remain future-effective until 2026-09-11, and no article-level rule is encoded yet. Next check on or after the effective date; this note records retrieval only and states no legal conclusion. + +## Retention applicability correction + +An intermediate product repair attempted to treat explicit `개인정보를 수집하지 않음` as sufficient evidence that retention was inapplicable. That implication was rejected before merge evidence because the authoritative PIPC terminology defines `처리` broadly and lists collection, storage, and retention as distinct included acts. Therefore no-collection cannot safely prove no-retention. Test-first commits `734a3d603761fd851f7d8dfc9434d92626518de6`, `dc4f9ad1079f90efb27165099f5dce13ddb4f637`, and `e0f25c0a003df46b487ae564d8fc4995581fbfca` establish the corrected contract: collection state and retention state are independent operator facts. Production commits `6207729b651f259f0c87cab3680bad207a60beda` and `25a75ad3ee5ded8b0e605c2fb551cc93b11ad568` add explicit unresolved/`보유함`/`보유하지 않음` retention status, require a retention period only for `보유함`, clear stale period data when moving away from that state, and project the explicit status without inferring it from no-collection. This remains a product authoring-completeness rule, not a legal-sufficiency conclusion. + +## Accessibility standards traceability + +W3C's WCAG 2.2 guidance states that authored visual focus indicators are subject to the Level AA non-text contrast requirement, and its Focus Visible guidance explicitly points focus indication to Success Criterion 1.4.11. The previous PolicyWeave focus color `#8eb59e` was approximately 2.27:1 against white, so it did not provide the 3:1 contrast expected for an authored focus indicator. Test-first commit `e5b77f1897ab13dd4f27ccd8b7fa724ba3e74bb0` required the high-contrast product token; production commit `e9e7dcc5c4160a70a9c483574fb651125589392d` moved generic and custom-checkbox focus outlines to `--green` (`#174f35`, approximately 9.52:1 against white); regression commit `ee73bc24e80b32851dcc6f519a457ca9d5efed56` computes the token contrast and enforces a minimum 3:1 ratio rather than relying only on literal CSS text. + +WCAG 2.2 Success Criterion 2.4.3 requires sequential focus navigation to preserve meaning and operability; W3C's understanding guidance also recognizes programmatically moving focus to non-interactive content when that content provides the logical next context. PolicyWeave changed the visible authoring surface after step-rail, previous/next, and review-warning activation while leaving focus on the triggering control. Test-first commit `e9ff9cd49532560b28d970236d4f64fa03f21d25` specifies that the newly active step heading receives programmatic focus. Production commits `960432e7557c55d13ab83106970f1e9bc93b415f` and `2ec1e10d44a04332d223e1be42a3fde9c28c7f40` implement and activate that bounded transition while excluding ordinary editing controls and the dedicated preview shortcut. These jsdom regressions establish logical focus-transition behavior only; real-browser keyboard traversal, Success Criterion 2.4.11 focus-not-obscured evidence, zoom, screen-reader behavior, and responsive screenshots remain open, so PolicyWeave does not claim full WCAG conformance from these repairs. + +## APA 7 references + +Personal Information Protection Commission. (2025). *표준 개인정보 보호지침* [Standard Personal Information Protection Guidelines] (Notice No. 2025-4, effective April 11, 2025). National Law Information Center. https://www.law.go.kr/LSW/admRulInfoP.do?admRulSeq=2100000257592&chrClsCd=010201 + +Personal Information Protection Commission. (2026, April 23). *개인정보 처리방침 작성지침(2026.4. 개정)* [Guideline for writing privacy policies, April 2026 revision]. https://pipc.go.kr/np/cop/bbs/selectBoardList.do?bbsId=BS217&mCode=D010030000 + +Republic of Korea. (2025). *개인정보 보호법* [Personal Information Protection Act] (Act No. 20897, effective October 2, 2025). National Law Information Center. https://www.law.go.kr/unSc.do?query=%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4%EB%B3%B4%ED%98%B8%EB%B2%95 + +Republic of Korea. (2026). *개인정보 보호법 일부개정법률* [Amendment to the Personal Information Protection Act] (Act No. 21445, promulgated March 10, 2026; general effective date September 11, 2026; Article 32-2(1) proviso and Article 75(2)(15) effective July 1, 2027). National Law Information Center. https://www.law.go.kr/LSW/lsInfoP.do?ancNo=21445&ancYd=20260310&efYd=20260911&lsiSeq=283839 + +Republic of Korea. (2026). *개인정보 보호법 시행령* [Enforcement Decree of the Personal Information Protection Act] (Presidential Decree No. 36121, promulgated February 19, 2026, effective August 20, 2026; legal-information sequence 283503). National Law Information Center. https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joNo=0032&lsiSeq=283503 + +World Wide Web Consortium. (2025). *Understanding Success Criterion 1.4.11: Non-text contrast*. Web Accessibility Initiative. https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html + +World Wide Web Consortium. (2025). *Understanding Success Criterion 2.4.3: Focus order*. Web Accessibility Initiative. https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html + +World Wide Web Consortium. (2025). *Understanding Success Criterion 2.4.7: Focus visible*. Web Accessibility Initiative. https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html + +## Traceability rules +1. Every future legal/rule/template implementation records source identifier, article/section where applicable, effective date, source revision or retrieval timestamp/digest, implementation symbol, and tests. +2. Future-effective law is never silently treated as already-effective law. A rule set declares the jurisdiction/effective-date snapshot it evaluates; provisions with distinct commencement dates remain distinct entries rather than inheriting a statute-level date. +3. Guidance is not promoted to statute, and product copy does not claim that following a guide guarantees compliance. +4. A source revision creates an explicit evaluation event against existing draft/published revisions; it does not mutate historical publication evidence. +5. LLM output, if later used to explain or propose wording, is never an authoritative legal source and cannot change review/publication state. +6. Collection absence is not evidence of absence of other processing acts such as storage or retention; applicability decisions require their own explicit source fact unless an authoritative rule proves a dependency. + +## Current gap +The seven-step workspace now captures the product's intended fact categories, including independent explicit retention applicability, but retention-period/legal-basis detail, third-party provision, international transfer, contact/controller information, and legal-basis review still need requirement-level mappings to the authoritative register, deterministic validation, and regression fixtures before PolicyWeave can claim those steps are legally complete. CSS-level focus contrast and deterministic step-focus transfer now have executable regression contracts, but real-browser accessibility evidence remains required before claiming WCAG conformance. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5d28fa8 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,15 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { parser: tseslint.parser, ecmaVersion: 2022, globals: globals.browser, parserOptions: { ecmaFeatures: { jsx: true }, sourceType: 'module' } }, + plugins: { '@typescript-eslint': tseslint.plugin, 'react-hooks': reactHooks, 'react-refresh': reactRefresh }, + rules: { ...js.configs.recommended.rules, ...reactHooks.configs.recommended.rules, ...reactRefresh.configs.vite.rules, 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'no-undef': 'off' }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..548f60d --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + PolicyWeave + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..88c33ae --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3918 @@ +{ + "name": "policyweave", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "policyweave", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "lucide-react": "1.38.0", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@axe-core/playwright": "4.13.0", + "@eslint/js": "10.0.1", + "@playwright/test": "1.63.0", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", + "@types/node": "26.4.1", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "@vitejs/plugin-react": "6.1.1", + "eslint": "10.9.1", + "eslint-plugin-react-hooks": "7.1.1", + "eslint-plugin-react-refresh": "0.5.5", + "globals": "17.11.0", + "jsdom": "30.0.1", + "typescript": "6.0.3", + "typescript-eslint": "8.68.0", + "vite": "8.2.2", + "vitest": "4.1.11" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/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/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/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/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.10.tgz", + "integrity": "sha512-xBja6gaAaH2R2c7eNyl0TY4dhnnZ2uhj+KXpLdEQ6M/wuk9bYFZM8wY0ykw3VO4TgEJ56KGlerXS/9KBKVR/Cg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@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/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", + "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/type-utils": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.68.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", + "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", + "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "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/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "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/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/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/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.417", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.417.tgz", + "integrity": "sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.5.tgz", + "integrity": "sha512-vG7yLURXNvCHy0FBdbZRwIu0BLPJMlUUJS2Ep7ud9w1YCLftFZtuEjyjhym0Qq9yuZ6LJUitNlu/hMk0gakXAw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.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/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/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": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.38.0.tgz", + "integrity": "sha512-xZCyBd/wiVUDactoCc+42TjL0aB7EBOXsuX+tjz+W/sGzw2KhHpL1NOH3FIaVUcpimvUBpIYfz34Ofj9S5JEzQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", + "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz", + "integrity": "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "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" + } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a6078bf --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{"name":"policyweave","private":true,"version":"0.1.0","license":"MIT","type":"module","scripts":{"dev":"vite --host 0.0.0.0","preview":"vite preview","build":"tsc -b && vite build","lint":"eslint .","test":"vitest run","test:watch":"vitest","test:e2e":"playwright test"},"dependencies":{"lucide-react":"1.38.0","react":"19.2.8","react-dom":"19.2.8"},"devDependencies":{"@axe-core/playwright":"4.13.0","@eslint/js":"10.0.1","@playwright/test":"1.63.0","@testing-library/jest-dom":"7.0.1","@testing-library/react":"16.3.3","@types/node":"26.4.1","@types/react":"19.2.18","@types/react-dom":"19.2.5","@vitejs/plugin-react":"6.1.1","eslint":"10.9.1","eslint-plugin-react-hooks":"7.1.1","eslint-plugin-react-refresh":"0.5.5","globals":"17.11.0","jsdom":"30.0.1","typescript":"6.0.3","typescript-eslint":"8.68.0","vite":"8.2.2","vitest":"4.1.11"}} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..61a7713 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: 'line', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + }, + projects: [ + { name: 'desktop-chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'tablet-chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 820, height: 1180 } } }, + { name: 'mobile-chromium', use: { ...devices['Pixel 5'] } }, + ], + webServer: { + command: 'npm run preview -- --host 127.0.0.1', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..ed4ca98 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,306 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import App from './App' + +const originalCreateObjectUrl = URL.createObjectURL +const originalRevokeObjectUrl = URL.revokeObjectURL + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.useRealTimers() + expect(URL.createObjectURL).toBe(originalCreateObjectUrl) + expect(URL.revokeObjectURL).toBe(originalRevokeObjectUrl) +}) + +function openCollectionStep(container: HTMLElement) { + fireEvent.click(container.querySelectorAll('.rail li button')[1]) +} + +function completeNonCollectionFacts(container: HTMLElement) { + const stepButtons = container.querySelectorAll('.rail li button') + + fireEvent.click(stepButtons[0]) + fireEvent.change(container.querySelector('input[name="serviceName"]')!, { target: { value: '예시 서비스' } }) + fireEvent.change(container.querySelector('input[name="serviceUrl"]')!, { target: { value: 'https://example.test' } }) + + fireEvent.click(stepButtons[3]) + fireEvent.change(container.querySelector('select[name="retentionStatus"]')!, { target: { value: 'applies' } }) + fireEvent.change(container.querySelector('input[name="retentionPeriod"]')!, { target: { value: '회원 탈퇴 시까지' } }) + + fireEvent.click(stepButtons[4]) + fireEvent.change(container.querySelector('select[name="thirdPartyStatus"]')!, { target: { value: 'no' } }) + + fireEvent.click(stepButtons[5]) + fireEvent.change(container.querySelector('select[name="internationalStatus"]')!, { target: { value: 'no' } }) + + fireEvent.click(stepButtons[6]) + fireEvent.change(container.querySelector('input[name="privacyOfficerName"]')!, { target: { value: '개인정보보호 담당' } }) + fireEvent.change(container.querySelector('input[name="privacyOfficerEmail"]')!, { target: { value: 'privacy@example.test' } }) +} + +describe('policy editing workflow', () => { + it('일곱 단계의 입력 화면을 전환한다', () => { + const { container } = render() + const buttons = container.querySelectorAll('.rail li button') + const headings = ['1. 서비스 정보', '2. 수집 항목', '3. 처리 목적', '4. 보유 기간', '5. 제3자 제공', '6. 국외 이전', '7. 개인정보 보호 담당자'] + for (const [index, heading] of headings.entries()) { + fireEvent.click(buttons[index]) + expect(container.querySelector('.form-panel h1')?.textContent).toBe(heading) + } + }) + + it('새 작업공간은 운영 사실을 임의 선택하지 않고 일곱 단계 미확인 사실을 공개 준비 차단에 포함한다', () => { + const { container } = render() + const selected = Array.from(container.querySelectorAll('.check-label input')).filter((input) => input.checked) + expect(selected).toHaveLength(0) + expect(container.querySelector('.review-stat.blocking b')?.textContent).toBe('8건') + expect(container.querySelector('.publish')?.disabled).toBe(true) + expect(container.querySelector('.document-warning')?.textContent).toContain('수집 항목') + }) + + it('서비스 URL을 입력하면 검토본의 적용 서비스 정보에 반영한다', () => { + const { container } = render() + fireEvent.click(container.querySelectorAll('.rail li button')[0]) + fireEvent.change(container.querySelector('input[name="serviceUrl"]')!, { target: { value: 'https://privacy.example.test' } }) + expect(container.querySelector('.paper')?.textContent).toContain('https://privacy.example.test') + }) + + it('자격정보가 포함된 서비스 URL은 검토본에 노출하지 않고 수정 대상으로 남긴다', () => { + const { container } = render() + fireEvent.change(container.querySelector('input[name="serviceUrl"]')!, { target: { value: 'https://operator:secret@example.test' } }) + + const reviewDraft = container.querySelector('.paper')?.textContent ?? '' + expect(reviewDraft).not.toContain('operator:secret') + expect(reviewDraft).toContain('서비스 URL 형식') + }) + + it('query 또는 fragment가 있는 서비스 URL은 다른 위치로 재작성하지 않고 수정 대상으로 남긴다', () => { + const { container } = render() + const serviceUrl = container.querySelector('input[name="serviceUrl"]')! + + for (const value of ['https://example.test/app?tenant=acme', 'https://example.test/#/privacy']) { + fireEvent.change(serviceUrl, { target: { value } }) + const reviewDraft = container.querySelector('.paper')?.textContent ?? '' + expect(reviewDraft).not.toContain(value) + expect(reviewDraft).toContain('서비스 URL 형식') + } + }) + + it('작성 사실을 JSON 파일로 로컬 내보내고 제공하지 않는 생성 기능은 노출하지 않는다', () => { + vi.useFakeTimers() + const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockImplementation((fileBlob) => { + void fileBlob + return 'blob:policyweave-draft' + }) + const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) + const clickDownload = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + const { container } = render() + const buttons = Array.from(container.querySelectorAll('button')) + const exportButton = buttons.find((button) => button.textContent?.includes('JSON 내보내기')) + expect(exportButton?.disabled).toBe(false) + expect(exportButton?.textContent).not.toContain('준비 중') + + fireEvent.click(exportButton!) + expect(createObjectUrl).toHaveBeenCalledOnce() + expect(createObjectUrl.mock.calls[0][0]).toBeInstanceOf(Blob) + expect(clickDownload).toHaveBeenCalledOnce() + expect(revokeObjectUrl).not.toHaveBeenCalled() + vi.runAllTimers() + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:policyweave-draft') + expect(buttons.find((button) => button.textContent?.includes('개인정보처리방침'))).toBeUndefined() + expect(container.querySelector('.document-name')?.tagName).toBe('SPAN') + expect(Array.from(container.querySelectorAll('.preview button')).some((button) => button.textContent?.includes('검토본 생성'))).toBe(false) + expect(container.querySelector('.preview')?.textContent).toContain('입력 내용은 검토본에 즉시 반영됩니다') + }) + + it('수집 단계에서는 선택 항목의 수집 경로와 수집 구분을 명시적으로 확인하고 처리 목적은 다음 단계에서 편집한다', () => { + const { container } = render() + openCollectionStep(container) + expect(container.querySelectorAll('input[placeholder="예: 회원가입 화면"]')).toHaveLength(0) + + const phone = container.querySelectorAll('.check-label input')[2] + fireEvent.click(phone) + const phoneItem = container.querySelectorAll('.item-list .item')[2] + expect(phoneItem.querySelector('select')?.value).toBe('') + expect(container.querySelectorAll('input[placeholder="예: 회원가입 화면"]')).toHaveLength(1) + + fireEvent.change(phoneItem.querySelector('select')!, { target: { value: '필수' } }) + expect(phoneItem.querySelector('select')?.value).toBe('필수') + + fireEvent.click(container.querySelectorAll('.rail li button')[2]) + expect(container.querySelector('input[name="purpose-phone"]')).not.toBeNull() + }) + + it('수집 항목을 해제하면 이전 처리 목적, 수집 경로, 수집 구분을 폐기해 재활성화 시 재검토한다', () => { + const { container } = render() + openCollectionStep(container) + const phoneCheckbox = () => container.querySelectorAll('.check-label input')[2] + + fireEvent.click(phoneCheckbox()) + const phoneItem = container.querySelectorAll('.item-list .item')[2] + fireEvent.change(phoneItem.querySelector('select')!, { target: { value: '필수' } }) + fireEvent.change(phoneItem.querySelector('input[placeholder="예: 회원가입 화면"]')!, { target: { value: 'SMS 인증 화면' } }) + + fireEvent.click(container.querySelectorAll('.rail li button')[2]) + fireEvent.change(container.querySelector('input[name="purpose-phone"]')!, { target: { value: '본인 확인 및 알림 발송' } }) + expect(container.querySelector('.review-stat.blocking b')?.textContent).toBe('7건') + + fireEvent.click(container.querySelectorAll('.rail li button')[1]) + fireEvent.click(phoneCheckbox()) + fireEvent.click(phoneCheckbox()) + + const reenabledPhone = container.querySelectorAll('.item-list .item')[2] + expect(reenabledPhone.querySelector('input[placeholder="예: 회원가입 화면"]')?.value).toBe('') + expect(reenabledPhone.querySelector('select')?.value).toBe('') + expect(container.querySelector('.review-stat.blocking b')?.textContent).toBe('10건') + + fireEvent.click(container.querySelectorAll('.rail li button')[2]) + expect(container.querySelector('input[name="purpose-phone"]')?.value).toBe('') + }) + + it('공백뿐인 처리 목적은 미리보기에서도 미입력으로 표시한다', () => { + const { container } = render() + openCollectionStep(container) + const nameCheckbox = container.querySelectorAll('.check-label input')[0] + fireEvent.click(nameCheckbox) + const nameItem = container.querySelectorAll('.item-list .item')[0] + fireEvent.change(nameItem.querySelector('select')!, { target: { value: '필수' } }) + fireEvent.change(nameItem.querySelector('input[placeholder="예: 회원가입 화면"]')!, { target: { value: '회원가입 화면' } }) + fireEvent.click(container.querySelectorAll('.rail li button')[2]) + + const purpose = container.querySelector('input[name="purpose-name"]')! + fireEvent.change(purpose, { target: { value: ' ' } }) + + expect(container.querySelector('.review-stat.blocking b')?.textContent).toBe('8건') + const nameRow = Array.from(container.querySelectorAll('.paper tbody tr')).find((row) => row.cells[0]?.textContent === '이름')! + expect(nameRow.cells[1]?.textContent).toBe('처리 목적 입력 필요') + expect(nameRow.cells[1]?.classList.contains('missing')).toBe(true) + expect(nameRow.cells[2]?.textContent).toBe('확인 필요') + }) + + it('미리보기 처리 목적 경고에서 처리 목적 단계로 이동한다', () => { + const { container } = render() + openCollectionStep(container) + const phone = container.querySelectorAll('.check-label input')[2] + fireEvent.click(phone) + const warningButton = Array.from(container.querySelectorAll('.document-warning button')).find((button) => button.textContent?.includes('처리 목적'))! + fireEvent.click(warningButton) + expect(container.querySelector('.form-panel h1')?.textContent).toBe('3. 처리 목적') + }) + + it('수집 여부 미확인 경고에서 수집 항목 단계로 이동한다', () => { + const { container } = render() + fireEvent.click(container.querySelectorAll('.rail li button')[0]) + const warningButton = Array.from(container.querySelectorAll('.document-warning button')).find((button) => button.textContent?.includes('수집 항목'))! + fireEvent.click(warningButton) + expect(container.querySelector('.form-panel h1')?.textContent).toBe('2. 수집 항목') + }) + + it('수집 구분 미확인 경고에서 수집 항목 단계로 이동한다', () => { + const { container } = render() + openCollectionStep(container) + fireEvent.click(container.querySelectorAll('.check-label input')[2]) + fireEvent.click(container.querySelectorAll('.rail li button')[0]) + const warningButton = Array.from(container.querySelectorAll('.document-warning button')).find((button) => button.textContent?.includes('수집 구분'))! + fireEvent.click(warningButton) + expect(container.querySelector('.form-panel h1')?.textContent).toBe('2. 수집 항목') + }) + + it('수집 경로 미확인 경고에서 수집 항목 단계로 이동한다', () => { + const { container } = render() + openCollectionStep(container) + fireEvent.click(container.querySelectorAll('.check-label input')[2]) + const phoneItem = container.querySelectorAll('.item-list .item')[2] + fireEvent.change(phoneItem.querySelector('select')!, { target: { value: '필수' } }) + fireEvent.click(container.querySelectorAll('.rail li button')[2]) + fireEvent.change(container.querySelector('input[name="purpose-phone"]')!, { target: { value: '본인 확인' } }) + fireEvent.click(container.querySelectorAll('.rail li button')[0]) + const warningButton = Array.from(container.querySelectorAll('.document-warning button')).find((button) => button.textContent?.includes('수집 경로'))! + fireEvent.click(warningButton) + expect(container.querySelector('.form-panel h1')?.textContent).toBe('2. 수집 항목') + }) + + it('보유 여부를 명시적으로 확인하고 없음 전환 시 이전 보유 기간을 폐기한다', () => { + const { container } = render() + const buttons = container.querySelectorAll('.rail li button') + fireEvent.click(buttons[3]) + + const status = container.querySelector('select[name="retentionStatus"]')! + expect(status.value).toBe('') + expect(container.querySelector('input[name="retentionPeriod"]')).toBeNull() + + fireEvent.change(status, { target: { value: 'applies' } }) + const period = container.querySelector('input[name="retentionPeriod"]')! + fireEvent.change(period, { target: { value: '회원 탈퇴 시까지' } }) + expect(container.querySelector('.paper')?.textContent).toContain('회원 탈퇴 시까지') + + fireEvent.change(status, { target: { value: 'none' } }) + expect(container.querySelector('input[name="retentionPeriod"]')).toBeNull() + expect(container.querySelector('.paper')?.textContent).toContain('보유하는 개인정보 없음으로 확인되었습니다.') + + fireEvent.change(status, { target: { value: 'applies' } }) + expect(container.querySelector('input[name="retentionPeriod"]')?.value).toBe('') + }) + + it('제3자 제공과 국외 이전은 확인 전 상태를 별도로 표현하고 없음 확인 시 종속 사실을 요구하지 않는다', () => { + const { container } = render() + const buttons = container.querySelectorAll('.rail li button') + + fireEvent.click(buttons[4]) + const thirdParty = container.querySelector('select[name="thirdPartyStatus"]')! + expect(thirdParty.value).toBe('') + expect(container.querySelector('input[name="thirdPartyRecipient"]')).toBeNull() + fireEvent.change(thirdParty, { target: { value: 'no' } }) + expect(container.querySelector('input[name="thirdPartyRecipient"]')).toBeNull() + + fireEvent.click(buttons[5]) + const international = container.querySelector('select[name="internationalStatus"]')! + expect(international.value).toBe('') + fireEvent.change(international, { target: { value: 'no' } }) + expect(container.querySelector('input[name="internationalCountry"]')).toBeNull() + }) + + it('제공 있음에서 없음으로 바꾸면 이전 수령자 사실을 폐기해 재활성화 시 재검토한다', () => { + const { container } = render() + const buttons = container.querySelectorAll('.rail li button') + fireEvent.click(buttons[4]) + const status = container.querySelector('select[name="thirdPartyStatus"]')! + fireEvent.change(status, { target: { value: 'yes' } }) + fireEvent.change(container.querySelector('input[name="thirdPartyRecipient"]')!, { target: { value: '외부 처리자' } }) + fireEvent.change(container.querySelector('input[name="thirdPartyPurpose"]')!, { target: { value: '업무 처리' } }) + fireEvent.change(status, { target: { value: 'no' } }) + fireEvent.change(status, { target: { value: 'yes' } }) + expect(container.querySelector('input[name="thirdPartyRecipient"]')?.value).toBe('') + expect(container.querySelector('input[name="thirdPartyPurpose"]')?.value).toBe('') + }) + + it('모든 제품 정의 필수 사실을 확인한 뒤에만 공개 준비 확인을 허용한다', () => { + const { container } = render() + openCollectionStep(container) + const phone = container.querySelectorAll('.check-label input')[2] + fireEvent.click(phone) + const phoneItem = container.querySelectorAll('.item-list .item')[2] + fireEvent.change(phoneItem.querySelector('select')!, { target: { value: '필수' } }) + fireEvent.change(phoneItem.querySelector('input[placeholder="예: 회원가입 화면"]')!, { target: { value: '회원가입 화면' } }) + fireEvent.click(container.querySelectorAll('.rail li button')[2]) + fireEvent.change(container.querySelector('input[name="purpose-phone"]')!, { target: { value: '본인 확인 및 알림 발송' } }) + + expect(container.querySelector('.review-stat.blocking b')?.textContent).toBe('7건') + expect(container.querySelector('.publish')?.disabled).toBe(true) + + completeNonCollectionFacts(container) + + expect(container.querySelector('.review-stat.blocking b')?.textContent).toBe('0건') + const publish = container.querySelector('.publish')! + expect(publish.disabled).toBe(false) + expect(publish.textContent).toContain('공개 준비 확인') + fireEvent.click(publish) + const message = container.querySelector('output')?.textContent ?? '' + expect(message).toContain('책임자와 검토') + expect(message).not.toContain('백엔드') + expect(message).not.toContain('저장소') + }) +}) diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..b05850e --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,249 @@ +import { useMemo, useState } from 'react' +import { AlertTriangle, Check, ChevronDown, ExternalLink, FileText, Link, Save } from 'lucide-react' +import { createPolicyExport, DraftFacts, getCompletedSteps, getDraftReview, getReview, initialFacts, initialItems, isWebServiceUrl, PolicyItem, steps } from './policy' + +type FactField = { + key: keyof DraftFacts + label: string + placeholder?: string + type?: 'text' | 'email' | 'url' | 'select' + options?: Array<{ value: string; label: string }> + visibleWhen?: { key: keyof DraftFacts; equals: string } +} + +/** Renders the seven-step authoring rail and completion progress derived from verified facts. */ +function StepRail({ current, completedSteps, setCurrent }: { current: number; completedSteps: Set; setCurrent: (step: number) => void }) { + return +} + +/** Renders previous/next navigation for the active authoring step. */ +function StepActions({ current, setCurrent }: { current: number; setCurrent: (step: number) => void }) { + return
+ + +
+} + +/** Renders a scalar-fact authoring step backed by the current draft facts. */ +function FactStep({ current, title, description, fields, facts, setFacts, setCurrent }: { + current: number + title: string + description: string + fields: FactField[] + facts: DraftFacts + setFacts: (facts: DraftFacts) => void + setCurrent: (step: number) => void +}) { + /** Updates one fact and clears dependent facts when its owning state makes them inapplicable. */ + const update = (key: keyof DraftFacts, value: string) => { + const next = { ...facts, [key]: value } as DraftFacts + if (key === 'retentionStatus' && value !== 'applies') next.retentionPeriod = '' + if (key === 'thirdPartyStatus' && value !== 'yes') { + next.thirdPartyRecipient = '' + next.thirdPartyPurpose = '' + } + if (key === 'internationalStatus' && value !== 'yes') { + next.internationalCountry = '' + next.internationalRecipient = '' + } + setFacts(next) + } + return
+

{current}. {title}

{description}

+
사실 기반 입력운영 중인 서비스와 계약·처리 흐름에서 확인한 사실만 입력하세요. 확인되지 않은 내용은 비워 두고 검토 대상으로 남깁니다.
+

확인 정보

+
{fields.filter((field) => !field.visibleWhen || facts[field.visibleWhen.key] === field.visibleWhen.equals).map((field) => )}
+ +
+} + +/** Captures collection facts only after the operator explicitly selects an item or attests that none are collected. */ +function CollectionForm({ items, setItems, noCollectionAttested, setNoCollectionAttested, setCurrent }: { + items: PolicyItem[] + setItems: (items: PolicyItem[]) => void + noCollectionAttested: boolean + setNoCollectionAttested: (attested: boolean) => void + setCurrent: (step: number) => void +}) { + /** Applies one collection-item patch while preserving every sibling item. */ + const update = (id: string, patch: Partial) => setItems(items.map((item) => item.id === id ? { ...item, ...patch } : item)) + /** Enforces a no-collection attestation by clearing facts that would contradict it. */ + const setNoCollection = (attested: boolean) => { + if (attested) setItems(items.map((item) => ({ ...item, enabled: false, purpose: '', detail: '', mode: '' }))) + setNoCollectionAttested(attested) + } + return
+

2. 수집 항목

서비스에서 실제로 수집하는 개인정보만 선택하세요. 선택한 항목에 따라 다음 단계가 달라집니다.

+
입력 원칙서비스 코드와 운영 절차에서 확인한 항목만 반영하세요. 추정으로 선택하지 않습니다.
+

수집 여부

+ +

기본 정보

+
수집 항목설명수집 여부
+
{items.map((item) =>
+
+ + {item.description} + +
+ {item.enabled && !noCollectionAttested &&
} +
)}
+ +
+} + +/** Captures processing purposes for collection items explicitly selected by the operator. */ +function PurposeForm({ items, setItems, noCollectionAttested, setCurrent }: { items: PolicyItem[]; setItems: (items: PolicyItem[]) => void; noCollectionAttested: boolean; setCurrent: (step: number) => void }) { + const enabled = items.filter((item) => item.enabled) + /** Updates the processing purpose for one selected collection item only. */ + const updatePurpose = (id: string, purpose: string) => setItems(items.map((item) => item.id === id ? { ...item, purpose } : item)) + return
+

3. 처리 목적

선택한 개인정보 항목마다 실제 처리 목적을 연결합니다. 목적이 없는 항목은 공개 검토를 통과할 수 없습니다.

+
검토 원칙포괄적인 문구를 새로 만들기보다 실제 기능·업무 목적과 연결하세요.
+

항목별 처리 목적

+ {noCollectionAttested ?

개인정보를 수집하지 않음으로 확인되었습니다. 수집 항목을 추가하려면 수집 항목 단계에서 이 확인을 해제하세요.

: enabled.length === 0 ?

수집 항목 단계에서 실제 수집 항목을 먼저 선택하세요.

:
{enabled.map((item) =>
)}
} + +
+} + +/** Chooses the editing surface that owns the active authoring step. */ +function EditingPanel({ current, items, setItems, noCollectionAttested, setNoCollectionAttested, facts, setFacts, setCurrent }: { + current: number + items: PolicyItem[] + setItems: (items: PolicyItem[]) => void + noCollectionAttested: boolean + setNoCollectionAttested: (attested: boolean) => void + facts: DraftFacts + setFacts: (facts: DraftFacts) => void + setCurrent: (step: number) => void +}) { + if (current === 2) return + if (current === 3) return + + const yesNoOptions = [ + { value: '', label: '확인 필요' }, + { value: 'yes', label: '있음' }, + { value: 'no', label: '없음' }, + ] + const retentionOptions = [ + { value: '', label: '확인 필요' }, + { value: 'applies', label: '보유함' }, + { value: 'none', label: '보유하지 않음' }, + ] + const stepConfig: Record = { + 1: { title: '서비스 정보', description: '개인정보처리방침이 적용되는 서비스와 공개 위치를 확인합니다.', fields: [ + { key: 'serviceName', label: '서비스 이름', placeholder: '예: 서비스 이름' }, + { key: 'serviceUrl', label: '서비스 URL', placeholder: 'https://example.com', type: 'url' }, + ] }, + 4: { title: '보유 기간', description: '개인정보 보유 여부를 먼저 확인하고, 실제 보유가 있는 경우 기간 또는 종료 조건을 기록합니다.', fields: [ + { key: 'retentionStatus', label: '개인정보 보유 여부', type: 'select', options: retentionOptions }, + { key: 'retentionPeriod', label: '대표 보유 기간 또는 종료 조건', placeholder: '예: 회원 탈퇴 시까지, 별도 보존 근거가 있는 항목은 해당 기간', visibleWhen: { key: 'retentionStatus', equals: 'applies' } }, + ] }, + 5: { title: '제3자 제공', description: '제3자 제공 여부를 먼저 확인하고, 실제 제공이 있는 경우 제공받는 자와 목적을 기록합니다.', fields: [ + { key: 'thirdPartyStatus', label: '제3자 제공 여부', type: 'select', options: yesNoOptions }, + { key: 'thirdPartyRecipient', label: '제공받는 자', placeholder: '실제 제공받는 자', visibleWhen: { key: 'thirdPartyStatus', equals: 'yes' } }, + { key: 'thirdPartyPurpose', label: '제공 목적', placeholder: '실제 제공 목적', visibleWhen: { key: 'thirdPartyStatus', equals: 'yes' } }, + ] }, + 6: { title: '국외 이전', description: '국외 이전 여부를 먼저 확인하고, 실제 이전이 있는 경우 국가와 수령자를 기록합니다.', fields: [ + { key: 'internationalStatus', label: '국외 이전 여부', type: 'select', options: yesNoOptions }, + { key: 'internationalCountry', label: '이전 국가', placeholder: '실제 이전 국가', visibleWhen: { key: 'internationalStatus', equals: 'yes' } }, + { key: 'internationalRecipient', label: '국외 수령자', placeholder: '실제 수령 법인 또는 서비스', visibleWhen: { key: 'internationalStatus', equals: 'yes' } }, + ] }, + 7: { title: '개인정보 보호 담당자', description: '개인정보 관련 문의를 받을 책임자와 연락 채널을 기록합니다.', fields: [ + { key: 'privacyOfficerName', label: '담당자 또는 담당 부서', placeholder: '예: 개인정보보호 담당' }, + { key: 'privacyOfficerEmail', label: '연락 이메일', placeholder: 'privacy@example.com', type: 'email' }, + ] }, + } + const config = stepConfig[current] ?? stepConfig[1] + return +} + +/** Projects verified authoring facts and deterministic readiness findings into the review draft. */ +function DocumentPreview({ items, noCollectionAttested, facts, setCurrent }: { items: PolicyItem[]; noCollectionAttested: boolean; facts: DraftFacts; setCurrent: (step: number) => void }) { + const review = useMemo(() => getReview(items, noCollectionAttested), [items, noCollectionAttested]) + const draftFindings = useMemo(() => getDraftReview(facts, noCollectionAttested), [facts, noCollectionAttested]) + const blockingCount = review.blockingCount + draftFindings.length + return
+

개인정보처리방침 미리보기

+
근거 법령 개인정보 보호법{blockingCount ? `검토 필요 ${blockingCount}` : '필수 확인 완료'}버전 0.1.0
+
+

{facts.serviceName || '개인정보처리방침'} (검토본)

+ {isWebServiceUrl(facts.serviceUrl.trim()) &&

적용 서비스: {facts.serviceUrl.trim()}

} +

{facts.serviceName || '서비스 운영자'}는 이용자의 개인정보를 중요하게 여기며, 확인된 실제 처리 사실을 바탕으로 다음 사항을 검토합니다.

+

제1조 (개인정보의 처리 목적)

+ {noCollectionAttested ?

운영자는 현재 서비스에서 개인정보를 수집하지 않음으로 확인했습니다.

:

아래 목적은 작성자가 확인한 운영 사실을 기준으로 표시됩니다.

} + {review.enabled.map((item) => { + const hasPurpose = item.purpose.trim().length > 0 + return + })}
수집 항목처리 목적검토 상태
{item.label}{hasPurpose ? item.purpose : '처리 목적 입력 필요'}{hasPurpose ? '입력됨' : '확인 필요'}
+ {review.selectionMissing &&
공개 전 확인실제 수집 항목 또는 개인정보를 수집하지 않는다는 운영 사실이 아직 확인되지 않았습니다.
} + {review.collectionContradiction &&
공개 전 확인수집하지 않음 확인과 선택된 수집 항목이 동시에 존재합니다.
} + {review.modeBlocking.length > 0 &&
공개 전 확인{review.modeBlocking.map((item) => item.label).join(', ')}의 수집 구분을 확인해야 합니다.
} + {review.pathBlocking.length > 0 &&
공개 전 확인{review.pathBlocking.map((item) => item.label).join(', ')}의 수집 경로를 확인해야 합니다.
} + {review.blocking.length > 0 &&
공개 전 확인{review.blocking.map((item) => item.label).join(', ')}의 처리 목적이 입력되지 않았습니다.
} + {draftFindings.map((finding) =>
공개 전 확인{finding.label} 확인이 필요합니다.
)} +

제2조 (처리 및 보유 기간)

{facts.retentionStatus === 'none' ? '보유하는 개인정보 없음으로 확인되었습니다.' : facts.retentionStatus === 'applies' ? facts.retentionPeriod || '보유 기간을 확인해야 합니다.' : '보유 여부 및 기간을 확인해야 합니다.'}

+

제3조 (제3자 제공)

{facts.thirdPartyStatus === 'no' ? '제3자 제공 없음으로 확인되었습니다.' : facts.thirdPartyStatus === 'yes' ? `${facts.thirdPartyRecipient || '제공받는 자 확인 필요'}에 ${facts.thirdPartyPurpose || '제공 목적 확인 필요'}으로 제공하는 흐름을 검토 중입니다.` : '제3자 제공 여부를 확인하는 단계가 남아 있습니다.'}

+

제4조 (국외 이전)

{facts.internationalStatus === 'no' ? '국외 이전 없음으로 확인되었습니다.' : facts.internationalStatus === 'yes' ? `${facts.internationalCountry || '국가 확인 필요'} · ${facts.internationalRecipient || '수령자 확인 필요'}` : '국외 이전 여부를 확인하는 단계가 남아 있습니다.'}

+

개인정보 보호 문의

{facts.privacyOfficerName || '담당자 확인 필요'} · {facts.privacyOfficerEmail || '연락처 확인 필요'}

+
+ 입력 내용은 검토본에 즉시 반영됩니다. 생성된 문서는 법률 자문이 아닙니다. 공개 전 책임자의 검토가 필요합니다. +
+} + +/** Coordinates PolicyWeave browser-only authoring state and readiness feedback. */ +export default function App() { + const [items, setItems] = useState(initialItems) + const [noCollectionAttested, setNoCollectionAttested] = useState(false) + const [facts, setFacts] = useState(initialFacts) + const [current, setCurrent] = useState(1) + const collectionReview = useMemo(() => getReview(items, noCollectionAttested), [items, noCollectionAttested]) + const draftFindings = useMemo(() => getDraftReview(facts, noCollectionAttested), [facts, noCollectionAttested]) + const completedSteps = useMemo(() => getCompletedSteps(items, noCollectionAttested, facts), [items, noCollectionAttested, facts]) + const blockingCount = collectionReview.blockingCount + draftFindings.length + const [message, setMessage] = useState('') + /** Reports readiness for responsible review without claiming that a publication occurred. */ + function publish() { setMessage(blockingCount ? '필수 확인 항목을 먼저 입력하세요.' : '필수 확인이 완료되었습니다. 현재 검토본을 책임자와 검토하고 필요한 사실을 보완하세요.') } + /** Downloads the deterministic local export and revokes its object URL after activation. */ + function exportDraft() { + let fileUrl: string | null = null + try { + fileUrl = URL.createObjectURL(new Blob([`${JSON.stringify(createPolicyExport(items, noCollectionAttested, facts), null, 2)}\n`], { type: 'application/json' })) + const downloadLink = document.createElement('a') + downloadLink.href = fileUrl + downloadLink.download = 'policyweave-draft.json' + downloadLink.click() + setMessage('') + } catch { + setMessage('JSON 파일을 내보내지 못했습니다. 다시 시도하세요.') + } finally { + if (fileUrl) { + const disposableFileUrl = fileUrl + setTimeout(() => URL.revokeObjectURL(disposableFileUrl), 0) + } + } + } + return
+
PolicyWeave{facts.serviceName || '내 서비스'} 개인정보처리방침작성 중버전 0.1.0 (임시저장) 브라우저 작업 중
+
+
검토 요약확인을 마친 뒤 공개 준비 상태를 확인하세요.
필수 확인 {blockingCount}건
권장 검토 {collectionReview.recommended.length}건
{message}
+
+} diff --git a/src/AuthoringFocusController.tsx b/src/AuthoringFocusController.tsx new file mode 100644 index 0000000..661f04f --- /dev/null +++ b/src/AuthoringFocusController.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react' + +/** + * Transfers focus to the active authoring-step heading after explicit step navigation. + * + * PolicyWeave replaces the editing panel when the operator uses the step rail, sequential + * previous/next controls, or a review warning. Moving focus to the new heading keeps keyboard and + * assistive-technology context aligned with the visible step without stealing focus from ordinary + * form controls or from the dedicated preview shortcut. + */ +export function AuthoringFocusController() { + useEffect(() => { + const pendingFocusTransfers = new Set() + + const handleNavigationClick = (event: MouseEvent) => { + if (!(event.target instanceof Element)) return + const button = event.target.closest('button') + if (!(button instanceof HTMLButtonElement)) return + if (!button.closest('.rail li, .form-actions, .document-warning')) return + + const timer = window.setTimeout(() => { + pendingFocusTransfers.delete(timer) + const heading = document.querySelector('.form-panel .section-head h1') + if (!heading) return + heading.tabIndex = -1 + heading.focus() + }, 0) + pendingFocusTransfers.add(timer) + } + + document.addEventListener('click', handleNavigationClick) + return () => { + document.removeEventListener('click', handleNavigationClick) + pendingFocusTransfers.forEach((timer) => window.clearTimeout(timer)) + } + }, []) + + return null +} diff --git a/src/authoring-focus.test.tsx b/src/authoring-focus.test.tsx new file mode 100644 index 0000000..b1dc39c --- /dev/null +++ b/src/authoring-focus.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import App from './App' +import { AuthoringFocusController } from './AuthoringFocusController' + +afterEach(cleanup) + +function renderAuthoringWorkspace() { + return render(<>) +} + +async function expectActiveStepHeading(container: HTMLElement, text: string) { + await waitFor(() => { + const heading = container.querySelector('.form-panel .section-head h1') + expect(heading?.textContent).toBe(text) + expect(document.activeElement).toBe(heading) + expect(heading?.tabIndex).toBe(-1) + }) +} + +describe('authoring focus transitions', () => { + it('moves focus to the newly selected step heading from the authoring rail', async () => { + const { container } = renderAuthoringWorkspace() + fireEvent.click(container.querySelectorAll('.rail li button')[0]) + + await expectActiveStepHeading(container, '1. 서비스 정보') + }) + + it('moves focus to the next step heading after sequential navigation', async () => { + const { container } = renderAuthoringWorkspace() + fireEvent.click(container.querySelectorAll('.rail li button')[0]) + await expectActiveStepHeading(container, '1. 서비스 정보') + + fireEvent.click(container.querySelector('.form-actions .primary')!) + + await expectActiveStepHeading(container, '2. 수집 항목') + }) + + it('moves focus to the owning step when a review warning is activated', async () => { + const { container } = renderAuthoringWorkspace() + fireEvent.click(container.querySelectorAll('.rail li button')[0]) + await expectActiveStepHeading(container, '1. 서비스 정보') + + const warningButton = Array.from(container.querySelectorAll('.document-warning button')) + .find((button) => button.textContent?.includes('수집 항목'))! + fireEvent.click(warningButton) + + await expectActiveStepHeading(container, '2. 수집 항목') + }) +}) diff --git a/src/dependency-manifest-contract.test.ts b/src/dependency-manifest-contract.test.ts new file mode 100644 index 0000000..e6e8d56 --- /dev/null +++ b/src/dependency-manifest-contract.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +type DependencyManifest = { + dependencies?: Record + devDependencies?: Record +} + +type LockPackage = { + license?: string + version?: string +} + +type PackageLock = { + packages: Record +} + +const packagePath = fileURLToPath(new URL('../package.json', import.meta.url)) +const lockPath = fileURLToPath(new URL('../package-lock.json', import.meta.url)) +const packageManifest = JSON.parse(readFileSync(packagePath, 'utf8')) as DependencyManifest +const packageLock = JSON.parse(readFileSync(lockPath, 'utf8')) as PackageLock +const lockManifest = packageLock.packages[''] +const exactVersion = /^\d+\.\d+\.\d+$/ + +describe('direct dependency manifest contract', () => { + it('pins every direct dependency to the reviewed lock resolution', () => { + const directDependencies = [ + ...Object.entries(packageManifest.dependencies ?? {}), + ...Object.entries(packageManifest.devDependencies ?? {}), + ] + const mutableOrMismatched = directDependencies.filter( + ([name, version]) => + !exactVersion.test(version) || + packageLock.packages[`node_modules/${name}`]?.version !== version, + ) + + expect(mutableOrMismatched).toEqual([]) + }) + + it('keeps compiler and bundler packages out of production dependencies', () => { + const buildPackages = ['@vitejs/plugin-react', 'typescript', 'vite'] + + expect(buildPackages.filter((name) => name in (packageManifest.dependencies ?? {}))).toEqual([]) + expect(buildPackages.filter((name) => !(name in (packageManifest.devDependencies ?? {})))).toEqual([]) + }) + + it('keeps the lock root synchronized with the package manifest', () => { + expect(lockManifest.dependencies).toEqual(packageManifest.dependencies) + expect(lockManifest.devDependencies).toEqual(packageManifest.devDependencies) + }) + + it('retains a machine-readable license for every locked package', () => { + const missingLicenses = Object.entries(packageLock.packages) + .filter(([path, lockedPackage]) => path !== '' && !lockedPackage.license?.trim()) + .map(([path]) => path) + + expect(missingLicenses).toEqual([]) + }) +}) diff --git a/src/initial-workspace.test.tsx b/src/initial-workspace.test.tsx new file mode 100644 index 0000000..38a0de1 --- /dev/null +++ b/src/initial-workspace.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import App from './App' + +afterEach(cleanup) + +describe('authoring progress truthfulness', () => { + it('starts with the first unresolved authoring responsibility and zero completed responsibilities', () => { + const { container } = render() + + expect(container.querySelector('.form-panel h1')?.textContent).toBe('1. 서비스 정보') + expect(container.querySelector('.progress-copy span')?.textContent).toBe('0/7 완료') + expect(container.querySelectorAll('.rail li.done')).toHaveLength(0) + expect(container.querySelector('.rail li.active small')?.textContent).toBe('확인 및 입력') + }) + + it('does not increase completion merely because the operator navigates past unresolved responsibilities', () => { + const { container } = render() + const railButtons = container.querySelectorAll('.rail li button') + + fireEvent.click(railButtons[6]) + + expect(container.querySelector('.form-panel h1')?.textContent).toBe('7. 개인정보 보호 담당자') + expect(container.querySelector('.progress-copy span')?.textContent).toBe('0/7 완료') + expect(container.querySelectorAll('.rail li.done')).toHaveLength(0) + expect(Array.from(container.querySelectorAll('.rail li small')).every((label) => label.textContent === '확인 및 입력')).toBe(true) + }) + + it('marks a responsibility complete only after its blocking facts are actually satisfied', () => { + const { container } = render() + fireEvent.change(container.querySelector('input[name="serviceName"]')!, { target: { value: 'Example Service' } }) + fireEvent.change(container.querySelector('input[name="serviceUrl"]')!, { target: { value: 'https://example.com' } }) + fireEvent.click(container.querySelector('.form-actions .primary')!) + + const firstStep = container.querySelectorAll('.rail li')[0] + expect(container.querySelector('.progress-copy span')?.textContent).toBe('1/7 완료') + expect(firstStep.classList.contains('done')).toBe(true) + expect(firstStep.querySelector('small')?.textContent).toBe('입력 확인됨') + }) +}) diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..97892e0 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import { AuthoringFocusController } from './AuthoringFocusController' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + + , +) diff --git a/src/no-collection.test.tsx b/src/no-collection.test.tsx new file mode 100644 index 0000000..fbb3e5d --- /dev/null +++ b/src/no-collection.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import App from './App' +import { getReview, initialItems } from './policy' + +afterEach(cleanup) + +describe('explicit no-collection attestation', () => { + it('clears the unresolved selection blocker without inventing collection items', () => { + const review = getReview(initialItems, true) + expect(review.enabled).toEqual([]) + expect(review.selectionMissing).toBe(false) + expect(review.collectionContradiction).toBe(false) + expect(review.blockingCount).toBe(0) + }) + + it('fails closed when a no-collection attestation coexists with a selected item', () => { + const items = initialItems.map((item) => item.id === 'phone' ? { ...item, enabled: true, mode: '필수' as const, purpose: '본인 확인', detail: '회원가입 화면' } : item) + const review = getReview(items, true) + expect(review.collectionContradiction).toBe(true) + expect(review.blockingCount).toBe(1) + }) + + it('does not infer retention from no-collection and accepts a separate explicit no-retention fact', () => { + const { container } = render() + fireEvent.click(container.querySelectorAll('.rail li button')[1]) + fireEvent.click(container.querySelector('input[name="noCollectionAttested"]')!) + + expect(container.querySelector('.review-stat.blocking b')?.textContent).toBe('7건') + expect(container.querySelectorAll('.rail li')[3].classList.contains('done')).toBe(false) + expect(container.querySelector('.paper')?.textContent).toContain('보유 여부 및 기간을 확인해야 합니다.') + + fireEvent.click(container.querySelectorAll('.rail li button')[3]) + fireEvent.change(container.querySelector('select[name="retentionStatus"]')!, { target: { value: 'none' } }) + + expect(container.querySelector('.review-stat.blocking b')?.textContent).toBe('6건') + expect(container.querySelectorAll('.rail li')[3].classList.contains('done')).toBe(true) + expect(container.querySelector('.paper')?.textContent).toContain('보유하는 개인정보 없음으로 확인되었습니다.') + }) + + it('invalidates stale item facts when the operator attests no collection and does not revive them when the attestation is removed', () => { + const { container } = render() + fireEvent.click(container.querySelectorAll('.rail li button')[1]) + const phone = container.querySelectorAll('.check-label input')[2] + fireEvent.click(phone) + const phoneItem = container.querySelectorAll('.item-list .item')[2] + fireEvent.change(phoneItem.querySelector('select')!, { target: { value: '필수' } }) + fireEvent.change(phoneItem.querySelector('input[placeholder="예: 회원가입 화면"]')!, { target: { value: '회원가입 화면' } }) + + fireEvent.click(container.querySelectorAll('.rail li button')[2]) + fireEvent.change(container.querySelector('input[name="purpose-phone"]')!, { target: { value: '본인 확인' } }) + fireEvent.click(container.querySelectorAll('.rail li button')[1]) + + const noCollection = container.querySelector('input[name="noCollectionAttested"]')! + expect(noCollection).not.toBeNull() + fireEvent.click(noCollection) + expect(container.querySelectorAll('.check-label input')[2].checked).toBe(false) + expect(container.querySelector('.review-stat.blocking b')?.textContent).toBe('7건') + + fireEvent.click(noCollection) + expect(container.querySelector('.review-stat.blocking b')?.textContent).toBe('8건') + fireEvent.click(container.querySelectorAll('.check-label input')[2]) + const reenabledPhone = container.querySelectorAll('.item-list .item')[2] + expect(reenabledPhone.querySelector('select')?.value).toBe('') + expect(reenabledPhone.querySelector('input[placeholder="예: 회원가입 화면"]')?.value).toBe('') + + fireEvent.click(container.querySelectorAll('.rail li button')[2]) + expect(container.querySelector('input[name="purpose-phone"]')?.value).toBe('') + }) +}) diff --git a/src/persistence-schema.test.ts b/src/persistence-schema.test.ts new file mode 100644 index 0000000..319cc3d --- /dev/null +++ b/src/persistence-schema.test.ts @@ -0,0 +1,65 @@ +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const migrationPath = fileURLToPath(new URL('../db/migrations/0001_policy_revision.sql', import.meta.url)) +const migrationSql = existsSync(migrationPath) ? readFileSync(migrationPath, 'utf8') : '' +const gapBaselinePath = fileURLToPath(new URL('../docs/product-technical-gap-baseline.md', import.meta.url)) +const gapBaseline = readFileSync(gapBaselinePath, 'utf8') + +describe('PostgreSQL policy revision schema', () => { + it('versions policy revisions within a tenant account', () => { + expect(migrationSql).toMatch(/create table policy_revision\s*\([\s\S]*policy_revision_id uuid primary key/i) + expect(migrationSql).toMatch(/unique\s*\(tenant_account_id, revision_number\)/i) + expect(migrationSql).toMatch(/no_collection_confirmed boolean not null/i) + expect(migrationSql).toMatch(/retention_status retention_fact_status not null/i) + }) + + it('normalizes revision-owned collection, purpose, and retention facts', () => { + for (const tableName of ['service_profile', 'collection_item', 'processing_purpose', 'retention_rule']) { + expect(migrationSql).toContain(`create table ${tableName}`) + } + + expect(migrationSql).toMatch(/foreign key \(policy_revision_id, collection_item_key\)[\s\S]*references collection_item/i) + }) + + it('fails closed on contradictory collection and retention state at commit', () => { + expect(migrationSql).toMatch(/create constraint trigger policy_revision_fact_contract/i) + expect(migrationSql).toMatch(/create constraint trigger collection_item_fact_contract[\s\S]*on collection_item/i) + expect(migrationSql).toMatch(/create constraint trigger retention_rule_fact_contract[\s\S]*on retention_rule/i) + expect(migrationSql).toMatch(/deferrable initially deferred/i) + expect(migrationSql).toMatch(/no_collection_confirmed[\s\S]*exists\s*\([\s\S]*from collection_item/i) + expect(migrationSql).toMatch(/retention_status = 'applies'[\s\S]*from retention_rule/i) + }) + + it('serializes fact checks without conflicting with foreign-key key-share locks', () => { + expect(migrationSql).toMatch(/from policy_revision as revision[\s\S]*for no key update;/i) + }) + + it('rejects moving a fact to another policy revision', () => { + expect(migrationSql).toMatch(/old\.policy_revision_id is distinct from new\.policy_revision_id/i) + + for (const tableName of ['service_profile', 'collection_item', 'processing_purpose', 'retention_rule']) { + expect(migrationSql).toMatch(new RegExp(`create constraint trigger ${tableName}_fact_contract[\\s\\S]*on ${tableName}`, 'i')) + } + }) + + it('declares item-level UPSERT idempotency on the revision natural key', () => { + expect(migrationSql).toMatch(/create function upsert_collection_item/i) + expect(migrationSql).toMatch(/on conflict \(policy_revision_id, collection_item_key\)[\s\S]*do update/i) + }) + + it('documents the same-transaction retention transition contract', () => { + expect(gapBaseline).toMatch(/transitioning away from `applies`[\s\S]*same transaction/i) + expect(gapBaseline).toMatch(/retained[^.]*`retention_rule`[^.]*deferred constraint[^.]*reject/i) + }) + + it('binds action-runtime evidence to immutable heads without a recursive current-head claim', () => { + const actionEvidence = gapBaseline.split('Action-runtime cleanup')[1]?.split('## Current baseline')[0] ?? '' + + expect(actionEvidence).toMatch(/head `23b3a1c7429aca2ea39e153c6504beee9d507d39` CI `34179633214`[\s\S]*?artifact `10038466559` is bound by digest `sha256:83d8e177c78856a41eb58c3312fb8a671903989deb7b3bb2b8a747f8eea9091b`/) + expect(actionEvidence).toMatch(/head `fba59b85c5ce33813147939f8124cb2d69b184a1` CI `34180350163`[\s\S]*?artifact `10038696518` is bound by digest `sha256:f31fd4d0dee1164e76e0841f01cc2c5f6f9ce73c6b07fc08e98ac9bd4b333fa8`/) + expect(actionEvidence).not.toMatch(/Exact-head CI/) + }) + +}) diff --git a/src/policy-boundary.test.ts b/src/policy-boundary.test.ts new file mode 100644 index 0000000..0e9d0d7 --- /dev/null +++ b/src/policy-boundary.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { createPolicyExport, getCompletedSteps, getDraftReview, getReview, initialFacts, initialItems } from './policy' + +const reviewReadyBase = { + ...initialFacts, + serviceName: '예시 서비스', + serviceUrl: 'https://example.test', + retentionStatus: 'applies' as const, + retentionPeriod: '회원 탈퇴 시까지', + thirdPartyStatus: 'no' as const, + internationalStatus: 'no' as const, + privacyOfficerName: '개인정보보호 담당', + privacyOfficerEmail: 'privacy@example.test', +} + +describe('policy boundary contracts', () => { + it('canonicalizes uppercase service URL spelling without changing the authored destination', () => { + const exported = createPolicyExport(initialItems, false, { ...initialFacts, serviceUrl: 'HTTPS://EXAMPLE.TEST/Privacy' }) + + expect(exported.policy_facts.service_profile.service_url).toBe('https://example.test/Privacy') + expect(exported.review_finding_codes).not.toContain('service_url_format') + }) + + it('treats a username-only service URL as inadmissible without leaking the username', () => { + const exported = createPolicyExport(initialItems, false, { ...initialFacts, serviceUrl: 'https://operator@example.test' }) + + expect(exported.policy_facts.service_profile.service_url).toBeNull() + expect(exported.review_finding_codes).toContain('service_url_format') + expect(JSON.stringify(exported)).not.toContain('operator') + }) + + it('counts contradiction alongside per-item blockers instead of masking either', () => { + const items = initialItems.map((item) => item.id === 'phone' ? { ...item, enabled: true } : item) + const review = getReview(items, true) + + expect(review.collectionContradiction).toBe(true) + expect(review.blockingCount).toBe(4) + }) + + it('excludes collection and purpose steps while a no-collection contradiction stands', () => { + const items = initialItems.map((item) => item.id === 'phone' + ? { ...item, enabled: true, mode: '필수' as const, purpose: '본인 확인', detail: '회원가입 화면' } + : item) + const completed = getCompletedSteps(items, true, reviewReadyBase) + + expect(completed.has(2)).toBe(false) + expect(completed.has(3)).toBe(false) + expect(completed.has(4)).toBe(true) + }) + + it('rejects malformed contact emails beyond the missing-at-sign case', () => { + for (const privacyOfficerEmail of ['a@b@c', '@example.test', 'privacy@', 'privacy @example.test']) { + expect(getDraftReview({ ...reviewReadyBase, privacyOfficerEmail }).map((finding) => finding.code)) + .toEqual(['privacy_contact_email_format']) + } + expect(getDraftReview({ ...reviewReadyBase, privacyOfficerEmail: 'privacy@example.test' })).toEqual([]) + }) + + it('exports a null retention period without inventing one when applies has only whitespace', () => { + const exported = createPolicyExport(initialItems, false, { + ...initialFacts, + retentionStatus: 'applies', + retentionPeriod: ' ', + }) + + expect(exported.policy_facts.retention.retention_period).toBeNull() + expect(exported.review_finding_codes).toContain('retention_period') + }) +}) diff --git a/src/policy-export.test.ts b/src/policy-export.test.ts new file mode 100644 index 0000000..a2b9f5b --- /dev/null +++ b/src/policy-export.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { createPolicyExport, initialFacts, initialItems } from './policy' + +describe('policy JSON export', () => { + it('projects normalized operator facts into a deterministic versioned draft', () => { + const items = initialItems.map((item) => item.id === 'email' + ? { ...item, enabled: true, mode: '필수' as const, purpose: ' Account access ', detail: ' Signup form ' } + : item) + const facts = { + ...initialFacts, + serviceName: ' Buyer Portal ', + serviceUrl: ' https://buyer.example.test/privacy ', + retentionStatus: 'none' as const, + thirdPartyStatus: 'no' as const, + internationalStatus: 'no' as const, + privacyOfficerName: ' Privacy Team ', + privacyOfficerEmail: ' privacy@example.test ', + } + + expect(createPolicyExport(items, false, facts)).toEqual({ + schema_version: 1, + document_state: 'review_ready', + policy_facts: { + service_profile: { + service_name: 'Buyer Portal', + service_url: 'https://buyer.example.test/privacy', + }, + no_collection_attested: false, + collection_items: [{ + collection_item_key: 'email', + collection_item_label: '이메일 주소', + collection_mode: '필수', + collection_path: 'Signup form', + processing_purpose: 'Account access', + }], + retention: { + retention_status: 'none', + retention_period: null, + }, + third_party_transfer: { + transfer_status: 'no', + recipient_name: null, + transfer_purpose: null, + }, + international_transfer: { + transfer_status: 'no', + destination_country: null, + recipient_name: null, + }, + privacy_contact: { + contact_name: 'Privacy Team', + contact_email: 'privacy@example.test', + }, + }, + review_finding_codes: [], + }) + }) + + it('preserves unresolved state and finding codes without inventing facts', () => { + const exported = createPolicyExport(initialItems, false, initialFacts) + + expect(exported.document_state).toBe('incomplete') + expect(exported.policy_facts.collection_items).toEqual([]) + expect(exported.policy_facts.retention).toEqual({ + retention_status: null, + retention_period: null, + }) + expect(exported.review_finding_codes).toContain('collection_selection') + expect(exported.review_finding_codes).toContain('service_name') + expect(exported.review_finding_codes).toContain('retention_status') + }) + + it('keeps UI empty-string sentinels out of the portable schema types', () => { + const exported = createPolicyExport(initialItems, false, initialFacts) + + expectTypeOf(exported.policy_facts.retention.retention_status).toEqualTypeOf<'applies' | 'none' | null>() + expectTypeOf(exported.policy_facts.third_party_transfer.transfer_status).toEqualTypeOf<'yes' | 'no' | null>() + expectTypeOf(exported.policy_facts.international_transfer.transfer_status).toEqualTypeOf<'yes' | 'no' | null>() + }) + + it('normalizes an unresolved collection mode without leaking the UI empty-string sentinel', () => { + const items = initialItems.map((item) => item.id === 'email' + ? { ...item, enabled: true, purpose: 'Account access', detail: 'Signup form' } + : item) + + const exported = createPolicyExport(items, false, initialFacts) + + expect(exported.policy_facts.collection_items[0].collection_mode).toBeNull() + expect(exported.review_finding_codes).toContain('collection_mode:email') + }) + + it('rejects query and fragment service URLs instead of rewriting the authored destination', () => { + for (const serviceUrl of [ + 'https://example.test/app?tenant=acme', + 'https://example.test/#/privacy', + 'https://example.test/privacy?', + 'https://example.test/privacy#', + 'https://example.test/privacy?access_token=query-secret#fragment-secret', + ]) { + const exported = createPolicyExport(initialItems, false, { ...initialFacts, serviceUrl }) + + expect(exported.policy_facts.service_profile.service_url).toBeNull() + expect(exported.review_finding_codes).toContain('service_url_format') + } + + const secretExport = createPolicyExport(initialItems, false, { + ...initialFacts, + serviceUrl: 'https://example.test/privacy?access_token=query-secret#fragment-secret', + }) + expect(JSON.stringify(secretExport)).not.toContain('query-secret') + expect(JSON.stringify(secretExport)).not.toContain('fragment-secret') + }) + + it('preserves encoded question marks and hashes as pathname data', () => { + const exported = createPolicyExport(initialItems, false, { + ...initialFacts, + serviceUrl: 'https://example.test/privacy%3Fpolicy%23section', + }) + + expect(exported.policy_facts.service_profile.service_url).toBe('https://example.test/privacy%3Fpolicy%23section') + expect(exported.review_finding_codes).not.toContain('service_url_format') + }) + + it('does not export credentials embedded in an invalid service URL', () => { + const exported = createPolicyExport(initialItems, false, { + ...initialFacts, + serviceUrl: 'https://operator:secret@example.test', + }) + + expect(exported.policy_facts.service_profile.service_url).toBeNull() + expect(exported.review_finding_codes).toContain('service_url_format') + expect(JSON.stringify(exported)).not.toContain('operator:secret') + }) +}) diff --git a/src/policy.test.ts b/src/policy.test.ts new file mode 100644 index 0000000..d8dc4ef --- /dev/null +++ b/src/policy.test.ts @@ -0,0 +1,199 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { getDraftReview, getReview, initialFacts, initialItems } from './policy' + +const workflowDecision = readFileSync('docs/ADR-0002-seven-step-authoring-workflow.md', 'utf8') +const policyDataDecision = readFileSync('docs/ADR-0001-policy-as-data.md', 'utf8') + +describe('collection policy review', () => { + it('starts without invented operational selections, collection modes, or processing purposes', () => { + expect(initialItems.filter((item) => item.enabled)).toEqual([]) + expect(initialItems.every((item) => item.mode === '')).toBe(true) + expect(initialItems.every((item) => item.purpose === '')).toBe(true) + const review = getReview(initialItems) + expect(review.selectionMissing).toBe(true) + expect(review.blockingCount).toBe(1) + }) + + it('blocks readiness when a selected item still lacks collection mode, processing purpose, and collection path evidence', () => { + const items = initialItems.map((item) => item.id === 'phone' ? { ...item, enabled: true } : item) + const review = getReview(items) + expect(review.selectionMissing).toBe(false) + expect(review.modeBlocking.map((item) => item.id)).toEqual(['phone']) + expect(review.blocking.map((item) => item.id)).toEqual(['phone']) + expect(review.pathBlocking.map((item) => item.id)).toEqual(['phone']) + expect(review.blockingCount).toBe(3) + }) + + it('treats a whitespace-only collection path as missing evidence', () => { + const items = initialItems.map((item) => item.id === 'phone' ? { ...item, enabled: true, mode: '필수' as const, purpose: '본인 확인', detail: ' \t ' } : item) + const review = getReview(items) + expect(review.modeBlocking).toEqual([]) + expect(review.blocking).toEqual([]) + expect(review.pathBlocking.map((item) => item.id)).toEqual(['phone']) + expect(review.blockingCount).toBe(1) + }) + + it('treats a whitespace-only purpose as missing after collection mode and path are confirmed', () => { + const items = initialItems.map((item) => item.id === 'phone' ? { ...item, enabled: true, mode: '필수' as const, purpose: ' \t ', detail: '회원가입 화면' } : item) + const review = getReview(items) + expect(review.modeBlocking).toEqual([]) + expect(review.pathBlocking).toEqual([]) + expect(review.blocking.map((item) => item.id)).toEqual(['phone']) + expect(review.blockingCount).toBe(1) + }) + + it('clears readiness findings once collection mode, purpose, and path evidence are explicit', () => { + const items = initialItems.map((item) => item.id === 'phone' ? { ...item, enabled: true, mode: '필수' as const, purpose: ' 본인 확인 ', detail: ' 회원가입 화면 ' } : item) + const review = getReview(items) + expect(review.blocking.map((item) => item.id)).not.toContain('phone') + expect(review.modeBlocking.map((item) => item.id)).not.toContain('phone') + expect(review.pathBlocking.map((item) => item.id)).not.toContain('phone') + expect(review.blockingCount).toBe(0) + }) +}) + +describe('seven-step draft readiness', () => { + it('documents retention readiness as an explicit conditional fact', () => { + expect(workflowDecision).toContain('explicit retention status') + expect(workflowDecision).toContain("When retention applies, a nonblank retention period or end condition is required; explicit `none` requires no period") + }) + + it('keeps unmerged architecture decisions proposed', () => { + expect(policyDataDecision).toContain('Proposed') + expect(workflowDecision).toContain('Status: Proposed') + }) + + it('starts with every unresolved non-collection authoring responsibility blocked', () => { + expect(getDraftReview(initialFacts).map((finding) => finding.code)).toEqual([ + 'service_name', + 'service_url', + 'retention_status', + 'third_party_status', + 'international_status', + 'privacy_contact_name', + 'privacy_contact_email', + ]) + }) + + it('does not infer retention applicability from no-collection', () => { + expect(getDraftReview(initialFacts, true).map((finding) => finding.code)).toContain('retention_status') + }) + + it('accepts an explicit no-retention fact without inventing a retention period', () => { + const facts = { + ...initialFacts, + serviceName: '예시 서비스', + serviceUrl: 'https://example.test', + retentionStatus: 'none' as const, + thirdPartyStatus: 'no' as const, + internationalStatus: 'no' as const, + privacyOfficerName: '개인정보보호 담당', + privacyOfficerEmail: 'privacy@example.test', + } + expect(getDraftReview(facts, true)).toEqual([]) + }) + + it('requires a retention period only after the operator confirms retained personal data exists', () => { + const facts = { + ...initialFacts, + serviceName: '예시 서비스', + serviceUrl: 'https://example.test', + retentionStatus: 'applies' as const, + thirdPartyStatus: 'no' as const, + internationalStatus: 'no' as const, + privacyOfficerName: '개인정보보호 담당', + privacyOfficerEmail: 'privacy@example.test', + } + expect(getDraftReview(facts).map((finding) => finding.code)).toEqual(['retention_period']) + expect(getDraftReview({ ...facts, retentionPeriod: '회원 탈퇴 시까지' })).toEqual([]) + }) + + it('treats explicit no-transfer attestations as complete without inventing recipients', () => { + const facts = { + ...initialFacts, + serviceName: '예시 서비스', + serviceUrl: 'https://example.test', + retentionStatus: 'applies' as const, + retentionPeriod: '회원 탈퇴 시까지', + thirdPartyStatus: 'no' as const, + internationalStatus: 'no' as const, + privacyOfficerName: '개인정보보호 담당', + privacyOfficerEmail: 'privacy@example.test', + } + expect(getDraftReview(facts)).toEqual([]) + }) + + it('requires dependent transfer facts only when the operator confirms a transfer exists', () => { + const facts = { + ...initialFacts, + serviceName: '예시 서비스', + serviceUrl: 'https://example.test', + retentionStatus: 'applies' as const, + retentionPeriod: '회원 탈퇴 시까지', + thirdPartyStatus: 'yes' as const, + internationalStatus: 'yes' as const, + privacyOfficerName: '개인정보보호 담당', + privacyOfficerEmail: 'privacy@example.test', + } + expect(getDraftReview(facts).map((finding) => finding.code)).toEqual([ + 'third_party_recipient', + 'third_party_purpose', + 'international_country', + 'international_recipient', + ]) + }) + + it('normalizes whitespace-only authoring facts as unresolved', () => { + const facts = { + ...initialFacts, + serviceName: ' ', + serviceUrl: '\t', + retentionStatus: 'applies' as const, + retentionPeriod: '\n', + thirdPartyStatus: 'no' as const, + internationalStatus: 'no' as const, + privacyOfficerName: ' ', + privacyOfficerEmail: ' ', + } + expect(getDraftReview(facts).map((finding) => finding.code)).toEqual([ + 'service_name', + 'service_url', + 'retention_period', + 'privacy_contact_name', + 'privacy_contact_email', + ]) + }) + + it('blocks malformed or non-web service URLs after presence is established', () => { + const base = { + ...initialFacts, + serviceName: '예시 서비스', + retentionStatus: 'applies' as const, + retentionPeriod: '회원 탈퇴 시까지', + thirdPartyStatus: 'no' as const, + internationalStatus: 'no' as const, + privacyOfficerName: '개인정보보호 담당', + privacyOfficerEmail: 'privacy@example.test', + } + expect(getDraftReview({ ...base, serviceUrl: 'not a url' }).map((finding) => finding.code)).toEqual(['service_url_format']) + expect(getDraftReview({ ...base, serviceUrl: 'javascript:alert(1)' }).map((finding) => finding.code)).toEqual(['service_url_format']) + expect(getDraftReview({ ...base, serviceUrl: 'https://operator:secret@example.test' }).map((finding) => finding.code)).toEqual(['service_url_format']) + expect(getDraftReview({ ...base, serviceUrl: 'https://example.test/path' })).toEqual([]) + }) + + it('blocks malformed privacy contact email after presence is established', () => { + const base = { + ...initialFacts, + serviceName: '예시 서비스', + serviceUrl: 'https://example.test', + retentionStatus: 'applies' as const, + retentionPeriod: '회원 탈퇴 시까지', + thirdPartyStatus: 'no' as const, + internationalStatus: 'no' as const, + privacyOfficerName: '개인정보보호 담당', + } + expect(getDraftReview({ ...base, privacyOfficerEmail: 'not-an-email' }).map((finding) => finding.code)).toEqual(['privacy_contact_email_format']) + expect(getDraftReview({ ...base, privacyOfficerEmail: 'privacy@example.test' })).toEqual([]) + }) +}) diff --git a/src/policy.ts b/src/policy.ts new file mode 100644 index 0000000..6f34076 --- /dev/null +++ b/src/policy.ts @@ -0,0 +1,262 @@ +/** Operator-selected collection basis for one collection item; empty means the operator has not confirmed a basis yet. */ +export type CollectionMode = '' | '필수' | '선택' +/** Operator-attested transfer state; empty means the operator has not confirmed either outcome yet. */ +export type DisclosureStatus = '' | 'yes' | 'no' +/** Operator-attested retention state; empty means the operator has not confirmed either outcome yet. */ +export type RetentionStatus = '' | 'applies' | 'none' + +/** One authorable personal-data collection item with its operator-established mode, purpose, and path evidence. */ +export type PolicyItem = { + id: string + label: string + description: string + purpose: string + enabled: boolean + mode: CollectionMode + detail?: string +} + +/** Operator-authored non-collection facts for the seven-step workspace; blank means unresolved, never none. */ +export type DraftFacts = { + serviceName: string + serviceUrl: string + retentionStatus: RetentionStatus + retentionPeriod: string + thirdPartyStatus: DisclosureStatus + thirdPartyRecipient: string + thirdPartyPurpose: string + internationalStatus: DisclosureStatus + internationalCountry: string + internationalRecipient: string + privacyOfficerName: string + privacyOfficerEmail: string +} + +/** One product-defined authoring gap that blocks readiness until its owning step resolves it. */ +export type DraftFinding = { + code: string + step: number + label: string +} + +/** Fresh-workspace collection catalog; every item starts disabled with no inferred mode or purpose. */ +export const initialItems: PolicyItem[] = [ + { id: 'name', label: '이름', description: '서비스 이용자 식별', purpose: '', enabled: false, mode: '' }, + { id: 'email', label: '이메일 주소', description: '계정 식별, 로그인, 중요 고지 수신', purpose: '', enabled: false, mode: '' }, + { id: 'phone', label: '휴대전화 번호', description: '본인 확인, 알림 발송', purpose: '', enabled: false, mode: '' }, + { id: 'usage', label: '서비스 이용 기록', description: '접속 로그, 이용 내역, 클릭 기록 등', purpose: '', enabled: false, mode: '' }, + { id: 'ip', label: '접속 IP 주소', description: '보안, 부정 이용 방지', purpose: '', enabled: false, mode: '' }, + { id: 'cookie', label: '쿠키 및 유사 기술', description: '설정 유지, 통계 분석', purpose: '', enabled: false, mode: '' }, + { id: 'address', label: '주소', description: '배송, 청구서 발송', purpose: '', enabled: false, mode: '' }, + { id: 'payment', label: '결제 정보', description: '결제 처리, 환불 처리', purpose: '', enabled: false, mode: '' }, + { id: 'content', label: '게시물 및 문의 내용', description: '게시 기능, 고객 문의 처리', purpose: '', enabled: false, mode: '' }, +] + +/** Fresh-workspace fact state; every value starts blank so readiness fails closed until the operator establishes it. */ +export const initialFacts: DraftFacts = { + serviceName: '', + serviceUrl: '', + retentionStatus: '', + retentionPeriod: '', + thirdPartyStatus: '', + thirdPartyRecipient: '', + thirdPartyPurpose: '', + internationalStatus: '', + internationalCountry: '', + internationalRecipient: '', + privacyOfficerName: '', + privacyOfficerEmail: '', +} + +/** Ordered seven authoring responsibilities, from service information through the privacy contact. */ +export const steps = ['서비스 정보', '수집 항목', '처리 목적', '보유 기간', '제3자 제공', '국외 이전', '개인정보 보호 담당자'] + +/** Returns a canonical credential-free HTTP(S) service URL, or null when the address is not admissible. */ +function normalizeWebServiceUrl(value: string): string | null { + try { + const url = new URL(value) + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || !url.hostname || url.username || url.password) return null + const normalized = url.toString() + if (normalized.includes('?') || normalized.includes('#')) return null + return normalized + } catch { + return null + } +} + +/** Returns whether a service URL is an absolute HTTP(S) web location suitable for a buyer-facing policy target. */ +export function isWebServiceUrl(value: string) { + return normalizeWebServiceUrl(value) !== null +} + +/** Applies the minimal address-shape contract needed for a usable contact channel without claiming mailbox existence. */ +function isContactEmail(value: string) { + return /^[^\s@]+@[^\s@]+$/.test(value) +} + +/** Derives deterministic readiness findings from operator-confirmed collection facts and an explicit no-collection attestation. */ +export function getReview(items: PolicyItem[], noCollectionAttested = false) { + const enabled = items.filter((item) => item.enabled) + const blocking = enabled.filter((item) => !item.purpose.trim()) + const modeBlocking = enabled.filter((item) => !item.mode) + const pathBlocking = enabled.filter((item) => !item.detail?.trim()) + const selectionMissing = enabled.length === 0 && !noCollectionAttested + const collectionContradiction = noCollectionAttested && enabled.length > 0 + const blockingCount = blocking.length + modeBlocking.length + pathBlocking.length + (selectionMissing ? 1 : 0) + (collectionContradiction ? 1 : 0) + const recommended = enabled.filter((item) => item.mode === '선택' && item.id !== 'usage') + return { enabled, blocking, modeBlocking, pathBlocking, selectionMissing, collectionContradiction, noCollectionAttested, blockingCount, recommended } +} + +/** Derives non-collection authoring findings without inferring retention state from collection state. */ +export function getDraftReview(facts: DraftFacts, _noCollectionAttested = false): DraftFinding[] { + const findings: DraftFinding[] = [] + /** Appends one stable finding only when its owning operator-authored value is blank. */ + const addWhenBlank = (value: string, code: string, step: number, label: string) => { + if (!value.trim()) findings.push({ code, step, label }) + } + + addWhenBlank(facts.serviceName, 'service_name', 1, '서비스 이름') + const serviceUrl = facts.serviceUrl.trim() + if (!serviceUrl) findings.push({ code: 'service_url', step: 1, label: '서비스 URL' }) + else if (!isWebServiceUrl(serviceUrl)) findings.push({ code: 'service_url_format', step: 1, label: '서비스 URL 형식' }) + + if (!facts.retentionStatus) { + findings.push({ code: 'retention_status', step: 4, label: '개인정보 보유 여부' }) + } else if (facts.retentionStatus === 'applies') { + addWhenBlank(facts.retentionPeriod, 'retention_period', 4, '보유 기간') + } + + if (!facts.thirdPartyStatus) { + findings.push({ code: 'third_party_status', step: 5, label: '제3자 제공 여부' }) + } else if (facts.thirdPartyStatus === 'yes') { + addWhenBlank(facts.thirdPartyRecipient, 'third_party_recipient', 5, '제3자 제공받는 자') + addWhenBlank(facts.thirdPartyPurpose, 'third_party_purpose', 5, '제3자 제공 목적') + } + + if (!facts.internationalStatus) { + findings.push({ code: 'international_status', step: 6, label: '국외 이전 여부' }) + } else if (facts.internationalStatus === 'yes') { + addWhenBlank(facts.internationalCountry, 'international_country', 6, '이전 국가') + addWhenBlank(facts.internationalRecipient, 'international_recipient', 6, '국외 수령자') + } + + addWhenBlank(facts.privacyOfficerName, 'privacy_contact_name', 7, '개인정보 보호 담당자') + const privacyOfficerEmail = facts.privacyOfficerEmail.trim() + if (!privacyOfficerEmail) findings.push({ code: 'privacy_contact_email', step: 7, label: '개인정보 보호 연락 이메일' }) + else if (!isContactEmail(privacyOfficerEmail)) findings.push({ code: 'privacy_contact_email_format', step: 7, label: '개인정보 보호 연락 이메일 형식' }) + + return findings +} + +/** Derives completed authoring responsibilities from the same fail-closed facts used by readiness review. */ +export function getCompletedSteps(items: PolicyItem[], noCollectionAttested: boolean, facts: DraftFacts) { + const collectionReview = getReview(items, noCollectionAttested) + const draftBlockedSteps = new Set(getDraftReview(facts, noCollectionAttested).map((finding) => finding.step)) + const completed = new Set() + + if (!draftBlockedSteps.has(1)) completed.add(1) + + const collectionEstablished = !collectionReview.selectionMissing && !collectionReview.collectionContradiction + if (collectionEstablished && collectionReview.modeBlocking.length === 0 && collectionReview.pathBlocking.length === 0) completed.add(2) + if (collectionEstablished && collectionReview.blocking.length === 0) completed.add(3) + + for (const step of [4, 5, 6, 7]) { + if (!draftBlockedSteps.has(step)) completed.add(step) + } + + return completed +} + + +/** Versioned local export of operator-authored policy facts and deterministic readiness evidence. */ +export type PolicyDraftExport = { + schema_version: 1 + document_state: 'incomplete' | 'review_ready' + policy_facts: { + service_profile: { + service_name: string | null + service_url: string | null + } + no_collection_attested: boolean + collection_items: Array<{ + collection_item_key: string + collection_item_label: string + collection_mode: Exclude | null + collection_path: string | null + processing_purpose: string | null + }> + retention: { + retention_status: Exclude | null + retention_period: string | null + } + third_party_transfer: { + transfer_status: Exclude | null + recipient_name: string | null + transfer_purpose: string | null + } + international_transfer: { + transfer_status: Exclude | null + destination_country: string | null + recipient_name: string | null + } + privacy_contact: { + contact_name: string | null + contact_email: string | null + } + } + review_finding_codes: string[] +} + +/** Creates a deterministic draft export without network access, inferred facts, or credential-bearing service URLs. */ +export function createPolicyExport(items: PolicyItem[], noCollectionAttested: boolean, facts: DraftFacts): PolicyDraftExport { + /** Normalizes optional human-entered text without inventing a non-empty fact. */ + const trimOrNull = (value: string) => value.trim() || null + const collectionReview = getReview(items, noCollectionAttested) + const reviewFindingCodes = [ + ...(collectionReview.selectionMissing ? ['collection_selection'] : []), + ...(collectionReview.collectionContradiction ? ['collection_contradiction'] : []), + ...collectionReview.modeBlocking.map((item) => `collection_mode:${item.id}`), + ...collectionReview.pathBlocking.map((item) => `collection_path:${item.id}`), + ...collectionReview.blocking.map((item) => `processing_purpose:${item.id}`), + ...getDraftReview(facts, noCollectionAttested).map((finding) => finding.code), + ] + const serviceUrl = facts.serviceUrl.trim() + + return { + schema_version: 1, + document_state: reviewFindingCodes.length === 0 ? 'review_ready' : 'incomplete', + policy_facts: { + service_profile: { + service_name: trimOrNull(facts.serviceName), + service_url: normalizeWebServiceUrl(serviceUrl), + }, + no_collection_attested: noCollectionAttested, + collection_items: collectionReview.enabled.map((item) => ({ + collection_item_key: item.id, + collection_item_label: item.label, + collection_mode: item.mode || null, + collection_path: trimOrNull(item.detail ?? ''), + processing_purpose: trimOrNull(item.purpose), + })), + retention: { + retention_status: facts.retentionStatus || null, + retention_period: facts.retentionStatus === 'applies' ? trimOrNull(facts.retentionPeriod) : null, + }, + third_party_transfer: { + transfer_status: facts.thirdPartyStatus || null, + recipient_name: facts.thirdPartyStatus === 'yes' ? trimOrNull(facts.thirdPartyRecipient) : null, + transfer_purpose: facts.thirdPartyStatus === 'yes' ? trimOrNull(facts.thirdPartyPurpose) : null, + }, + international_transfer: { + transfer_status: facts.internationalStatus || null, + destination_country: facts.internationalStatus === 'yes' ? trimOrNull(facts.internationalCountry) : null, + recipient_name: facts.internationalStatus === 'yes' ? trimOrNull(facts.internationalRecipient) : null, + }, + privacy_contact: { + contact_name: trimOrNull(facts.privacyOfficerName), + contact_email: trimOrNull(facts.privacyOfficerEmail), + }, + }, + review_finding_codes: reviewFindingCodes, + } +} diff --git a/src/postgres-concurrency-contract.test.ts b/src/postgres-concurrency-contract.test.ts new file mode 100644 index 0000000..21af59d --- /dev/null +++ b/src/postgres-concurrency-contract.test.ts @@ -0,0 +1,29 @@ +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const workflowPath = fileURLToPath(new URL('../.github/workflows/ci.yml', import.meta.url)) +const concurrencyPath = fileURLToPath(new URL('../db/tests/policy_revision_concurrency.sh', import.meta.url)) + +const workflowSource = readFileSync(workflowPath, 'utf8') +const concurrencyTest = existsSync(concurrencyPath) ? readFileSync(concurrencyPath, 'utf8') : '' + +describe('PostgreSQL concurrent writer evidence contract', () => { + it('observes real PostgreSQL lock waits without timing-based transaction sleeps', () => { + expect(concurrencyTest).toContain('mkfifo') + expect(concurrencyTest).toContain("wait_event_type = 'Lock'") + expect(concurrencyTest).toContain('policyweave_parent_writer') + expect(concurrencyTest).toContain('policyweave_item_writer') + expect(concurrencyTest).toContain('statement_timeout=15000') + expect(concurrencyTest).not.toContain('pg_sleep') + }) + + it('proves competing facts fail closed and same-item UPSERTs converge', () => { + expect(concurrencyTest).toContain('no-collection confirmation conflicts with collection items') + expect(concurrencyTest).toMatch(/count\(\*\)[\s\S]*Concurrent contact email/) + expect(concurrencyTest).toMatch( + /stored_mode[\s\S]*stored_path[\s\S]*if[\s\S]*stored_mode is distinct from 'optional'[\s\S]*stored_path is distinct from 'Account profile form'/i, + ) + expect(workflowSource).toContain('run: sh db/tests/policy_revision_concurrency.sh') + }) +}) diff --git a/src/postgres-restore-contract.test.ts b/src/postgres-restore-contract.test.ts new file mode 100644 index 0000000..e043d70 --- /dev/null +++ b/src/postgres-restore-contract.test.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const workflowPath = fileURLToPath(new URL('../.github/workflows/ci.yml', import.meta.url)) +const restorePath = fileURLToPath(new URL('../db/tests/policy_revision_restore.sh', import.meta.url)) + +const workflowSource = readFileSync(workflowPath, 'utf8') +const restoreTest = existsSync(restorePath) ? readFileSync(restorePath, 'utf8') : '' + +describe('PostgreSQL restart and restore evidence contract', () => { + it('restarts the digest-pinned PostgreSQL service and reconnects without fabricated delays', () => { + expect(restoreTest).toContain('docker restart') + expect(restoreTest).toContain('pg_isready') + expect(restoreTest).toContain('checkpoint') + expect(restoreTest).not.toContain('pg_sleep') + expect(restoreTest).not.toContain('sleep 5') + }) + + it('restores a custom dump after rollback and keeps collection independent from retention', () => { + expect(restoreTest).toContain('--format=custom') + expect(restoreTest).toContain('0001_policy_revision.down.sql') + expect(restoreTest).toContain('pg_restore') + expect(restoreTest).toContain("no_collection_confirmed = true") + expect(restoreTest).toContain("retention_status = 'none'") + expect(restoreTest).toContain("retention_status = 'applies'") + expect(restoreTest).toMatch( + /stored_item_mode[\s\S]*stored_item_path[\s\S]*stored_item_mode is distinct from 'required'[\s\S]*stored_item_path is distinct from 'Account registration form'/i, + ) + expect(restoreTest).toContain("stored_service_url is distinct from 'https://restore.example.test'") + expect(restoreTest).toContain('collection_without_retention_count <> 1') + expect(restoreTest).toContain( + "60000000-0000-4000-8000-000000000003', '50000000-0000-4000-8000-000000000001', 3, 'none'", + ) + expect(restoreTest).toMatch( + /upsert_collection_item\(\s*'60000000-0000-4000-8000-000000000003',\s*'support_email'/, + ) + expect(restoreTest).toMatch( + /revision\.policy_revision_id = '60000000-0000-4000-8000-000000000003'[\s\S]*?revision\.no_collection_confirmed = false[\s\S]*?revision\.retention_status = 'none'[\s\S]*?item\.collection_item_key = 'support_email'/, + ) + expect(restoreTest).toContain('no-collection confirmation conflicts with collection items') + expect(workflowSource).toContain('run: sh db/tests/policy_revision_restore.sh') + }) + + it('commits applies retention only after the required rule exists in the same transaction', () => { + const appliesIndex = restoreTest.indexOf("1, 'applies'") + const beginIndex = restoreTest.lastIndexOf('begin;', appliesIndex) + const ruleIndex = restoreTest.indexOf('insert into retention_rule', appliesIndex) + const commitIndex = restoreTest.indexOf('commit;', Math.max(appliesIndex, ruleIndex)) + expect(appliesIndex).toBeGreaterThan(-1) + expect(beginIndex).toBeGreaterThan(-1) + expect(beginIndex).toBeLessThan(appliesIndex) + expect(ruleIndex).toBeGreaterThan(appliesIndex) + expect(commitIndex).toBeGreaterThan(ruleIndex) + }) +}) diff --git a/src/postgres-runtime-contract.test.ts b/src/postgres-runtime-contract.test.ts new file mode 100644 index 0000000..f70b7e4 --- /dev/null +++ b/src/postgres-runtime-contract.test.ts @@ -0,0 +1,55 @@ +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const workflowPath = fileURLToPath(new URL('../.github/workflows/ci.yml', import.meta.url)) +const runtimeTestPath = fileURLToPath(new URL('../db/tests/policy_revision_runtime.sh', import.meta.url)) +const rollbackPath = fileURLToPath(new URL('../db/migrations/0001_policy_revision.down.sql', import.meta.url)) + +const workflowSource = readFileSync(workflowPath, 'utf8') +const runtimeTest = existsSync(runtimeTestPath) ? readFileSync(runtimeTestPath, 'utf8') : '' +const rollbackSql = existsSync(rollbackPath) ? readFileSync(rollbackPath, 'utf8') : '' + +describe('PostgreSQL runtime evidence contract', () => { + it('runs the migration against an immutable PostgreSQL 18 service', () => { + expect(workflowSource).toMatch(/services:\s*\n\s+postgres:/) + expect(workflowSource).toContain('postgres@sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2') + expect(workflowSource).toMatch(/pg_isready -U policyweave_ci -d policyweave_test/) + expect(workflowSource).toMatch(/run: sh db\/tests\/policy_revision_runtime\.sh/) + }) + + it('exercises the migration, rollback, idempotency, and deferred failures', () => { + expect(runtimeTest).toContain('0001_policy_revision.sql') + expect(runtimeTest).toContain('0001_policy_revision.down.sql') + expect(runtimeTest).toMatch(/server_version_num[^\n]*180000/) + expect(runtimeTest).toMatch(/upsert_collection_item[\s\S]*count\(\*\)/) + expect(runtimeTest).toContain("stored_mode <> 'optional'") + expect(runtimeTest).toContain("stored_path <> 'Account profile form'") + expect(runtimeTest).toContain( + "expect_failure no_collection_conflict 'no-collection confirmation conflicts with collection items'", + ) + expect(runtimeTest).toContain( + "expect_failure retention_rule_missing 'retention status applies requires a retention rule'", + ) + expect(runtimeTest).toContain( + "expect_failure revision_owner_change 'revision-owned facts cannot move between policy revisions'", + ) + expect(runtimeTest).toMatch(/grep -F -- "\$expected_message" "\$failure_log"/) + }) + + it('removes every migration-owned object during rollback', () => { + for (const objectName of [ + 'upsert_collection_item', + 'enforce_policy_revision_facts', + 'processing_purpose', + 'retention_rule', + 'collection_item', + 'service_profile', + 'policy_revision', + 'collection_fact_mode', + 'retention_fact_status', + ]) { + expect(rollbackSql).toContain(objectName) + } + }) +}) diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..b1d5df3 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,153 @@ +:root { + font-family: Pretendard, "Noto Sans KR", system-ui, sans-serif; + color: #202622; + background: #f8f9f7; + font-synthesis: none; + --green: #174f35; + --green-soft: #eaf1ec; + --line: #d9ded9; + --muted: #656d67; + --paper: #fffefb; + --amber: #a85b00; +} +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; } +button, input, select, textarea { font: inherit; } +button { cursor: pointer; } +:focus-visible { outline: 3px solid var(--green); outline-offset: 2px; } +.topbar { + min-height: 58px; + display: flex; + align-items: center; + gap: 18px; + padding: 0 20px; + border-bottom: 1px solid var(--line); + background: white; +} +.brand { font: 700 27px Georgia, serif; color: var(--green); text-decoration: none; margin-right: 10px; } +.document-name { display: flex; align-items: center; gap: 7px; background: none; border: 0; font-weight: 700; } +.status { background: var(--green-soft); color: var(--green); padding: 5px 10px; border-radius: 4px; font-size: 12px; } +.version, .save-state { font-size: 12px; color: var(--muted); } +.save-state { margin-left: auto; display: flex; align-items: center; gap: 5px; } +.outline, .primary { + min-height: 38px; + padding: 0 16px; + border-radius: 4px; + border: 1px solid #cfd5d0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + background: white; + color: #29302b; +} +.primary { background: var(--green); color: white; border-color: var(--green); font-weight: 700; } +.primary:disabled { background: #aeb8b1; border-color: #aeb8b1; cursor: not-allowed; } +.workspace { + display: grid; + grid-template-columns: 240px minmax(520px, 1fr) minmax(540px, 1.05fr); + height: calc(100vh - 152px); + min-height: 630px; +} +.rail, .form-panel, .preview { min-width: 0; background: white; } +.rail { border-right: 1px solid var(--line); padding: 22px 14px 14px; display: flex; flex-direction: column; } +.progress-copy { display: flex; justify-content: space-between; font-size: 12px; } +.progress-copy span { color: var(--muted); } +.progress { height: 7px; background: #eceeec; border-radius: 5px; margin: 15px 0 18px; overflow: hidden; } +.progress i { display: block; height: 100%; background: var(--green); } +.rail ol { padding: 0; margin: 0; list-style: none; } +.rail li { position: relative; } +.rail li:not(:last-child)::after { content: ""; position: absolute; left: 13px; top: 40px; height: 24px; border-left: 1px solid var(--line); } +.rail li button { width: 100%; display: flex; gap: 13px; align-items: flex-start; text-align: left; border: 0; background: transparent; padding: 12px 5px; border-radius: 4px; } +.rail li.active button { background: #f4f7f4; outline: 1px solid #cad5cc; } +.step-number { width: 26px; height: 26px; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; border: 1px solid #cbd1cc; border-radius: 50%; font-size: 12px; } +.done .step-number, .active .step-number { background: var(--green); border-color: var(--green); color: white; } +.rail b { display: block; font-size: 13px; margin-top: 3px; } +.rail small { display: block; color: var(--muted); font-size: 11px; margin-top: 5px; } +.rail .full { margin-top: auto; width: 100%; } +.form-panel { overflow: auto; padding: 24px 20px; border-right: 1px solid var(--line); } +.section-head h1 { font-size: 20px; margin: 0 0 7px; } +.section-head p { font-size: 12px; color: var(--muted); margin: 0 0 17px; } +.notice { border: 1px solid #cfd9e3; background: #f7fafc; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; font-size: 12px; } +.notice span { color: var(--muted); } +.form-panel h2 { font-size: 13px; margin: 20px 0 9px; } +.table-head, .item-row { display: grid; grid-template-columns: 1.05fr 1.55fr 92px; gap: 14px; align-items: center; } +.table-head { border-bottom: 1px solid var(--line); padding: 0 8px 8px; color: #555f58; font-size: 11px; } +.item { border-bottom: 1px solid #e4e7e4; } +.item-row { min-height: 42px; padding: 6px 7px; font-size: 12px; } +.check-label { display: flex; align-items: center; gap: 8px; } +.check-label input { position: absolute; opacity: 0; } +.box { width: 17px; height: 17px; border: 1px solid #b8beba; display: flex; align-items: center; justify-content: center; color: transparent; } +.check-label input:focus-visible + .box { outline: 3px solid var(--green); outline-offset: 2px; } +.check-label input:checked + .box { background: var(--green); border-color: var(--green); color: white; } +.item-row > span { color: #5f6862; } +.select-wrap { position: relative; } +.select-wrap select { width: 100%; height: 32px; padding: 0 28px 0 10px; border: 1px solid #d1d6d2; background: white; appearance: none; } +.select-wrap svg { position: absolute; right: 8px; top: 9px; pointer-events: none; } +.conditional { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 0 7px 10px; padding: 10px; background: #f7f8f7; border: 1px solid #e1e4e1; } +.conditional label { font-size: 10px; color: var(--muted); } +.conditional input { display: block; width: 100%; height: 32px; margin-top: 5px; border: 1px solid #d5d9d6; padding: 0 8px; } +.field-stack { display: grid; gap: 18px; margin-top: 22px; } +.field-stack label { display: grid; gap: 7px; font-size: 13px; font-weight: 700; } +.field-stack textarea { min-height: 88px; resize: vertical; border: 1px solid #cfd5d0; padding: 10px; line-height: 1.5; } +.form-actions { display: flex; justify-content: space-between; margin-top: 18px; } +.preview { padding: 14px 20px 18px; overflow: auto; background: #fafbfa; } +.preview-title, .meta { display: flex; align-items: center; gap: 12px; } +.preview-title { justify-content: space-between; } +.preview-title h2 { font-size: 14px; } +.meta { font-size: 11px; color: var(--muted); margin: 8px 0 12px; } +.meta b, .warn-tag, .ok-tag { background: #eef1ee; padding: 5px 8px; border-radius: 4px; font-weight: 500; } +.warn-tag { color: var(--amber); background: #fff4e7; border: 1px solid #f3d5ae; } +.ok-tag { color: var(--green); background: var(--green-soft); } +.paper { background: var(--paper); border: 1px solid #d6d7d4; padding: 23px 27px; min-height: 500px; font-family: "Noto Serif KR", Batang, serif; box-shadow: 0 2px 5px #00000008; } +.paper h2 { text-align: center; font-size: 20px; margin: 0 0 20px; } +.paper h3 { font-size: 14px; margin: 22px 0 8px; } +.paper p { font-size: 11px; line-height: 1.85; } +.paper table { border-collapse: collapse; width: 100%; font-size: 10px; margin: 14px 0; } +.paper th, .paper td { border: 1px solid #d4d4d0; padding: 7px; text-align: left; } +.paper th { background: #f5f4f0; } +.missing { color: #9b4e00; background: #fff8ee; } +.document-warning { display: flex; align-items: flex-start; gap: 10px; border: 1px solid #edc791; background: #fff9ef; color: #874c08; padding: 11px; font-family: Pretendard, sans-serif; font-size: 11px; } +.document-warning span { display: flex; flex: 1; flex-direction: column; gap: 3px; } +.document-warning .outline { flex: 0 0 auto; } +.wide { width: 100%; margin-top: 14px; } +.legal-note { display: block; text-align: center; color: var(--muted); margin-top: 8px; } +.review-bar { min-height: 94px; border-top: 1px solid var(--line); background: white; padding: 12px 20px; display: grid; grid-template-columns: 230px 170px 170px minmax(260px, 1fr); gap: 15px; align-items: center; position: relative; } +.review-bar > div:first-child { display: flex; flex-direction: column; gap: 5px; } +.review-bar small { color: var(--muted); } +.review-stat { border-left: 1px solid var(--line); padding-left: 18px; display: flex; gap: 12px; align-items: center; color: var(--green); } +.review-stat span { font-size: 11px; color: var(--muted); } +.review-stat b { display: block; font-size: 20px; color: #222; } +.review-stat.blocking { color: var(--amber); } +.publish { height: 44px; } +.review-bar output { position: absolute; right: 22px; bottom: 2px; font-size: 10px; color: var(--amber); } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } + +@media (max-width: 1300px) { + .workspace { grid-template-columns: 210px minmax(0, 1fr); height: auto; min-height: 630px; } + .preview { display: block; grid-column: 1 / -1; border-top: 1px solid var(--line); max-height: 720px; } + .review-bar { grid-template-columns: 1fr 150px 150px 240px; } +} + +@media (max-width: 720px) { + .topbar { height: auto; flex-wrap: wrap; padding: 10px 14px; gap: 9px; } + .version, .save-state { display: none; } + .workspace { display: block; height: auto; min-height: 0; } + .rail { border-right: 0; border-bottom: 1px solid var(--line); padding: 12px; } + .rail ol { display: flex; overflow-x: auto; gap: 6px; } + .rail li { min-width: 145px; } + .rail li::after, .rail .full { display: none; } + .form-panel { border: 0; padding: 18px 14px; } + .preview { display: block; padding: 18px 14px; border-top: 1px solid var(--line); max-height: none; } + .table-head { display: none; } + .item-row { grid-template-columns: 1fr 78px; } + .item-row > span { grid-row: 2; grid-column: 1 / -1; padding-left: 25px; } + .conditional { grid-template-columns: 1fr; } + .review-bar { grid-template-columns: 1fr 1fr; padding: 12px 14px; } + .review-bar > div:first-child, .publish { grid-column: 1 / -1; } + .review-bar output { position: static; grid-column: 1 / -1; text-align: right; } + .document-name { max-width: 190px; overflow: hidden; white-space: nowrap; } + .brand { font-size: 23px; } + .paper { padding: 18px 14px; } + .document-warning { flex-wrap: wrap; } +} diff --git a/src/styles.test.ts b/src/styles.test.ts new file mode 100644 index 0000000..0aeb144 --- /dev/null +++ b/src/styles.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(new URL('./styles.css', import.meta.url), 'utf8') + +const mediaPatterns = { + 1300: /@media \(max-width: 1300px\) \{([\s\S]*?)(?=\n\}\n(?:\n@media|$))/, + 720: /@media \(max-width: 720px\) \{([\s\S]*?)(?=\n\}\n(?:\n@media|$))/, +} as const + +function mediaBlock(maxWidth: keyof typeof mediaPatterns) { + const match = css.match(mediaPatterns[maxWidth]) + if (!match) throw new Error(`Missing max-width ${maxWidth}px media block`) + return match[1] +} + +function relativeLuminance(hex: string) { + const channels = hex.slice(1).match(/.{2}/g)?.map((channel) => Number.parseInt(channel, 16) / 255) + if (!channels || channels.length !== 3) throw new Error(`Invalid RGB hex: ${hex}`) + const linear = channels.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4) + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] +} + +function contrastRatio(left: string, right: string) { + const luminances = [relativeLuminance(left), relativeLuminance(right)].sort((a, b) => b - a) + return (luminances[0] + 0.05) / (luminances[1] + 0.05) +} + +describe('responsive review workspace CSS contract', () => { + it('keeps the policy preview available through tablet widths', () => { + const tablet = mediaBlock(1300) + expect(tablet).toContain('.preview { display: block;') + }) + + it('places mobile publish output in normal flow across the full review bar', () => { + const mobile = mediaBlock(720) + expect(mobile).toContain('.review-bar output { position: static; grid-column: 1 / -1;') + }) + + it('uses a focus token that preserves at least 3:1 contrast against white', () => { + const green = css.match(/--green:\s*(#[0-9a-fA-F]{6});/)?.[1] + expect(green).toBeDefined() + expect(contrastRatio(green!, '#ffffff')).toBeGreaterThanOrEqual(3) + expect(css).toContain(':focus-visible { outline: 3px solid var(--green); outline-offset: 2px; }') + expect(css).toContain('.check-label input:focus-visible + .box { outline: 3px solid var(--green); outline-offset: 2px; }') + }) + + it('keeps muted small text above 4.5:1 on every authored surface', () => { + const muted = css.match(/--muted:\s*(#[0-9a-fA-F]{6});/)?.[1] + expect(muted).toBeDefined() + for (const background of ['#ffffff', '#f4f7f4', '#f7f8f7', '#eef1ee']) { + expect(contrastRatio(muted!, background)).toBeGreaterThanOrEqual(4.5) + } + }) +}) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/workflow-contract.test.ts b/src/workflow-contract.test.ts new file mode 100644 index 0000000..ee84add --- /dev/null +++ b/src/workflow-contract.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const workflowPath = fileURLToPath(new URL('../.github/workflows/ci.yml', import.meta.url)) +const workflowSource = readFileSync(workflowPath, 'utf8') + +describe('repository CI concurrency contract', () => { + it('cancels stale heads within the same pull request or branch', () => { + expect(workflowSource).toMatch(/concurrency:\s*\n\s+group:\s*policyweave-ci-\$\{\{ github\.workflow \}\}-\$\{\{ github\.event\.pull_request\.number \|\| github\.ref \}\}/) + expect(workflowSource).toMatch(/cancel-in-progress:\s*true/) + }) +}) + +describe('repository dependency evidence contract', () => { + it('publishes a CycloneDX SBOM from the exact installed lock graph', () => { + expect(workflowSource).toMatch( + /name:\s*Generate exact-head dependency SBOM[\s\S]*npm sbom --sbom-format cyclonedx > dependency-evidence\/policyweave-dependency-sbom\.cdx\.json/, + ) + expect(workflowSource).toMatch( + /name:\s*policyweave-dependency-sbom[\s\S]*path:\s*dependency-evidence\/policyweave-dependency-sbom\.cdx\.json/, + ) + expect(workflowSource).toMatch( + /name:\s*policyweave-browser-evidence[\s\S]*path:\s*test-results/, + ) + }) +}) diff --git a/tests/e2e/authoring.spec.ts b/tests/e2e/authoring.spec.ts new file mode 100644 index 0000000..891dd70 --- /dev/null +++ b/tests/e2e/authoring.spec.ts @@ -0,0 +1,278 @@ +import AxeBuilder from '@axe-core/playwright' +import { expect, test, type Download, type Page } from '@playwright/test' + +async function readDownload(download: Download) { + const downloadStream = await download.createReadStream() + downloadStream.setEncoding('utf8') + let downloadContent = '' + for await (const contentChunk of downloadStream) downloadContent += contentChunk + return downloadContent +} + +async function installDownloadAudit(page: Page) { + await page.addInitScript(() => { + const audit = { created: [] as Array<{ url: string; type: string }>, revoked: [] as string[] } + const createObjectUrl = URL.createObjectURL.bind(URL) + const revokeObjectUrl = URL.revokeObjectURL.bind(URL) + Object.defineProperty(window, '__policyweaveDownloadAudit', { value: audit }) + URL.createObjectURL = (object) => { + const url = createObjectUrl(object) + audit.created.push({ url, type: object instanceof Blob ? object.type : '' }) + return url + } + URL.revokeObjectURL = (url) => { + audit.revoked.push(url) + revokeObjectUrl(url) + } + }) +} + +test('renders a truthful responsive initial workspace without serious accessibility violations', async ({ page }, testInfo) => { + await page.goto('/') + + await expect(page.getByRole('heading', { level: 1 })).toHaveText('1. 서비스 정보') + await expect(page.getByText('0/7 완료')).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('initial-workspace.png'), fullPage: true, animations: 'disabled' }) + + const viewportOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(viewportOverflow).toBeLessThanOrEqual(0) + + const accessibility = await new AxeBuilder({ page }).analyze() + expect(accessibility.violations.filter((violation) => ['serious', 'critical'].includes(violation.impact ?? ''))).toEqual([]) +}) + +test('preserves keyboard context through the explicit no-collection path', async ({ page }) => { + await page.goto('/') + + const collectionStep = page.getByRole('button', { name: /수집 항목/ }).first() + await collectionStep.focus() + await page.keyboard.press('Enter') + const collectionHeading = page.getByRole('heading', { level: 1, name: '2. 수집 항목' }) + await expect(collectionHeading).toBeFocused() + const headingBounds = await collectionHeading.boundingBox() + expect(headingBounds).not.toBeNull() + expect(headingBounds!.y).toBeGreaterThanOrEqual(0) + expect(headingBounds!.y + headingBounds!.height).toBeLessThanOrEqual(page.viewportSize()!.height) + + const noCollection = page.getByRole('checkbox', { name: '개인정보를 수집하지 않음으로 확인' }) + await noCollection.focus() + await page.keyboard.press('Space') + await expect(noCollection).toBeChecked() + await expect(page.getByText('2/7 완료')).toBeVisible() + + const nextStep = page.getByRole('button', { name: '다음 단계' }) + await nextStep.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('heading', { level: 1, name: '3. 처리 목적' })).toBeFocused() + await expect(page.getByText('개인정보를 수집하지 않음으로 확인되었습니다.')).toBeVisible() +}) + +test('keeps the owning step heading visible after keyboard navigation from a review warning', async ({ page }) => { + await page.goto('/') + + const serviceWarning = page + .locator('.document-warning') + .filter({ hasText: '서비스 이름 확인이 필요합니다.' }) + .getByRole('button', { name: '서비스 정보 확인', exact: true }) + await serviceWarning.scrollIntoViewIfNeeded() + await serviceWarning.focus() + await page.keyboard.press('Enter') + + const serviceHeading = page.getByRole('heading', { level: 1, name: '1. 서비스 정보' }) + await expect(serviceHeading).toBeFocused() + const headingBounds = await serviceHeading.boundingBox() + expect(headingBounds).not.toBeNull() + expect(headingBounds!.y).toBeGreaterThanOrEqual(0) + expect(headingBounds!.y + headingBounds!.height).toBeLessThanOrEqual(page.viewportSize()!.height) +}) + +test('invalidates stale retention evidence through responsive browser transitions', async ({ page }) => { + await page.goto('/') + + const retentionStep = page.locator('.rail').getByRole('button', { name: /보유 기간/ }) + await retentionStep.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('heading', { level: 1, name: '4. 보유 기간' })).toBeFocused() + + const retentionStatus = page.getByLabel('개인정보 보유 여부') + await retentionStatus.selectOption('applies') + const retentionPeriod = page.getByLabel('대표 보유 기간 또는 종료 조건') + await retentionPeriod.fill('회원 탈퇴 시까지') + + const retentionRailItem = page.locator('.rail li').filter({ hasText: '보유 기간' }) + await expect(retentionRailItem).toHaveClass(/done/) + await expect(page.locator('.paper').getByText('회원 탈퇴 시까지', { exact: true })).toBeVisible() + + await retentionStatus.selectOption('none') + await expect(retentionPeriod).toHaveCount(0) + await expect(retentionRailItem).toHaveClass(/done/) + await expect(page.locator('.paper').getByText('보유하는 개인정보 없음으로 확인되었습니다.', { exact: true })).toBeVisible() + + await retentionStatus.selectOption('applies') + const renewedRetentionPeriod = page.getByLabel('대표 보유 기간 또는 종료 조건') + await expect(renewedRetentionPeriod).toHaveValue('') + await expect(retentionRailItem).not.toHaveClass(/done/) + await expect(page.locator('.paper').getByText('보유 기간을 확인해야 합니다.', { exact: true })).toBeVisible() +}) + +test('reflows the core authoring flow at an effective 200% browser zoom', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'Browser zoom reflow is measured from the desktop viewport.') + + const viewport = page.viewportSize() + expect(viewport).not.toBeNull() + await page.setViewportSize({ width: Math.floor(viewport!.width / 2), height: Math.floor(viewport!.height / 2) }) + await page.goto('/') + + const viewportOverflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ) + expect(viewportOverflow).toBeLessThanOrEqual(0) + + await expect(page.getByRole('heading', { level: 1, name: '1. 서비스 정보' })).toBeVisible() + const nextStep = page.getByRole('button', { name: '다음 단계' }) + await nextStep.scrollIntoViewIfNeeded() + await nextStep.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('heading', { level: 1, name: '2. 수집 항목' })).toBeFocused() +}) + +test('downloads a versioned policy draft with real browser payload semantics', async ({ page }) => { + await page.goto('/') + await page.getByLabel('서비스 이름').fill('Buyer Portal') + + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: /JSON 내보내기/ }).click() + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe('policyweave-draft.json') + const downloadContent = await readDownload(download) + + const exportedDraft = JSON.parse(downloadContent) + expect(exportedDraft).toMatchObject({ + schema_version: 1, + document_state: 'incomplete', + policy_facts: { + service_profile: { + service_name: 'Buyer Portal', + service_url: null, + }, + }, + }) + expect(exportedDraft.review_finding_codes).toEqual(expect.arrayContaining(['service_url', 'collection_selection'])) +}) + +test('keeps keyboard exports byte-stable and revokes every JSON object URL', async ({ page }) => { + await installDownloadAudit(page) + await page.goto('/') + await page.getByLabel('서비스 이름').fill('Buyer Portal') + + const exportButton = page.getByRole('button', { name: /JSON 내보내기/ }) + const exportedBytes: string[] = [] + for (let exportAttempt = 0; exportAttempt < 2; exportAttempt += 1) { + const downloadPromise = page.waitForEvent('download') + await exportButton.focus() + await page.keyboard.press('Enter') + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('policyweave-draft.json') + exportedBytes.push(await readDownload(download)) + } + + expect(exportedBytes[1]).toBe(exportedBytes[0]) + await expect.poll(() => page.evaluate(() => { + const audit = (window as typeof window & { __policyweaveDownloadAudit: { created: Array<{ url: string; type: string }>; revoked: string[] } }).__policyweaveDownloadAudit + return { created: audit.created.length, revoked: audit.revoked.length } + })).toEqual({ created: 2, revoked: 2 }) + const downloadAudit = await page.evaluate(() => (window as typeof window & { + __policyweaveDownloadAudit: { created: Array<{ url: string; type: string }>; revoked: string[] } + }).__policyweaveDownloadAudit) + expect(downloadAudit.created.map(({ type }) => type)).toEqual(['application/json', 'application/json']) + expect(new Set(downloadAudit.created.map(({ url }) => url)).size).toBe(2) + expect(downloadAudit.revoked).toEqual(downloadAudit.created.map(({ url }) => url)) +}) + +test('reports a download activation failure and revokes its JSON object URL', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'One browser profile proves the activation-error lifecycle.') + + await installDownloadAudit(page) + await page.addInitScript(() => { + const click = HTMLAnchorElement.prototype.click + HTMLAnchorElement.prototype.click = function () { + if (this.download === 'policyweave-draft.json') throw new Error('simulated download activation failure') + click.call(this) + } + }) + await page.goto('/') + + const pageErrors: Error[] = [] + page.on('pageerror', (error) => pageErrors.push(error)) + await page.getByRole('button', { name: /JSON 내보내기/ }).click() + await expect(page.locator('output')).toHaveText('JSON 파일을 내보내지 못했습니다. 다시 시도하세요.') + + await expect.poll(() => page.evaluate(() => { + const audit = (window as typeof window & { + __policyweaveDownloadAudit: { created: Array<{ url: string; type: string }>; revoked: string[] } + }).__policyweaveDownloadAudit + return { created: audit.created.map(({ url }) => url), revoked: audit.revoked } + })).toEqual({ created: [expect.any(String)], revoked: [expect.any(String)] }) + const downloadAudit = await page.evaluate(() => (window as typeof window & { + __policyweaveDownloadAudit: { created: Array<{ url: string; type: string }>; revoked: string[] } + }).__policyweaveDownloadAudit) + expect(downloadAudit.revoked).toEqual(downloadAudit.created.map(({ url }) => url)) + expect(pageErrors).toEqual([]) +}) + +test('reports an object URL creation failure without leaking a page error', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'One browser profile proves the pre-activation error boundary.') + + await page.addInitScript(() => { + URL.createObjectURL = () => { + throw new Error('simulated object URL creation failure') + } + }) + await page.goto('/') + + const pageErrors: Error[] = [] + page.on('pageerror', (error) => pageErrors.push(error)) + await page.getByRole('button', { name: /JSON 내보내기/ }).click() + await expect(page.locator('output')).toHaveText('JSON 파일을 내보내지 못했습니다. 다시 시도하세요.') + expect(pageErrors).toEqual([]) +}) + +test('exports a review-ready no-collection draft without unresolved findings', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'One complete export proves state semantics; layout coverage is exercised separately.') + + await page.goto('/') + await page.getByLabel('서비스 이름').fill('Example Service') + await page.getByLabel('서비스 URL').fill('https://example.test/privacy') + await page.locator('.rail').getByRole('button', { name: /수집 항목/ }).click() + await page.getByRole('checkbox', { name: '개인정보를 수집하지 않음으로 확인' }).check() + await page.locator('.rail').getByRole('button', { name: /보유 기간/ }).click() + await page.getByLabel('개인정보 보유 여부').selectOption('none') + await page.locator('.rail').getByRole('button', { name: /제3자 제공/ }).click() + await page.getByLabel('제3자 제공 여부').selectOption('no') + await page.locator('.rail').getByRole('button', { name: /국외 이전/ }).click() + await page.getByLabel('국외 이전 여부').selectOption('no') + await page.locator('.rail').getByRole('button', { name: /개인정보 보호 담당자/ }).click() + await page.getByLabel('담당자 또는 담당 부서').fill('Privacy Team') + await page.getByLabel('연락 이메일').fill('privacy@example.test') + await expect(page.getByText('7/7 완료')).toBeVisible() + + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: /JSON 내보내기/ }).click() + const exportedDraft = JSON.parse(await readDownload(await downloadPromise)) + + expect(exportedDraft.document_state).toBe('review_ready') + expect(exportedDraft.review_finding_codes).toEqual([]) +}) + +test('accepts touch activation for the mobile JSON download', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'mobile-chromium', 'Touch activation is scoped to the touch-enabled mobile project.') + + await page.goto('/') + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: /JSON 내보내기/ }).tap() + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe('policyweave-draft.json') + expect(JSON.parse(await readDownload(download)).schema_version).toBe(1) +}) diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..6d9778c --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"lib":["ES2022","DOM","DOM.Iterable"],"types":["node"],"allowJs":false,"skipLibCheck":true,"esModuleInterop":true,"allowSyntheticDefaultImports":true,"strict":true,"forceConsistentCasingInFileNames":true,"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx"},"include":["src"]} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff0aea3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1 @@ +{"files":[],"references":[{"path":"./tsconfig.app.json"},{"path":"./tsconfig.node.json"}]} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..39054ef --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1 @@ +{"compilerOptions":{"composite":true,"skipLibCheck":true,"module":"ESNext","moduleResolution":"Bundler","allowImportingTsExtensions":true,"noEmit":true},"include":["vite.config.ts"]} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..158b4df --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,9 @@ +import react from '@vitejs/plugin-react' +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [react()], + test: { + exclude: [...configDefaults.exclude, 'tests/e2e/**'], + }, +})