From 212b62c8bb3e13ccdcc0cc3152a6631ba455033f Mon Sep 17 00:00:00 2001 From: huyz <739820174@qq.com> Date: Thu, 8 Oct 2026 23:59:29 +0800 Subject: [PATCH 1/3] feat(vault): add workspace CAS snapshots and commits (#234) Closes #234. Add bounded workspace-scoped logical note identity, immutable revisions, tombstones, discovery, and snapshot/commit HTTP endpoints. Record compatibility boundaries and isolated PostgreSQL validation. --- CHANGELOG.md | 7 + SPEC.md | 22 + docs/TESTING.md | 8 +- docs/VAULT_SYNC.md | 109 ++++ docs/acceptance/VAULT_SYNC_VALIDATION.md | 73 +++ docs/schemas/vault-snapshot.v1.schema.json | 35 ++ scripts/verify.sh | 7 +- server/cmd/memd/main.go | 2 + server/internal/api/api.go | 4 + server/internal/api/handlers_vault.go | 146 ++++++ server/internal/api/handlers_vault_test.go | 145 ++++++ server/internal/db/migration_sequence_test.go | 7 + .../db/migrations/0027_vault_sync.sql | 66 +++ server/internal/vaultsync/README.md | 6 + server/internal/vaultsync/service.go | 470 ++++++++++++++++++ .../vaultsync/service_integration_test.go | 235 +++++++++ server/internal/vaultsync/service_test.go | 150 ++++++ 17 files changed, 1487 insertions(+), 5 deletions(-) create mode 100644 docs/VAULT_SYNC.md create mode 100644 docs/acceptance/VAULT_SYNC_VALIDATION.md create mode 100644 docs/schemas/vault-snapshot.v1.schema.json create mode 100644 server/internal/api/handlers_vault.go create mode 100644 server/internal/api/handlers_vault_test.go create mode 100644 server/internal/db/migrations/0027_vault_sync.sql create mode 100644 server/internal/vaultsync/README.md create mode 100644 server/internal/vaultsync/service.go create mode 100644 server/internal/vaultsync/service_integration_test.go create mode 100644 server/internal/vaultsync/service_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b3334..cd24c0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Added +- Workspace-scoped Markdown Vault sync HTTP API (`#234`): discover Vaults, pull bounded + snapshots and atomically commit logical-note deltas with whole-Vault CAS. + Independent empty notes, immutable revisions and deletion tombstones preserve + identity and concurrent edits; workspace, read/write and token-path boundaries + reuse canonical authorization. Additive migration 0027 leaves existing files + unchanged. See `docs/VAULT_SYNC.md` for limits, downgrade data loss and the + explicit boundary from current workspace-bundle export/import formats. - Per-agent memory namespace filter on lexical recall (`#227`): `POST /v1/context` and `mem_context` accept `agent_id` plus optional `extra_agent_ids`. When set, structured-memory recall hard-filters `producer_agent` before ranking so diff --git a/SPEC.md b/SPEC.md index ce49097..79da954 100644 --- a/SPEC.md +++ b/SPEC.md @@ -254,6 +254,28 @@ workspace import 的数据库 commit 若无法确认结果,服务端不得删 --- +### F9A · Workspace Markdown Vault 同步(HTTP v1) + +需求:[mem #234](https://github.com/bytefolk/mem/issues/234)。 + +`GET /v1/vault/list`、`GET /v1/vault/snapshot?vaultId=` 与 +`POST /v1/vault/commit` 提供工作区内的逻辑笔记同步底座。每份笔记使用独立 +`noteId`,相同正文和空笔记不会因 blob 去重而合并。commit 传递 +`{vaultId,baseRevision,title?,entries:[{noteId,path,content,deleted?,properties?}]}` +增量;整个 Vault 的 head 在单个事务内执行 CAS,过期 head 返回 `409` +及 `currentRevision`,不覆盖其他客户端。删除保留 tombstone 与不可原地更新的 +正文 revision;重命名保留逻辑 ID。只读需 `read`,提交需 `read` + `write`, +复用现有 workspace membership 和 `/Vaults/` token path 边界。 +properties 仅为有界数据,不能授予工具或身份权限。图片等原件继续使用 files API。 + +`vault-snapshot.v1` 是独立的当前状态可迁移合同,详见 +[API、限制与错误码](docs/VAULT_SYNC.md) 和 +[JSON Schema](docs/schemas/vault-snapshot.v1.schema.json)。CLI/MCP/Web 本次不新增 +Vault 命令或独立笔记编辑器。现有 workspace bundle v1/v2 不包含 Vault 表和历史; +完整历史仍需数据库备份。服务不运行桌面文件监听或自动合并客户端冲突;客户端必须 +拉取 head、三方合并并保留冲突副本。迁移 0027 不改变 files/auth/memory 表;降级 +到 0026 会丢弃 Vault 数据,操作前需备份。 + ## 4. 非功能需求 | 维度 | 目标 | diff --git a/docs/TESTING.md b/docs/TESTING.md index a3204af..1deec06 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -274,8 +274,10 @@ Required tests: - `TestManagedAISettlementOutboxPostgres` - `TestReleasedFileStageRetryPostgres` - `TestDurableContextPostgres` +- `TestVaultSyncPostgres` (two-client CAS, distinct empty notes, auth/workspace, + immutable history, tombstones, path swap/title and migration 27 down/up) -Expected result: migrations reach the declared current head (currently `20`), +Expected result: migrations reach the declared current head (currently `27`), all rollback-state assertions pass, every named test prints `PASS`, all commands exit `0`, and the race run reports no data race. @@ -449,8 +451,8 @@ Use this table in pull requests and add implementation-specific scenarios: | V2 | Worker processing regressions remain hermetic | `make test-worker` | Exit `0`; real-model gate explicitly skipped | | V3 | Localization, theme, enrichment, memory, transfer and managed-embedding control surfaces work in a browser | `make test-web` | Typecheck/lint/build, the localization audit, all browser acceptance suites and managed status mapping pass | | V4 | High-risk Go paths are race-free | `make test-race` | Exit `0`; no data-race warning | -| V5 | Fresh schema, rollback and PostgreSQL semantics hold | `make test-integration` | Migration head and eighteen named tests pass, none skipped | -| V6 | DB concurrency paths are race-free | `make test-integration-race` | The same eighteen tests pass under `-race` | +| V5 | Fresh schema, rollback and PostgreSQL semantics hold | `make test-integration` | Migration head and all required named tests pass, none skipped | +| V6 | DB concurrency paths are race-free | `make test-integration-race` | The same required tests pass under `-race` | | V7 | Real service boundaries agree | `make test-acceptance` | HTTP, CLI and MCP share one isolated service; memory citation/provenance, bounded checkpoint listing, full checkpoint get, lifecycle and forget redaction pass | | V8 | Five config shapes and the real adapter preserve the host-neutral MCP contract | `MEM_MCP_CERT_BINARY=... make test-agent-certification` | All fixtures and current-adapter scenarios pass with no skip | | V9 | Multilingual visual quality meets the chosen checkpoint | Opt-in command in section 7 | All fixed ranking assertions pass | diff --git a/docs/VAULT_SYNC.md b/docs/VAULT_SYNC.md new file mode 100644 index 0000000..3f78447 --- /dev/null +++ b/docs/VAULT_SYNC.md @@ -0,0 +1,109 @@ +# Workspace Vault sync v1 + +Implements [mem #234](https://github.com/bytefolk/mem/issues/234). + +This additive text-note service keeps logical identity independent of the +existing `/v1/files` content-deduplication model. It never changes that model, +invokes AI, or treats note properties as permissions. + +All endpoints use existing Bearer authentication and workspace membership. +`X-Workspace-ID`/a bound token selects the workspace. A token must also cover +the canonical path `/Vaults/`. + +* `GET /v1/vault/list` (`read`) returns + `{ "vaults": [{ "vaultId", "title", "revision", "updatedAt" }] }`. + Out-of-path Vault IDs are omitted, without revealing their count. +* `GET /v1/vault/snapshot?vaultId=` (`read`) returns a full snapshot. + A new Vault returns revision `0`, empty title, `updatedAt: null`, and `[]` + without creating a row. +* `POST /v1/vault/commit` (`read` + `write`) accepts + `{ "vaultId", "baseRevision", "title"?, "entries": [...] }`. + Entries are **deltas**, not a replacement of the full Vault. An entry is + `{ "noteId", "path", "content", "deleted"?, "properties"? }`. + Unlisted notes remain unchanged. `deleted: true` requires empty content. + Omitted properties retain existing metadata; a new note defaults to `{}`. + Empty entries are allowed only with a title update. + +Both snapshot and successful commit return: + +```json +{ + "schemaVersion": "vault-snapshot.v1", + "vaultId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "title": "Knowledge", + "revision": 1, + "updatedAt": "2026-10-07T12:00:00Z", + "entries": [ + { + "noteId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "path": "岗位/空白笔记.md", + "content": "", + "revision": 1, + "deleted": false, + "properties": {} + } + ] +} +``` + +IDs are nonzero UUIDs. Vault IDs in requests use canonical lowercase form. +Paths are canonical relative POSIX paths up to 512 UTF-8 bytes, preserving +Chinese and spaces. Absolute paths, traversal, backslashes, control characters, +Windows-forbidden filename characters and trailing dots/spaces are rejected. +Two active notes cannot share a case-folded path. Logical notes with identical +content, including empty content, retain separate IDs. Text is UTF-8; only +newline, carriage return and tab control characters are accepted. Attachments +continue to use the existing mem file API and references rather than binary +data embedded in this text protocol. + +One transaction locks and compares the whole Vault head. Successful commits +advance it exactly once and append content revisions; mutable head pointers +select current versions. Old entry revisions cannot be updated in place. +Delete creates a retained tombstone; it does not physically erase history. +Rename/swap changes logical paths while preserving note IDs. + +* Stale head: `409 { "error": "vault_revision_conflict", "currentRevision": N }`. +* Active path collision: `409`, `error: vault_path_conflict`. +* Invalid body/path/identity: `400`, `error: vault_request_invalid`. +* Capacity/body/history limit: `413`, `error: vault_limit_exceeded`. +* Unavailable storage: `503`, `error: vault_unavailable`; success is not assumed. + +Clients keep their own base snapshot and local manifest. On 409 they pull the +current head, merge nonoverlapping edits, and preserve conflicts as explicit +copies. A lost response is reconciled through snapshot readback, never a blind +overwrite. The service does not modify local files or promise a sync daemon. + +Bounds: 4 MiB HTTP commit body, 1 MiB per note, 16 KiB per properties object, +256 UTF-8 bytes per title, 512 delta entries, 1000 heads **including tombstones**, +16 MiB current serialized content/path/properties, 64 MiB retained entry-history +content/path/properties, 10,000 commits and 64 Vaults per workspace. Limits +refuse atomically; snapshots are never silently truncated. A full snapshot may +require roughly 32 MiB on the wire due to JSON escaping; callers must use a +bounded Vault-specific response budget rather than an 8 MiB file-card budget. + +Validation: unit/API tests are hermetic. `TestVaultSyncPostgres` requires an +explicit `MEM_TEST_DB` whose database name ends in `_test`; it verifies real +two-client races, distinct empty notes, workspace/auth isolation, tombstones, +immutable history, swaps, title CAS and additive migration down/up. + +## Compatibility and portability boundary + +The HTTP API is the canonical implementation. This initial additive surface +adds no CLI, MCP or standalone mem Web Vault editor; existing adapters and file +operations retain their contracts. Clients may synchronize ordinary Markdown +files and upload attachments through the existing file API. + +The current workspace-bundle v1/v2 export/import formats do not contain Vault +heads or revision history. A Vault snapshot is a separate portable current-state +contract (docs/schemas/vault-snapshot.v1.schema.json). It has no auth tokens, +workspace membership or actor audit fields, and importing its properties never +grants permissions. Restoring a snapshot into an authorized target Vault uses +a normal CAS commit with the target head and a client-selected logical Vault +ID. Full history remains in PostgreSQL and requires an operator database backup; +this API does not claim to transport retained history between deployments. + +Migration 0027 only creates new Vault tables and indexes. Downgrading to 0026 +drops all Vault state and history, so preserve a database backup or current +snapshot before an operator downgrade. Existing files, auth and memory tables +are untouched. No login recovery, deployment credentials or model dependency +is introduced. diff --git a/docs/acceptance/VAULT_SYNC_VALIDATION.md b/docs/acceptance/VAULT_SYNC_VALIDATION.md new file mode 100644 index 0000000..59688bc --- /dev/null +++ b/docs/acceptance/VAULT_SYNC_VALIDATION.md @@ -0,0 +1,73 @@ +# Vault sync validation ledger + +Issue: [mem #234](https://github.com/bytefolk/mem/issues/234). +Base: `c5f3fd591973dfaf0e36baecfee8cb8295c1bcb7` (`origin/main`). +Validation date: 2026-10-08. This ledger records implementation-side evidence; +it is not independent review approval or a deployment/release claim. + +Environment: Linux amd64 through WSL, Go 1.25.0, PostgreSQL 16 with the CI-pinned +`pgvector/pgvector@sha256:00ba258a66dac104fd5171074a0084462a64a1369d8513f3d0a634e2f24d15bc` +image. A new loopback-only container used tmpfs PostgreSQL data and the synthetic +control database `mem_vault_publish_test`. Each integration-runner phase created +and removed its own nonce-marked `mem_verify_*_test` database. No deployed +database, credentials, container or user configuration was read or changed. + +| Check | Exact command (repository root unless specified) | Result | +| --- | --- | --- | +| Server/CLI/MCP compilation | `(cd server && env -u MEM_TEST_DB go build ./...)` | PASS | +| Go static analysis | `(cd server && env -u MEM_TEST_DB go vet ./...)` | PASS | +| All hermetic Go regressions | `(cd server && env -u MEM_TEST_DB go test -count=1 ./...)` | PASS; database tests excluded here and verified separately | +| High-risk hermetic race checks, including Vault | `bash scripts/verify.sh race` | PASS; no data race | +| Fresh schema, strict populated 23 → 27 upgrade, historical rollback round trips, HNSW checks and all required PostgreSQL tests | `MEM_TEST_DB= bash scripts/verify.sh integration` | PASS; migration head 27; no required test skipped | +| All required PostgreSQL tests under race detector | `MEM_TEST_DB= bash scripts/verify.sh integration-race` | PASS; no required test skipped or data race | +| Whitespace and schema serialization | `git diff --check`; Python `json.load` on `docs/schemas/vault-snapshot.v1.schema.json` | PASS; JSON parsing is not a claim of full JSON Schema conformance testing | + +The initial Windows worktree had CRLF shell/golden-fixture copies. The first +Linux attempt failed before integration setup (`pipefail\r`) and the Go doctor +golden comparison rejected line endings. Only those validation working copies +were converted to the Git-equivalent LF content; the commands above were rerun +and passed. No unrelated fixture, shell content or file-mode change is included +in this issue's diff. + +## Observable Vault acceptance + +`TestVaultSyncPostgres` passed in both normal and race integration runners: + +- separate UUID identities preserve two independently synchronized empty notes; +- two actual clients writing the same base revision have exactly one winner + and one `409` conflict; the losing edit never overwrites the winner; +- real router authentication, workspace selection and read/write scopes deny + reader writes and keep another workspace's same Vault UUID isolated; +- successful commits append immutable old text, advance the head once, retain + deletion tombstones, support path swaps and use the same CAS for title changes; +- real discovery lists the workspace's Vault, and real HTTP stale writes return + the observed `currentRevision`; +- migration `27 → 26 → 27` is exercised only on disposable data, preserving + existing file/auth schemas while explicitly dropping Vault state on downgrade. + +Hermetic `internal/vaultsync` and `internal/api` tests additionally cover +malformed identity/path/text/properties, distinct note identity, deterministic +delta planning, stale heads, active-path collisions, count/byte bounds, +tombstones, unavailable storage, strict HTTP bodies, anonymous denial and +token-path filtering for both snapshot and discovery. Property values are +untrusted data and do not add workspace membership or tool authority. + +## Compatibility and honest limits + +- API: additive canonical HTTP routes; existing file/blob identity is unchanged. + The new note ID is independent of content SHA-256. +- CLI/MCP/Web: N/A for new Vault UI/commands in this backend change. Their + existing behavior is covered by the complete Go suite where applicable; + no separate Vault adapter or desktop sync daemon is claimed. +- Worker/Web build/browser suites: N/A; no Worker, React Web, protobuf, model or + queue behavior changed in this scoped server addition. +- Provenance/privacy: public snapshot contains current note data/properties, + with no authentication material, actor audit IDs or new authorization model. +- Retry: a stale or uncertain commit is reconciled through current-head + readback; clients retain their base and preserve divergent edits as copies. + No request-level idempotency key or server-side automatic merge is claimed. +- Recall warnings: N/A; this surface does not change search, recall or ranking. +- Portability: `vault-snapshot.v1` transports current state. Workspace bundle + v1/v2 does not include Vault state; retained history needs database backup. +- Deployment: NOT VERIFIED by this publication worktree; no production + migration, restart, credential issuance, release or hosted-service rollout ran. diff --git a/docs/schemas/vault-snapshot.v1.schema.json b/docs/schemas/vault-snapshot.v1.schema.json new file mode 100644 index 0000000..a66a423 --- /dev/null +++ b/docs/schemas/vault-snapshot.v1.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/bytefolk/mem/blob/main/docs/schemas/vault-snapshot.v1.schema.json", + "title": "Workspace Vault current-state snapshot v1", + "description": "Canonical GET snapshot and successful commit response. This transports current logical notes and tombstones, not auth state or retained history. UTF-8 byte bounds are additionally enforced by the server.", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "vaultId", "title", "revision", "updatedAt", "entries"], + "properties": { + "schemaVersion": { "const": "vault-snapshot.v1" }, + "vaultId": { "$ref": "#/$defs/id" }, + "title": { "type": "string", "maxLength": 256, "$comment": "At most 256 UTF-8 bytes; empty only before the first commit." }, + "revision": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, + "updatedAt": { "type": ["string", "null"], "format": "date-time" }, + "entries": { + "type": "array", "maxItems": 1000, + "items": { + "type": "object", "additionalProperties": false, + "required": ["noteId", "path", "content", "revision", "deleted", "properties"], + "properties": { + "noteId": { "$ref": "#/$defs/id" }, + "path": { "type": "string", "minLength": 1, "maxLength": 512, "$comment": "Canonical safe relative POSIX path, at most 512 UTF-8 bytes. Active paths are case-fold unique." }, + "content": { "type": "string", "maxLength": 1048576, "$comment": "At most 1 MiB UTF-8 bytes; only tab, CR and LF control characters are accepted." }, + "revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "deleted": { "type": "boolean" }, + "properties": { "type": "object", "$comment": "At most 16 KiB serialized JSON. Untrusted application data, never a permission grant." } + }, + "allOf": [{ "if": { "properties": { "deleted": { "const": true } } }, "then": { "properties": { "content": { "const": "" } } } }] + } + } + }, + "$defs": { + "id": { "type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "not": { "const": "00000000-0000-0000-0000-000000000000" } } + } +} diff --git a/scripts/verify.sh b/scripts/verify.sh index 85dbdd3..d7b3ac6 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -4,7 +4,7 @@ set -euo pipefail REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" MODE="${1:-unit}" -EXPECTED_MIGRATION_HEAD=26 +EXPECTED_MIGRATION_HEAD=27 MIGRATION_ROLLBACK_TARGET=11 MODEL_TEXT_CANONICAL_BASE=15 WORKSPACE_AI_PROFILE_BASE=16 @@ -65,6 +65,7 @@ run_race() { ./internal/file \ ./internal/folder \ ./internal/memory \ + ./internal/vaultsync \ ./internal/handoff \ ./internal/workspacelock \ ./internal/workspacebundle \ @@ -350,6 +351,7 @@ run_postgres_tests() { TestReleasedFileStageRetryPostgres TestDurableContextPostgres TestTextANNFileSemanticsPostgres + TestVaultSyncPostgres ) integration_log="$(mktemp "${TMPDIR:-/tmp}/mem-integration.XXXXXX")" @@ -360,8 +362,9 @@ run_postgres_tests() { MEM_TEST_DB="$MEM_TEST_DB" go test \ ${race_flag:+"$race_flag"} \ -v -count=1 -p 1 -timeout 20m \ - -run '^(TestMemoryPostgres|TestHandoffPostgres|TestWorkspaceTransferPostgres|TestWorkspaceTransferMergeConservativePostgres|TestHandoffCrossAgentHTTPIntegration|TestRelocateHTTPPostgres|TestMemoryPathLifecycleIntegration|TestWorkspacePathLockingIntegration|TestFilePathLockingIntegration|TestAnnotationDecisionIntegration|TestIndexerEnrichmentIntegration|TestRecomputePerson|TestManagedEmbeddingEntitlementPostgres|TestManagedSearchReplayPostgres|TestManagedEmbeddingHTTPAuthorizationPostgres|TestAIProfilePostgres|TestIndexGenerationPostgres|TestManagedAISettlementOutboxPostgres|TestReleasedFileStageRetryPostgres|TestDurableContextPostgres|TestTextANNFileSemanticsPostgres)$' \ + -run '^(TestMemoryPostgres|TestHandoffPostgres|TestWorkspaceTransferPostgres|TestWorkspaceTransferMergeConservativePostgres|TestHandoffCrossAgentHTTPIntegration|TestRelocateHTTPPostgres|TestMemoryPathLifecycleIntegration|TestWorkspacePathLockingIntegration|TestFilePathLockingIntegration|TestAnnotationDecisionIntegration|TestIndexerEnrichmentIntegration|TestRecomputePerson|TestManagedEmbeddingEntitlementPostgres|TestManagedSearchReplayPostgres|TestManagedEmbeddingHTTPAuthorizationPostgres|TestAIProfilePostgres|TestIndexGenerationPostgres|TestManagedAISettlementOutboxPostgres|TestReleasedFileStageRetryPostgres|TestDurableContextPostgres|TestTextANNFileSemanticsPostgres|TestVaultSyncPostgres)$' \ ./internal/memory \ + ./internal/vaultsync \ ./internal/handoff \ ./internal/workspacetransfer \ ./internal/api \ diff --git a/server/cmd/memd/main.go b/server/cmd/memd/main.go index 2cf20cc..da6b14f 100644 --- a/server/cmd/memd/main.go +++ b/server/cmd/memd/main.go @@ -37,6 +37,7 @@ import ( "github.com/PeterGuy326/mem/server/internal/relator" "github.com/PeterGuy326/mem/server/internal/search" "github.com/PeterGuy326/mem/server/internal/storage" + "github.com/PeterGuy326/mem/server/internal/vaultsync" "github.com/PeterGuy326/mem/server/internal/workerclient" "github.com/PeterGuy326/mem/server/internal/workspace" "github.com/PeterGuy326/mem/server/internal/workspacebundle" @@ -236,6 +237,7 @@ func run() error { Search: searchSvc, Context: contextSvc, Memory: memorySvc, + VaultSync: vaultsync.New(database.Pool), DurableContext: durableContextSvc, Handoff: handoffSvc, Provider: providerSvc, diff --git a/server/internal/api/api.go b/server/internal/api/api.go index d7edd63..6f32efb 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -174,6 +174,7 @@ type Server struct { Search SearchService // optional; nil disables /v1/search Context *contextpack.Service Memory MemoryService + VaultSync VaultSyncService DurableContext DurableContextService Handoff handoff.ServicePort Provider *provider.Service // optional; nil disables /v1/providers @@ -241,6 +242,9 @@ func (s *Server) Router() http.Handler { r.Get("/v1/capabilities", s.handleCapabilities) r.Get("/v1/workspaces", s.handleListWorkspaces) r.Get("/v1/workspaces/current", s.handleCurrentWorkspace) + r.With(s.requireScope(auth.ScopeRead)).Get("/v1/vault/list", s.handleVaultList) + r.With(s.requireScope(auth.ScopeRead)).Get("/v1/vault/snapshot", s.handleVaultSnapshot) + r.With(s.requireScope(auth.ScopeRead), s.requireScope(auth.ScopeWrite)).Post("/v1/vault/commit", s.handleVaultCommit) r.With(s.requireScope(auth.ScopeRead)).Get( "/v1/workspaces/current/ai-profile", s.handleGetAIProfile, ) diff --git a/server/internal/api/handlers_vault.go b/server/internal/api/handlers_vault.go new file mode 100644 index 0000000..738930e --- /dev/null +++ b/server/internal/api/handlers_vault.go @@ -0,0 +1,146 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/google/uuid" + + "github.com/PeterGuy326/mem/server/internal/auth" + "github.com/PeterGuy326/mem/server/internal/vaultsync" +) + +const maxVaultCommitBodyBytes = 4 << 20 + +type VaultSyncService interface { + Snapshot(context.Context, uuid.UUID, uuid.UUID) (vaultsync.Snapshot, error) + List(context.Context, uuid.UUID) ([]vaultsync.Summary, error) + Commit(context.Context, vaultsync.CommitCommand) (vaultsync.Snapshot, error) +} + +func (s *Server) handleVaultSnapshot(w http.ResponseWriter, r *http.Request) { + if s.VaultSync == nil { + writeVaultError(w, vaultsync.ErrUnavailable) + return + } + values := r.URL.Query()["vaultId"] + if len(values) != 1 { + writeVaultError(w, vaultsync.ErrInvalid) + return + } + id, err := vaultsync.ParseVaultID(values[0]) + if err != nil { + writeVaultError(w, err) + return + } + if !requireTokenPath(w, r, vaultsync.ScopePath(id)) { + return + } + snapshot, err := s.VaultSync.Snapshot(r.Context(), currentWorkspace(r).ID, id) + if err != nil { + writeVaultError(w, err) + return + } + writeVaultJSON(w, snapshot) +} + +func (s *Server) handleVaultList(w http.ResponseWriter, r *http.Request) { + if s.VaultSync == nil { + writeVaultError(w, vaultsync.ErrUnavailable) + return + } + vaults, err := s.VaultSync.List(r.Context(), currentWorkspace(r).ID) + if err != nil { + writeVaultError(w, err) + return + } + visible := []vaultsync.Summary{} + for _, vault := range vaults { + // Listing is also path-scoped; it must not reveal unauthorized Vault IDs. + if tokenAllowsPath(r, vaultsync.ScopePath(vault.VaultID)) { + visible = append(visible, vault) + } + } + writeVaultJSON(w, map[string]any{"vaults": visible}) +} + +type vaultCommitBody struct { + VaultID string `json:"vaultId"` + BaseRevision *int64 `json:"baseRevision"` + Title *string `json:"title,omitempty"` + Entries []vaultsync.Mutation `json:"entries"` +} + +func (s *Server) handleVaultCommit(w http.ResponseWriter, r *http.Request) { + if s.VaultSync == nil { + writeVaultError(w, vaultsync.ErrUnavailable) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxVaultCommitBodyBytes) + var body vaultCommitBody + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&body); err != nil { + var limit *http.MaxBytesError + if errors.As(err, &limit) { + writeVaultError(w, vaultsync.ErrLimit) + } else { + writeVaultError(w, vaultsync.ErrInvalid) + } + return + } + if ensureJSONEOF(decoder) != nil || body.BaseRevision == nil { + writeVaultError(w, vaultsync.ErrInvalid) + return + } + id, err := vaultsync.ParseVaultID(body.VaultID) + if err != nil { + writeVaultError(w, err) + return + } + if !requireTokenPath(w, r, vaultsync.ScopePath(id)) { + return + } + actor := r.Context().Value(ctxActor).(*auth.User) + command, err := vaultsync.NormalizeCommand(vaultsync.CommitCommand{WorkspaceID: currentWorkspace(r).ID, + ActorUserID: actor.ID, VaultID: id, BaseRevision: *body.BaseRevision, Title: body.Title, Entries: body.Entries}) + if err != nil { + writeVaultError(w, err) + return + } + snapshot, err := s.VaultSync.Commit(r.Context(), command) + if err != nil { + writeVaultError(w, err) + return + } + writeVaultJSON(w, snapshot) +} + +func writeVaultJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + encoder := json.NewEncoder(w) + encoder.SetEscapeHTML(false) + _ = encoder.Encode(value) +} + +func writeVaultError(w http.ResponseWriter, err error) { + var conflict *vaultsync.RevisionConflict + switch { + case errors.As(err, &conflict): + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "vault_revision_conflict", "currentRevision": conflict.CurrentRevision}) + case errors.Is(err, vaultsync.ErrPathConflict): + writeError(w, http.StatusConflict, "vault_path_conflict", "active Vault paths must be unique; existing notes were preserved") + case errors.Is(err, vaultsync.ErrLimit): + writeError(w, http.StatusRequestEntityTooLarge, "vault_limit_exceeded", "Vault payload, note count, or retained history exceeds its bound") + case errors.Is(err, vaultsync.ErrInvalid): + writeError(w, http.StatusBadRequest, "vault_request_invalid", "invalid Vault identity, revision, relative path or text payload") + default: + writeError(w, http.StatusServiceUnavailable, "vault_unavailable", "Vault sync is unavailable; no successful commit was confirmed") + } +} diff --git a/server/internal/api/handlers_vault_test.go b/server/internal/api/handlers_vault_test.go new file mode 100644 index 0000000..8164881 --- /dev/null +++ b/server/internal/api/handlers_vault_test.go @@ -0,0 +1,145 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/PeterGuy326/mem/server/internal/auth" + "github.com/PeterGuy326/mem/server/internal/vaultsync" +) + +type vaultStub struct { + calls int + workspaceID uuid.UUID + command vaultsync.CommitCommand + result vaultsync.Snapshot + vaults []vaultsync.Summary + err error +} + +func (s *vaultStub) Snapshot(_ context.Context, workspaceID, _ uuid.UUID) (vaultsync.Snapshot, error) { + s.calls++ + s.workspaceID = workspaceID + return s.result, s.err +} +func (s *vaultStub) List(_ context.Context, workspaceID uuid.UUID) ([]vaultsync.Summary, error) { + s.calls++ + s.workspaceID = workspaceID + return s.vaults, s.err +} +func (s *vaultStub) Commit(_ context.Context, cmd vaultsync.CommitCommand) (vaultsync.Snapshot, error) { + s.calls++ + s.command = cmd + return s.result, s.err +} + +func TestVaultRoutesScopeWorkspaceAndConflict(t *testing.T) { + id := uuid.New() + body := `{"vaultId":"` + id.String() + `","baseRevision":0,"entries":[{"noteId":"` + uuid.NewString() + `","path":"中文/空笔记.md","content":"","properties":{"tools":["write"]}}]}` + stub := &vaultStub{result: vaultsync.Snapshot{SchemaVersion: vaultsync.SchemaVersion, VaultID: id, Revision: 1, Entries: []vaultsync.Entry{}}} + server := &Server{VaultSync: stub} + req, actor, _, workspace := memoryHandlerContext(httptest.NewRequest(http.MethodPost, "/v1/vault/commit", strings.NewReader(body)), nil) + req.Context().Value(ctxToken).(*auth.Token).Scopes = []string{auth.ScopeRead, auth.ScopeWrite} + res := httptest.NewRecorder() + server.requireScope(auth.ScopeRead)(server.requireScope(auth.ScopeWrite)(http.HandlerFunc(server.handleVaultCommit))).ServeHTTP(res, req) + if res.Code != 200 || stub.command.WorkspaceID != workspace || stub.command.ActorUserID != actor { + t.Fatalf("authenticated Vault binding: status=%d command=%+v", res.Code, stub.command) + } + stub.err = &vaultsync.RevisionConflict{CurrentRevision: 1} + req, _, _, _ = memoryHandlerContext(httptest.NewRequest(http.MethodPost, "/v1/vault/commit", strings.NewReader(body)), nil) + res = httptest.NewRecorder() + server.handleVaultCommit(res, req) + if res.Code != 409 || !strings.Contains(res.Body.String(), `"currentRevision":1`) { + t.Fatalf("CAS conflict: %d %s", res.Code, res.Body.String()) + } +} + +func TestVaultReadOnlyPathAndListIsolation(t *testing.T) { + allowed, denied := uuid.New(), uuid.New() + stub := &vaultStub{vaults: []vaultsync.Summary{{VaultID: allowed}, {VaultID: denied}}} + server := &Server{VaultSync: stub} + req, _, _, workspace := memoryHandlerContext(httptest.NewRequest(http.MethodGet, "/v1/vault/list", nil), []string{vaultsync.ScopePath(allowed)}) + res := httptest.NewRecorder() + server.handleVaultList(res, req) + if res.Code != 200 || stub.workspaceID != workspace || strings.Contains(res.Body.String(), denied.String()) || !strings.Contains(res.Body.String(), allowed.String()) { + t.Fatal("Vault list leaked an unauthorized ID") + } + for _, scopes := range [][]string{{auth.ScopeRead}, {auth.ScopeWrite}} { + req, _, _, _ = memoryHandlerContext(httptest.NewRequest(http.MethodPost, "/v1/vault/commit", strings.NewReader(`{}`)), nil) + req.Context().Value(ctxToken).(*auth.Token).Scopes = scopes + res = httptest.NewRecorder() + server.requireScope(auth.ScopeRead)(server.requireScope(auth.ScopeWrite)(http.HandlerFunc(server.handleVaultCommit))).ServeHTTP(res, req) + if res.Code != 403 { + t.Fatal("commit did not require both read and write") + } + } + previous := stub.calls + req, _, _, _ = memoryHandlerContext(httptest.NewRequest(http.MethodGet, "/v1/vault/snapshot?vaultId="+denied.String(), nil), []string{vaultsync.ScopePath(allowed)}) + res = httptest.NewRecorder() + server.handleVaultSnapshot(res, req) + if res.Code != 403 || stub.calls != previous { + t.Fatal("out-of-path snapshot reached storage") + } +} + +func TestVaultMalformedOversizedAndUnavailableFailClosed(t *testing.T) { + stub := &vaultStub{} + server := &Server{VaultSync: stub} + for _, body := range []string{`{"vaultId":"bad","baseRevision":0,"entries":[]}`, `{"vaultId":"` + uuid.NewString() + `","entries":[]}`, `{"vaultId":"` + uuid.NewString() + `","baseRevision":0,"entries":[],"workspaceId":"override"}`, `{} {}`} { + req, _, _, _ := memoryHandlerContext(httptest.NewRequest(http.MethodPost, "/v1/vault/commit", strings.NewReader(body)), nil) + res := httptest.NewRecorder() + server.handleVaultCommit(res, req) + if res.Code != 400 || stub.calls != 0 { + t.Fatalf("invalid body reached storage: %d", res.Code) + } + } + big := `{"vaultId":"` + uuid.NewString() + `","baseRevision":0,"entries":[{"noteId":"` + uuid.NewString() + `","path":"note.md","content":"` + strings.Repeat("x", maxVaultCommitBodyBytes) + `"}]}` + req, _, _, _ := memoryHandlerContext(httptest.NewRequest(http.MethodPost, "/v1/vault/commit", strings.NewReader(big)), nil) + res := httptest.NewRecorder() + server.handleVaultCommit(res, req) + if res.Code != 413 || stub.calls != 0 { + t.Fatal("oversize body reached storage") + } + stub.err = errors.New("private database credential") + req, _, _, _ = memoryHandlerContext(httptest.NewRequest(http.MethodGet, "/v1/vault/snapshot?vaultId="+uuid.NewString(), nil), nil) + res = httptest.NewRecorder() + server.handleVaultSnapshot(res, req) + if res.Code != 503 || strings.Contains(res.Body.String(), "credential") { + t.Fatal("database failure leaked a diagnostic") + } +} + +func TestVaultRoutesRegistered(t *testing.T) { + routes := (&Server{}).Router().(chi.Routes) + seen := map[string]bool{} + if err := chi.Walk(routes, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + seen[method+" "+route] = true + return nil + }); err != nil { + t.Fatal(err) + } + for _, route := range []string{"GET /v1/vault/list", "GET /v1/vault/snapshot", "POST /v1/vault/commit"} { + if !seen[route] { + t.Fatal("unregistered route", route) + } + } + res := httptest.NewRecorder() + (&Server{Log: slog.New(slog.NewTextHandler(io.Discard, nil))}).Router().ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/v1/vault/list", nil)) + if res.Code != 401 { + t.Fatal("Vault route is public") + } + var body map[string]any + if json.Unmarshal(res.Body.Bytes(), &body) != nil { + t.Fatal("auth refusal must be JSON") + } +} diff --git a/server/internal/db/migration_sequence_test.go b/server/internal/db/migration_sequence_test.go index b1fa7a0..abde944 100644 --- a/server/internal/db/migration_sequence_test.go +++ b/server/internal/db/migration_sequence_test.go @@ -116,6 +116,13 @@ func TestMigrationUpgradeSequence(t *testing.T) { t.Fatalf("producer_agent index=%d, err=%v", producerIdx, err) } } + if version >= 27 { + var tables int + if err := sqldb.QueryRowContext(ctx, `SELECT count(*) FROM pg_tables WHERE schemaname='public' + AND tablename IN ('vaults','vault_commits','vault_entry_revisions','vault_entry_heads')`).Scan(&tables); err != nil || tables != 4 { + t.Fatalf("Vault tables=%d, want=4, err=%v", tables, err) + } + } t.Logf("PASS: strict Goose upgrade to %d; complete history and populated data preserved", version) } // The real startup path must accept the upgraded history unchanged. diff --git a/server/internal/db/migrations/0027_vault_sync.sql b/server/internal/db/migrations/0027_vault_sync.sql new file mode 100644 index 0000000..c69ad2a --- /dev/null +++ b/server/internal/db/migrations/0027_vault_sync.sql @@ -0,0 +1,66 @@ +-- +goose Up +CREATE TABLE vaults ( + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + id uuid NOT NULL, + title text NOT NULL CHECK (octet_length(title) BETWEEN 1 AND 256), + revision bigint NOT NULL DEFAULT 0 CHECK (revision BETWEEN 0 AND 9007199254740991), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, id) +); + +CREATE TABLE vault_commits ( + workspace_id uuid NOT NULL, + vault_id uuid NOT NULL, + revision bigint NOT NULL CHECK (revision BETWEEN 1 AND 9007199254740991), + title text NOT NULL, + actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, vault_id, revision), + FOREIGN KEY (workspace_id, vault_id) REFERENCES vaults(workspace_id, id) ON DELETE CASCADE +); + +CREATE TABLE vault_entry_revisions ( + workspace_id uuid NOT NULL, + vault_id uuid NOT NULL, + note_id uuid NOT NULL, + revision bigint NOT NULL, + path text NOT NULL CHECK (octet_length(path) BETWEEN 1 AND 512), + content text NOT NULL CHECK (octet_length(content) <= 1048576), + properties jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(properties) = 'object' AND octet_length(properties::text) <= 16384), + deleted boolean NOT NULL DEFAULT false, + CHECK (NOT deleted OR content = ''), + PRIMARY KEY (workspace_id, vault_id, note_id, revision), + FOREIGN KEY (workspace_id, vault_id, revision) REFERENCES vault_commits(workspace_id, vault_id, revision) ON DELETE CASCADE +); + +CREATE TABLE vault_entry_heads ( + workspace_id uuid NOT NULL, + vault_id uuid NOT NULL, + note_id uuid NOT NULL, + revision bigint NOT NULL, + path text NOT NULL, + deleted boolean NOT NULL, + PRIMARY KEY (workspace_id, vault_id, note_id), + FOREIGN KEY (workspace_id, vault_id, note_id, revision) + REFERENCES vault_entry_revisions(workspace_id, vault_id, note_id, revision) ON DELETE CASCADE +); +-- Blob identity is never note identity. Separate empty notes keep separate IDs. +CREATE UNIQUE INDEX vault_entry_active_path ON vault_entry_heads(workspace_id, vault_id, lower(path)) WHERE NOT deleted; + +-- Content revisions are append-only; head pointers are the mutable projection. +-- +goose StatementBegin +CREATE FUNCTION reject_vault_entry_revision_update() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'Vault entry revisions are immutable' USING ERRCODE = '23514'; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER vault_entry_revisions_immutable BEFORE UPDATE ON vault_entry_revisions + FOR EACH ROW EXECUTE FUNCTION reject_vault_entry_revision_update(); + +-- +goose Down +DROP TABLE vault_entry_heads; +DROP TABLE vault_entry_revisions; +DROP FUNCTION reject_vault_entry_revision_update(); +DROP TABLE vault_commits; +DROP TABLE vaults; diff --git a/server/internal/vaultsync/README.md b/server/internal/vaultsync/README.md new file mode 100644 index 0000000..5ebeee1 --- /dev/null +++ b/server/internal/vaultsync/README.md @@ -0,0 +1,6 @@ +# Workspace Vault sync + +The canonical wire contract, bounds and compatibility boundaries are documented +in [docs/VAULT_SYNC.md](../../../docs/VAULT_SYNC.md). + +The snapshot schema is [vault-snapshot.v1](../../../docs/schemas/vault-snapshot.v1.schema.json). diff --git a/server/internal/vaultsync/service.go b/server/internal/vaultsync/service.go new file mode 100644 index 0000000..402eb8c --- /dev/null +++ b/server/internal/vaultsync/service.go @@ -0,0 +1,470 @@ +// Package vaultsync stores workspace-scoped text Vaults with append-only +// revisions and an atomic Vault-wide compare-and-swap head. File/blob upload +// deduplication is deliberately outside this logical note identity model. +package vaultsync + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "sort" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/PeterGuy326/mem/server/internal/workspacelock" +) + +const ( + SchemaVersion = "vault-snapshot.v1" + MaxFileBytes = 1 << 20 + MaxPropertiesBytes = 16 << 10 + MaxCurrentBytes = 16 << 20 + MaxHistoryBytes = 64 << 20 + MaxEntries = 1000 + MaxMutationEntries = 512 + MaxVaults = 64 + MaxCommits = 10000 + MaxSafeRevision int64 = 9007199254740991 +) + +var ( + ErrInvalid = errors.New("invalid Vault request") + ErrLimit = errors.New("Vault limit exceeded") + ErrPathConflict = errors.New("Vault path conflict") + ErrUnavailable = errors.New("Vault sync is unavailable") +) + +type RevisionConflict struct{ CurrentRevision int64 } + +func (e *RevisionConflict) Error() string { return "Vault revision conflict" } + +type Mutation struct { + NoteID uuid.UUID `json:"noteId"` + Path string `json:"path"` + Content string `json:"content"` + Deleted bool `json:"deleted,omitempty"` + Properties json.RawMessage `json:"properties,omitempty"` +} + +type Entry struct { + NoteID uuid.UUID `json:"noteId"` + Path string `json:"path"` + Content string `json:"content"` + Revision int64 `json:"revision"` + Deleted bool `json:"deleted"` + Properties json.RawMessage `json:"properties"` +} + +type Snapshot struct { + SchemaVersion string `json:"schemaVersion"` + VaultID uuid.UUID `json:"vaultId"` + Title string `json:"title"` + Revision int64 `json:"revision"` + UpdatedAt *time.Time `json:"updatedAt"` + Entries []Entry `json:"entries"` +} + +type Summary struct { + VaultID uuid.UUID `json:"vaultId"` + Title string `json:"title"` + Revision int64 `json:"revision"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CommitCommand struct { + WorkspaceID uuid.UUID + ActorUserID uuid.UUID + VaultID uuid.UUID + BaseRevision int64 + Title *string + Entries []Mutation +} + +type Service struct{ pool *pgxpool.Pool } + +func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } +func ScopePath(id uuid.UUID) string { return "/Vaults/" + id.String() } + +func ParseVaultID(raw string) (uuid.UUID, error) { + id, err := uuid.Parse(raw) + if err != nil || id == uuid.Nil || id.String() != raw { + return uuid.Nil, ErrInvalid + } + return id, nil +} + +func ValidatePath(value string) error { + if !utf8.ValidString(value) || len(value) == 0 || len(value) > 512 || path.IsAbs(value) || path.Clean(value) != value || + strings.ContainsAny(value, "\\:*?\"<>|") || strings.IndexFunc(value, unicode.IsControl) >= 0 { + return ErrInvalid + } + for _, part := range strings.Split(value, "/") { + if part == "." || part == ".." || strings.HasSuffix(part, ".") || strings.HasSuffix(part, " ") { + return ErrInvalid + } + } + return nil +} + +func normalizeProperties(raw json.RawMessage) (json.RawMessage, error) { + if len(raw) == 0 { + return nil, nil + } // Existing metadata is preserved when omitted. + if len(raw) > MaxPropertiesBytes || !utf8.Valid(raw) { + return nil, ErrLimit + } + var value map[string]any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil || value == nil { + return nil, ErrInvalid + } + if containsZero(value) { + return nil, ErrInvalid + } + if err := decoder.Decode(new(any)); err != io.EOF { + return nil, ErrInvalid + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, ErrInvalid + } + // PostgreSQL jsonb may add spaces between keys/values; this bound applies + // to its actual persisted representation as well as the compact wire value. + var indented bytes.Buffer + if err := json.Indent(&indented, encoded, "", ""); err != nil || indented.Len() > MaxPropertiesBytes { + return nil, ErrLimit + } + return encoded, nil +} + +func containsZero(value any) bool { + switch typed := value.(type) { + case string: + return strings.ContainsRune(typed, 0) + case map[string]any: + for key, item := range typed { + if strings.ContainsRune(key, 0) || containsZero(item) { + return true + } + } + case []any: + for _, item := range typed { + if containsZero(item) { + return true + } + } + } + return false +} + +func NormalizeCommand(cmd CommitCommand) (CommitCommand, error) { + if cmd.WorkspaceID == uuid.Nil || cmd.ActorUserID == uuid.Nil || cmd.VaultID == uuid.Nil || cmd.BaseRevision < 0 || + cmd.BaseRevision >= MaxSafeRevision || len(cmd.Entries) > MaxMutationEntries || (len(cmd.Entries) == 0 && cmd.Title == nil) { + return cmd, ErrInvalid + } + if cmd.Title != nil && (!utf8.ValidString(*cmd.Title) || len(*cmd.Title) == 0 || len(*cmd.Title) > 256 || + strings.TrimSpace(*cmd.Title) == "" || strings.IndexFunc(*cmd.Title, unicode.IsControl) >= 0) { + return cmd, ErrInvalid + } + seen := make(map[uuid.UUID]bool) + cmd.Entries = append([]Mutation(nil), cmd.Entries...) + for i, entry := range cmd.Entries { + if entry.NoteID == uuid.Nil || seen[entry.NoteID] || ValidatePath(entry.Path) != nil || !utf8.ValidString(entry.Content) || + strings.IndexFunc(entry.Content, func(r rune) bool { return unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' }) >= 0 || (entry.Deleted && entry.Content != "") { + return cmd, ErrInvalid + } + if len(entry.Content) > MaxFileBytes { + return cmd, ErrLimit + } + seen[entry.NoteID] = true + properties, err := normalizeProperties(entry.Properties) + if err != nil { + return cmd, err + } + cmd.Entries[i].Properties = properties + } + return cmd, nil +} + +// plan applies a delta without silently discarding notes, tombstones or a +// conflict. Properties are data and can never grant tools or membership. +func plan(current Snapshot, cmd CommitCommand) (Snapshot, error) { + if current.Revision != cmd.BaseRevision { + return Snapshot{}, &RevisionConflict{current.Revision} + } + entries := make(map[uuid.UUID]Entry, len(current.Entries)+len(cmd.Entries)) + for _, entry := range current.Entries { + entries[entry.NoteID] = entry + } + next := current + next.Revision++ + if cmd.Title != nil { + next.Title = *cmd.Title + } + if next.Title == "" { + next.Title = "Vault " + cmd.VaultID.String()[:8] + } + for _, mutation := range cmd.Entries { + properties := mutation.Properties + if len(properties) == 0 { + properties = entries[mutation.NoteID].Properties + } + if len(properties) == 0 { + properties = json.RawMessage(`{}`) + } + entries[mutation.NoteID] = Entry{NoteID: mutation.NoteID, Path: mutation.Path, Content: mutation.Content, + Deleted: mutation.Deleted, Properties: append(json.RawMessage(nil), properties...), Revision: next.Revision} + } + if len(entries) > MaxEntries { + return Snapshot{}, ErrLimit + } + paths := make(map[string]bool) + total := 0 + next.Entries = make([]Entry, 0, len(entries)) + for _, entry := range entries { + if !entry.Deleted { + key := strings.ToLower(entry.Path) + if paths[key] { + return Snapshot{}, ErrPathConflict + } + paths[key] = true + } + total += len(entry.Content) + len(entry.Properties) + len(entry.Path) + if total > MaxCurrentBytes { + return Snapshot{}, ErrLimit + } + next.Entries = append(next.Entries, entry) + } + sort.Slice(next.Entries, func(i, j int) bool { + if next.Entries[i].Path == next.Entries[j].Path { + return next.Entries[i].NoteID.String() < next.Entries[j].NoteID.String() + } + return next.Entries[i].Path < next.Entries[j].Path + }) + return next, nil +} + +type querier interface { + Query(context.Context, string, ...any) (pgx.Rows, error) + QueryRow(context.Context, string, ...any) pgx.Row +} + +func readSnapshot(ctx context.Context, q querier, workspaceID, vaultID uuid.UUID) (Snapshot, error) { + snapshot := Snapshot{SchemaVersion: SchemaVersion, VaultID: vaultID, Entries: []Entry{}} + var updated time.Time + err := q.QueryRow(ctx, `SELECT title, revision, updated_at FROM vaults WHERE workspace_id=$1 AND id=$2`, workspaceID, vaultID). + Scan(&snapshot.Title, &snapshot.Revision, &updated) + if errors.Is(err, pgx.ErrNoRows) { + return snapshot, nil + } + if err != nil { + return Snapshot{}, err + } + snapshot.UpdatedAt = &updated + rows, err := q.Query(ctx, `SELECT r.note_id, r.path, r.content, r.revision, r.deleted, r.properties + FROM vault_entry_heads h JOIN vault_entry_revisions r USING(workspace_id,vault_id,note_id,revision) + WHERE h.workspace_id=$1 AND h.vault_id=$2 ORDER BY r.path COLLATE "C", r.note_id LIMIT $3`, workspaceID, vaultID, MaxEntries+1) + if err != nil { + return Snapshot{}, err + } + defer rows.Close() + for rows.Next() { + var entry Entry + if err := rows.Scan(&entry.NoteID, &entry.Path, &entry.Content, &entry.Revision, &entry.Deleted, &entry.Properties); err != nil { + return Snapshot{}, err + } + snapshot.Entries = append(snapshot.Entries, entry) + } + if len(snapshot.Entries) > MaxEntries { + return Snapshot{}, ErrLimit + } + return snapshot, rows.Err() +} + +func (s *Service) Snapshot(ctx context.Context, workspaceID, vaultID uuid.UUID) (Snapshot, error) { + if s == nil || s.pool == nil { + return Snapshot{}, ErrUnavailable + } + if workspaceID == uuid.Nil || vaultID == uuid.Nil { + return Snapshot{}, ErrInvalid + } + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return Snapshot{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + snapshot, err := readSnapshot(ctx, tx, workspaceID, vaultID) + if err != nil { + return Snapshot{}, err + } + if err := tx.Commit(ctx); err != nil { + return Snapshot{}, err + } + return snapshot, nil +} + +func (s *Service) List(ctx context.Context, workspaceID uuid.UUID) ([]Summary, error) { + if s == nil || s.pool == nil { + return nil, ErrUnavailable + } + if workspaceID == uuid.Nil { + return nil, ErrInvalid + } + rows, err := s.pool.Query(ctx, `SELECT id,title,revision,updated_at FROM vaults WHERE workspace_id=$1 ORDER BY updated_at DESC,id LIMIT $2`, workspaceID, MaxVaults+1) + if err != nil { + return nil, err + } + defer rows.Close() + result := []Summary{} + for rows.Next() { + var item Summary + if err := rows.Scan(&item.VaultID, &item.Title, &item.Revision, &item.UpdatedAt); err != nil { + return nil, err + } + result = append(result, item) + } + if len(result) > MaxVaults { + return nil, ErrLimit + } + return result, rows.Err() +} + +func (s *Service) Commit(ctx context.Context, raw CommitCommand) (Snapshot, error) { + if s == nil || s.pool == nil { + return Snapshot{}, ErrUnavailable + } + cmd, err := NormalizeCommand(raw) + if err != nil { + return Snapshot{}, err + } + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return Snapshot{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + // Take the existing stronger workspace coordination lock first. Ordinary + // file writers retain KEY SHARE compatibility, while Vault commits and + // creation quotas serialize across distinct Vault IDs in this workspace. + if _, err := workspacelock.ForAIProfileCoordination(ctx, tx, cmd.WorkspaceID); err != nil { + return Snapshot{}, err + } + var count int + if err := tx.QueryRow(ctx, `SELECT count(*) FROM vaults WHERE workspace_id=$1`, cmd.WorkspaceID).Scan(&count); err != nil { + return Snapshot{}, err + } + var exists bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM vaults WHERE workspace_id=$1 AND id=$2)`, cmd.WorkspaceID, cmd.VaultID).Scan(&exists); err != nil { + return Snapshot{}, err + } + if !exists && count >= MaxVaults { + return Snapshot{}, ErrLimit + } + defaultTitle := "Vault " + cmd.VaultID.String()[:8] + if _, err := tx.Exec(ctx, `INSERT INTO vaults(workspace_id,id,title) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, cmd.WorkspaceID, cmd.VaultID, defaultTitle); err != nil { + return Snapshot{}, err + } + var revision int64 + if err := tx.QueryRow(ctx, `SELECT revision FROM vaults WHERE workspace_id=$1 AND id=$2 FOR UPDATE`, cmd.WorkspaceID, cmd.VaultID).Scan(&revision); err != nil { + return Snapshot{}, err + } + if revision != cmd.BaseRevision { + return Snapshot{}, &RevisionConflict{revision} + } + current, err := readSnapshot(ctx, tx, cmd.WorkspaceID, cmd.VaultID) + if err != nil { + return Snapshot{}, err + } + next, err := plan(current, cmd) + if err != nil { + return Snapshot{}, err + } + var historyBytes int64 + var commits int + if err := tx.QueryRow(ctx, `SELECT COALESCE(sum(octet_length(content)+octet_length(properties::text)+octet_length(path)),0) + FROM vault_entry_revisions WHERE workspace_id=$1 AND vault_id=$2`, cmd.WorkspaceID, cmd.VaultID).Scan(&historyBytes); err != nil { + return Snapshot{}, err + } + if err := tx.QueryRow(ctx, `SELECT count(*) FROM vault_commits WHERE workspace_id=$1 AND vault_id=$2`, cmd.WorkspaceID, cmd.VaultID).Scan(&commits); err != nil { + return Snapshot{}, err + } + changed := make(map[uuid.UUID]Entry) + for _, entry := range next.Entries { + if entry.Revision == next.Revision { + changed[entry.NoteID] = entry + historyBytes += int64(len(entry.Content) + len(entry.Path) + len(entry.Properties)) + } + } + if historyBytes > MaxHistoryBytes || commits >= MaxCommits { + return Snapshot{}, ErrLimit + } + updated := time.Now().UTC() + if _, err := tx.Exec(ctx, `INSERT INTO vault_commits(workspace_id,vault_id,revision,title,actor_user_id,created_at) VALUES($1,$2,$3,$4,$5,$6)`, + cmd.WorkspaceID, cmd.VaultID, next.Revision, next.Title, cmd.ActorUserID, updated); err != nil { + return Snapshot{}, err + } + ids := make([]uuid.UUID, 0, len(changed)) + for id := range changed { + ids = append(ids, id) + } + // Delete only mutable pointers inside the atomic transaction. This permits + // swaps/renames without a transient uniqueness conflict; history remains. + if _, err := tx.Exec(ctx, `DELETE FROM vault_entry_heads WHERE workspace_id=$1 AND vault_id=$2 AND note_id=ANY($3::uuid[])`, cmd.WorkspaceID, cmd.VaultID, ids); err != nil { + return Snapshot{}, err + } + for _, entry := range changed { + if _, err := tx.Exec(ctx, `INSERT INTO vault_entry_revisions(workspace_id,vault_id,note_id,revision,path,content,deleted,properties) + VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb)`, cmd.WorkspaceID, cmd.VaultID, entry.NoteID, entry.Revision, entry.Path, entry.Content, entry.Deleted, string(entry.Properties)); err != nil { + return Snapshot{}, classifyWriteError(err) + } + if _, err := tx.Exec(ctx, `INSERT INTO vault_entry_heads(workspace_id,vault_id,note_id,revision,path,deleted) VALUES($1,$2,$3,$4,$5,$6)`, + cmd.WorkspaceID, cmd.VaultID, entry.NoteID, entry.Revision, entry.Path, entry.Deleted); err != nil { + return Snapshot{}, classifyWriteError(err) + } + } + // Enforce quotas against PostgreSQL's actual jsonb representation, including + // its spacing, rather than estimating persisted bytes from compact JSON. + var persistedCurrent, persistedHistory int64 + if err := tx.QueryRow(ctx, `SELECT COALESCE(sum(octet_length(r.content)+octet_length(r.properties::text)+octet_length(r.path)),0) + FROM vault_entry_heads h JOIN vault_entry_revisions r USING(workspace_id,vault_id,note_id,revision) + WHERE h.workspace_id=$1 AND h.vault_id=$2`, cmd.WorkspaceID, cmd.VaultID).Scan(&persistedCurrent); err != nil { + return Snapshot{}, err + } + if err := tx.QueryRow(ctx, `SELECT COALESCE(sum(octet_length(content)+octet_length(properties::text)+octet_length(path)),0) + FROM vault_entry_revisions WHERE workspace_id=$1 AND vault_id=$2`, cmd.WorkspaceID, cmd.VaultID).Scan(&persistedHistory); err != nil { + return Snapshot{}, err + } + if persistedCurrent > MaxCurrentBytes || persistedHistory > MaxHistoryBytes { + return Snapshot{}, ErrLimit + } + if _, err := tx.Exec(ctx, `UPDATE vaults SET title=$3,revision=$4,updated_at=$5 WHERE workspace_id=$1 AND id=$2`, cmd.WorkspaceID, cmd.VaultID, next.Title, next.Revision, updated); err != nil { + return Snapshot{}, err + } + if err := tx.Commit(ctx); err != nil { + return Snapshot{}, err + } + next.UpdatedAt = &updated + return next, nil +} + +func classifyWriteError(err error) error { + var pgError *pgconn.PgError + if errors.As(err, &pgError) && pgError.Code == "23505" { + return ErrPathConflict + } + if errors.As(err, &pgError) && pgError.Code == "23514" { + return ErrLimit + } + return fmt.Errorf("Vault write failed: %w", err) +} diff --git a/server/internal/vaultsync/service_integration_test.go b/server/internal/vaultsync/service_integration_test.go new file mode 100644 index 0000000..c56086a --- /dev/null +++ b/server/internal/vaultsync/service_integration_test.go @@ -0,0 +1,235 @@ +package vaultsync_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/pressly/goose/v3" + + "github.com/PeterGuy326/mem/server/internal/api" + "github.com/PeterGuy326/mem/server/internal/auth" + memdb "github.com/PeterGuy326/mem/server/internal/db" + "github.com/PeterGuy326/mem/server/internal/vaultsync" + "github.com/PeterGuy326/mem/server/internal/workspace" +) + +// Uses only an explicitly selected isolated _test database. No production +// migration, credential recovery, or container restart occurs in this test. +func TestVaultSyncPostgres(t *testing.T) { + dsn := os.Getenv("MEM_TEST_DB") + if dsn == "" { + t.Skip("MEM_TEST_DB not set; real PostgreSQL Vault verification was not executed") + } + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + t.Fatal("invalid isolated test database configuration") + } + if !strings.HasSuffix(config.ConnConfig.Database, "_test") { + t.Fatal("refusing to modify a database whose name does not end in _test") + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + database, err := memdb.Open(ctx, dsn) + if err != nil { + t.Fatal("could not connect to isolated test database") + } + t.Cleanup(database.Close) + if err := database.Migrate(ctx); err != nil { + t.Fatalf("isolated migrations: %v", err) + } + createTenant := func() (uuid.UUID, uuid.UUID) { + user, ws := uuid.New(), uuid.New() + if _, err := database.Pool.Exec(ctx, `INSERT INTO users(id,email,password_hash) VALUES($1,$2,'test-hash')`, user, user.String()+"@vault.test"); err != nil { + t.Fatal(err) + } + if _, err := database.Pool.Exec(ctx, `INSERT INTO workspaces(id,name,resource_owner_user_id) VALUES($1,'Vault test',$2)`, ws, user); err != nil { + t.Fatal(err) + } + if _, err := database.Pool.Exec(ctx, `INSERT INTO workspace_memberships(workspace_id,user_id,role) VALUES($1,$2,'owner')`, ws, user); err != nil { + t.Fatal(err) + } + return user, ws + } + userA, wsA := createTenant() + userB, wsB := createTenant() + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _ = database.Pool.Exec(cleanupCtx, `DELETE FROM users WHERE id=$1 OR id=$2`, userA, userB) + }) + service := vaultsync.New(database.Pool) + vault, noteA, noteB := uuid.New(), uuid.New(), uuid.New() + command := vaultsync.CommitCommand{WorkspaceID: wsA, ActorUserID: userA, VaultID: vault, Entries: []vaultsync.Mutation{ + {NoteID: noteA, Path: "岗位/第一份 空笔记.md", Content: ""}, {NoteID: noteB, Path: "共享/第二份 空笔记.md", Content: ""}}} + empty, err := service.Snapshot(ctx, wsA, vault) + if err != nil || empty.Revision != 0 || len(empty.Entries) != 0 { + t.Fatal("new Vault snapshot", err) + } + first, err := service.Commit(ctx, command) + if err != nil || first.Revision != 1 || len(first.Entries) != 2 { + t.Fatalf("two independent empty notes: %+v %v", first, err) + } + // Two clients read the same head, then race a different edit. Exactly one + // succeeds; the other gets a conflict without overwriting the winner. + var wg sync.WaitGroup + var lock sync.Mutex + successes, conflicts := 0, 0 + for _, text := range []string{"客户端 A 内容", "客户端 B 内容"} { + wg.Add(1) + go func(text string) { + defer wg.Done() + cmd := command + cmd.BaseRevision = 1 + cmd.Entries = []vaultsync.Mutation{{NoteID: noteA, Path: command.Entries[0].Path, Content: text}} + _, err := service.Commit(ctx, cmd) + lock.Lock() + defer lock.Unlock() + var conflict *vaultsync.RevisionConflict + if err == nil { + successes++ + } else if errors.As(err, &conflict) && conflict.CurrentRevision == 2 { + conflicts++ + } else { + t.Errorf("unexpected concurrent result: %v", err) + } + }(text) + } + wg.Wait() + if successes != 1 || conflicts != 1 { + t.Fatalf("CAS race successes=%d conflicts=%d", successes, conflicts) + } + second, err := service.Snapshot(ctx, wsA, vault) + if err != nil || second.Revision != 2 || len(second.Entries) != 2 { + t.Fatal("consistent snapshot after race", err) + } + other, err := service.Snapshot(ctx, wsB, vault) + if err != nil || other.Revision != 0 || len(other.Entries) != 0 { + t.Fatal("another workspace saw the Vault", err) + } + // Old text revisions remain unchanged and cannot be updated in place. + var original string + if err := database.Pool.QueryRow(ctx, `SELECT content FROM vault_entry_revisions WHERE workspace_id=$1 AND vault_id=$2 AND note_id=$3 AND revision=1`, wsA, vault, noteA).Scan(&original); err != nil || original != "" { + t.Fatal("original revision changed", err) + } + if _, err := database.Pool.Exec(ctx, `UPDATE vault_entry_revisions SET content='rewrite' WHERE workspace_id=$1 AND vault_id=$2 AND note_id=$3 AND revision=1`, wsA, vault, noteA); err == nil { + t.Fatal("immutable revision accepted UPDATE") + } + command.BaseRevision = 2 + command.Entries = []vaultsync.Mutation{{NoteID: noteA, Path: "岗位/第一份 空笔记.md", Deleted: true}} + third, err := service.Commit(ctx, command) + if err != nil || third.Revision != 3 || len(third.Entries) != 2 { + t.Fatal("delete tombstone commit", err) + } + for _, entry := range third.Entries { + if entry.NoteID == noteA && (!entry.Deleted || entry.Content != "") { + t.Fatal("missing tombstone") + } + } + // Test swap/rename under the actual unique index, then title-only commit. + command.BaseRevision = 3 + command.Entries = []vaultsync.Mutation{{NoteID: noteA, Path: "共享/第二份 空笔记.md", Content: "恢复"}, {NoteID: noteB, Path: "岗位/第一份 空笔记.md", Content: "交换"}} + fourth, err := service.Commit(ctx, command) + if err != nil || fourth.Revision != 4 { + t.Fatal("atomic path swap", err) + } + title := "组织知识库" + command.BaseRevision = 4 + command.Entries = nil + command.Title = &title + fifth, err := service.Commit(ctx, command) + if err != nil || fifth.Revision != 5 || fifth.Title != title { + t.Fatal("title CAS", err) + } + listed, err := service.List(ctx, wsA) + if err != nil || len(listed) != 1 || listed[0].VaultID != vault { + t.Fatal("Vault discovery", err) + } + // Real HTTP + canonical auth + workspace middleware use sandbox-only tokens. + authService := auth.New(database.Pool) + ttl := time.Now().Add(time.Minute) + writer, _, err := authService.CreateToken(ctx, userA, &wsA, "vault-test-writer", []string{auth.ScopeRead, auth.ScopeWrite}, nil, &ttl, false) + if err != nil { + t.Fatal(err) + } + reader, _, err := authService.CreateToken(ctx, userA, &wsA, "vault-test-reader", []string{auth.ScopeRead}, nil, &ttl, false) + if err != nil { + t.Fatal(err) + } + foreign, _, err := authService.CreateToken(ctx, userB, &wsB, "vault-test-foreign", []string{auth.ScopeRead}, nil, &ttl, false) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer((&api.Server{Auth: authService, Workspace: workspace.New(database.Pool), VaultSync: service, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}).Router()) + defer server.Close() + request := func(method, route, token string, body any) (int, []byte) { + t.Helper() + var encoded []byte + if body != nil { + encoded, err = json.Marshal(body) + if err != nil { + t.Fatal(err) + } + } + req, err := http.NewRequestWithContext(ctx, method, server.URL+route, bytes.NewReader(encoded)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + payload, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, payload + } + status, body := request("GET", "/v1/vault/snapshot?vaultId="+vault.String(), reader, nil) + if status != 200 || !bytes.Contains(body, []byte("组织知识库")) { + t.Fatal("authenticated snapshot HTTP", status) + } + status, _ = request("POST", "/v1/vault/commit", reader, map[string]any{"vaultId": vault, "baseRevision": 5, "title": "拒绝只读写入", "entries": []any{}}) + if status != 403 { + t.Fatal("read-only credential could commit", status) + } + status, body = request("GET", "/v1/vault/snapshot?vaultId="+vault.String(), foreign, nil) + if status != 200 || !bytes.Contains(body, []byte(`"revision":0`)) || bytes.Contains(body, []byte("组织知识库")) { + t.Fatal("HTTP cross-workspace isolation", status) + } + status, body = request("POST", "/v1/vault/commit", writer, map[string]any{"vaultId": vault, "baseRevision": 1, "title": "stale", "entries": []any{}}) + if status != 409 || !bytes.Contains(body, []byte(`"currentRevision":5`)) { + t.Fatalf("HTTP CAS conflict: %d %s", status, body) + } + // The additive migration is reversible on a disposable test deployment; + // dropping Vault tables does not change existing files or auth tables. + server.Close() + sqlDB, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal("could not open isolated migration rehearsal") + } + defer sqlDB.Close() + goose.SetBaseFS(os.DirFS("../db")) + if err := goose.DownToContext(ctx, sqlDB, "migrations", 26); err != nil { + t.Fatalf("isolated Vault down migration: %v", err) + } + if err := database.Migrate(ctx); err != nil { + t.Fatalf("isolated Vault up migration: %v", err) + } +} diff --git a/server/internal/vaultsync/service_test.go b/server/internal/vaultsync/service_test.go new file mode 100644 index 0000000..cd81ccb --- /dev/null +++ b/server/internal/vaultsync/service_test.go @@ -0,0 +1,150 @@ +package vaultsync + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/google/uuid" +) + +func validCommand() CommitCommand { + return CommitCommand{WorkspaceID: uuid.New(), ActorUserID: uuid.New(), VaultID: uuid.New(), + Entries: []Mutation{{NoteID: uuid.New(), Path: "知识/空白笔记.md", Content: ""}}} +} + +func TestLogicalIdentityEmptyNotesAndCAS(t *testing.T) { + cmd := validCommand() + cmd.Entries = append(cmd.Entries, Mutation{NoteID: uuid.New(), Path: "知识/另一个空笔记.md", Content: ""}) + empty := Snapshot{SchemaVersion: SchemaVersion, VaultID: cmd.VaultID, Entries: []Entry{}} + first, err := plan(empty, cmd) + if err != nil || first.Revision != 1 || len(first.Entries) != 2 || first.Entries[0].NoteID == first.Entries[1].NoteID { + t.Fatalf("separate empty note identities: %+v, %v", first, err) + } + _, err = plan(first, cmd) + var conflict *RevisionConflict + if !errors.As(err, &conflict) || conflict.CurrentRevision != 1 { + t.Fatalf("stale client must conflict: %v", err) + } + if len(first.Entries) != 2 || first.Entries[0].Content != "" { + t.Fatal("conflict modified original snapshot") + } +} + +func TestDeltaPreservesUntouchedNotesMetadataAndTombstones(t *testing.T) { + cmd := validCommand() + cmd.Entries[0].Properties = json.RawMessage(`{"positionIds":["writer"],"source":"user"}`) + first, _ := plan(Snapshot{VaultID: cmd.VaultID}, cmd) + noteID := cmd.Entries[0].NoteID + cmd.BaseRevision = 1 + cmd.Entries = []Mutation{{NoteID: noteID, Path: "知识/改名.md", Content: "第二版"}, + {NoteID: uuid.New(), Path: "共享 SOP.md", Content: "流程"}} + second, err := plan(first, cmd) + if err != nil || len(second.Entries) != 2 { + t.Fatal(err) + } + for _, entry := range second.Entries { + if entry.NoteID == noteID && !strings.Contains(string(entry.Properties), "writer") { + t.Fatal("omitted properties erased metadata") + } + } + cmd.BaseRevision = 2 + cmd.Entries = []Mutation{{NoteID: noteID, Path: "知识/改名.md", Deleted: true}} + third, err := plan(second, cmd) + if err != nil || len(third.Entries) != 2 { + t.Fatal("deletion must retain tombstone and unrelated note", err) + } + for _, entry := range third.Entries { + if entry.NoteID == noteID && (!entry.Deleted || entry.Content != "" || entry.Revision != 3) { + t.Fatal("invalid tombstone") + } + } + if first.Entries[0].Path != "知识/空白笔记.md" || first.Revision != 1 { + t.Fatal("later revisions mutated previous snapshot") + } +} + +func TestPathsDuplicatesAndUntrustedPropertiesAreValidated(t *testing.T) { + for _, name := range []string{"../secret.md", "/absolute.md", "a\\b.md", "a/../b.md", "a//b.md", "a\x00.md", "C:notes.md", "dir/a ", "dir/a.", "a?.md"} { + if ValidatePath(name) == nil { + t.Fatalf("unsafe path accepted: %q", name) + } + } + if err := ValidatePath("客户 ACME/项目 決策.md"); err != nil { + t.Fatal(err) + } + for _, properties := range []string{`null`, `[]`, `{"source":"\u0000"}`, `{"source":true} trailing`} { + cmd := validCommand() + cmd.Entries[0].Properties = json.RawMessage(properties) + if _, err := NormalizeCommand(cmd); err == nil { + t.Fatalf("invalid metadata accepted: %s", properties) + } + } + cmd := validCommand() + cmd.Entries[0].Properties = json.RawMessage(`{"tools":["write"],"authority":"admin"}`) + normalized, err := NormalizeCommand(cmd) + if err != nil || normalized.ActorUserID != cmd.ActorUserID || normalized.WorkspaceID != cmd.WorkspaceID { + t.Fatal("metadata changed authorization", err) + } + cmd.Entries = append(cmd.Entries, cmd.Entries[0]) + if _, err := NormalizeCommand(cmd); !errors.Is(err, ErrInvalid) { + t.Fatal("duplicate note ID accepted") + } + cmd = validCommand() + cmd.Entries[0].Content = "secret" + cmd.Entries[0].Deleted = true + if _, err := NormalizeCommand(cmd); !errors.Is(err, ErrInvalid) { + t.Fatal("tombstone retained content") + } +} + +func TestActivePathsConflictCaseInsensitivelyAndSwapsAreValid(t *testing.T) { + cmd := validCommand() + cmd.Entries = []Mutation{{NoteID: uuid.New(), Path: "A.md"}, {NoteID: uuid.New(), Path: "a.md"}} + if _, err := plan(Snapshot{VaultID: cmd.VaultID}, cmd); !errors.Is(err, ErrPathConflict) { + t.Fatal("Windows path collision accepted") + } + cmd.Entries[1].Path = "B.md" + first, err := plan(Snapshot{VaultID: cmd.VaultID}, cmd) + if err != nil { + t.Fatal(err) + } + cmd.BaseRevision = 1 + cmd.Entries[0].Path = "B.md" + cmd.Entries[1].Path = "A.md" + if _, err := plan(first, cmd); err != nil { + t.Fatal("atomic path swap rejected", err) + } +} + +func TestBoundsAndTitleOnlyCAS(t *testing.T) { + cmd := validCommand() + cmd.Entries[0].Content = strings.Repeat("x", MaxFileBytes+1) + if _, err := NormalizeCommand(cmd); !errors.Is(err, ErrLimit) { + t.Fatal("oversize note accepted") + } + cmd = validCommand() + cmd.Entries = nil + if _, err := NormalizeCommand(cmd); err == nil { + t.Fatal("empty mutation accepted") + } + title := "岗位知识库" + cmd.Title = &title + cmd, err := NormalizeCommand(cmd) + if err != nil { + t.Fatal(err) + } + result, err := plan(Snapshot{VaultID: cmd.VaultID}, cmd) + if err != nil || result.Title != title || result.Revision != 1 || len(result.Entries) != 0 { + t.Fatal("title-only commit failed", err) + } + large := Snapshot{VaultID: cmd.VaultID} + for i := 0; i < MaxEntries; i++ { + large.Entries = append(large.Entries, Entry{NoteID: uuid.New(), Path: "old.md", Deleted: true, Properties: json.RawMessage(`{}`)}) + } + cmd.Entries = []Mutation{{NoteID: uuid.New(), Path: "new.md"}} + if _, err := plan(large, cmd); !errors.Is(err, ErrLimit) { + t.Fatal("tombstones were excluded from note-count bound") + } +} From 0b91975ee96968482903cedd3dd3121580b17d9c Mon Sep 17 00:00:00 2001 From: huyz <739820174@qq.com> Date: Fri, 9 Oct 2026 00:30:53 +0800 Subject: [PATCH 2/3] fix(web): remove vulnerable compiler dependency chains Refs #234. Replace the unpatched braces dependency graph with official Tailwind/PostCSS 4 and typescript-eslint 8; retain the existing theme and utility behavior with browser compatibility checks. Linux and Windows audit thresholds remain unchanged and report zero findings. All 109 Web tests, production build and browser acceptance pass. --- web/package-lock.json | 1886 +++++++++++++++++------------------- web/package.json | 9 +- web/postcss.config.js | 2 +- web/src/styles/globals.css | 34 +- web/theme-acceptance.mjs | 37 + 5 files changed, 969 insertions(+), 999 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index ce35cc0..5eec4fe 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,16 +18,17 @@ "react-dom": "^19.2.8", "react-router": "^8.3.0", "sonner": "^1.7.0", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^3.7.0" }, "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", + "@typescript-eslint/eslint-plugin": "^8.71.1", + "@typescript-eslint/parser": "^8.71.1", "@vitejs/plugin-react": "^6.0.4", "autoprefixer": "^10.4.20", "eslint": "^8.57.1", @@ -38,7 +39,7 @@ "playwright": "^1.60.0", "postcss": "^8.5.23", "prettier": "^3.3.3", - "tailwindcss": "^3.4.14", + "tailwindcss": "^4.3.3", "typescript": "^5.6.3", "vite": "^8.1.5", "vitest": "^4.1.10" @@ -312,7 +313,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" @@ -325,7 +325,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -337,7 +336,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -396,9 +394,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -502,9 +500,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -645,6 +643,17 @@ "@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", @@ -1724,159 +1733,691 @@ "dev": true, "license": "MIT" }, - "node_modules/@tanstack/query-core": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", - "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" } }, - "node_modules/@tanstack/react-query": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", - "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", - "license": "MIT", + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "@tanstack/query-core": "5.100.10" + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, - "peerDependencies": { - "react": "^18 || ^19" + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@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==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "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" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@testing-library/jest-dom": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", - "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "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" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=22", - "npm": ">=6", - "yarn": ">=1" + "node": ">= 12.0.0" }, - "peerDependencies": { - "@testing-library/dom": ">=10 <11" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "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" + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@testing-library/user-event": { - "version": "14.6.3", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz", - "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12", - "npm": ">=6" + "node": ">= 12.0.0" }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "peer": true + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "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==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "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" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "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/@testing-library/user-event": { + "version": "14.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz", + "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/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" }, @@ -1935,122 +2476,159 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", - "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/type-utils": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" + "@typescript-eslint/parser": "^8.71.1", + "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.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "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/parser": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", - "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "debug": "^4.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", - "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "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.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "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": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", - "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -2058,75 +2636,88 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", - "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "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.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", - "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", - "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.71.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/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/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", @@ -2339,34 +2930,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2396,16 +2959,6 @@ "dequal": "^2.0.3" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2483,40 +3036,27 @@ "require-from-string": "^2.0.2" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/brace-expansion/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", - "dependencies": { - "fill-range": "^7.1.1" - }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, "node_modules/browserslist": { @@ -2563,16 +3103,6 @@ "node": ">=6" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -2621,44 +3151,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -2713,16 +3205,6 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2793,19 +3275,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2885,33 +3354,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2947,6 +3389,20 @@ "dev": true, "license": "MIT" }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -2960,16 +3416,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -3111,9 +3557,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3225,36 +3671,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "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", @@ -3319,19 +3735,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3407,16 +3810,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3472,9 +3865,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3511,26 +3904,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "ISC" }, "node_modules/graphemer": { "version": "1.4.0", @@ -3559,19 +3938,6 @@ "node": ">=8" } }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/headers-polyfill": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", @@ -3662,35 +4028,6 @@ "dev": true, "license": "ISC" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3731,16 +4068,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -3766,13 +4093,13 @@ "license": "ISC" }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-tokens": { @@ -4153,26 +4480,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4233,40 +4540,16 @@ "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/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "@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", @@ -4278,16 +4561,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4371,18 +4654,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -4419,36 +4690,6 @@ "node": ">=18" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -4586,13 +4827,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -4600,16 +4834,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "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", @@ -4624,39 +4848,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/playwright": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", @@ -4733,133 +4924,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -5073,29 +5137,6 @@ } } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -5130,28 +5171,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5275,9 +5294,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "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": { @@ -5337,16 +5356,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -5358,9 +5367,9 @@ } }, "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==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5452,29 +5461,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5488,19 +5474,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -5522,9 +5495,9 @@ } }, "node_modules/tailwind-merge": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", - "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", "license": "MIT", "funding": { "type": "github", @@ -5532,41 +5505,24 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, "engines": { - "node": ">=14.0.0" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/text-table": { @@ -5576,29 +5532,6 @@ "dev": true, "license": "MIT" }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5694,19 +5627,6 @@ "dev": true, "license": "MIT" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -5734,25 +5654,18 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "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": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5800,9 +5713,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "engines": { @@ -5910,13 +5823,6 @@ } } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", diff --git a/web/package.json b/web/package.json index 2988971..177f4d8 100644 --- a/web/package.json +++ b/web/package.json @@ -37,16 +37,17 @@ "react-dom": "^19.2.8", "react-router": "^8.3.0", "sonner": "^1.7.0", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^3.7.0" }, "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", + "@typescript-eslint/eslint-plugin": "^8.71.1", + "@typescript-eslint/parser": "^8.71.1", "@vitejs/plugin-react": "^6.0.4", "autoprefixer": "^10.4.20", "eslint": "^8.57.1", @@ -57,7 +58,7 @@ "playwright": "^1.60.0", "postcss": "^8.5.23", "prettier": "^3.3.3", - "tailwindcss": "^3.4.14", + "tailwindcss": "^4.3.3", "typescript": "^5.6.3", "vite": "^8.1.5", "vitest": "^4.1.10" diff --git a/web/postcss.config.js b/web/postcss.config.js index 2aa7205..51a6e4e 100644 --- a/web/postcss.config.js +++ b/web/postcss.config.js @@ -1,6 +1,6 @@ export default { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, }, }; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 41ef4dd..3e10e37 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -1,8 +1,14 @@ @import './design-tokens.generated.css'; +@import 'tailwindcss'; -@tailwind base; -@tailwind components; -@tailwind utilities; +@config '../../tailwind.config.js'; + +/* Preserve the existing v3 utility scale while removing its unpatched + * dependency chain. The product palette remains in the pinned JS config. */ +@theme { + --radius-sm: 0.125rem; + --blur-sm: 4px; +} @layer base { html, @@ -11,6 +17,16 @@ height: 100%; } + button:not(:disabled), + [role='button']:not(:disabled) { + cursor: pointer; + } + + input::placeholder, + textarea::placeholder { + color: rgb(156 163 175); + } + body { @apply bg-bg text-fg font-sans antialiased; font-size: 14px; @@ -48,7 +64,7 @@ /* Focus ring — keyboard only, geometric not glow-y */ :focus-visible { - @apply outline-none ring-2 ring-accent/60 ring-offset-2 ring-offset-bg; + @apply outline-hidden ring-2 ring-accent/60 ring-offset-2 ring-offset-bg; } /* Heading defaults */ @@ -108,3 +124,13 @@ background: linear-gradient(90deg, transparent, rgb(var(--border-strong) / 0.25), transparent); animation: shimmer 1.6s infinite; } + +/* Keep existing class consumers' keyboard outline visible in forced colors. + * These declarations follow the v4 utility layer's outline-style: none. */ +@layer utilities { + .outline-none, + .focus-visible\:outline-none:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; + } +} diff --git a/web/theme-acceptance.mjs b/web/theme-acceptance.mjs index a347a50..e2f0bfe 100644 --- a/web/theme-acceptance.mjs +++ b/web/theme-acceptance.mjs @@ -116,6 +116,38 @@ async function assertTheme(page, theme) { assert.equal(await page.locator('[data-toast-theme]').getAttribute('data-toast-theme'), theme); } +async function assertUtilityCompatibility(page) { + await page.keyboard.press('Tab'); + const values = await page.evaluate(() => { + const probe = document.createElement('button'); + probe.className = 'rounded-sm backdrop-blur-sm text-fg bg-accent/10 outline-none'; + const focusProbe = document.createElement('button'); + focusProbe.className = 'focus-visible:outline-none'; + const reference = document.createElement('span'); + reference.style.color = 'rgb(var(--fg) / calc(var(--fg-opacity, 1) * 1))'; + reference.style.backgroundColor = 'rgb(var(--accent) / calc(var(--accent-opacity, 1) * 0.1))'; + document.body.append(probe, focusProbe, reference); focusProbe.focus(); + const rgba = (value) => { + const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1; + const context = canvas.getContext('2d'); context.fillStyle = value; context.fillRect(0, 0, 1, 1); + return [...context.getImageData(0, 0, 1, 1).data]; + }; + const style = getComputedStyle(probe); const focused = getComputedStyle(focusProbe); const expected = getComputedStyle(reference); + const result = { radius: style.borderRadius, blur: style.backdropFilter, cursor: style.cursor, + outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, + focusVisible: focusProbe.matches(':focus-visible'), focusOutlineStyle: focused.outlineStyle, focusOutlineWidth: focused.outlineWidth, + foreground: rgba(style.color), expectedForeground: rgba(expected.color), + accent: rgba(style.backgroundColor), expectedAccent: rgba(expected.backgroundColor) }; + probe.remove(); focusProbe.remove(); reference.remove(); return result; + }); + assert.equal(values.radius, '2px'); assert.equal(values.blur, 'blur(4px)'); assert.equal(values.cursor, 'pointer'); + assert.equal(values.outlineStyle, 'solid'); assert.equal(values.outlineWidth, '2px'); + assert.equal(values.focusVisible, true); assert.equal(values.focusOutlineStyle, 'solid'); assert.equal(values.focusOutlineWidth, '2px'); + for (const [actual, expected] of [[values.foreground, values.expectedForeground], [values.accent, values.expectedAccent]]) { + assert.ok(actual.every((value, index) => Math.abs(value - expected[index]) <= 1), 'theme color/opacity utility changed'); + } +} + const vite = startVite(); let browser; let failure; @@ -135,6 +167,7 @@ try { const lightToggle = defaultPage.getByRole('button', { name: 'Switch to light theme' }); await lightToggle.waitFor(); await assertTheme(defaultPage, 'dark'); + await assertUtilityCompatibility(defaultPage); assert.equal( await defaultPage.evaluate(() => localStorage.getItem('mem.theme')), null, @@ -148,6 +181,10 @@ try { await lightToggle.click(); await assertTheme(defaultPage, 'light'); + await assertUtilityCompatibility(defaultPage); + await defaultPage.emulateMedia({ forcedColors: 'active' }); + await assertUtilityCompatibility(defaultPage); + await defaultPage.emulateMedia({ forcedColors: 'none' }); assert.equal(await defaultPage.evaluate(() => localStorage.getItem('mem.theme')), 'light'); assert.equal( (await defaultPage.evaluate(() => getComputedStyle(document.body).backgroundColor)).replaceAll( From 18db8a26791b3827b86e8d3c9e11fec5ac171999 Mon Sep 17 00:00:00 2001 From: huyz <739820174@qq.com> Date: Fri, 9 Oct 2026 00:30:54 +0800 Subject: [PATCH 3/3] fix(ci): build lifecycle storage from pinned official sources Refs #234. Former anonymous MinIO/mc image pulls fail before lifecycle setup. Verify fixed official source archive hashes, build a run-specific test image and remove owned resources without changing production assets or assertions. Record CI baseline causes, public advisories, complete Web validation and the full lifecycle run with Go 1.25.0. --- CHANGELOG.md | 11 +++ docs/TESTING.md | 9 +++ docs/acceptance/VAULT_SYNC_VALIDATION.md | 91 +++++++++++++++++++++++- scripts/acceptance_agent_memory.sh | 79 +++++++++++++++++++- 4 files changed, 187 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd24c0c..b449a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ The project publishes 0.x prerelease versions; a stable release line is not yet Design: `docs/agent-memory-namespace.md`. - `mem put --watch` one-way foreground directory watch: poll `--interval` (default 30s), ingest new files after one quiet interval, report changed files without re-ingesting, and never propagate local deletes. Refs #110. +### Fixed + +- Restore Linux/Windows Web dependency audit gates by removing the unpatched + braces build-tool chain with official Tailwind/PostCSS 4 and typescript-eslint + 8, while retaining existing theme utility behavior and browser acceptance. + Apply compatible transitive security updates without relaxing audit thresholds. + Validation and public advisory references: `docs/acceptance/VAULT_SYNC_VALIDATION.md`. +- Build isolated lifecycle-test MinIO/mc images from checksum-pinned official + sources when former public images are no longer anonymously pullable. Keep + production assets and all HTTP/CLI/MCP assertions unchanged. Refs #234. + ### Changed - The live recall producer (`python3 -m benchmarks.recall produce`) reads engine, profile, provider and embedding dimension from the running memd instead of CLI labels, scores the multilingual v1 set (English and Chinese, including image-description queries), and stays on-demand rather than a CI gate. Refs #175. diff --git a/docs/TESTING.md b/docs/TESTING.md index 1deec06..480a42e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -300,6 +300,15 @@ Run the isolated, model-free process test: make test-acceptance ``` +The script builds temporary MinIO/mc binaries from checksum-pinned official +source archives because Community Edition is now source-only. It uses the +existing Go toolchain, `tar` and `sha256sum`, then builds a uniquely named test +image on a pinned official Alpine runtime. The temporary Compose override, +image and run directory are removed by its ownership-checked cleanup. This +does not replace images or credentials in a production deployment. Network +access to the fixed source archives and locked Go module dependencies is +required; missing artifacts or checksum mismatches fail the gate. + The script owns a per-run Compose project, random loopback PostgreSQL/MinIO ports, an ephemeral database/store and a new memd process on an atomically locked, twice-preflighted loopback port. It builds the current CLI, MCP adapter diff --git a/docs/acceptance/VAULT_SYNC_VALIDATION.md b/docs/acceptance/VAULT_SYNC_VALIDATION.md index 59688bc..f16d0c4 100644 --- a/docs/acceptance/VAULT_SYNC_VALIDATION.md +++ b/docs/acceptance/VAULT_SYNC_VALIDATION.md @@ -59,8 +59,8 @@ untrusted data and do not add workspace membership or tool authority. - CLI/MCP/Web: N/A for new Vault UI/commands in this backend change. Their existing behavior is covered by the complete Go suite where applicable; no separate Vault adapter or desktop sync daemon is claimed. -- Worker/Web build/browser suites: N/A; no Worker, React Web, protobuf, model or - queue behavior changed in this scoped server addition. +- Worker: N/A; no Worker, protobuf, model or queue behavior changed. The Web + tooling follow-up and its complete validation are recorded below. - Provenance/privacy: public snapshot contains current note data/properties, with no authentication material, actor audit IDs or new authorization model. - Retry: a stale or uncertain commit is reconciled through current-head @@ -71,3 +71,90 @@ untrusted data and do not add workspace membership or tool authority. v1/v2 does not include Vault state; retained history needs database backup. - Deployment: NOT VERIFIED by this publication worktree; no production migration, restart, credential issuance, release or hosted-service rollout ran. + +## CI baseline repair (2026-10-09) + +The first PR run passed Go, PostgreSQL, Worker, existing Web browser acceptance, +deployment profiles, CodeQL and dependency review. Three gates failed for +unchanged baseline dependencies or external artifacts, before exercising any +Vault code: + +- [Linux Web audit](https://github.com/bytefolk/mem/actions/runs/37806469015/job/113411946797) + and [Windows audit](https://github.com/bytefolk/mem/actions/runs/37806469015/job/113411946574) + both reported 14 high and 2 moderate findings in the original lockfile. +- [HTTP/CLI/MCP lifecycle](https://github.com/bytefolk/mem/actions/runs/37806468988/job/113411942810) + stopped at an unauthorized pull of the original public MinIO image; no + application service or migration had started. + +This follow-up keeps the audit thresholds, all lifecycle assertions and CI +timeouts. It does not suppress vulnerabilities, ignore findings or disable a +check. + +### Dependency cause and compatibility + +The published [braces stack-exhaustion advisory](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm) +affects every available version through 3.0.3 and lists no patched version. +Tailwind 3.4.19 still requires chokidar 3, fast-glob and micromatch, which retain +that chain. typescript-eslint 7 also retains it through globby/fast-glob. These +are active CSS and TypeScript build tools, so removing an unused package or +applying a compatible braces patch cannot remove the finding. + +Use official Tailwind 4.3.3 and its same-version PostCSS plugin, plus +typescript-eslint 8.71.1 (which supports the existing ESLint 8.57.1). Keep the +existing theme/config palette, font, animation and shadow definitions. Use +tailwind-merge 3.7.0 for its [supported Tailwind 4 class semantics](https://github.com/dcastil/tailwind-merge/blob/main/README.md). +Apply compatible lockfile patches for +[brace-expansion](https://github.com/advisories/GHSA-q2hr-2g5m-vwhr), +[source-map-js](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) and the published +undici advisories surfaced by npm audit. No vulnerable package is renamed or +replaced by an unreviewed fork to hide its audit identity. + +Follow the [official compiler upgrade contract](https://tailwindcss.com/docs/upgrade-guide): +the existing JS theme is loaded explicitly with `@config`, and compilation uses +`@tailwindcss/postcss`. Global compatibility declarations retain the existing +2px small radius, 4px backdrop blur, button cursor, placeholder defaults and +keyboard outlines. No React component or product flow is migrated. + +| Verification | Result | +| --- | --- | +| Linux Node 24.11.1 / npm 11.17.0 `npm run audit` | PASS; zero vulnerabilities at original production-moderate and all-dependencies-high thresholds | +| Windows Node 24.19.0 / npm 11.17.0 `npm.cmd run audit` | PASS; both original thresholds and shell-free Windows invocation pass | +| `npm run lint`, `npm run typecheck`, `npm test`, `npm run build` | PASS; 109 unit tests; pinned token bridge checks 334 contrast pairs; production build succeeds | +| Fresh `npm ci` followed by `npm run audit` and `npm run build` | PASS; checked lockfile reproduces the zero-finding audit and same production assets | +| `npm run test:i18n`, `test:theme`, `test:enrichment`, `test:memory`, `test:transfer`, `test:managed-embedding` | PASS; all original assertions retained | +| New theme compatibility browser probes | PASS; dark/light RGB and opacity, radius/blur, pointer and 2px solid keyboard outline also verified under forced colors | + +The token-source and generated-CSS validation copies were normalized to their +Git-equivalent LF bytes for Linux's pinned-byte check. Neither the upstream +token hash nor generated content was changed in the commit. The existing +production bundle-size warning remains visible. + +### Lifecycle artifact repair + +MinIO Community Edition's [official source-only distribution contract](https://github.com/minio/minio#source-only-distribution) +no longer provides the anonymously pullable historical artifacts used by this +test. Use the official +[MinIO source](https://github.com/minio/minio/tree/7aac2a2c5b7c882e68c1ce017d8256be2feea27f) +and [mc source](https://github.com/minio/mc/tree/77f82e18b5401a65958f1619df6ebb994634bd88), +pinning both commits and archive SHA-256 before extraction. Build with existing +Go using `-mod=readonly`, then place the binaries and their upstream licenses +into a fixed-digest official Alpine runtime image. A temporary Compose override +uses only the uniquely named test image, with `pull_policy: never`; the existing +test Compose graph and production deployment assets are unchanged. + +| Verification | Result | +| --- | --- | +| `bash -n scripts/acceptance_agent_memory.sh` | PASS | +| Official ShellCheck 0.11.0 on the final script | PASS | +| Full `scripts/acceptance_agent_memory.sh` with the CI-exact `GOTOOLCHAIN=go1.25.0` | PASS; official-source build, fresh migration 27, healthy MinIO, real HTTP/CLI/MCP and PostgreSQL forgetting/redaction assertions all complete | +| Ownership cleanup | PASS; run-specific containers, networks, volumes, image, directory and port lock removed; production containers unchanged | + +The local Windows-owned worktree used `GOFLAGS=-buildvcs=false` only while +executing its validation binary builds, because Linux Git cannot parse its +Windows `.git` pointer. The script's checksum-pinned upstream tarball builds +explicitly disable VCS stamping, rather than accidentally attributing those +sources to the enclosing checkout. The CI checkout, Go version, timeout and +all existing service and lifecycle assertions remain unchanged. + +Remote CI on the repaired head must still rerun. Local pass is not substituted +for required remote checks, CODEOWNERS approval or resolved review conversations. diff --git a/scripts/acceptance_agent_memory.sh b/scripts/acceptance_agent_memory.sh index f913556..99977cb 100755 --- a/scripts/acceptance_agent_memory.sh +++ b/scripts/acceptance_agent_memory.sh @@ -4,7 +4,9 @@ set -euo pipefail REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" COMPOSE_FILE="${REPO_ROOT}/docker-compose.test.yml" +COMPOSE_OVERRIDE="" COMPOSE_PROJECT="" +MINIO_IMAGE="" PG_PORT="${MEM_ACCEPTANCE_PG_PORT:-0}" S3_PORT="${MEM_ACCEPTANCE_S3_PORT:-0}" HTTP_PORT_REQUESTED="${MEM_ACCEPTANCE_HTTP_PORT:-}" @@ -33,11 +35,15 @@ require_command() { } compose() { + local -a compose_files=(-f "$COMPOSE_FILE") + if [[ -n "$COMPOSE_OVERRIDE" ]]; then + compose_files+=(-f "$COMPOSE_OVERRIDE") + fi MEM_TEST_PROJECT="$COMPOSE_PROJECT" \ MEM_TEST_PG_PORT="$PG_PORT" \ MEM_TEST_S3_PORT="$S3_PORT" \ MEM_TEST_DB_NAME="$DB_NAME" \ - docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" --profile e2e "$@" + docker compose -p "$COMPOSE_PROJECT" "${compose_files[@]}" --profile e2e "$@" } cleanup() { @@ -63,6 +69,14 @@ cleanup() { printf 'WARNING: failed to remove Compose project %s\n' "$COMPOSE_PROJECT" >&2 cleanup_status=1 fi + if [[ -n "$MINIO_IMAGE" ]] && + docker image inspect "$MINIO_IMAGE" >/dev/null 2>&1; then + if [[ "$MINIO_IMAGE" != "${COMPOSE_PROJECT}:minio" ]] || + ! docker image rm "$MINIO_IMAGE" >/dev/null 2>&1; then + printf 'WARNING: failed to remove acceptance MinIO image\n' >&2 + cleanup_status=1 + fi + fi if [[ -n "$E2E_DIR" && "$E2E_DIR" == "${E2E_ROOT}/acceptance."* ]]; then if ! rm -rf -- "$E2E_DIR"; then printf 'WARNING: failed to remove acceptance directory\n' >&2 @@ -92,6 +106,8 @@ require_command curl require_command jq require_command od require_command tr +require_command tar +require_command sha256sum curl_safe() { command curl --connect-timeout 2 --max-time 20 "$@" @@ -184,6 +200,64 @@ published_port() { printf '%s\n' "${address##*:}" } +build_test_minio() { + # MinIO CE is source-only; its former public minio and mc images no longer + # pull anonymously. Build the official sources, without third-party mirrors. + # https://github.com/minio/minio#source-only-distribution + local minio_revision="7aac2a2c5b7c882e68c1ce017d8256be2feea27f" + local mc_revision="77f82e18b5401a65958f1619df6ebb994634bd88" + local build_dir="${E2E_DIR}/minio-build" + mkdir -p "${build_dir}/minio" "${build_dir}/mc" "${build_dir}/licenses" + curl --fail --silent --show-error --location --retry 2 \ + --connect-timeout 5 --max-time 180 \ + "https://codeload.github.com/minio/minio/tar.gz/${minio_revision}" \ + --output "${build_dir}/minio.tar.gz" + curl --fail --silent --show-error --location --retry 2 \ + --connect-timeout 5 --max-time 180 \ + "https://codeload.github.com/minio/mc/tar.gz/${mc_revision}" \ + --output "${build_dir}/mc.tar.gz" + printf '%s %s\n' \ + '71794c2df26aad0cc99e8421c58b7aa2dd55969f979b0e7d1e931042e9fabcd6' \ + "${build_dir}/minio.tar.gz" \ + '167415edd21bc29f5360943dac64272aa5cda0a39f3070b15cfeca671c43d975' \ + "${build_dir}/mc.tar.gz" | sha256sum --check --status \ + || die "official MinIO source checksum mismatch" + tar -xzf "${build_dir}/minio.tar.gz" --strip-components=1 -C "${build_dir}/minio" + tar -xzf "${build_dir}/mc.tar.gz" --strip-components=1 -C "${build_dir}/mc" + ( + cd "${build_dir}/minio" + CGO_ENABLED=0 GOOS=linux go build -mod=readonly -trimpath -buildvcs=false \ + -o "${build_dir}/minio-bin" . + ) + ( + cd "${build_dir}/mc" + CGO_ENABLED=0 GOOS=linux go build -mod=readonly -trimpath -buildvcs=false \ + -o "${build_dir}/mc-bin" . + ) + cp "${build_dir}/minio/LICENSE" "${build_dir}/licenses/minio-LICENSE" + cp "${build_dir}/mc/LICENSE" "${build_dir}/licenses/mc-LICENSE" + cat >"${build_dir}/Dockerfile" <<'DOCKERFILE' +FROM alpine:3.22.6@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 +COPY minio-bin /usr/local/bin/minio +COPY mc-bin /usr/local/bin/mc +COPY licenses /usr/share/licenses/minio +ENTRYPOINT ["minio"] +DOCKERFILE + printf 'minio/\nmc/\n*.tar.gz\n' >"${build_dir}/.dockerignore" + MINIO_IMAGE="${COMPOSE_PROJECT}:minio" + docker build --tag "$MINIO_IMAGE" "$build_dir" + COMPOSE_OVERRIDE="${E2E_DIR}/minio-compose.yml" + cat >"$COMPOSE_OVERRIDE" <