diff --git a/.github/workflows/collaborative-notes.yml b/.github/workflows/collaborative-notes.yml new file mode 100644 index 00000000..125b065e --- /dev/null +++ b/.github/workflows/collaborative-notes.yml @@ -0,0 +1,29 @@ +name: Collaborative notes +on: + pull_request: + paths: + - 'applications/collaborative-notes/**' + - '.github/workflows/collaborative-notes.yml' + push: + paths: + - 'applications/collaborative-notes/**' + - '.github/workflows/collaborative-notes.yml' +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: applications/collaborative-notes + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24.21.0' + cache: npm + cache-dependency-path: applications/collaborative-notes/package-lock.json + - run: npm ci + - run: npm run format:check + - run: npm run build + - run: npm test diff --git a/applications/collaborative-notes/.env.example b/applications/collaborative-notes/.env.example new file mode 100644 index 00000000..e8d57d5e --- /dev/null +++ b/applications/collaborative-notes/.env.example @@ -0,0 +1,8 @@ +PGHOST=your-cloud-hostname +PGPORT=5432 +PGDATABASE=postgres +PGUSER=notes_runtime +PGPASSWORD=replace-with-private-runtime-password +PGSSLMODE=verify-full +PGSSLROOTCERT=/absolute/private/path/ca.pem +WORKSPACE_TOKEN=replace-with-at-least-32-random-characters diff --git a/applications/collaborative-notes/.gitignore b/applications/collaborative-notes/.gitignore new file mode 100644 index 00000000..cb8ef470 --- /dev/null +++ b/applications/collaborative-notes/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +dist-server/ +.local/ +.env +*.pem diff --git a/applications/collaborative-notes/README.md b/applications/collaborative-notes/README.md new file mode 100644 index 00000000..5c7e9e2d --- /dev/null +++ b/applications/collaborative-notes/README.md @@ -0,0 +1,148 @@ +# Collaborative notes with Hocuspocus, Yjs and Postgres + +A small trusted workspace for three shared plain-text notes. Two editors can type, delete, work offline and reconnect. CodeMirror binds directly to `Y.Text`; Hocuspocus synchronizes updates. ClickHouse Managed Postgres stores original binary CRDT snapshots in `BYTEA`. + +Synchronization and persistence have separate UI messages. A synchronized editor is **not** a database receipt. A snapshot receipt is broadcast only after its captured update has been merged and committed. Later edits can still be pending. An abrupt server loss can lose uncommitted edits. + +This is one loopback server process and one trusted workspace, without application accounts, remote cursor awareness or horizontal coordination. All token holders can edit all three notes. The token stays in browser memory, never URLs or browser storage. Rotate it by changing the private environment and restarting the server; there is no per-user revocation. Do not expose this local demo to a public network. + +## Requirements and install + +Tested on 2 October 2026 with Linux ARM64, Node 24.21.0 LTS, TypeScript 7.0.2, Hocuspocus server/provider 4.7.0, Yjs 13.6.33, React 19.3.0, Vite 8.3.2, node-postgres 8.23.1 and PostgreSQL 18.6. Exact packages and transitive dependencies are locked in `package-lock.json`. Browser helpers use Playwright 1.63.0. + +```bash +cd applications/collaborative-notes +npm ci +npm run build +npm test +npm run format:check +``` + +The production client bundle includes CodeMirror and Yjs; Vite reports its normal 500 kB chunk warning (548 kB uncompressed in this fixture). No lazy-loading or throughput claims are made. `npm audit --omit=optional` reported no known vulnerabilities on the tested date. + +## Create a dedicated Cloud fixture + +Install and authenticate [clickhousectl](https://github.com/ClickHouse/clickhousectl), using API-key authentication for writes. See the [Managed Postgres documentation](https://clickhouse.com/docs/products/managed-postgres/). Use a dedicated disposable service because bootstrap changes database-wide PUBLIC grants. This tested shape is AWS `us-east-1`, `c6gd.large`, PostgreSQL 18, no HA; it incurs account compute/storage costs while retained. Confirm current available shapes and pricing before creating it. + +```bash +umask 077 +mkdir -p .local +export ORG_ID=your-organization-id +clickhousectl cloud postgres create --json --org-id "$ORG_ID" \ + --name collaborative-notes-demo --provider aws --region us-east-1 \ + --size c6gd.large --pg-version 18 --ha-type none > .local/create.json +export SERVICE_ID=$(python3 -c 'import json; print(json.load(open(".local/create.json"))["id"])') +clickhousectl cloud postgres get "$SERVICE_ID" --json --org-id "$ORG_ID" > .local/current.json +# Repeat get until current.json reports state "running"; then retrieve the CA. +clickhousectl cloud postgres certs get "$SERVICE_ID" --org-id "$ORG_ID" \ + --output .local/ca.pem +``` + +Creation JSON contains credentials; `get` does not return the password. Keep all these files private. Generate four private environment files with standard-library Python; no secrets belong in the client build: + +```bash +python3 - <<'PY' +import json, secrets, shlex +from pathlib import Path +p = Path('.local') +d = json.loads((p / 'create.json').read_text()) +base = dict(PGHOST=d['hostname'], PGPORT='5432', PGDATABASE='postgres', + PGSSLMODE='verify-full', PGSSLROOTCERT=str((p / 'ca.pem').resolve())) +owner, runtime, token = [secrets.token_urlsafe(32) for _ in range(3)] +files = { + 'admin.env': dict(base, PGUSER=d['username'], PGPASSWORD=d['password'], + OWNER_PASSWORD=owner, RUNTIME_PASSWORD=runtime), + 'migration.env': dict(base, PGUSER='notes_owner', PGPASSWORD=owner), + 'runtime.env': dict(base, PGUSER='notes_runtime', PGPASSWORD=runtime, WORKSPACE_TOKEN=token), + 'test.env': dict(base, PGUSER='notes_runtime', PGPASSWORD=runtime, WORKSPACE_TOKEN=token, + OWNER_USER='notes_owner', OWNER_PASSWORD=owner, + ADMIN_USER=d['username'], ADMIN_PASSWORD=d['password']), +} +for name, env in files.items(): + f = p / name + f.write_text(''.join(f'{k}={shlex.quote(v)}\n' for k, v in env.items())) + f.chmod(0o600) +PY +``` + +## Bootstrap, migrate and seed + +Use Bash and a PostgreSQL `psql` client. Environment files must be exported to child processes. Bootstrap runs once as administrator, migrations and seed as `notes_owner`, and the application as `notes_runtime`. There is no startup schema creation. + +```bash +set -a; source .local/admin.env; set +a +psql -v ON_ERROR_STOP=1 -v owner_password="$OWNER_PASSWORD" \ + -v runtime_password="$RUNTIME_PASSWORD" -f sql/bootstrap.sql +set -a; source .local/migration.env; set +a +psql -v ON_ERROR_STOP=1 -f sql/001_up.sql +node dist-server/server/seed.js +psql -v ON_ERROR_STOP=1 -f sql/grants.sql +``` + +`seed` creates each original Yjs document once; repeating it preserves existing binary state. The reviewed `001_down.sql` removes all note data; use it only for a disposable migration lifecycle check, followed by up, seed and grants again. Up is deliberately a one-time migration, not an implicit schema repair. Runtime gets SELECT and column-level UPDATE for state/revision/time only. It cannot create tables or temporary tables, insert/delete notes, or edit titles. The shared runtime role is trusted: direct SQL can replace binary data without the API validators. There is no RLS or per-note user authorization. + +## Run the local demo + +Open a new terminal that has not sourced setup credentials, or use the clean environment below. Start the server with **only** runtime credentials. The server rejects any other `PGUSER`. + +```bash +env -i HOME="$HOME" PATH="$PATH" bash --noprofile --norc -c ' + set -a; source .local/runtime.env; set +a + node dist-server/server/main.js > .local/server.log 2>&1 & + echo $! > .local/server.pid +' +# In another terminal, without database credentials: +npm run preview +``` + +Open `http://127.0.0.1:5173` in two browser windows and paste the private `WORKSPACE_TOKEN` into each password field. Do not paste it into a URL. Select a note, type and delete, then use **Work offline** and **Reconnect**. Reconnect before switching or leaving: switching destroys that tab's document and discards unsynchronized local edits. + +Vite preview serves the built client and proxies `/collab` to the loopback WebSocket server. It is explicitly a local demo, not a production hosting configuration. The server validates the exact browser Origin and allowed Host before upgrade, then Hocuspocus authenticates the token and fixed document name. Load and mutation hooks preserve that document-scoped context. No wildcard CORS configuration or token-signing endpoint is provided. + +## Storage invariants and limits + +A store copies its captured binary update before awaiting, locks the row with `FOR UPDATE`, merges stored and incoming bytes with `Y.mergeUpdates`, validates, updates and commits using one pg client. Older/repeated snapshots cannot overwrite newer insertion/deletion history. An identical merged snapshot retains its revision and timestamp. The receipt identifies the captured update's SHA-256, not a promise that every tab's newest keystroke was included. + +Incoming frames are limited to 64 KiB; complete stored CRDT state to 512 KiB; rendered text to 10,000 UTF-16 code units. Only the `content` text root is accepted: no rich-text attributes, embedded objects, disallowed controls or incomplete causal updates. Delete history also consumes binary space, so text below its limit can still hit the binary bound. The three fixed documents have at most 12 physical sockets, 32 queued messages per document, a four-connection pool and finite connect/SQL/lock waits. These are small-fixture bounds, not performance estimates. + +The pinned Hocuspocus hooks debounce storage at 1.5 seconds with a five-second maximum debounce. Failure broadcasts an honest warning and keeps uncommitted changes in server memory. A subsequent edit can trigger another save; this implementation does not promise an automatic retry after a failed hook. Shutdown attempts native pending stores but has a 20-second deadline; neither browser disconnect nor graceful exit is presented as proof of commit. Observe a receipt or inspect committed state before a persistence demonstration. + +## Reproduce acceptance + +Tests use only synthetic documents in a dedicated fixture. `tests/cloud.mjs` and the Cloud browser controls require `test.env`, because failure triggers and row-lock controls use the owner role. Those credentials remain in the Node test process; Chromium receives only HOME/PATH/LANG. Install the native browser once: + +```bash +npx playwright install --with-deps chromium +set -a; source .local/test.env; set +a +node tests/cloud.mjs +node tests/socket-controls.mjs +export EVIDENCE_DIR=/tmp/collaborative-notes-evidence +REQUIRE_CLOUD=1 node tests/browser.mjs +``` + +The Cloud storage helper covers insertion/deletion with reordered/repeated snapshots, independent competing row locks, a deferred COMMIT failure, role denials, actual-factory TLS failures and binary/allowlist bounds. The certificate negative removes trusted CA; the hostname negative calls Node `checkServerIdentity` with a substituted invalid hostname. Production uses the official CA and default hostname verification without a custom verifier. The pinned pg source sets the TLS connection host and DNS servername from `PGHOST`. + +The browser helper opens two separate Chromium processes, checks simultaneous edits, deletes, offline/reconnect convergence, delayed and failed saves, subsequent recovery, desktop/mobile layout and empty browser storage. It writes the committed binary/text/revision/time snapshot and screenshots. Do not rerun on that same edited fixture expecting the marker-count assertions to remain one; use a fresh disposable migration and seed first. + +Restart requires Linux `/proc`, Python 3, a direct server PID file from the run command and the browser helper's `committed-state.json`. The helper verifies the PID's application directory and command, waits for its actual exit, then starts a runtime-only replacement. Run from a fresh environment with runtime credentials, retaining the same evidence directory: + +```bash +set -a; source .local/runtime.env; set +a +SERVER_PID_FILE="$PWD/.local/server.pid" \ +RUNTIME_ENV_FILE="$PWD/.local/runtime.env" python3 tests/restart.py +node tests/restart-browser.mjs +``` + +Native protocol rejection controls can be run separately against their own memory-only test server, **with the real server stopped**: `node tests/gate-controls.mjs`. They demonstrate rejected invalid/oversized/incomplete updates leave unchanged bounded state and a valid same-note edit still proceeds. They are not Cloud durability evidence. + +## Cleanup + +Stop the local processes, then remove only your dedicated fixture: + +```bash +kill "$(cat .local/server.pid)" +clickhousectl cloud postgres delete "$SERVICE_ID" --org-id "$ORG_ID" +clickhousectl cloud postgres list --org-id "$ORG_ID" +``` + +Verify your exact ID is absent. Keep private files out of Git; delete them according to your own credential-retention policy. The example's acceptance fixture was deleted after review, and its native VM was stopped and preserved. diff --git a/applications/collaborative-notes/index.html b/applications/collaborative-notes/index.html new file mode 100644 index 00000000..05ec2d09 --- /dev/null +++ b/applications/collaborative-notes/index.html @@ -0,0 +1,12 @@ + + + + + + Notes, together + + +
+ + + diff --git a/applications/collaborative-notes/package-lock.json b/applications/collaborative-notes/package-lock.json new file mode 100644 index 00000000..c120d3bd --- /dev/null +++ b/applications/collaborative-notes/package-lock.json @@ -0,0 +1,1782 @@ +{ + "name": "collaborative-notes", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "collaborative-notes", + "version": "1.0.0", + "dependencies": { + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.13", + "@hocuspocus/provider": "4.7.0", + "@hocuspocus/server": "4.7.0", + "pg": "8.23.1", + "react": "19.3.0", + "react-dom": "19.3.0", + "ws": "8.22.0", + "y-codemirror.next": "0.3.6", + "y-protocols": "1.0.7", + "yjs": "13.6.33" + }, + "devDependencies": { + "@types/node": "26.6.4", + "@types/pg": "8.23.1", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@types/ws": "8.18.2", + "@vitejs/plugin-react": "6.1.1", + "playwright": "1.63.0", + "prettier": "3.9.9", + "typescript": "7.0.2", + "vite": "8.3.2" + }, + "engines": { + "node": ">=24 <25" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@hocuspocus/common": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-4.7.0.tgz", + "integrity": "sha512-KyS6dXoIcWsd+v3gwd6nnZW04M77df5XstPNkOwqoDxwuncygobEN7xsf0x2PmdvlQdQyRaLm2SF4r1GZZGRnA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.117" + } + }, + "node_modules/@hocuspocus/provider": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-4.7.0.tgz", + "integrity": "sha512-BOAqoj6g4EZQ9pTwIiR7bNl1QkMAnrr8EY69jMcdhSLFtTrsKaE8hsDIEncPFnGq+khWRFCAPEJSM7F9HC7fvQ==", + "license": "MIT", + "dependencies": { + "@hocuspocus/common": "^4.7.0", + "@lifeomic/attempt": "^3.1.0", + "lib0": "^0.2.117" + }, + "peerDependencies": { + "y-protocols": "^1.0.6", + "yjs": "^13.6.8" + } + }, + "node_modules/@hocuspocus/server": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-4.7.0.tgz", + "integrity": "sha512-rlHSxGXEkYvHgQa9Q8prjMHChIl7daZlXL1pezifS8j+svLHQPKLfevNBxFKc+ZW5pLpUiUJGU++O8eJlSt3Ew==", + "license": "MIT", + "dependencies": { + "@hocuspocus/common": "^4.7.0", + "async-mutex": "^0.5.0", + "crossws": "^0.4.4", + "kleur": "^4.1.5", + "lib0": "^0.2.117" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "y-protocols": "^1.0.6", + "yjs": "^13.6.8" + } + }, + "node_modules/@lifeomic/attempt": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz", + "integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==", + "license": "MIT" + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.4.12.tgz", + "integrity": "sha512-aypfsr6t0uNvkqaZc6zvBfXzC6pLI0/sIulpkV6RwCVtZqG5ebBzv4weImKK0VNCj91Wl9F5j7p5WU4MNrybng==", + "license": "MIT", + "peerDependencies": { + "srvx": ">=0.11.5" + }, + "peerDependenciesMeta": { + "srvx": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lib0": { + "version": "0.2.119", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz", + "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "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": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y-codemirror.next": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/y-codemirror.next/-/y-codemirror.next-0.3.6.tgz", + "integrity": "sha512-GnmVXhTe+UtoFbbaSdwhq6gdAQZdIY9az0Xj6HR2J4PO6Yw4w3xaaSssU+6T72WhXAI0wf9FbuY2s0Ms+WrexA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.42" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "yjs": "^13.5.6" + } + }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/yjs": { + "version": "13.6.33", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz", + "integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + } + } +} diff --git a/applications/collaborative-notes/package.json b/applications/collaborative-notes/package.json new file mode 100644 index 00000000..08ba0516 --- /dev/null +++ b/applications/collaborative-notes/package.json @@ -0,0 +1,42 @@ +{ + "name": "collaborative-notes", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24 <25" + }, + "scripts": { + "build": "tsc -p tsconfig.app.json && tsc -p tsconfig.server.json && vite build", + "start": "node dist-server/server/main.js", + "dev": "vite --host 127.0.0.1 --port 5173 --strictPort", + "preview": "vite preview --host 127.0.0.1 --port 5173 --strictPort", + "test": "node --test dist-server/tests/unit.test.js", + "format:check": "prettier --check src server shared tests *.json vite.config.ts index.html" + }, + "dependencies": { + "@hocuspocus/server": "4.7.0", + "@hocuspocus/provider": "4.7.0", + "yjs": "13.6.33", + "y-protocols": "1.0.7", + "y-codemirror.next": "0.3.6", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.13", + "react": "19.3.0", + "react-dom": "19.3.0", + "pg": "8.23.1", + "ws": "8.22.0" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@types/node": "26.6.4", + "@types/pg": "8.23.1", + "@types/ws": "8.18.2", + "typescript": "7.0.2", + "vite": "8.3.2", + "@vitejs/plugin-react": "6.1.1", + "playwright": "1.63.0", + "prettier": "3.9.9" + } +} diff --git a/applications/collaborative-notes/server/binary.ts b/applications/collaborative-notes/server/binary.ts new file mode 100644 index 00000000..1bd13191 --- /dev/null +++ b/applications/collaborative-notes/server/binary.ts @@ -0,0 +1,49 @@ +import * as Y from "yjs"; +import { MAX_STATE_BYTES, MAX_TEXT_LENGTH } from "../shared/spec.js"; + +export function validateState(bytes: Uint8Array): void { + if (bytes.byteLength > MAX_STATE_BYTES) + throw new Error("Document binary limit exceeded."); + const preview = new Y.Doc({ gc: false }); + try { + Y.applyUpdate(preview, bytes); + if (preview.store.pendingStructs || preview.store.pendingDs) + throw new Error("Incomplete note update rejected."); + if ([...preview.share.keys()].some((key) => key !== "content")) + throw new Error("Only plain note text is supported."); + const text = preview.getText("content"); + // Text-only content: no embedded objects or formatting attributes. + if ( + text + .toDelta() + .some( + (part: { insert?: unknown; attributes?: unknown }) => + typeof part.insert !== "string" || part.attributes, + ) + ) + throw new Error("Only plain note text is supported."); + const value = text.toString(); + if ( + value.length > MAX_TEXT_LENGTH || + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\uFFFD]/u.test(value) + ) + throw new Error("Note text limit or character rule exceeded."); + } finally { + preview.destroy(); + } +} +export function mergeState( + stored: Uint8Array, + incoming: Uint8Array, +): Uint8Array { + const merged = Y.mergeUpdates([stored, incoming]); + validateState(merged); + return merged; +} +export function seedState(text: string): Uint8Array { + const document = new Y.Doc(); + document.getText("content").insert(0, text); + const bytes = Y.encodeStateAsUpdate(document); + document.destroy(); + return bytes; +} diff --git a/applications/collaborative-notes/server/database.ts b/applications/collaborative-notes/server/database.ts new file mode 100644 index 00000000..0440009a --- /dev/null +++ b/applications/collaborative-notes/server/database.ts @@ -0,0 +1,50 @@ +import { readFileSync } from "node:fs"; +import pg, { type PoolConfig } from "pg"; +export function databaseConfig( + env: NodeJS.ProcessEnv = process.env, +): PoolConfig { + for (const key of [ + "PGHOST", + "PGDATABASE", + "PGUSER", + "PGPASSWORD", + "PGSSLROOTCERT", + ]) { + if (!env[key]) throw new Error(`Missing ${key}.`); + } + if (env.PGSSLMODE !== "verify-full") + throw new Error("PGSSLMODE must be verify-full."); + const port = Number(env.PGPORT ?? "5432"); + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new Error("PGPORT must be an integer from 1 to 65535."); + if ( + !/^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test( + env.PGHOST!, + ) + ) + throw new Error("PGHOST must be one DNS hostname or IPv4 address."); + return { + host: env.PGHOST, + port, + database: env.PGDATABASE, + user: env.PGUSER, + password: env.PGPASSWORD, + ssl: { + ca: readFileSync(env.PGSSLROOTCERT!, "utf8"), + rejectUnauthorized: true, + }, + max: 4, + connectionTimeoutMillis: 5000, + idleTimeoutMillis: 30000, + maxLifetimeSeconds: 900, + statement_timeout: 10000, + lock_timeout: 5000, + idle_in_transaction_session_timeout: 15000, + application_name: "collaborative-notes", + }; +} +export function createPool(env: NodeJS.ProcessEnv = process.env): pg.Pool { + const pool = new pg.Pool(databaseConfig(env)); + pool.on("error", () => console.error("An idle database connection failed.")); + return pool; +} diff --git a/applications/collaborative-notes/server/main.ts b/applications/collaborative-notes/server/main.ts new file mode 100644 index 00000000..999c549d --- /dev/null +++ b/applications/collaborative-notes/server/main.ts @@ -0,0 +1,33 @@ +import { createPool } from "./database.js"; +import { PostgresStorage } from "./store.js"; +import { createServer } from "./protocol.js"; +if (process.env.PGUSER !== "notes_runtime") + throw new Error("Start the server using only notes_runtime credentials."); +const pool = createPool(); +const storage = new PostgresStorage(pool); +await storage.verify(); +const server = createServer(storage, process.env.WORKSPACE_TOKEN ?? ""); +await server.listen(); +console.log("Collaborative notes listening on loopback port 1234."); +let stopping = false; +async function shutdown(): Promise { + if (stopping) return; + stopping = true; + // A failed Hocuspocus store may retain an in-memory document. Bound shutdown; + // never describe an unconfirmed snapshot as durable merely because we exit. + const timeout = setTimeout(() => { + console.error("Shutdown deadline reached; uncommitted edits may be lost."); + process.exit(1); + }, 20000); + try { + await server.destroy(); + await pool.end(); + clearTimeout(timeout); + process.exit(0); + } catch { + console.error("Shutdown failed; uncommitted edits may be lost."); + process.exit(1); + } +} +process.on("SIGTERM", () => void shutdown()); +process.on("SIGINT", () => void shutdown()); diff --git a/applications/collaborative-notes/server/protocol.ts b/applications/collaborative-notes/server/protocol.ts new file mode 100644 index 00000000..fedc0906 --- /dev/null +++ b/applications/collaborative-notes/server/protocol.ts @@ -0,0 +1,155 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import { Server } from "@hocuspocus/server"; +import * as Y from "yjs"; +import { + allowedDocument, + MAX_FRAME_BYTES, + type Storage, +} from "../shared/spec.js"; +import { mergeState, validateState } from "./binary.js"; + +type Context = { workspace: true; document: string }; +class DocumentQueue { + private tail: Promise = Promise.resolve(); + private pending = 0; + async acquire(): Promise<() => void> { + if (this.pending >= 32) throw new Error("Note message queue is full."); + this.pending++; + const previous = this.tail; + let done!: () => void; + this.tail = new Promise((resolve) => { + done = resolve; + }); + await previous; + let released = false; + return () => { + if (!released) { + released = true; + this.pending--; + done(); + } + }; + } +} +export function createServer(storage: Storage, token: string): Server { + if (token.length < 32 || token.length > 256) + throw new Error("Use a private workspace token of 32–256 bytes."); + const digest = createHash("sha256").update(token).digest(); + const authorized = (candidate: string) => + candidate.length <= 256 && + timingSafeEqual(digest, createHash("sha256").update(candidate).digest()); + const queues = new Map(); + const permits = new WeakMap void>(); + const sockets = new Set(); + const check = (name: string, context: Context | undefined) => { + if ( + !allowedDocument(name) || + !context?.workspace || + context.document !== name + ) + throw new Error("Workspace authorization required."); + }; + const server = new Server({ + address: "127.0.0.1", + port: 1234, + stopOnSignals: false, + quiet: true, + timeout: 15000, + debounce: 1500, + maxDebounce: 5000, + websocketOptions: { maxPayload: MAX_FRAME_BYTES, perMessageDeflate: false }, + maxUnauthenticatedQueueSize: MAX_FRAME_BYTES, + maxUnauthenticatedQueueMessages: 32, + maxPendingDocuments: 1, + async onUpgrade({ request, socket }) { + if ( + request.headers.origin !== "http://127.0.0.1:5173" || + !["127.0.0.1:5173", "127.0.0.1:1234"].includes( + request.headers.host ?? "", + ) || + request.url !== "/" || + sockets.size >= 12 + ) { + socket.end( + "HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + ); + throw undefined; + } + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }, + async onAuthenticate({ documentName, token }) { + if (!allowedDocument(documentName) || !authorized(token)) + throw new Error("Workspace authorization required."); + return { workspace: true, document: documentName }; + }, + async onTokenSync({ documentName, token, context }) { + check(documentName, context); + if (!authorized(token)) + throw new Error("Workspace authorization required."); + }, + async onLoadDocument({ documentName, context }) { + check(documentName, context); + const bytes = await storage.load(documentName); + validateState(bytes); + return bytes; + }, + async beforeHandleMessage({ documentName, context, update }) { + check(documentName, context); + if (update.byteLength > MAX_FRAME_BYTES) + throw new Error("Message limit exceeded."); + let queue = queues.get(documentName); + if (!queue) { + queue = new DocumentQueue(); + queues.set(documentName, queue); + } + permits.set(update, await queue.acquire()); + }, + async afterHandleMessage({ update }) { + permits.get(update)?.(); + permits.delete(update); + }, + async beforeSync({ documentName, context, document, type, payload }) { + check(documentName, context); + if (type === 1 || type === 2) + mergeState(Y.encodeStateAsUpdate(document), payload); + }, + async beforeHandleAwareness({ documentName, context, states }) { + check(documentName, context); + // This small plain-text editor omits remote cursor/profile awareness. + // Drop all client metadata, including the decoder scratch local state. + states.clear(); + }, + async onStateless({ connection }) { + connection.sendStateless( + JSON.stringify({ + type: "info", + message: + "Snapshots are periodic; synchronization is not a database receipt.", + }), + ); + }, + async onStoreDocument({ documentName, document }) { + if (!allowedDocument(documentName)) throw new Error("Unknown note."); + const captured = Y.encodeStateAsUpdate(document); + validateState(captured); + try { + const receipt = await storage.store(documentName, captured); + document.broadcastStateless( + JSON.stringify({ type: "stored", receipt }), + ); + } catch { + document.broadcastStateless( + JSON.stringify({ type: "store-failed", document: documentName }), + ); + throw new Error( + "Database snapshot storage failed; uncommitted changes remain in memory.", + ); + } + }, + }); + server.httpServer.requestTimeout = 10000; + server.httpServer.headersTimeout = 5000; + server.httpServer.maxHeadersCount = 32; + return server; +} diff --git a/applications/collaborative-notes/server/seed.ts b/applications/collaborative-notes/server/seed.ts new file mode 100644 index 00000000..37883a10 --- /dev/null +++ b/applications/collaborative-notes/server/seed.ts @@ -0,0 +1,29 @@ +import { createPool } from "./database.js"; +import { seedState } from "./binary.js"; +import { NOTES } from "../shared/spec.js"; +const pool = createPool(); +try { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + for (const note of NOTES) { + await client.query( + "INSERT INTO notes.documents (id, title, state) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", + [ + note.id, + note.title, + Buffer.from(seedState(`${note.title}\nStart a shared note here.\n`)), + ], + ); + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + console.log("Three seeded notes present; existing CRDT state preserved."); +} finally { + await pool.end(); +} diff --git a/applications/collaborative-notes/server/store.ts b/applications/collaborative-notes/server/store.ts new file mode 100644 index 00000000..d3af4b64 --- /dev/null +++ b/applications/collaborative-notes/server/store.ts @@ -0,0 +1,77 @@ +import { createHash } from "node:crypto"; +import type pg from "pg"; +import { + allowedDocument, + NOTES, + type Storage, + type StoreReceipt, +} from "../shared/spec.js"; +import { mergeState, validateState } from "./binary.js"; +type Row = { state: Buffer; revision: number; stored_at: Date }; +export class PostgresStorage implements Storage { + constructor(private readonly pool: pg.Pool) {} + async verify(): Promise { + const result = await this.pool.query( + "SELECT id, state FROM notes.documents ORDER BY id", + ); + if ( + result.rows.length !== NOTES.length || + result.rows.some((row) => !allowedDocument(row.id)) + ) { + throw new Error("Run the reviewed migration and seed before starting."); + } + for (const row of result.rows) validateState(row.state); + } + async load(name: string): Promise { + if (!allowedDocument(name)) throw new Error("Unknown note."); + const result = await this.pool.query( + "SELECT state, revision, stored_at FROM notes.documents WHERE id = $1", + [name], + ); + if (result.rows.length !== 1) throw new Error("Seeded note missing."); + validateState(result.rows[0]!.state); + return result.rows[0]!.state; + } + async store(name: string, captured: Uint8Array): Promise { + if (!allowedDocument(name)) throw new Error("Unknown note."); + // Copy before awaiting: the receipt identifies this immutable captured update. + const incoming = Buffer.from(captured); + validateState(incoming); + const capturedHash = createHash("sha256").update(incoming).digest("hex"); + const client = await this.pool.connect(); + let discard = false; + try { + await client.query("BEGIN"); + const result = await client.query( + "SELECT state, revision, stored_at FROM notes.documents WHERE id = $1 FOR UPDATE", + [name], + ); + let row = result.rows[0]; + if (!row) throw new Error("Seeded note missing."); + const merged = Buffer.from(mergeState(row.state, incoming)); + if (!merged.equals(row.state)) { + if (row.revision >= 1000000000) + throw new Error("Snapshot revision limit reached."); + const changed = await client.query( + "UPDATE notes.documents SET state = $1, revision = revision + 1, stored_at = clock_timestamp() WHERE id = $2 RETURNING state, revision, stored_at", + [merged, name], + ); + row = changed.rows[0]!; + } + await client.query("COMMIT"); + return { + document: name, + revision: row.revision, + storedAt: row.stored_at.toISOString(), + capturedHash, + }; + } catch (error) { + await client.query("ROLLBACK").catch(() => { + discard = true; + }); + throw error; + } finally { + client.release(discard); + } + } +} diff --git a/applications/collaborative-notes/shared/spec.ts b/applications/collaborative-notes/shared/spec.ts new file mode 100644 index 00000000..f91113e7 --- /dev/null +++ b/applications/collaborative-notes/shared/spec.ts @@ -0,0 +1,21 @@ +export const NOTES = [ + { id: "release-planning", title: "Release planning" }, + { id: "meeting-notes", title: "Meeting notes" }, + { id: "workshop-checklist", title: "Workshop checklist" }, +] as const; +export const MAX_STATE_BYTES = 512 * 1024; +export const MAX_FRAME_BYTES = 64 * 1024; +export const MAX_TEXT_LENGTH = 10000; +export function allowedDocument(name: string): boolean { + return NOTES.some((note) => note.id === name); +} +export type StoreReceipt = { + document: string; + revision: number; + storedAt: string; + capturedHash: string; +}; +export type Storage = { + load(name: string): Promise; + store(name: string, captured: Uint8Array): Promise; +}; diff --git a/applications/collaborative-notes/sql/001_down.sql b/applications/collaborative-notes/sql/001_down.sql new file mode 100644 index 00000000..abf3605e --- /dev/null +++ b/applications/collaborative-notes/sql/001_down.sql @@ -0,0 +1,4 @@ +BEGIN; +SELECT pg_advisory_xact_lock(1002, 31); +DROP TABLE notes.documents; +COMMIT; diff --git a/applications/collaborative-notes/sql/001_up.sql b/applications/collaborative-notes/sql/001_up.sql new file mode 100644 index 00000000..ebd4308a --- /dev/null +++ b/applications/collaborative-notes/sql/001_up.sql @@ -0,0 +1,10 @@ +BEGIN; +SELECT pg_advisory_xact_lock(1002, 31); +CREATE TABLE notes.documents ( + id text PRIMARY KEY CHECK (id IN ('release-planning', 'meeting-notes', 'workshop-checklist')), + title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 80), + state bytea NOT NULL CHECK (octet_length(state) BETWEEN 2 AND 524288), + revision integer NOT NULL DEFAULT 0 CHECK (revision BETWEEN 0 AND 1000000000), + stored_at timestamptz NOT NULL DEFAULT clock_timestamp() +); +COMMIT; diff --git a/applications/collaborative-notes/sql/bootstrap.sql b/applications/collaborative-notes/sql/bootstrap.sql new file mode 100644 index 00000000..2e1deb54 --- /dev/null +++ b/applications/collaborative-notes/sql/bootstrap.sql @@ -0,0 +1,9 @@ +-- Run once as the dedicated service administrator. psql receives private variables. +\set ON_ERROR_STOP on +CREATE ROLE notes_owner LOGIN NOINHERIT PASSWORD :'owner_password'; +CREATE ROLE notes_runtime LOGIN NOINHERIT PASSWORD :'runtime_password'; +REVOKE CREATE, TEMPORARY ON DATABASE postgres FROM PUBLIC; +REVOKE CREATE ON SCHEMA public FROM PUBLIC; +GRANT CONNECT ON DATABASE postgres TO notes_owner, notes_runtime; +GRANT CREATE ON DATABASE postgres TO notes_owner; +CREATE SCHEMA notes AUTHORIZATION notes_owner; diff --git a/applications/collaborative-notes/sql/grants.sql b/applications/collaborative-notes/sql/grants.sql new file mode 100644 index 00000000..8be7cc9f --- /dev/null +++ b/applications/collaborative-notes/sql/grants.sql @@ -0,0 +1,6 @@ +-- Run as notes_owner after migration and seed. +REVOKE ALL ON SCHEMA notes FROM PUBLIC; +GRANT USAGE ON SCHEMA notes TO notes_runtime; +REVOKE ALL ON notes.documents FROM PUBLIC, notes_runtime; +GRANT SELECT ON notes.documents TO notes_runtime; +GRANT UPDATE (state, revision, stored_at) ON notes.documents TO notes_runtime; diff --git a/applications/collaborative-notes/src/main.tsx b/applications/collaborative-notes/src/main.tsx new file mode 100644 index 00000000..12873f75 --- /dev/null +++ b/applications/collaborative-notes/src/main.tsx @@ -0,0 +1,215 @@ +import React, { useEffect, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { EditorState } from "@codemirror/state"; +import { EditorView, keymap } from "@codemirror/view"; +import { yCollab, yUndoManagerKeymap } from "y-codemirror.next"; +import { HocuspocusProvider } from "@hocuspocus/provider"; +import * as Y from "yjs"; +import { NOTES, type StoreReceipt } from "../shared/spec.js"; +import "./style.css"; + +function Editor({ name, token }: { name: string; token: string }) { + const element = useRef(null); + const provider = useRef(null); + const [status, setStatus] = useState("Connecting"); + const [synced, setSynced] = useState(false); + const [paused, setPaused] = useState(false); + const [receipt, setReceipt] = useState(null); + const [failed, setFailed] = useState(false); + const [authFailed, setAuthFailed] = useState(false); + useEffect(() => { + const document = new Y.Doc(); + const text = document.getText("content"); + const undo = new Y.UndoManager(text); + const connection = new HocuspocusProvider({ + url: `ws://${window.location.host}/collab`, + name, + document, + token, + awareness: null, + onStatus: ({ status }) => { + setStatus(status); + if (status !== "connected") setSynced(false); + }, + onSynced: ({ state }) => setSynced(state), + onAuthenticationFailed: () => { + setAuthFailed(true); + setStatus("Authentication failed"); + }, + onStateless: ({ payload }) => { + try { + const message = JSON.parse(payload); + if (message.type === "stored" && message.receipt?.document === name) { + setReceipt(message.receipt); + setFailed(false); + } + if (message.type === "store-failed" && message.document === name) + setFailed(true); + } catch { + /* Ignore non-receipt messages. */ + } + }, + }); + provider.current = connection; + const view = new EditorView({ + parent: element.current!, + state: EditorState.create({ + doc: text.toString(), + extensions: [ + EditorView.lineWrapping, + EditorView.contentAttributes.of({ "aria-label": "Note text" }), + keymap.of(yUndoManagerKeymap), + yCollab(text, null, { undoManager: undo }), + EditorView.theme({ + "&": { fontSize: "17px" }, + ".cm-content": { + fontFamily: "inherit", + minHeight: "290px", + padding: "20px", + }, + ".cm-scroller": { fontFamily: "inherit", lineHeight: "1.65" }, + "&.cm-focused": { outline: "none" }, + }), + ], + }), + }); + return () => { + provider.current = null; + view.destroy(); + connection.destroy(); + undo.destroy(); + document.destroy(); + }; + }, [name, token]); + function pause() { + provider.current?.disconnect(); + setPaused(true); + setSynced(false); + } + function resume() { + provider.current?.connect(); + setPaused(false); + } + return ( +
+
+
+

Shared note

+

{NOTES.find((note) => note.id === name)?.title}

+
+ + {status === "connected" + ? synced + ? "Connected and synchronized" + : "Catching up" + : paused + ? "Offline in this tab" + : status} + +
+
+
+ + Undo / redo: Ctrl+Z / Ctrl+Shift+Z +
+ {authFailed && ( +

+ The workspace token was rejected. Leave this workspace and enter a + valid token. +

+ )} + {paused && ( +

+ Offline edits stay in this tab. Reconnect before leaving or switching + notes. +

+ )} +
+ Periodic database snapshots +

+ {failed + ? "The last storage attempt failed. Shared edits are not confirmed stored and may be lost if the server stops." + : receipt + ? `A snapshot was committed at ${new Date(receipt.storedAt).toLocaleTimeString()}. Later edits may still be waiting.` + : "Connected editors sync immediately. Database storage happens separately, every few seconds."} +

+
+
+ ); +} +function App() { + const [entry, setEntry] = useState(""); + const [token, setToken] = useState(null); + const [name, setName] = useState(NOTES[0].id); + const [session, setSession] = useState(0); + function connect(event: React.FormEvent) { + event.preventDefault(); + if (entry.length < 32 || entry.length > 256) return; + setToken(entry); + setEntry(""); + setSession((value) => value + 1); + } + return ( +
+
+
n
+
+

A trusted shared workspace

+

Notes, together.

+

Keep the plan in one place while everyone adds their part.

+
+
+ {!token ? ( +
+

Enter the workspace

+ + setEntry(event.target.value)} + minLength={32} + maxLength={256} + required + /> + +

+ The token stays in this tab’s memory. Use the local fixture token + supplied by your workspace operator. +

+
+ ) : ( +
+ + +
+ )} +
+ Live synchronization is separate from periodic storage. A sudden server + loss can lose edits that were never committed. +
+
+ ); +} +createRoot(document.getElementById("root")!).render(); diff --git a/applications/collaborative-notes/src/style.css b/applications/collaborative-notes/src/style.css new file mode 100644 index 00000000..266de12f --- /dev/null +++ b/applications/collaborative-notes/src/style.css @@ -0,0 +1,266 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; + background: #f3f5f2; + color: #213b35; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} +main { + max-width: 1160px; + margin: 0 auto; + padding: 48px 28px; +} +header { + display: flex; + gap: 20px; + align-items: center; + margin-bottom: 38px; +} +.mark { + background: #24574c; + color: #e8f4d8; + font-family: Georgia, serif; + font-size: 40px; + width: 65px; + height: 65px; + border-radius: 20px; + text-align: center; + line-height: 58px; +} +.eyebrow { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.16em; + font-weight: 700; + color: #6f8176; + margin: 0 0 8px; +} +h1 { + font-size: 42px; + letter-spacing: -0.04em; + margin: 0 0 8px; +} +h2 { + font-size: 23px; + letter-spacing: -0.02em; + margin: 0; +} +p { + line-height: 1.55; +} +header p:last-child { + margin: 0; + color: #667970; +} +.connect, +.note { + background: #fff; + border: 1px solid #d9e1d9; + border-radius: 18px; +} +.connect { + max-width: 560px; + padding: 32px; + margin: auto; +} +.connect h2 { + margin-bottom: 26px; +} +label { + display: block; + font-weight: 600; + margin-bottom: 8px; +} +input { + width: 100%; + padding: 12px; + border: 1px solid #c4d1c8; + border-radius: 8px; + font: inherit; + margin-bottom: 16px; +} +button { + font: inherit; + border: 1px solid #ccd8ce; + background: #fff; + color: #24574c; + border-radius: 9px; + padding: 10px 16px; + cursor: pointer; +} +button:hover { + background: #f0f5ee; +} +.connect button { + background: #24574c; + color: white; + width: 100%; + border: 0; +} +.connect p { + font-size: 13px; + color: #738175; +} +.workspace { + display: grid; + grid-template-columns: 215px minmax(0, 1fr); + gap: 26px; +} +nav { + padding-top: 16px; +} +nav button { + display: block; + width: 100%; + text-align: left; + background: transparent; + border-color: transparent; + margin-bottom: 9px; +} +.selected { + background: #e1ebde !important; + color: #24574c; + font-weight: 650; +} +.nav-help { + color: #738175; + font-size: 12px; + line-height: 1.65; + padding: 24px 10px; +} +.leave { + font-size: 13px; + border-color: #d7e0d6 !important; +} +.note { + overflow: hidden; +} +.note-top { + padding: 26px 28px; + display: flex; + gap: 15px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #e8eee7; +} +.status { + font-size: 12px; + color: #668069; + max-width: 160px; + text-align: right; +} +.editor { + background: #fffffc; +} +.note-bottom { + display: flex; + gap: 18px; + align-items: center; + justify-content: space-between; + padding: 15px 24px; + border-top: 1px solid #e8eee7; +} +.note-bottom span { + font-size: 12px; + color: #7a887e; +} +.persistence { + background: #f7f9f4; + padding: 20px 27px; +} +.persistence strong { + font-size: 13px; +} +.persistence p { + font-size: 13px; + color: #728071; + margin: 6px 0 0; +} +.notice, +.error { + font-size: 13px; + padding: 8px 24px; +} +.error { + color: #983b34; +} +.notice { + color: #856435; +} +footer { + font-size: 12px; + color: #7c887c; + text-align: center; + margin-top: 30px; + line-height: 1.6; +} +button:disabled { + opacity: 0.5; + cursor: default; +} +@media (max-width: 700px) { + main { + padding: 26px 16px; + } + header { + gap: 12px; + align-items: flex-start; + } + .mark { + width: 46px; + height: 46px; + flex-shrink: 0; + font-size: 30px; + line-height: 43px; + border-radius: 13px; + } + h1 { + font-size: 32px; + } + header p:last-child { + font-size: 14px; + } + .workspace { + grid-template-columns: 1fr; + gap: 10px; + } + nav { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0; + } + nav .eyebrow { + width: 100%; + } + nav button { + width: auto; + font-size: 12px; + padding: 8px; + margin: 0; + } + .nav-help { + width: 100%; + padding: 7px 0; + } + .note-top { + padding: 20px; + align-items: flex-start; + } + .note-top h2 { + font-size: 20px; + } + .note-bottom { + padding: 12px 16px; + flex-wrap: wrap; + gap: 10px; + } + .persistence { + padding: 18px 20px; + } + .connect { + padding: 24px; + } +} diff --git a/applications/collaborative-notes/src/vite-env.d.ts b/applications/collaborative-notes/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/applications/collaborative-notes/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/applications/collaborative-notes/tests/browser.mjs b/applications/collaborative-notes/tests/browser.mjs new file mode 100644 index 00000000..2514a6fb --- /dev/null +++ b/applications/collaborative-notes/tests/browser.mjs @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { chromium } from "playwright"; +const evidence = + process.env.EVIDENCE_DIR || "/tmp/collaborative-notes-evidence"; +await mkdir(evidence, { recursive: true }); +const token = process.env.WORKSPACE_TOKEN; +if (!token) throw new Error("Set private WORKSPACE_TOKEN."); +const cleanEnv = { + HOME: process.env.HOME, + PATH: process.env.PATH, + LANG: "C.UTF-8", +}; +const first = await chromium.launch({ env: cleanEnv }); +const second = await chromium.launch({ env: cleanEnv }); +async function text(page) { + return page + .locator(".cm-line") + .evaluateAll((lines) => lines.map((line) => line.textContent).join("\n")); +} +async function converge(a, b) { + const end = Date.now() + 15000; + while (Date.now() < end) { + const left = await text(a); + const right = await text(b); + if (left === right) return left; + await a.waitForTimeout(80); + } + throw new Error("Editors did not converge."); +} +async function insert(page, value) { + const editor = page.locator(".cm-content"); + await editor.click(); + await page.keyboard.press("Control+End"); + await page.keyboard.insertText(value); +} +try { + const a = await first.newPage({ viewport: { width: 1280, height: 900 } }); + const b = await second.newPage({ viewport: { width: 390, height: 844 } }); + for (const page of [a, b]) { + await page.goto(process.env.UI_BASE_URL || "http://127.0.0.1:5173"); + await page.getByLabel("Workspace token").fill(token); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await page + .getByText("Connected and synchronized", { exact: true }) + .waitFor({ timeout: 30000 }); + } + await converge(a, b); + await Promise.all([insert(a, " Alpha"), insert(b, " Beta")]); + let merged = await converge(a, b); + assert.equal(merged.split("Alpha").length - 1, 1); + assert.equal(merged.split("Beta").length - 1, 1); + await a.locator(".cm-content").click(); + await a.keyboard.press("Control+Home"); + await a.keyboard.press("Delete"); + const deleted = await converge(a, b); + assert.equal(deleted.length, merged.length - 1); + await a.getByRole("button", { name: "Work offline", exact: true }).click(); + await a.getByText("Offline in this tab", { exact: true }).waitFor(); + await insert(a, " Offline"); + await insert(b, " Online"); + assert.notEqual(await text(a), await text(b)); + await b.locator(".cm-content").click(); + await b.keyboard.press("Control+Home"); + await b.keyboard.press("Delete"); + await a.getByRole("button", { name: "Reconnect", exact: true }).click(); + await a + .getByText("Connected and synchronized", { exact: true }) + .waitFor({ timeout: 30000 }); + let final = await converge(a, b); + for (const value of ["Alpha", "Beta", "Offline", "Online"]) + assert.equal(final.split(value).length - 1, 1, value); + assert.equal( + await a.evaluate(() => localStorage.length + sessionStorage.length), + 0, + ); + assert(!a.url().includes(token)); + assert.equal( + await b.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); + if (process.env.REQUIRE_CLOUD === "1") { + const { createPool } = await import("../dist-server/server/database.js"); + const Y = await import("yjs"); + const owner = createPool({ + ...process.env, + PGUSER: process.env.OWNER_USER, + PGPASSWORD: process.env.OWNER_PASSWORD, + }); + const read = async () => { + const row = ( + await owner.query( + "SELECT state,revision,stored_at FROM notes.documents WHERE id='release-planning'", + ) + ).rows[0]; + const doc = new Y.Doc(); + Y.applyUpdate(doc, row.state); + const value = doc.getText("content").toString(); + doc.destroy(); + return { ...row, text: value }; + }; + const waitStored = async (expected) => { + const end = Date.now() + 15000; + while (Date.now() < end) { + const row = await read(); + if (row.text === expected) return row; + await a.waitForTimeout(100); + } + throw new Error("Cloud snapshot did not commit expected text."); + }; + try { + await waitStored(final); + await a + .getByText(/A snapshot was committed at/) + .waitFor({ timeout: 15000 }); + const before = await read(); + const receipt = await a.locator(".persistence p").innerText(); + const blocker = await owner.connect(); + await blocker.query("BEGIN"); + await blocker.query( + "SELECT id FROM notes.documents WHERE id='release-planning' FOR UPDATE", + ); + try { + await insert(a, " Delayed"); + final = await converge(a, b); + await a.waitForTimeout(2300); + assert.equal((await read()).text, before.text); + assert.equal(await a.locator(".persistence p").innerText(), receipt); + console.log( + "Delayed store: peers synchronized while row lock held; database and last receipt remained previous committed snapshot.", + ); + } finally { + await blocker.query("COMMIT"); + blocker.release(); + } + await waitStored(final); + await a.waitForTimeout(300); + const committed = await read(); + await owner.query( + "CREATE FUNCTION notes.fail_browser_store() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic store failure'; END $$", + ); + await owner.query( + "CREATE TRIGGER fixture_browser_failure BEFORE UPDATE ON notes.documents FOR EACH ROW EXECUTE FUNCTION notes.fail_browser_store()", + ); + try { + await insert(b, " Failed"); + final = await converge(a, b); + await a + .getByText(/The last storage attempt failed/) + .waitFor({ timeout: 15000 }); + assert.deepEqual((await read()).state, committed.state); + assert.equal((await read()).revision, committed.revision); + console.log( + "Forced storage failure: shared edit visible, explicit failure displayed, prior Cloud binary/revision unchanged.", + ); + } finally { + await owner.query( + "DROP TRIGGER fixture_browser_failure ON notes.documents", + ); + await owner.query("DROP FUNCTION notes.fail_browser_store()"); + } + await insert(a, " Recovered"); + final = await converge(a, b); + const durable = await waitStored(final); + await a + .getByText(/A snapshot was committed at/) + .waitFor({ timeout: 15000 }); + await b + .getByText(/A snapshot was committed at/) + .waitFor({ timeout: 15000 }); + await writeFile( + path.join(evidence, "committed-state.json"), + JSON.stringify( + { + document: "release-planning", + text: final, + stateHex: durable.state.toString("hex"), + revision: durable.revision, + storedAt: durable.stored_at.toISOString(), + }, + null, + 2, + ), + ); + console.log( + "Later edit triggers successful storage and recovers prior unsaved edit; commit-only receipt visible in both browsers.", + ); + } finally { + await owner.end(); + } + } + await a.screenshot({ + path: path.join(evidence, "desktop.png"), + fullPage: true, + }); + await b.screenshot({ + path: path.join(evidence, "mobile.png"), + fullPage: true, + }); + await writeFile( + path.join(evidence, "browser-state.json"), + JSON.stringify({ document: "release-planning", text: final }, null, 2), + ); + console.log( + "Two independent native Chromium processes converged after concurrent inserts, actual deletes, offline edits and reconnect; no duplicates, token storage or horizontal overflow. Synchronization is not claimed as database commit.", + ); +} finally { + await first.close(); + await second.close(); +} diff --git a/applications/collaborative-notes/tests/cloud.mjs b/applications/collaborative-notes/tests/cloud.mjs new file mode 100644 index 00000000..c382dcc5 --- /dev/null +++ b/applications/collaborative-notes/tests/cloud.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { checkServerIdentity } from "node:tls"; +import pg from "pg"; +import * as Y from "yjs"; +import { databaseConfig, createPool } from "../dist-server/server/database.js"; +import { PostgresStorage } from "../dist-server/server/store.js"; +const runtime = createPool(); +const owner = createPool({ + ...process.env, + PGUSER: process.env.OWNER_USER, + PGPASSWORD: process.env.OWNER_PASSWORD, +}); +const storage = new PostgresStorage(runtime); +let cases = 0; +async function control(name, fn) { + await fn(); + cases++; + console.log(`PASS ${name}`); +} +async function row() { + return ( + await owner.query( + "SELECT state,revision,stored_at FROM notes.documents WHERE id='meeting-notes'", + ) + ).rows[0]; +} +try { + await control( + "actual binary inserts/deletes, reordered/repeated snapshots", + async () => { + const base = await storage.load("meeting-notes"); + const doc = new Y.Doc({ gc: false }); + Y.applyUpdate(doc, base); + const initial = doc.getText("content").toString(); + doc.getText("content").insert(initial.length, " ABC"); + const older = Y.encodeStateAsUpdate(doc); + doc.getText("content").delete(initial.length + 2, 1); + const newer = Y.encodeStateAsUpdate(doc); + await storage.store("meeting-notes", newer); + await storage.store("meeting-notes", older); + await storage.store("meeting-notes", newer); + const loaded = new Y.Doc({ gc: false }); + Y.applyUpdate(loaded, await storage.load("meeting-notes")); + assert.equal(loaded.getText("content").toString(), initial + " AC"); + doc.destroy(); + loaded.destroy(); + }, + ); + await control( + "independent row-lock contention merges both snapshots", + async () => { + const base = await storage.load("meeting-notes"); + const a = new Y.Doc({ gc: false }); + const b = new Y.Doc({ gc: false }); + Y.applyUpdate(a, base); + Y.applyUpdate(b, base); + a.getText("content").insert(0, "left "); + b.getText("content").insert(0, "right "); + const blocker = await owner.connect(); + await blocker.query("BEGIN"); + await blocker.query( + "SELECT id FROM notes.documents WHERE id='meeting-notes' FOR UPDATE", + ); + let completed = 0; + const operations = [ + storage.store("meeting-notes", Y.encodeStateAsUpdate(a)), + storage.store("meeting-notes", Y.encodeStateAsUpdate(b)), + ].map((p) => p.then(() => completed++)); + await new Promise((r) => setTimeout(r, 400)); + assert.equal(completed, 0); + await blocker.query("COMMIT"); + blocker.release(); + await Promise.all(operations); + const merged = new Y.Doc(); + Y.applyUpdate(merged, await storage.load("meeting-notes")); + const value = merged.getText("content").toString(); + assert(value.includes("left ")); + assert(value.includes("right ")); + a.destroy(); + b.destroy(); + merged.destroy(); + }, + ); + await control( + "actual deferred commit failure rolls back binary and revision", + async () => { + const before = await row(); + await owner.query( + "CREATE FUNCTION notes.fail_commit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture commit failure'; END $$", + ); + await owner.query( + "CREATE CONSTRAINT TRIGGER fixture_failure AFTER UPDATE ON notes.documents DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION notes.fail_commit()", + ); + try { + const doc = new Y.Doc(); + Y.applyUpdate(doc, before.state); + doc.getText("content").insert(0, "rollback "); + await assert.rejects( + storage.store("meeting-notes", Y.encodeStateAsUpdate(doc)), + /fixture commit failure/, + ); + doc.destroy(); + const after = await row(); + assert.deepEqual(after, before); + } finally { + await owner.query("DROP TRIGGER fixture_failure ON notes.documents"); + await owner.query("DROP FUNCTION notes.fail_commit()"); + } + }, + ); + await control( + "runtime role rejects DDL, TEMP, insertion, deletion and metadata edits", + async () => { + for (const sql of [ + "CREATE TABLE notes.forbidden(id int)", + "CREATE TEMP TABLE forbidden(id int)", + "INSERT INTO notes.documents(id,title,state) VALUES('x','x',decode('0000','hex'))", + "DELETE FROM notes.documents WHERE id='meeting-notes'", + "UPDATE notes.documents SET title='changed' WHERE id='meeting-notes'", + ]) { + await assert.rejects(runtime.query(sql), (e) => e.code === "42501"); + } + }, + ); + await control( + "verified CA positive and certificate/name-specific negatives through actual factory", + async () => { + await runtime.query("SELECT 1"); + const caConfig = databaseConfig(); + caConfig.ssl = { ...caConfig.ssl, ca: "" }; + const wrongCA = new pg.Pool(caConfig); + try { + await assert.rejects(wrongCA.query("SELECT 1"), (e) => + [ + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "SELF_SIGNED_CERT_IN_CHAIN", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + ].includes(e.code), + ); + } finally { + await wrongCA.end(); + } + const nameConfig = databaseConfig(); + nameConfig.ssl = { + ...nameConfig.ssl, + checkServerIdentity: (_host, cert) => + checkServerIdentity("wrong-host.invalid", cert), + }; + const wrongName = new pg.Pool(nameConfig); + try { + await assert.rejects( + wrongName.query("SELECT 1"), + (e) => e.code === "ERR_TLS_CERT_ALTNAME_INVALID", + ); + } finally { + await wrongName.end(); + } + console.log( + "Hostname negative invokes Node checkServerIdentity with substituted wrong-host.invalid; production factory has no custom verifier.", + ); + }, + ); + await control( + "fixed allowlist and binary bounds rejected before writing", + async () => { + await assert.rejects(storage.load("foreign-note"), /Unknown note/); + await assert.rejects( + storage.store("meeting-notes", new Uint8Array(524289)), + /limit/i, + ); + }, + ); + console.log(`${cases} distinct Cloud storage/role/TLS controls passed.`); +} finally { + await runtime.end(); + await owner.end(); +} diff --git a/applications/collaborative-notes/tests/gate-controls.mjs b/applications/collaborative-notes/tests/gate-controls.mjs new file mode 100644 index 00000000..0a91b389 --- /dev/null +++ b/applications/collaborative-notes/tests/gate-controls.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + HocuspocusProvider, + HocuspocusProviderWebsocket, +} from "@hocuspocus/provider"; +import WebSocket from "ws"; +import * as Y from "yjs"; +import { createServer } from "../dist-server/server/protocol.js"; +import { mergeState, seedState } from "../dist-server/server/binary.js"; +const token = process.env.WORKSPACE_TOKEN; +let stored = seedState("Safe"); +const memory = { + async load() { + return stored; + }, + async store(name, captured) { + stored = mergeState(stored, captured); + return { + document: name, + revision: 1, + storedAt: new Date().toISOString(), + capturedHash: createHash("sha256").update(captured).digest("hex"), + }; + }, +}; +const server = createServer(memory, token); +await server.listen(); +class LocalSocket extends WebSocket { + constructor(url) { + super(url, { headers: { Origin: "http://127.0.0.1:5173" } }); + } +} +async function connect() { + const doc = new Y.Doc(); + let resolve; + const ready = new Promise((r) => (resolve = r)); + const websocket = new HocuspocusProviderWebsocket({ + url: "ws://127.0.0.1:1234", + WebSocketPolyfill: LocalSocket, + }); + const provider = new HocuspocusProvider({ + websocketProvider: websocket, + name: "meeting-notes", + document: doc, + token, + onSynced: ({ state }) => { + if (state) resolve(); + }, + }); + provider.attach(); + await Promise.race([ + ready, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Native provider sync timeout")), + 10000, + ), + ), + ]); + return { + doc, + provider, + websocket, + destroy() { + provider.destroy(); + websocket.destroy(); + doc.destroy(); + }, + }; +} +async function wait(predicate) { + const end = Date.now() + 10000; + while (Date.now() < end) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, 50)); + } + throw new Error("Control did not complete."); +} +try { + const good = await connect(); + const live = server.hocuspocus.documents.get("meeting-notes"); + const before = Y.encodeStateAsUpdate(live); + for (const invalid of ["unexpected", "oversize", "pending", "unexpected"]) { + const bad = await connect(); + let rejected = false; + bad.websocket.on("disconnect", () => { + rejected = true; + }); + bad.provider.on("close", () => { + rejected = true; + }); + if (invalid === "unexpected") + bad.doc.getMap("wrong-root").set("evil", "ignored"); + else if (invalid === "oversize") + bad.doc.getText("content").insert(0, "x".repeat(10001)); + else { + const orphan = new Y.Doc(); + orphan.getText("content").insert(0, "unknown"); + const vector = Y.encodeStateVector(orphan); + orphan.getText("content").insert(7, " missing"); + const update = Y.encodeStateAsUpdate(orphan, vector); + orphan.destroy(); + const varUint = (value) => { + const bytes = []; + do { + let byte = value & 127; + value = Math.floor(value / 128); + if (value) byte |= 128; + bytes.push(byte); + } while (value); + return Buffer.from(bytes); + }; + const name = Buffer.from("meeting-notes"); + bad.websocket.webSocket.send( + Buffer.concat([ + varUint(name.length), + name, + Buffer.from([0, 2]), + varUint(update.length), + Buffer.from(update), + ]), + ); + } + await wait(() => rejected); + bad.destroy(); + assert.equal(live.getText("content").toString(), "Safe"); + assert.deepEqual(Y.encodeStateAsUpdate(live), before); + } + good.doc.getText("content").insert(4, " accepted"); + await wait(() => live.getText("content").toString() === "Safe accepted"); + assert(!live.store.pendingStructs && !live.store.pendingDs); + good.destroy(); + console.log( + "Rejected repeated invalid/oversized updates leave unchanged bounded state/no pending structs; valid same-note edit succeeds afterward (permit release proven).", + ); +} finally { + await server.destroy(); +} diff --git a/applications/collaborative-notes/tests/preflight-server.ts b/applications/collaborative-notes/tests/preflight-server.ts new file mode 100644 index 00000000..47d94c0f --- /dev/null +++ b/applications/collaborative-notes/tests/preflight-server.ts @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import { createServer } from "../server/protocol.js"; +import { mergeState, seedState } from "../server/binary.js"; +import { NOTES, type Storage } from "../shared/spec.js"; +const states = new Map( + NOTES.map((note) => [ + note.id as string, + seedState(`Start the ${note.title.toLowerCase()} here.\n`), + ]), +); +const revisions = new Map(); +const memory: Storage = { + async load(name) { + const state = states.get(name); + if (!state) throw new Error("Missing fixture note."); + return state; + }, + async store(name, captured) { + const previous = states.get(name); + if (!previous) throw new Error("Missing fixture note."); + states.set(name, mergeState(previous, captured)); + const revision = (revisions.get(name) ?? 0) + 1; + revisions.set(name, revision); + return { + document: name, + revision, + storedAt: new Date().toISOString(), + capturedHash: createHash("sha256").update(captured).digest("hex"), + }; + }, +}; +const server = createServer(memory, process.env.WORKSPACE_TOKEN!); +await server.listen(); +process.once("SIGTERM", () => { + void server.destroy().then(() => process.exit(0)); +}); +console.log( + "Native in-memory protocol gate listening; no Cloud/durability claim.", +); diff --git a/applications/collaborative-notes/tests/restart-browser.mjs b/applications/collaborative-notes/tests/restart-browser.mjs new file mode 100644 index 00000000..ec6b95a5 --- /dev/null +++ b/applications/collaborative-notes/tests/restart-browser.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { chromium } from "playwright"; +import { createPool } from "../dist-server/server/database.js"; +const evidence = + process.env.EVIDENCE_DIR || "/tmp/collaborative-notes-evidence"; +const expected = JSON.parse( + await readFile(path.join(evidence, "committed-state.json"), "utf8"), +); +const pool = createPool(); +const browser = await chromium.launch({ + env: { HOME: process.env.HOME, PATH: process.env.PATH, LANG: "C.UTF-8" }, +}); +try { + const row = ( + await pool.query( + "SELECT state,revision,stored_at FROM notes.documents WHERE id=$1", + [expected.document], + ) + ).rows[0]; + assert.equal(row.state.toString("hex"), expected.stateHex); + assert.equal(row.revision, expected.revision); + assert.equal(row.stored_at.toISOString(), expected.storedAt); + const page = await browser.newPage(); + await page.goto(process.env.UI_BASE_URL || "http://127.0.0.1:5173"); + await page.getByLabel("Workspace token").fill(process.env.WORKSPACE_TOKEN); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await page + .getByText("Connected and synchronized", { exact: true }) + .waitFor({ timeout: 30000 }); + const value = await page + .locator(".cm-line") + .evaluateAll((lines) => lines.map((line) => line.textContent).join("\n")); + assert.equal(value, expected.text); + await page.screenshot({ + path: path.join(evidence, "restart.png"), + fullPage: true, + }); + await writeFile( + path.join(evidence, "restart-state.json"), + JSON.stringify( + { + document: expected.document, + text: value, + stateHex: row.state.toString("hex"), + revision: row.revision, + storedAt: row.stored_at.toISOString(), + }, + null, + 2, + ), + ); + console.log( + "PASS exact committed Cloud binary, revision, timestamp and fresh-browser text survived actual process replacement; no reconstructed JSON document or duplicated content.", + ); +} finally { + await browser.close(); + await pool.end(); +} diff --git a/applications/collaborative-notes/tests/restart.py b/applications/collaborative-notes/tests/restart.py new file mode 100644 index 00000000..20e57449 --- /dev/null +++ b/applications/collaborative-notes/tests/restart.py @@ -0,0 +1,37 @@ +"""Restart only a reviewed local fixture process, with a private runtime env file.""" +import os +import shlex +import signal +import subprocess +import time +from pathlib import Path + +root = Path(__file__).resolve().parents[1] +evidence = Path(os.environ.get('EVIDENCE_DIR', '/tmp/collaborative-notes-evidence')) +evidence.mkdir(parents=True, exist_ok=True) +pid_file = Path(os.environ['SERVER_PID_FILE']) +runtime_env = Path(os.environ['RUNTIME_ENV_FILE']) +old = int(pid_file.read_text()) +assert Path(f'/proc/{old}/cwd').resolve() == root +assert b'dist-server/server/main.js' in Path(f'/proc/{old}/cmdline').read_bytes() +os.kill(old, signal.SIGTERM) +for _ in range(250): + if not Path(f'/proc/{old}').exists(): + break + time.sleep(0.1) +else: + raise RuntimeError('Original process did not exit before replacement') +env = {'HOME': os.environ['HOME'], 'PATH': os.environ['PATH'], 'LANG': 'C.UTF-8'} +allowed = {'PGHOST', 'PGPORT', 'PGDATABASE', 'PGUSER', 'PGPASSWORD', 'PGSSLMODE', 'PGSSLROOTCERT', 'WORKSPACE_TOKEN'} +for line in runtime_env.read_text().splitlines(): + key, value = line.split('=', 1) + if key not in allowed: + raise RuntimeError(f'Unexpected runtime key: {key}') + env[key] = shlex.split(value)[0] +with (evidence / 'replacement-server.log').open('ab') as log: + replacement = subprocess.Popen(['node', 'dist-server/server/main.js'], cwd=root, env=env, stdout=log, stderr=log, start_new_session=True) +pid_file.write_text(str(replacement.pid)) +time.sleep(2) +assert replacement.poll() is None +(evidence / 'restart-process.txt').write_text(f'Original {old} exited before replacement {replacement.pid}; runtime-only keys {sorted(env)}\n') +print((evidence / 'restart-process.txt').read_text(), end='') diff --git a/applications/collaborative-notes/tests/socket-controls.mjs b/applications/collaborative-notes/tests/socket-controls.mjs new file mode 100644 index 00000000..1b73feb5 --- /dev/null +++ b/applications/collaborative-notes/tests/socket-controls.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import WebSocket from "ws"; +import { + HocuspocusProvider, + HocuspocusProviderWebsocket, +} from "@hocuspocus/provider"; +import * as Y from "yjs"; +async function rejected(headers) { + await new Promise((resolve, reject) => { + const ws = new WebSocket("ws://127.0.0.1:1234", { headers }); + ws.once("unexpected-response", (_req, res) => { + assert.equal(res.statusCode, 403); + res.resume(); + ws.terminate(); + resolve(); + }); + ws.once("open", () => { + ws.close(); + reject(new Error("Unexpected upgrade")); + }); + ws.once("error", () => {}); + setTimeout( + () => reject(new Error("Upgrade rejection timeout")), + 5000, + ).unref(); + }); +} +class LocalSocket extends WebSocket { + constructor(url) { + super(url, { headers: { Origin: "http://127.0.0.1:5173" } }); + } +} +async function denied(name, token) { + const doc = new Y.Doc(); + const websocket = new HocuspocusProviderWebsocket({ + url: "ws://127.0.0.1:1234", + WebSocketPolyfill: LocalSocket, + }); + let resolve; + const failed = new Promise((r) => (resolve = r)); + const provider = new HocuspocusProvider({ + websocketProvider: websocket, + name, + document: doc, + token, + awareness: null, + onAuthenticationFailed: () => resolve(), + }); + provider.attach(); + try { + await Promise.race([ + failed, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Auth rejection timeout")), 7000), + ), + ]); + assert.equal(doc.getText("content").length, 0); + } finally { + provider.destroy(); + websocket.destroy(); + doc.destroy(); + } +} +await rejected({ Origin: "https://foreign.invalid" }); +await rejected({ Origin: "http://127.0.0.1:5173", Host: "foreign.invalid" }); +await denied("release-planning", "x".repeat(43)); +await denied("foreign-note", process.env.WORKSPACE_TOKEN); +await new Promise((resolve, reject) => { + const ws = new LocalSocket("ws://127.0.0.1:1234"); + ws.once("open", () => ws.send(Buffer.alloc(65537))); + ws.once("close", (code) => { + assert.equal(code, 1009); + resolve(); + }); + ws.on("error", () => {}); + setTimeout( + () => reject(new Error("Payload rejection timeout")), + 5000, + ).unref(); +}); +console.log( + "PASS foreign Origin and Host reject upgrades; invalid token and unknown document cannot load; over-limit raw frame closes with1009.", +); diff --git a/applications/collaborative-notes/tests/unit.test.ts b/applications/collaborative-notes/tests/unit.test.ts new file mode 100644 index 00000000..3e89cc8c --- /dev/null +++ b/applications/collaborative-notes/tests/unit.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as Y from "yjs"; +import { mergeState, seedState, validateState } from "../server/binary.js"; +test("Yjs binary merge retains insertions and deletions through reordered repeated snapshots", () => { + const baseline = seedState("ABC"); + const writer = new Y.Doc(); + Y.applyUpdate(writer, baseline); + const text = writer.getText("content"); + text.insert(3, "D"); + const inserted = Y.encodeStateAsUpdate(writer); + text.delete(1, 1); + const deleted = Y.encodeStateAsUpdate(writer); + const final = mergeState( + mergeState(mergeState(deleted, baseline), inserted), + deleted, + ); + const loaded = new Y.Doc(); + Y.applyUpdate(loaded, final); + assert.equal(loaded.getText("content").toString(), "ACD"); + writer.destroy(); + loaded.destroy(); +}); +test("unexpected roots, rich text and missing CRDT dependencies are rejected", () => { + const orphan = new Y.Doc(); + orphan.getText("content").insert(0, "unknown"); + const vector = Y.encodeStateVector(orphan); + orphan.getText("content").insert(7, "missing"); + assert.throws(() => validateState(Y.encodeStateAsUpdate(orphan, vector))); + orphan.destroy(); + const doc = new Y.Doc(); + doc.getMap("unexpected").set("x", "no"); + assert.throws(() => validateState(Y.encodeStateAsUpdate(doc))); + doc.destroy(); + const rich = new Y.Doc(); + rich.getText("content").insertEmbed(0, { html: "unsafe" }); + assert.throws(() => validateState(Y.encodeStateAsUpdate(rich))); + rich.destroy(); +}); +test("rendered text and binary state limits are distinct", () => { + assert.throws(() => validateState(seedState("x".repeat(10001)))); + assert.throws(() => validateState(new Uint8Array(512 * 1024 + 1))); + validateState(seedState("Valid 🚀\nplain note")); +}); diff --git a/applications/collaborative-notes/tsconfig.app.json b/applications/collaborative-notes/tsconfig.app.json new file mode 100644 index 00000000..c472d426 --- /dev/null +++ b/applications/collaborative-notes/tsconfig.app.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "types": ["vite/client"] + }, + "include": ["src", "shared", "vite.config.ts"] +} diff --git a/applications/collaborative-notes/tsconfig.server.json b/applications/collaborative-notes/tsconfig.server.json new file mode 100644 index 00000000..b62f1c76 --- /dev/null +++ b/applications/collaborative-notes/tsconfig.server.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "rootDir": ".", + "outDir": "dist-server", + "types": ["node"] + }, + "include": ["server", "shared", "tests"] +} diff --git a/applications/collaborative-notes/vite.config.ts b/applications/collaborative-notes/vite.config.ts new file mode 100644 index 00000000..523866dd --- /dev/null +++ b/applications/collaborative-notes/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +const proxy = { + "/collab": { + target: "ws://127.0.0.1:1234", + ws: true, + changeOrigin: false, + rewrite: () => "/", + }, +}; +export default defineConfig({ + plugins: [react()], + server: { + host: "127.0.0.1", + port: 5173, + strictPort: true, + allowedHosts: ["127.0.0.1"], + cors: false, + proxy, + }, + preview: { + host: "127.0.0.1", + port: 5173, + strictPort: true, + allowedHosts: ["127.0.0.1"], + cors: false, + proxy, + }, +});