From e1abbe8e8e9c4495a433bc246907b250e22e7814 Mon Sep 17 00:00:00 2001 From: IMT <1llum1n4t1@duck.com> Date: Mon, 22 Jun 2026 23:20:50 +0900 Subject: [PATCH 01/22] =?UTF-8?q?refactor(sync):=20chrome.storage.sync=20?= =?UTF-8?q?=E7=9B=B4=E5=8F=A9=E3=81=8D=E3=82=92=20transport=20=E6=8A=BD?= =?UTF-8?q?=E8=B1=A1=E3=81=B8=E6=8B=AC=E3=82=8A=E5=87=BA=E3=81=99=EF=BC=88?= =?UTF-8?q?=E6=8C=99=E5=8B=95=E4=B8=8D=E5=A4=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit リアルタイム relay 同期(A→C→B)の地ならし(A)。sync.js の chrome.storage.sync 直接呼び出し6箇所(機能検出 / readSync の getAll / push の set·remove)を transport インターフェース(isAvailable/getAll/set/remove)へ集約。既定の ChromeSyncTransport は メソッド内で呼び出し時に chrome を解決し現状と完全同一に振る舞う(読込時に参照を キャプチャしない=テストの後付けモック互換)。setSyncTransport で将来 RelayTransport (Cloudflare Worker 経由の HTTP)へ差し替えるための境界を用意。 挙動は不変。_sync_repro.mjs 195 PASS / 0 FAIL を維持。 Co-Authored-By: Claude Opus 4.8 --- src/shared/sync.js | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/shared/sync.js b/src/shared/sync.js index 9c20b77..ae109c8 100644 --- a/src/shared/sync.js +++ b/src/shared/sync.js @@ -451,8 +451,26 @@ export function gcTombstones(tomb, now, exempt) { // chrome.storage I/O 層(副作用あり) // ════════════════════════════════════════════════════════════════ -const hasSync = () => - typeof chrome !== "undefined" && chrome.storage && chrome.storage.sync; +// ─── リモート transport 抽象(差し替え境界)───────────────────────── +// chrome.storage.sync の直叩きを transport インターフェース(isAvailable / getAll / set / +// remove)へ括り出す。既定は ChromeSyncTransport=メソッド内で呼び出し時に chrome を解決し、 +// 現状と完全同一に振る舞う(モジュール読込時に参照をキャプチャしない=テストの後付けモック互換)。 +// 将来 RelayTransport(Cloudflare Worker 経由の HTTP)へ setSyncTransport で差し替えるための境界。 +const chromeSyncTransport = { + isAvailable: () => typeof chrome !== "undefined" && chrome.storage && chrome.storage.sync, + getAll: () => chrome.storage.sync.get(null), + set: (obj) => chrome.storage.sync.set(obj), + remove: (keys) => chrome.storage.sync.remove(keys), +}; +let _transport = chromeSyncTransport; +// transport を差し替える(falsy を渡すと既定の ChromeSyncTransport へ戻る)。テスト・別実装の注入点。 +export function setSyncTransport(t) { + _transport = t || chromeSyncTransport; +} +export function getSyncTransport() { + return _transport; +} +const hasSync = () => _transport.isAvailable(); async function getLocalNotes() { const raw = await chrome.storage.local.get(STORAGE_KEYS.notes); @@ -480,7 +498,7 @@ async function getLocalTrash() { // rawByDomain : 格納されている生 item(書き込み要否の比較を符号化形同士で行うため) // corrupt : 復号に失敗したドメイン集合(reconcile はこれらを「今回触らない」で隔離) async function readSync() { - const all = await chrome.storage.sync.get(null); + const all = await _transport.getAll(); // settings item は「キーが在るか」で会計する(truthy かではない)。破損で false/0/"" 等の falsy 値に // なっても cloud に物理的に残り slot/バイトを占有するため、`|| null` で存在を握り潰すと会計から漏れ、 // 上限近傍で「実 quota 超過なのに gate 通過→write_failed」になる(決定的 item_limit に倒せない。Codex)。 @@ -1099,17 +1117,17 @@ async function _reconcile(opts) { // =「item 消去済みだが墓石未保存」の復活窓を作らない(Codex#7 / S20)。満杯+新規 meta で枠が無い回は // 上流で metaDeferred 扱いにして削除自体を保留する(remove-first で墓石喪失する経路は廃止。Codex・S42)。 // よってここで removeKeys がある=既存 meta の更新 か 新規でも枠あり=meta-set が新規 item を足せる。 - await chrome.storage.sync.set({ [SYNC_KEYS.meta]: metaOp }); - await chrome.storage.sync.remove(removeKeys); + await _transport.set({ [SYNC_KEYS.meta]: metaOp }); + await _transport.remove(removeKeys); } else { // 既存墓石のみの回(今回 meta を書かない)→ remove 直行(墓石は既永続化済み=復活窓なし)。 - await chrome.storage.sync.remove(removeKeys); + await _transport.remove(removeKeys); } const rest = {}; for (const k of Object.keys(setOps)) if (k !== SYNC_KEYS.meta) rest[k] = setOps[k]; - if (Object.keys(rest).length) await chrome.storage.sync.set(rest); + if (Object.keys(rest).length) await _transport.set(rest); } else if (hasSet) { - await chrome.storage.sync.set(setOps); + await _transport.set(setOps); } if (hasSet || removeKeys.length) { // 自エコー判定用に「キー→push した値(JSON)」を記録する。remove は null。 From 18ac12735de9214780c84702e9992312e36acf03 Mon Sep 17 00:00:00 2001 From: IMT <1llum1n4t1@duck.com> Date: Mon, 22 Jun 2026 23:53:10 +0900 Subject: [PATCH 02/22] =?UTF-8?q?feat(relay):=20=E5=90=8C=E6=9C=9F?= =?UTF-8?q?=E3=83=AA=E3=83=AC=E3=83=BC=E3=81=AE=E8=B6=B3=E5=A0=B4(B1)=20?= =?UTF-8?q?=E2=80=94=20Worker=20+=20VaultDO=20+=20D1=20=E3=82=B9=E3=82=AD?= =?UTF-8?q?=E3=83=BC=E3=83=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit クラウド同期モード用 Cloudflare Workers リレーの server 側を新規スキャフォルド。 notify-then-pull(store-and-forward): vault 単位の VaultDO(Hibernatable WebSocket)が 暗号文ストア窓口(D1: petarin-sync) + per-vault seq 採番 + 変更ピンの fan-out を担う。 - src/index.ts: 薄い router。vaultId を SALT 付き SHA-256 でハッシュ化して DO を引く(横入り防止)。 - src/vault-do.ts: WS(acceptWebSocket=ハイバネ)/push/pull/catchup。push で D1 upsert→ seq 採番→他端末へ {t:changed,d,seq} broadcast(送信WS無料)。 - src/auth.ts: 自己完結ペアリング鍵。vault の ECDSA P-256 公開鍵を first-write-wins 登録し 以降のリクエストを署名検証(秘密はサーバーに無い)。本文は端末側 vaultKey で AES-GCM 暗号化、 ドメイン名も HMAC ハッシュ化=サーバーは中身もサイトも知らない。 - migrations/0001_init.sql + wrangler.toml + README + tsconfig + package.json/lock。 Ferry-relay の流儀(DO+ハイバネ/SALT ハッシュ/Custom Domain のみ/Rate Limit 三段)に倣う。 topology は別(あちらは 2 peer 生パススルー、こちらは N端末 store-and-forward)。 tsc --noEmit 通過。未デプロイ(D1 作成・SALT・ドメイン確定は明示 GO 後)。 Co-Authored-By: Claude Opus 4.8 --- infra/cloudflare/relay/.gitignore | 5 + infra/cloudflare/relay/README.md | 68 ++ .../cloudflare/relay/migrations/0001_init.sql | 17 + infra/cloudflare/relay/package.json | 17 + infra/cloudflare/relay/pnpm-lock.yaml | 888 ++++++++++++++++++ infra/cloudflare/relay/pnpm-workspace.yaml | 4 + infra/cloudflare/relay/src/auth.ts | 34 + infra/cloudflare/relay/src/index.ts | 62 ++ infra/cloudflare/relay/src/vault-do.ts | 198 ++++ infra/cloudflare/relay/tsconfig.json | 14 + infra/cloudflare/relay/wrangler.toml | 56 ++ 11 files changed, 1363 insertions(+) create mode 100644 infra/cloudflare/relay/.gitignore create mode 100644 infra/cloudflare/relay/README.md create mode 100644 infra/cloudflare/relay/migrations/0001_init.sql create mode 100644 infra/cloudflare/relay/package.json create mode 100644 infra/cloudflare/relay/pnpm-lock.yaml create mode 100644 infra/cloudflare/relay/pnpm-workspace.yaml create mode 100644 infra/cloudflare/relay/src/auth.ts create mode 100644 infra/cloudflare/relay/src/index.ts create mode 100644 infra/cloudflare/relay/src/vault-do.ts create mode 100644 infra/cloudflare/relay/tsconfig.json create mode 100644 infra/cloudflare/relay/wrangler.toml diff --git a/infra/cloudflare/relay/.gitignore b/infra/cloudflare/relay/.gitignore new file mode 100644 index 0000000..ff00fad --- /dev/null +++ b/infra/cloudflare/relay/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.wrangler/ +dist/ +*.log +.dev.vars diff --git a/infra/cloudflare/relay/README.md b/infra/cloudflare/relay/README.md new file mode 100644 index 0000000..6ad58f0 --- /dev/null +++ b/infra/cloudflare/relay/README.md @@ -0,0 +1,68 @@ +# petarin-relay + +ぺたりんの「クラウド同期モード」用 Cloudflare Workers リレー。**任意・既定 OFF**。 +拡張本体は `chrome.storage.local` が真実の源のまま。relay を使うのはクラウド同期を ON にした人だけ。 + +## 方式: notify-then-pull(store-and-forward) + +vault(同期グループ)ごとに 1 つの `VaultDO`(Durable Object)が次を担う: + +1. **暗号文ストアの窓口** — 本体は D1(`petarin-sync`)。 +2. **per-vault の seq 採番** — catchup(差分取り込み)の基準。 +3. **Hibernatable WebSocket の fan-out ハブ** — 変更ピンを他端末へ broadcast。 + +``` +PC編集 → PUT /push(暗号文を D1 へ) → DO が {t:changed,d,seq} を WS broadcast + → 他端末が GET /pull?d=… で該当ドメインだけ取得 → 端末側で復号+既存マージ +``` + +ferry-relay の「2 peer 生パススルー」とは別物(あちらは同時オンライン前提のファイル転送用)。 + +## プライバシー(E2E) + +- サーバーは**暗号文しか受け取らない**。本文は端末側 `vaultKey`(AES-GCM)で暗号化済み。 +- **ドメイン名も端末側で HMAC ハッシュ化**して送るため、サーバーは「どのサイトか」も知らない。 +- 認証は**自己完結ペアリング鍵**: vault は ECDSA P-256 鍵ペアを持ち、QR/コードで端末間に秘密鍵を渡す。 + 公開鍵は初回 first-write-wins で `VaultDO` に登録。以降は**署名で検証**(秘密はサーバーに無い)。 + +## プロトコル + +vaultId はルーティングのみに使い、`SHA-256(vaultId + SALT)` でハッシュ化してから `idFromName`(漏洩時の横入り防止)。 + +| 経路 | 認証の渡し方 | 内容 | +| --- | --- | --- | +| `GET /health` | なし | 疎通確認("OK") | +| `GET /sync?vault=…`(WS upgrade) | クエリ `ts/sig/pubkey` | ハイバネ WS を確立 | +| `PUT /push` | ヘッダ `X-Vault-*` | body `{d,c,n}` を D1 upsert→seq 採番→broadcast→`{seq}` | +| `GET /pull?d=…` | ヘッダ `X-Vault-*` | `{d,c,n,seq}`(無ければ 404) | +| `GET /catchup?since=…` | ヘッダ `X-Vault-*` | `{changes:[{d,seq}], seq}` | + +署名対象の正規文字列(端末側と一致させる): `vaultId\nts\nmethod\npath\nsha256hex(body)`。 +ヘッダ: `X-Vault-Id` / `X-Vault-Ts`(unix ms・±5分) / `X-Vault-Sig`(ECDSA P-256 SHA-256, raw r‖s, base64url) / 初回のみ `X-Vault-Pubkey`(SPKI base64url)。WS はこれらをクエリ `vault/ts/sig/pubkey` で渡す。 + +## セットアップ / デプロイ + +```bash +pnpm -C infra/cloudflare/relay install +pnpm -C infra/cloudflare/relay typecheck # tsc --noEmit + +# D1 を作成し、出力 database_id を wrangler.toml に貼る +pnpm dlx wrangler d1 create petarin-sync +pnpm -C infra/cloudflare/relay d1:migrate:local # ローカルにスキーマ適用 +pnpm -C infra/cloudflare/relay dev # wrangler dev でローカル起動 + +# 本番化(明示 GO 後) +openssl rand -hex 32 | pnpm dlx wrangler secret put SALT +pnpm -C infra/cloudflare/relay d1:migrate:remote +pnpm -C infra/cloudflare/relay deploy # Custom Domain は wrangler.toml で確定してから +``` + +Workers Paid($5/月・Ferry / RealTimeTranslator と共有)必須(Durable Objects のため)。 +個人規模なら従量はほぼ枠内(アイドルはハイバネで duration 課金 0)。 + +## まだ無いもの(B の続き) + +- 客側 `RelayTransport`(拡張の `setSyncTransport` に差す。HTTP + E2E 暗復号 + ドメイン HMAC)。 +- ペアリング UI(QR 生成/読取 + コード貼り付け)と manage の同期パネルのモード選択(排他 3 モード)。 +- background.js の WS 接続保持・変更ピン受信→該当ドメインだけ reconcile・前面復帰 catchup。 +- モバイル background 時の即時性(catchup のみ / Web Push 併用)はスマホアプリ設計後に決定。 diff --git a/infra/cloudflare/relay/migrations/0001_init.sql b/infra/cloudflare/relay/migrations/0001_init.sql new file mode 100644 index 0000000..c8cfe1e --- /dev/null +++ b/infra/cloudflare/relay/migrations/0001_init.sql @@ -0,0 +1,17 @@ +-- ぺたりん 同期リレーの暗号文ストア(D1: petarin-sync)。 +-- サーバーは暗号文しか持たない: ciphertext/nonce は端末側 vaultKey で AES-GCM 暗号化済み、 +-- domain_hash は端末側で HMAC ハッシュ化済み(サーバーは「どのサイトか」も知らない)。 +-- vault_id はハッシュ化済み DO id 文字列(生 vaultId は保存しない)。 + +CREATE TABLE IF NOT EXISTS notes ( + vault_id TEXT NOT NULL, -- ハッシュ化済み vault 識別子(= DO id 文字列) + domain_hash TEXT NOT NULL, -- HMAC(vaultKey, domain) の hex + ciphertext TEXT NOT NULL, -- AES-GCM 暗号文(base64url) + nonce TEXT NOT NULL, -- AES-GCM nonce/IV(base64url) + seq INTEGER NOT NULL, -- per-vault 単調増加(catchup の since 比較に使う) + updated_at INTEGER NOT NULL, -- サーバー受領時刻(ms) + PRIMARY KEY (vault_id, domain_hash) +); + +-- catchup(seq > since)と per-vault 走査用。 +CREATE INDEX IF NOT EXISTS idx_notes_vault_seq ON notes (vault_id, seq); diff --git a/infra/cloudflare/relay/package.json b/infra/cloudflare/relay/package.json new file mode 100644 index 0000000..30b41ea --- /dev/null +++ b/infra/cloudflare/relay/package.json @@ -0,0 +1,17 @@ +{ + "name": "petarin-relay", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "d1:migrate:local": "wrangler d1 migrations apply petarin-sync --local", + "d1:migrate:remote": "wrangler d1 migrations apply petarin-sync --remote" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260526.1", + "typescript": "^5.6.0", + "wrangler": "^4.94.0" + } +} diff --git a/infra/cloudflare/relay/pnpm-lock.yaml b/infra/cloudflare/relay/pnpm-lock.yaml new file mode 100644 index 0000000..e107efb --- /dev/null +++ b/infra/cloudflare/relay/pnpm-lock.yaml @@ -0,0 +1,888 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260526.1 + version: 4.20260621.1 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + wrangler: + specifier: ^4.94.0 + version: 4.103.0(@cloudflare/workers-types@4.20260621.1) + +packages: + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260617.1': + resolution: {integrity: sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260617.1': + resolution: {integrity: sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260617.1': + resolution: {integrity: sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260617.1': + resolution: {integrity: sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260617.1': + resolution: {integrity: sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260621.1': + resolution: {integrity: sha512-c4xrf4shZdDOK1ihh1UKzlS/3MDYiGThT/Oqr4Y3qR9NLCSNzHB7rt+Vk/LOp0ZSNjA+7WNJEQsOhpiQtpT2GA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + miniflare@4.20260617.1: + resolution: {integrity: sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw==} + engines: {node: '>=22.0.0'} + hasBin: true + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + workerd@1.20260617.1: + resolution: {integrity: sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.103.0: + resolution: {integrity: sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260617.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + 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 + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + +snapshots: + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260617.1 + + '@cloudflare/workerd-darwin-64@1.20260617.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260617.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260617.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260617.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260617.1': + optional: true + + '@cloudflare/workers-types@4.20260621.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.17': {} + + blake3-wasm@2.1.5: {} + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + fsevents@2.3.3: + optional: true + + kleur@4.1.5: {} + + miniflare@4.20260617.1: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.28.0 + workerd: 1.20260617.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + semver@7.8.5: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + supports-color@10.2.2: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici@7.28.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + workerd@1.20260617.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260617.1 + '@cloudflare/workerd-darwin-arm64': 1.20260617.1 + '@cloudflare/workerd-linux-64': 1.20260617.1 + '@cloudflare/workerd-linux-arm64': 1.20260617.1 + '@cloudflare/workerd-windows-64': 1.20260617.1 + + wrangler@4.103.0(@cloudflare/workers-types@4.20260621.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260617.1 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260617.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260621.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.17 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/infra/cloudflare/relay/pnpm-workspace.yaml b/infra/cloudflare/relay/pnpm-workspace.yaml new file mode 100644 index 0000000..2cd53ef --- /dev/null +++ b/infra/cloudflare/relay/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: set this to true or false + sharp: set this to true or false + workerd: set this to true or false diff --git a/infra/cloudflare/relay/src/auth.ts b/infra/cloudflare/relay/src/auth.ts new file mode 100644 index 0000000..fe76db5 --- /dev/null +++ b/infra/cloudflare/relay/src/auth.ts @@ -0,0 +1,34 @@ +/** + * vault リクエストの ECDSA P-256 署名検証ヘルパ。 + * サーバーは vault の「公開鍵」だけ保持し、秘密鍵は端末から出ない(自己完結ペアリング鍵)。 + * WebCrypto の ECDSA P-256(SHA-256)はブラウザ・Workers ともに広くサポートされる(Ed25519 の + * 旧 Chrome 非対応問題を避ける選択)。署名は IEEE P1363 raw(r||s, 64 バイト)。 + */ + +/** base64url(パディング無し可)→ Uint8Array。 */ +export function b64urlToBytes(s: string): Uint8Array { + const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4); + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +export async function sha256Hex(bytes: Uint8Array): Promise { + const buf = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** SPKI(DER)公開鍵を verify 用 CryptoKey へ。 */ +export function importVerifyKey(spki: Uint8Array): Promise { + return crypto.subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]); +} + +/** 署名対象の正規文字列: vaultId\nts\nmethod\npath\nbodyHashHex(端末側と一致させること)。 */ +export function signString(vaultId: string, ts: string, method: string, path: string, bodyHashHex: string): string { + return [vaultId, ts, method, path, bodyHashHex].join("\n"); +} + +export function verifySig(key: CryptoKey, data: string, sig: Uint8Array): Promise { + return crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, sig, new TextEncoder().encode(data)); +} diff --git a/infra/cloudflare/relay/src/index.ts b/infra/cloudflare/relay/src/index.ts new file mode 100644 index 0000000..dd5ced3 --- /dev/null +++ b/infra/cloudflare/relay/src/index.ts @@ -0,0 +1,62 @@ +/** + * ぺたりん 同期リレー (Cloudflare Workers + Durable Objects) + * + * 方式: notify-then-pull の store-and-forward。vault(同期グループ)ごとに 1 つの VaultDO が + * (1) 暗号文 blob の窓口(本体は D1) (2) per-vault の seq 採番 (3) Hibernatable WebSocket の fan-out ハブ + * を担う。編集→push で D1 へ暗号文を貯め、薄い変更ピンだけ WS で他端末へ broadcast、受信側は + * 該当ドメインだけ pull する。ferry-relay の「2 peer 生パススルー」とは別物(あちらは同時オンライン前提)。 + * + * プライバシー: サーバーは暗号文しか受け取らない(本文は端末側 vaultKey で AES-GCM 暗号化)。 + * ドメイン名も端末側で HMAC ハッシュ化して送るため、サーバーは「どのサイトか」も知らない。 + * + * 認証(自己完結ペアリング鍵): vault は ECDSA P-256 鍵ペアを持ち、QR で端末間に秘密鍵を渡す。公開鍵は + * 初回 first-write-wins で VaultDO に登録。以降の各リクエストは署名で検証(サーバーは秘密を持たない)。 + * + * Worker 本体は薄い router: vaultId を SALT 付き SHA-256 でハッシュ化して DO を引き、リクエストを丸ごと転送する。 + */ +import { VaultDO } from "./vault-do"; +export { VaultDO }; + +export interface Env { + VAULT: DurableObjectNamespace; + DB: D1Database; + SALT: string; + RATELIMIT_IP?: RateLimit; + RATELIMIT_VAULT?: RateLimit; +} + +/** Cloudflare Rate Limit binding の最小型(@cloudflare/workers-types と互換)。 */ +export interface RateLimit { + limit(opts: { key: string }): Promise<{ success: boolean }>; +} + +export default { + async fetch(req: Request, env: Env): Promise { + const url = new URL(req.url); + if (url.pathname === "/health") return new Response("OK"); + + // vaultId: HTTP はヘッダ、WS はブラウザがヘッダを付けられないのでクエリで受ける。 + const isWs = req.headers.get("Upgrade") === "websocket"; + const vaultId = isWs ? url.searchParams.get("vault") : req.headers.get("X-Vault-Id"); + if (!vaultId) return new Response("Missing vault", { status: 400 }); + + // IP レート制限(粗い網。vault 単位の制限は DO 内で認証後に掛ける)。 + if (env.RATELIMIT_IP) { + const ip = req.headers.get("CF-Connecting-IP") || "unknown"; + const { success } = await env.RATELIMIT_IP.limit({ key: ip }); + if (!success) return new Response("Rate limited", { status: 429 }); + } + + // 生 vaultId を idFromName へ直入れすると漏洩時に第三者が同じ vault へ到達できるため、 + // SALT 付き SHA-256 でハッシュ化してから DO を引く(ferry-relay と同方針)。 + const idStr = await hashVaultId(vaultId, env.SALT); + const stub = env.VAULT.get(env.VAULT.idFromName(idStr)); + return stub.fetch(req); + }, +}; + +async function hashVaultId(vaultId: string, salt: string): Promise { + const data = new TextEncoder().encode(vaultId + "|" + salt); + const buf = await crypto.subtle.digest("SHA-256", data); + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/infra/cloudflare/relay/src/vault-do.ts b/infra/cloudflare/relay/src/vault-do.ts new file mode 100644 index 0000000..f426dc9 --- /dev/null +++ b/infra/cloudflare/relay/src/vault-do.ts @@ -0,0 +1,198 @@ +/** + * VaultDO — vault(同期グループ)単位の Durable Object。 + * - Hibernatable WebSocket(state.acceptWebSocket)で各端末の接続を受ける。アイドル中はメモリ退避され + * duration(GB-s)課金が止まる(CF 公式: "idle and eligible for hibernation are not billed for duration")。 + * - push: 暗号文 blob を D1 へ upsert し、薄い変更ピン {t:'changed', d, seq} を他端末へ broadcast。 + * - pull: 該当ドメインの暗号文を D1 から返す。catchup: seq>since の変更一覧を返す(前面復帰/再接続用)。 + * - seq: この DO は vault 単位=単一スレッドなので storage 上のカウンタで単調採番できる。 + * - 認証: vault 公開鍵を first-write-wins で登録し、以降のリクエストは署名で検証(秘密はサーバーに無い)。 + */ +import type { Env } from "./index"; +import { b64urlToBytes, sha256Hex, importVerifyKey, signString, verifySig } from "./auth"; + +const TS_WINDOW_MS = 5 * 60 * 1000; // 署名タイムスタンプの許容ずれ(リプレイ窓) + +interface AuthResult { + ok: boolean; + status: number; + msg: string; +} + +export class VaultDO { + state: DurableObjectState; + env: Env; + + constructor(state: DurableObjectState, env: Env) { + this.state = state; + this.env = env; + } + + async fetch(req: Request): Promise { + const url = new URL(req.url); + + if (req.headers.get("Upgrade") === "websocket") return this.handleWs(req, url); + + // HTTP は本文ハッシュまで含めて署名検証する(改竄防止)。 + const bodyBytes = req.method === "GET" ? new Uint8Array() : new Uint8Array(await req.arrayBuffer()); + const auth = await this.verify(req, url, bodyBytes, false); + if (!auth.ok) return new Response(auth.msg, { status: auth.status }); + + if (url.pathname === "/push" && req.method === "PUT") return this.handlePush(bodyBytes); + if (url.pathname === "/pull" && req.method === "GET") return this.handlePull(url); + if (url.pathname === "/catchup" && req.method === "GET") return this.handleCatchup(url); + return new Response("Not found", { status: 404 }); + } + + /** + * 署名検証 + 公開鍵の first-write-wins 登録。 + * fromQuery=true(WS)はブラウザがヘッダを付けられないため ts/sig/pubkey をクエリで受ける。 + */ + async verify(req: Request, url: URL, bodyBytes: Uint8Array, fromQuery: boolean): Promise { + const g = (h: string, q: string) => (fromQuery ? url.searchParams.get(q) : req.headers.get(h)); + const vaultId = g("X-Vault-Id", "vault"); + const ts = g("X-Vault-Ts", "ts"); + const sigB64 = g("X-Vault-Sig", "sig"); + const pubB64 = g("X-Vault-Pubkey", "pubkey"); + if (!vaultId || !ts || !sigB64) return { ok: false, status: 401, msg: "Missing auth" }; + + const tsn = Number(ts); + if (!Number.isFinite(tsn) || Math.abs(Date.now() - tsn) > TS_WINDOW_MS) { + return { ok: false, status: 401, msg: "Stale ts" }; + } + + // 公開鍵: 登録済みならそれで検証。未登録で pubkey 提示があれば first-write-wins で登録(= vault 作成)。 + let storedPub = await this.state.storage.get("pubkey"); + if (!storedPub) { + if (!pubB64) return { ok: false, status: 401, msg: "Vault not registered" }; + storedPub = pubB64; + await this.state.storage.put("pubkey", storedPub); + } + + let key: CryptoKey; + try { + key = await importVerifyKey(b64urlToBytes(storedPub)); + } catch { + return { ok: false, status: 500, msg: "Bad stored key" }; + } + + const method = fromQuery ? "GET" : req.method; + const bodyHash = await sha256Hex(bodyBytes); + const data = signString(vaultId, ts, method, url.pathname, bodyHash); + let ok = false; + try { + ok = await verifySig(key, data, b64urlToBytes(sigB64)); + } catch { + ok = false; + } + if (!ok) return { ok: false, status: 401, msg: "Bad signature" }; + + // vault 単位レート制限(認証後)。 + if (this.env.RATELIMIT_VAULT) { + const { success } = await this.env.RATELIMIT_VAULT.limit({ key: vaultId }); + if (!success) return { ok: false, status: 429, msg: "Rate limited" }; + } + return { ok: true, status: 200, msg: "ok" }; + } + + async handleWs(req: Request, url: URL): Promise { + // WS はクエリ(ts/sig/pubkey)で署名検証。本文は空。 + const auth = await this.verify(req, url, new Uint8Array(), true); + if (!auth.ok) return new Response(auth.msg, { status: auth.status }); + + const pair = new WebSocketPair(); + // ハイバネートを効かせるため acceptWebSocket を使う(addEventListener は使わない)。 + this.state.acceptWebSocket(pair[1]); + return new Response(null, { status: 101, webSocket: pair[0] }); + } + + async handlePush(bodyBytes: Uint8Array): Promise { + let body: { d?: string; c?: string; n?: string }; + try { + body = JSON.parse(new TextDecoder().decode(bodyBytes)); + } catch { + return new Response("Bad body", { status: 400 }); + } + const { d, c, n } = body; + if (!d || !c || !n) return new Response("Missing fields", { status: 400 }); + + // per-vault の seq を単調採番(この DO は vault 単位=単一スレッドなので競合しない)。 + let seq = (await this.state.storage.get("seq")) || 0; + seq += 1; + const vid = this.state.id.toString(); // ハッシュ化済み vault 識別子 + const updatedAt = Date.now(); + + await this.env.DB.prepare( + `INSERT INTO notes (vault_id, domain_hash, ciphertext, nonce, seq, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(vault_id, domain_hash) DO UPDATE SET + ciphertext = excluded.ciphertext, nonce = excluded.nonce, + seq = excluded.seq, updated_at = excluded.updated_at` + ) + .bind(vid, d, c, n, seq, updatedAt) + .run(); + await this.state.storage.put("seq", seq); + + // 変更ピンを他端末へ broadcast(本体は載せない=送信WS無料・受信20:1)。push 元が WS を + // 張っていても自分の ping を受けるが、クライアント側の自エコー抑止(wasJustPushed)で弾く契約。 + const msg = JSON.stringify({ t: "changed", d, seq }); + for (const ws of this.state.getWebSockets()) { + if (ws.readyState === WebSocket.OPEN) { + try { + ws.send(msg); + } catch { + /* 片側切断は無視(close ハンドラで整理) */ + } + } + } + return Response.json({ seq }); + } + + async handlePull(url: URL): Promise { + const d = url.searchParams.get("d"); + if (!d) return new Response("Missing d", { status: 400 }); + const vid = this.state.id.toString(); + const row = await this.env.DB.prepare( + `SELECT ciphertext AS c, nonce AS n, seq FROM notes WHERE vault_id = ?1 AND domain_hash = ?2` + ) + .bind(vid, d) + .first<{ c: string; n: string; seq: number }>(); + if (!row) return new Response("Not found", { status: 404 }); + return Response.json({ d, c: row.c, n: row.n, seq: row.seq }); + } + + async handleCatchup(url: URL): Promise { + const sinceRaw = Number(url.searchParams.get("since") || "0"); + const since = Number.isFinite(sinceRaw) ? sinceRaw : 0; + const vid = this.state.id.toString(); + const { results } = await this.env.DB.prepare( + `SELECT domain_hash AS d, seq FROM notes WHERE vault_id = ?1 AND seq > ?2 ORDER BY seq` + ) + .bind(vid, since) + .all<{ d: string; seq: number }>(); + const max = results.length ? results[results.length - 1].seq : since; + return Response.json({ changes: results, seq: max }); + } + + // クライアントの keepalive のみ想定。'ping'→'pong'。配信はサーバー→クライアント方向なので他は無視。 + async webSocketMessage(ws: WebSocket, msg: ArrayBuffer | string): Promise { + if (msg === "ping") { + try { + ws.send("pong"); + } catch { + /* noop */ + } + } + } + + async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean): Promise { + try { + ws.close(code >= 1000 && code <= 1015 ? code : 1000); + } catch { + /* 既に閉じている場合は無視 */ + } + } + + async webSocketError(_ws: WebSocket, _error: unknown): Promise { + /* ハイバネ管理に委ねる(個別の後始末は不要) */ + } +} diff --git a/infra/cloudflare/relay/tsconfig.json b/infra/cloudflare/relay/tsconfig.json new file mode 100644 index 0000000..6067a12 --- /dev/null +++ b/infra/cloudflare/relay/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/infra/cloudflare/relay/wrangler.toml b/infra/cloudflare/relay/wrangler.toml new file mode 100644 index 0000000..5766784 --- /dev/null +++ b/infra/cloudflare/relay/wrangler.toml @@ -0,0 +1,56 @@ +name = "petarin-relay" +main = "src/index.ts" +compatibility_date = "2026-05-26" + +# Workers Paid プラン($5/月・Ferry / RealTimeTranslator と共有)必須(Durable Objects 利用のため)。 +# ぺたりんのリレーは notify-then-pull(暗号文の store-and-forward + N端末 fan-out)。ferry-relay の +# 「2 peer 生パススルー」とは別物(あちらは同時オンライン前提のファイル転送用)。 +# ハイバネート WebSocket でアイドル中はメモリ 0 / duration 課金 0(CF 公式保証)。 + +# Custom Domain でのみ公開し口を増やさない(ferry-relay と同方針)。デプロイ時にホスト名を確定する。 +workers_dev = false +preview_urls = false + +# === Durable Object: vault(同期グループ)単位の fan-out ハブ + 暗号文ストア窓口 + seq 採番 === +[[durable_objects.bindings]] +name = "VAULT" +class_name = "VaultDO" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["VaultDO"] + +# === D1: 暗号文 blob ストア(petarin-sync) === +# 作成: `pnpm dlx wrangler d1 create petarin-sync` → 出力された database_id を貼る。 +# スキーマ適用: `pnpm dlx wrangler d1 migrations apply petarin-sync --local`(/--remote)。 +[[d1_databases]] +binding = "DB" +database_name = "petarin-sync" +database_id = "REPLACE_WITH_D1_DATABASE_ID" + +# === カスタムドメイン(デプロイ時に確定) === +# relay.petarin.nephilim.jp は仮。nephilim.jp Zone(Yuro 所有)配下に通すなら有効化する。 +# [[routes]] +# pattern = "relay.petarin.nephilim.jp" +# custom_domain = true + +# === Rate Limit(ferry-relay と同じ思想。namespace_id は Petarin 用に別番号) === +[[unsafe.bindings]] +name = "RATELIMIT_IP" +type = "ratelimit" +namespace_id = "2001" +simple = { limit = 60, period = 60 } + +[[unsafe.bindings]] +name = "RATELIMIT_VAULT" +type = "ratelimit" +namespace_id = "2002" +simple = { limit = 120, period = 60 } + +# === secret(手動投入) === +# 生 vaultId 直入れによる横入り防止用ソルト: +# openssl rand -hex 32 | pnpm dlx wrangler secret put SALT + +[observability] +enabled = true +head_sampling_rate = 1 From 012f636227e250ffb427af209058d5409107ef12 Mon Sep 17 00:00:00 2001 From: IMT <1llum1n4t1@duck.com> Date: Tue, 23 Jun 2026 00:04:55 +0900 Subject: [PATCH 03/22] =?UTF-8?q?feat(relay):=20=E5=AE=A2=E5=81=B4=20Relay?= =?UTF-8?q?Transport=20+=20vault=20=E6=9A=97=E5=8F=B7=E3=83=A2=E3=82=B8?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=83=AB(B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A の setSyncTransport に差す客側 transport を実装。リレーを「暗号化された chrome.storage.sync ミラー」として見せ、sync.js のマージ頭脳を無改造で動かす。 - src/shared/vault.js: vault 識別子と暗号プリミティブ。ECDSA P-256 鍵ペア(QR で端末間共有)/ vaultKey→HKDF で AES-GCM・HMAC 派生 / item 暗号化(平文に元キーを内包)/ ドメイン HMAC ハッシュ / リクエスト署名(relay auth.ts と一致) / pairing 入出力。 - src/shared/relay-transport.js: isAvailable/getAll(/dump)/set(/push)/remove(/item DELETE) を 署名付きで実装。値は AES-GCM 暗号化・キーは HMAC ハッシュ。getLastSeq を realtime 用に公開。 - relay(vault-do.ts): getAll 用 GET /dump と remove 用 DELETE /item を追加(seq 進行 + broadcast)。 - scripts/_vault_selftest.mjs: 暗号 round-trip・ハッシュ決定性・「署名が relay 検証を通る」契約を Node で検証(7 PASS)。relay tsc --noEmit 通過。 B3 で background の WS 配線・モード選択・ペアリング UI を繋ぎ、デプロイ後に実機疎通。 Co-Authored-By: Claude Opus 4.8 --- infra/cloudflare/relay/src/vault-do.ts | 37 +++++++ scripts/_vault_selftest.mjs | 71 ++++++++++++++ src/shared/relay-transport.js | 90 +++++++++++++++++ src/shared/vault.js | 130 +++++++++++++++++++++++++ 4 files changed, 328 insertions(+) create mode 100644 scripts/_vault_selftest.mjs create mode 100644 src/shared/relay-transport.js create mode 100644 src/shared/vault.js diff --git a/infra/cloudflare/relay/src/vault-do.ts b/infra/cloudflare/relay/src/vault-do.ts index f426dc9..d6ec456 100644 --- a/infra/cloudflare/relay/src/vault-do.ts +++ b/infra/cloudflare/relay/src/vault-do.ts @@ -40,6 +40,8 @@ export class VaultDO { if (url.pathname === "/push" && req.method === "PUT") return this.handlePush(bodyBytes); if (url.pathname === "/pull" && req.method === "GET") return this.handlePull(url); if (url.pathname === "/catchup" && req.method === "GET") return this.handleCatchup(url); + if (url.pathname === "/dump" && req.method === "GET") return this.handleDump(); + if (url.pathname === "/item" && req.method === "DELETE") return this.handleDelete(url); return new Response("Not found", { status: 404 }); } @@ -173,6 +175,41 @@ export class VaultDO { return Response.json({ changes: results, seq: max }); } + // 全 item を 1 回で返す(客側 RelayTransport.getAll = chrome.storage.sync.get(null) 相当)。 + async handleDump(): Promise { + const vid = this.state.id.toString(); + const { results } = await this.env.DB.prepare( + `SELECT domain_hash AS d, ciphertext AS c, nonce AS n, seq FROM notes WHERE vault_id = ?1` + ) + .bind(vid) + .all<{ d: string; c: string; n: string; seq: number }>(); + const seq = (await this.state.storage.get("seq")) || 0; + return Response.json({ items: results, seq }); + } + + // item 削除(remove)。行を消し seq を進め、他端末へ変更ピンを broadcast。削除の伝播自体は + // エンジンの墓石(meta item の更新)が担うが、即時通知のため ping は出す。 + async handleDelete(url: URL): Promise { + const d = url.searchParams.get("d"); + if (!d) return new Response("Missing d", { status: 400 }); + const vid = this.state.id.toString(); + await this.env.DB.prepare(`DELETE FROM notes WHERE vault_id = ?1 AND domain_hash = ?2`).bind(vid, d).run(); + let seq = (await this.state.storage.get("seq")) || 0; + seq += 1; + await this.state.storage.put("seq", seq); + const msg = JSON.stringify({ t: "changed", d, seq }); + for (const ws of this.state.getWebSockets()) { + if (ws.readyState === WebSocket.OPEN) { + try { + ws.send(msg); + } catch { + /* 片側切断は無視 */ + } + } + } + return Response.json({ seq }); + } + // クライアントの keepalive のみ想定。'ping'→'pong'。配信はサーバー→クライアント方向なので他は無視。 async webSocketMessage(ws: WebSocket, msg: ArrayBuffer | string): Promise { if (msg === "ping") { diff --git a/scripts/_vault_selftest.mjs b/scripts/_vault_selftest.mjs new file mode 100644 index 0000000..3cf2683 --- /dev/null +++ b/scripts/_vault_selftest.mjs @@ -0,0 +1,71 @@ +// vault.js の暗号プリミティブと「署名が relay(auth.ts 相当)を通る」契約の自己検証。 +// 実行: node scripts/_vault_selftest.mjs +// WebCrypto は Node 22 の globalThis.crypto を使う(ブラウザ・Workers と同 API)。 + +import { + generateVault, + importVault, + encryptItem, + decryptItem, + keyHash, + signRequest, + sha256Hex, + b64urlToBytes, + exportPairingCode, + parsePairingCode, +} from "../src/shared/vault.js"; + +let PASS = 0, + FAIL = 0; +function ok(cond, name, detail) { + if (cond) { + PASS++; + console.log(" ✅ " + name); + } else { + FAIL++; + console.log(" ❌ " + name + (detail ? " → " + detail : "")); + } +} + +// 1. vault 生成 → pairing コード → 別端末で import(鍵が引き継げる) +const v = await generateVault("https://relay.example/"); +const code = exportPairingCode(v); +const v2 = await importVault(parsePairingCode(code)); +ok(v2.vaultId === v.vaultId && v2.relayUrl === "https://relay.example/", "pairing コードで vaultId/url を引き継ぐ"); + +// 2. AES-GCM round-trip(別端末の派生鍵で復号でき、元キー/値が戻る) +const value = { d: "example.com", n: ["本文\nテスト", "🍎"], t: 123 }; +const { c, n } = await encryptItem(v.aesKey, "petarin:notes", value); +const dec = await decryptItem(v2.aesKey, c, n); +ok(dec.k === "petarin:notes" && dec.v.d === "example.com" && dec.v.n[1] === "🍎" && dec.v.t === 123, "AES-GCM round-trip(別端末の鍵で復号)"); + +// 3. ドメイン/キーハッシュは端末間で一致し決定的、異なる入力は異なる +const h1 = await keyHash(v.hmacKey, "github.com"); +const h2 = await keyHash(v2.hmacKey, "github.com"); +const h3 = await keyHash(v.hmacKey, "reddit.com"); +ok(h1 === h2 && /^[0-9a-f]{64}$/.test(h1), "keyHash は端末間一致・64hex・決定的"); +ok(h1 !== h3, "異なるキーは異なるハッシュ"); + +// 4. 署名が relay 検証(ECDSA P-256, vaultId\\nts\\nmethod\\npath\\nbodyHash)を通る +const ts = String(Date.now()); +const bodyObj = { d: h1, c, n }; +const body = new TextEncoder().encode(JSON.stringify(bodyObj)); +const sig = await signRequest(v.signPrivKey, v.vaultId, ts, "PUT", "/push", body); +const pub = await crypto.subtle.importKey("spki", b64urlToBytes(v.pairing.pk), { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]); +const data = [v.vaultId, ts, "PUT", "/push", await sha256Hex(body)].join("\n"); +const good = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, pub, b64urlToBytes(sig), new TextEncoder().encode(data)); +ok(good, "署名が relay 検証を通る(契約一致)"); +const tampered = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, pub, b64urlToBytes(sig), new TextEncoder().encode(data + "x")); +ok(!tampered, "改竄した署名対象は検証失敗"); + +// 5. 不正 pairing は弾く +let threw = false; +try { + await importVault({ v: 1, id: "x" }); +} catch { + threw = true; +} +ok(threw, "不完全な pairing payload は importVault で throw"); + +console.log(`\n結果: ${PASS} PASS / ${FAIL} FAIL`); +if (FAIL) process.exit(1); diff --git a/src/shared/relay-transport.js b/src/shared/relay-transport.js new file mode 100644 index 0000000..e30e811 --- /dev/null +++ b/src/shared/relay-transport.js @@ -0,0 +1,90 @@ +// RelayTransport — sync.js の transport インターフェース(isAvailable / getAll / set / remove)を +// Cloudflare relay(petarin-relay)に対して実装する。 +// +// 肝: リレーを「暗号化された chrome.storage.sync ミラー」として見せる。これにより sync.js の +// マージ頭脳(3-way / 墓石 / LWW)は無改造のまま動く。値は AES-GCM 暗号化、キーは HMAC ハッシュ化して +// addressing する。リアルタイム(WS の変更ピン)と catchup の seq は background が別途使う(ここでは扱わない)。 +// +// 注意: 容量会計は chrome.storage.sync 固有なので、relay モードでは reconcile(opts) に巨大な +// totalBudget/perItemBudget を渡して容量ロジックを実質無効化する(呼び出し側=background の責務)。 + +import { keyHash, encryptItem, decryptItem, signRequest } from "./vault.js"; + +const ENC = new TextEncoder(); + +export function createRelayTransport(vault) { + let lastSeq = 0; + + // 署名付きで relay を叩く。path はクエリ無し(署名対象 = pathname のみ。relay auth.ts と一致)。 + async function req(method, pathname, query, bodyObj) { + const ts = String(Date.now()); + const body = bodyObj != null ? ENC.encode(JSON.stringify(bodyObj)) : new Uint8Array(); + const sig = await signRequest(vault.signPrivKey, vault.vaultId, ts, method, pathname, body); + const headers = { + "X-Vault-Id": vault.vaultId, + "X-Vault-Ts": ts, + "X-Vault-Sig": sig, + "X-Vault-Pubkey": vault.pubB64, // 初回 first-write-wins 登録。2回目以降サーバーは無視。 + }; + const init = { method, headers }; + if (bodyObj != null) { + headers["Content-Type"] = "application/json"; + init.body = body; + } + const url = vault.relayUrl.replace(/\/+$/, "") + pathname + (query ? "?" + query : ""); + return fetch(url, init); + } + + return { + // sync.js は hasSync() を同期的に評価するので isAvailable も同期で返す。 + isAvailable: () => !!(vault && vault.relayUrl && vault.signPrivKey), + + // chrome.storage.sync.get(null) 相当: 全 item を 1 回取得して復号し { 元キー: 元値 } へ復元。 + async getAll() { + const res = await req("GET", "/dump", null, null); + if (!res.ok) throw new Error("relay dump failed: " + res.status); + const { items, seq } = await res.json(); + if (typeof seq === "number") lastSeq = seq; + const out = {}; + for (const it of items || []) { + try { + const { k, v } = await decryptItem(vault.aesKey, it.c, it.n); + out[k] = v; + } catch { + // 復号失敗(別鍵/破損)は取り込まない=「全削除」と誤認させない。当該 item はスキップ。 + } + } + return out; + }, + + // chrome.storage.sync.set(obj) 相当: 各キーを暗号化して push。 + async set(obj) { + for (const k of Object.keys(obj)) { + const d = await keyHash(vault.hmacKey, k); + const { c, n } = await encryptItem(vault.aesKey, k, obj[k]); + const res = await req("PUT", "/push", null, { d, c, n }); + if (!res.ok) throw new Error("relay push failed: " + res.status); + const j = await res.json().catch(() => null); + if (j && typeof j.seq === "number") lastSeq = j.seq; + } + }, + + // chrome.storage.sync.remove(keys) 相当: 各キーのハッシュで item 削除。 + async remove(keys) { + const arr = Array.isArray(keys) ? keys : [keys]; + for (const k of arr) { + const d = await keyHash(vault.hmacKey, k); + const res = await req("DELETE", "/item", "d=" + encodeURIComponent(d), null); + if (!res.ok && res.status !== 404) throw new Error("relay delete failed: " + res.status); + const j = await res.json().catch(() => null); + if (j && typeof j.seq === "number") lastSeq = j.seq; + } + }, + + // background が realtime/catchup の基準に使う(最後に観測した seq)。 + getLastSeq: () => lastSeq, + setLastSeq: (s) => { + if (typeof s === "number") lastSeq = s; + }, + }; +} diff --git a/src/shared/vault.js b/src/shared/vault.js new file mode 100644 index 0000000..cf29a99 --- /dev/null +++ b/src/shared/vault.js @@ -0,0 +1,130 @@ +// ぺたりん クラウド同期の vault(同期グループ)識別子と暗号プリミティブ。 +// +// 自己完結ペアリング鍵: vault は ECDSA P-256 鍵ペア(QR/コードで端末間に秘密鍵を渡す)を持ち、 +// 本文は vaultKey 由来の AES-GCM で端末側暗号化、ドメイン名/キーは HMAC でハッシュ化する。 +// サーバー(relay)は公開鍵・暗号文・ハッシュしか見ない=中身もサイトも知らない。 +// +// WebCrypto の ECDSA P-256(SHA-256) / AES-GCM / HKDF / HMAC を使う(ブラウザ・Node22・Workers 共通)。 +// background(module) と manage(module) から import する。content.js(classic) は同期しないので不要。 + +const subtle = () => globalThis.crypto.subtle; +const ENC = new TextEncoder(); +const DEC = new TextDecoder(); + +// ── base64url(パディング無し)───────────────────────────────────── +export function bytesToB64url(bytes) { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +export function b64urlToBytes(s) { + const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4); + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function randomBytes(n) { + const a = new Uint8Array(n); + globalThis.crypto.getRandomValues(a); + return a; +} + +export async function sha256Hex(bytes) { + const buf = await subtle().digest("SHA-256", bytes); + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +// ── vaultKey から aesKey / hmacKey を HKDF 派生(用途別に分ける)────── +async function deriveKeys(vaultKeyBytes) { + const base = await subtle().importKey("raw", vaultKeyBytes, "HKDF", false, ["deriveKey"]); + const salt = new Uint8Array(0); + const aesKey = await subtle().deriveKey( + { name: "HKDF", hash: "SHA-256", salt, info: ENC.encode("petarin:vault:aes") }, + base, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + const hmacKey = await subtle().deriveKey( + { name: "HKDF", hash: "SHA-256", salt, info: ENC.encode("petarin:vault:hmac") }, + base, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + return { aesKey, hmacKey }; +} + +// ── ドメイン/キーの安定ハッシュ(HMAC-SHA256 hex)。relay の addressing に使う ─── +export async function keyHash(hmacKey, key) { + const sig = await subtle().sign("HMAC", hmacKey, ENC.encode(key)); + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +// ── item 暗号化: 平文 = JSON({k: 元キー, v: 元値})。サーバーは元キーも知らない ─── +export async function encryptItem(aesKey, key, value) { + const iv = randomBytes(12); + const pt = ENC.encode(JSON.stringify({ k: key, v: value })); + const ct = await subtle().encrypt({ name: "AES-GCM", iv }, aesKey, pt); + return { c: bytesToB64url(new Uint8Array(ct)), n: bytesToB64url(iv) }; +} +export async function decryptItem(aesKey, c, n) { + const pt = await subtle().decrypt({ name: "AES-GCM", iv: b64urlToBytes(n) }, aesKey, b64urlToBytes(c)); + return JSON.parse(DEC.decode(pt)); // { k, v } +} + +// ── リクエスト署名(ECDSA P-256 SHA-256, raw r‖s)。relay auth.ts と一致させる ── +// 署名対象: vaultId\nts\nmethod\npath\nsha256hex(body) +export async function signRequest(signPrivKey, vaultId, ts, method, path, bodyBytes) { + const bodyHash = await sha256Hex(bodyBytes); + const data = [vaultId, ts, method, path, bodyHash].join("\n"); + const sig = await subtle().sign({ name: "ECDSA", hash: "SHA-256" }, signPrivKey, ENC.encode(data)); + return bytesToB64url(new Uint8Array(sig)); +} + +// ── vault 生成 / pairing 入出力 ─────────────────────────────────── +// pairing payload(QR/コードで渡す): { v, id, url, k(vaultKey), sk(署名秘密JWK), pk(署名公開SPKI) } +export async function generateVault(relayUrl) { + const vaultId = bytesToB64url(randomBytes(16)); + const vaultKeyBytes = randomBytes(32); + const kp = await subtle().generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const skJwk = await subtle().exportKey("jwk", kp.privateKey); + const pkSpki = new Uint8Array(await subtle().exportKey("spki", kp.publicKey)); + const pairing = { v: 1, id: vaultId, url: relayUrl, k: bytesToB64url(vaultKeyBytes), sk: skJwk, pk: bytesToB64url(pkSpki) }; + return buildVault(pairing, vaultKeyBytes); +} +export async function importVault(pairing) { + if (!pairing || pairing.v !== 1 || !pairing.id || !pairing.k || !pairing.sk || !pairing.pk) { + throw new Error("invalid pairing payload"); + } + return buildVault(pairing, b64urlToBytes(pairing.k)); +} +async function buildVault(pairing, vaultKeyBytes) { + const { aesKey, hmacKey } = await deriveKeys(vaultKeyBytes); + const signPrivKey = await subtle().importKey( + "jwk", + pairing.sk, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"] + ); + return { + vaultId: pairing.id, + relayUrl: pairing.url, + pubB64: pairing.pk, // X-Vault-Pubkey(first-write-wins 登録用 SPKI base64url) + aesKey, + hmacKey, + signPrivKey, + pairing, // 別端末への引き継ぎ・local 保存用(never sync) + }; +} + +// pairing を 1 本の文字列(QR/コード)に。逆は parsePairingCode。 +export function exportPairingCode(vault) { + return bytesToB64url(ENC.encode(JSON.stringify(vault.pairing))); +} +export function parsePairingCode(code) { + return JSON.parse(DEC.decode(b64urlToBytes(code))); +} From b6cb1956a861f50e7092c95a759fc655b75f43c7 Mon Sep 17 00:00:00 2001 From: IMT <1llum1n4t1@duck.com> Date: Tue, 23 Jun 2026 00:18:12 +0900 Subject: [PATCH 04/22] =?UTF-8?q?chore(relay):=20petarin-sync=20D1=20?= =?UTF-8?q?=E3=82=92=E4=BD=9C=E6=88=90=E3=81=97=20wrangler.toml=20?= =?UTF-8?q?=E3=81=AB=E7=B4=90=E4=BB=98=E3=81=91=20+=20=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=82=AB=E3=83=AB=20e2e=20=E6=95=B4=E5=82=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - petarin-sync D1(903b1fb1…・APAC)を作成し remote にスキーマ適用。database_id を wrangler.toml へ。 - scripts/_relay_e2e.mjs: vault.js + relay-transport.js で set→dump→WS変更ピン→remove を実通しする e2e(リレーは wrangler dev か deploy 後の URL を RELAY_URL で指定)。 - .gitignore に .wrangler-state/ を追加。pnpm の build 許可を onlyBuiltDependencies に。 - ローカル workerd が std::terminate で起動不能(この Windows 環境固有・ratelimit 無関係)。 unsafe ratelimit binding はローカル dev でコメントアウト(本番デプロイ時に再有効化)。 ランタイム検証はデプロイ後に実機 e2e で行う。 Co-Authored-By: Claude Opus 4.8 --- infra/cloudflare/relay/.gitignore | 1 + infra/cloudflare/relay/pnpm-workspace.yaml | 4 + infra/cloudflare/relay/wrangler.toml | 27 +++--- scripts/_relay_e2e.mjs | 100 +++++++++++++++++++++ 4 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 scripts/_relay_e2e.mjs diff --git a/infra/cloudflare/relay/.gitignore b/infra/cloudflare/relay/.gitignore index ff00fad..a678d6f 100644 --- a/infra/cloudflare/relay/.gitignore +++ b/infra/cloudflare/relay/.gitignore @@ -1,5 +1,6 @@ node_modules/ .wrangler/ +.wrangler-state/ dist/ *.log .dev.vars diff --git a/infra/cloudflare/relay/pnpm-workspace.yaml b/infra/cloudflare/relay/pnpm-workspace.yaml index 2cd53ef..acaebeb 100644 --- a/infra/cloudflare/relay/pnpm-workspace.yaml +++ b/infra/cloudflare/relay/pnpm-workspace.yaml @@ -2,3 +2,7 @@ allowBuilds: esbuild: set this to true or false sharp: set this to true or false workerd: set this to true or false +onlyBuiltDependencies: + - esbuild + - workerd + - sharp diff --git a/infra/cloudflare/relay/wrangler.toml b/infra/cloudflare/relay/wrangler.toml index 5766784..32a1cf9 100644 --- a/infra/cloudflare/relay/wrangler.toml +++ b/infra/cloudflare/relay/wrangler.toml @@ -26,7 +26,7 @@ new_sqlite_classes = ["VaultDO"] [[d1_databases]] binding = "DB" database_name = "petarin-sync" -database_id = "REPLACE_WITH_D1_DATABASE_ID" +database_id = "903b1fb1-218e-4225-bad2-0af60f8e0846" # === カスタムドメイン(デプロイ時に確定) === # relay.petarin.nephilim.jp は仮。nephilim.jp Zone(Yuro 所有)配下に通すなら有効化する。 @@ -35,17 +35,20 @@ database_id = "REPLACE_WITH_D1_DATABASE_ID" # custom_domain = true # === Rate Limit(ferry-relay と同じ思想。namespace_id は Petarin 用に別番号) === -[[unsafe.bindings]] -name = "RATELIMIT_IP" -type = "ratelimit" -namespace_id = "2001" -simple = { limit = 60, period = 60 } - -[[unsafe.bindings]] -name = "RATELIMIT_VAULT" -type = "ratelimit" -namespace_id = "2002" -simple = { limit = 120, period = 60 } +# 注意: unsafe ratelimit binding は `wrangler dev` のローカル workerd が std::terminate で +# 起動失敗するため、ローカル開発時はコメントアウトする。デプロイ前に必ず有効化すること +# (コードは env.RATELIMIT_* 未定義でも動くが、本番では rate limit を効かせる)。 +# [[unsafe.bindings]] +# name = "RATELIMIT_IP" +# type = "ratelimit" +# namespace_id = "2001" +# simple = { limit = 60, period = 60 } +# +# [[unsafe.bindings]] +# name = "RATELIMIT_VAULT" +# type = "ratelimit" +# namespace_id = "2002" +# simple = { limit = 120, period = 60 } # === secret(手動投入) === # 生 vaultId 直入れによる横入り防止用ソルト: diff --git a/scripts/_relay_e2e.mjs b/scripts/_relay_e2e.mjs new file mode 100644 index 0000000..2f38802 --- /dev/null +++ b/scripts/_relay_e2e.mjs @@ -0,0 +1,100 @@ +// B1+B2 のローカル e2e スモーク。リレーを wrangler dev で起動してから実行する。 +// 1) cd infra/cloudflare/relay && wrangler dev (別プロセス・既定 127.0.0.1:8787) +// 2) RELAY_URL=http://127.0.0.1:8787 node scripts/_relay_e2e.mjs +// vault.js + relay-transport.js を実コードのまま使い、set→dump→WS変更ピン→remove を実通しする。 +// 君の CF アカウントには触らない(ローカル D1 / ローカル DO)。 + +import { generateVault, signRequest } from "../src/shared/vault.js"; +import { createRelayTransport } from "../src/shared/relay-transport.js"; + +const RELAY = process.env.RELAY_URL || "http://127.0.0.1:8787"; +let PASS = 0, + FAIL = 0; +function ok(cond, name, detail) { + if (cond) { + PASS++; + console.log(" ✅ " + name); + } else { + FAIL++; + console.log(" ❌ " + name + (detail ? " → " + detail : "")); + } +} +const timeout = (ms) => new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms)); + +// リレー起動待ち(/health)。 +async function waitHealthy(tries = 40) { + for (let i = 0; i < tries; i++) { + try { + const r = await fetch(RELAY + "/health"); + if (r.ok) return true; + } catch { + /* まだ起動していない */ + } + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +const healthy = await waitHealthy(); +ok(healthy, "リレー /health 応答(wrangler dev 起動確認)"); +if (!healthy) { + console.log(`\n結果: ${PASS} PASS / ${FAIL} FAIL(リレー未起動のため中断)`); + process.exit(1); +} + +const vault = await generateVault(RELAY); +const t = createRelayTransport(vault); + +// 1. set → getAll(dump) → 復号して元キー/値が戻る(chrome.storage.sync ミラーとして機能) +await t.set({ + "petarin:notes": { d: "example.com", n: ["こんにちは\n世界", "🍎"] }, + "petarin:sync:meta": { v: 1, tomb: {} }, +}); +const all = await t.getAll(); +ok(all["petarin:notes"] && all["petarin:notes"].d === "example.com" && all["petarin:notes"].n[1] === "🍎", "set→getAll round-trip(暗号化KVミラー)"); +ok(all["petarin:sync:meta"] && all["petarin:sync:meta"].v === 1, "meta item も round-trip"); + +// 2. WS realtime: 受信側を張って push → 変更ピンを受信 +const ts = String(Date.now()); +const sig = await signRequest(vault.signPrivKey, vault.vaultId, ts, "GET", "/sync", new Uint8Array()); +const wsUrl = + RELAY.replace(/^http/, "ws") + + "/sync?vault=" + encodeURIComponent(vault.vaultId) + + "&ts=" + ts + "&sig=" + encodeURIComponent(sig) + "&pubkey=" + encodeURIComponent(vault.pubB64); +let pingData = null; +try { + const ws = new WebSocket(wsUrl); + const opened = new Promise((res, rej) => { + ws.onopen = res; + ws.onerror = () => rej(new Error("ws error")); + }); + const gotPing = new Promise((res) => { + ws.onmessage = (e) => res(e.data); + }); + await Promise.race([opened, timeout(3000)]); + await t.set({ "petarin:notes": { d: "example.com", n: ["更新した"] } }); + pingData = await Promise.race([gotPing, timeout(3000)]); + ws.close(); +} catch (e) { + pingData = "ERR:" + (e && e.message); +} +let ping = null; +try { + ping = JSON.parse(pingData); +} catch { + /* noop */ +} +ok(ping && ping.t === "changed" && typeof ping.seq === "number", "WS が変更ピン {t:changed,seq} を受信(realtime fanout)", String(pingData)); + +// 3. remove → dump に無い +await t.remove(["petarin:notes"]); +const all2 = await t.getAll(); +ok(!("petarin:notes" in all2), "remove 後は dump に無い"); +ok("petarin:sync:meta" in all2, "無関係 item は残る"); + +// 4. 署名なし/不正は弾かれる(401) +const bad = await fetch(RELAY + "/dump", { headers: { "X-Vault-Id": vault.vaultId } }); +ok(bad.status === 401, "署名なしリクエストは 401"); + +console.log(`\n結果: ${PASS} PASS / ${FAIL} FAIL`); +if (FAIL) process.exit(1); From c4f31ba985b88a868df53c82450679cbb6633b38 Mon Sep 17 00:00:00 2001 From: IMT <1llum1n4t1@duck.com> Date: Tue, 23 Jun 2026 00:22:18 +0900 Subject: [PATCH 05/22] =?UTF-8?q?chore(relay):=20CF=20=E3=81=B8=E3=83=87?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=82=A4=E3=81=97=E5=AE=9F=E6=A9=9F=20e2e=20?= =?UTF-8?q?=E3=81=A7=E6=A4=9C=E8=A8=BC(7=20PASS)=20+=20SALT=20=E6=8A=95?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - petarin-relay を CF へデプロイ(https://petarin-relay.1llum1n4t1.workers.dev)。実 Durable Object(ハイバネ WS) + 実 D1 + Worker で notify-then-pull が動作。 - 実機 e2e 7 PASS: 暗号化KVミラー往復 / meta往復 / WS変更ピン受信 / 削除 / 無認証401。 - SALT を本番 secret として投入(vaultId ハッシュ化・横入り防止)。 - ローカル workerd が当環境で起動不能(std::terminate)のため検証はデプロイ実機で実施。 - workers.dev は開発中の一時公開。本番 go-live で Custom Domain + workers_dev=false へ戻す。 unsafe ratelimit binding も本番で再有効化する(local dev では workerd が落ちるためコメントアウト中)。 Co-Authored-By: Claude Opus 4.8 --- infra/cloudflare/relay/wrangler.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/infra/cloudflare/relay/wrangler.toml b/infra/cloudflare/relay/wrangler.toml index 32a1cf9..3946601 100644 --- a/infra/cloudflare/relay/wrangler.toml +++ b/infra/cloudflare/relay/wrangler.toml @@ -7,8 +7,10 @@ compatibility_date = "2026-05-26" # 「2 peer 生パススルー」とは別物(あちらは同時オンライン前提のファイル転送用)。 # ハイバネート WebSocket でアイドル中はメモリ 0 / duration 課金 0(CF 公式保証)。 -# Custom Domain でのみ公開し口を増やさない(ferry-relay と同方針)。デプロイ時にホスト名を確定する。 -workers_dev = false +# 開発中は workers.dev を一時的に開けて実機検証する(リレーは vault 署名認証必須=無認証は 401 +# でデータ露出なし)。本番 go-live 時は ferry-relay と同様 Custom Domain(relay.petarin.nephilim.jp 等) +# へ寄せ、workers_dev=false に戻して口を絞る。 +workers_dev = true preview_urls = false # === Durable Object: vault(同期グループ)単位の fan-out ハブ + 暗号文ストア窓口 + seq 採番 === From 1edc857b1d491dfe9503b977dae95c68fedb2cd8 Mon Sep 17 00:00:00 2001 From: IMT <1llum1n4t1@duck.com> Date: Tue, 23 Jun 2026 00:52:02 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat(relay):=20CORS=20=E3=82=92=E8=BF=94?= =?UTF-8?q?=E3=81=97=E3=81=A6=E6=8B=A1=E5=BC=B5SW/=E3=83=A2=E3=83=90?= =?UTF-8?q?=E3=82=A4=E3=83=ABWebView=E3=81=8B=E3=82=89=20cross-origin=20?= =?UTF-8?q?=E3=81=A7=E5=8F=A9=E3=81=91=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relay URL は環境で変わる(dev=workers.dev / 本番=custom domain)ため、クライアント側 manifest に origin を焼かず relay 側で CORS を開ける。認証は署名(X-Vault-*)で行い Cookie を 使わないので ACAO は "*"。OPTIONS preflight は 204、WS upgrade(101)は webSocket 保持のため 再構築せず CORS も付けない。fetch を handle() に分離。デプロイ + e2e 7 PASS / CORS 実レス確認済。 Co-Authored-By: Claude Opus 4.8 --- infra/cloudflare/relay/src/index.ts | 61 ++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/infra/cloudflare/relay/src/index.ts b/infra/cloudflare/relay/src/index.ts index dd5ced3..61e882c 100644 --- a/infra/cloudflare/relay/src/index.ts +++ b/infra/cloudflare/relay/src/index.ts @@ -30,31 +30,54 @@ export interface RateLimit { limit(opts: { key: string }): Promise<{ success: boolean }>; } +// 拡張機能(SW)・モバイル WebView から cross-origin で叩くため CORS を返す。relay URL は環境で変わる +// (dev=workers.dev / 本番=custom domain)ので、クライアント側 manifest に origin を焼かず relay 側で開ける。 +// 認証は署名(X-Vault-* / クエリ sig)で行うため Cookie は使わず ACAO は "*" でよい。 +const CORS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type,X-Vault-Id,X-Vault-Ts,X-Vault-Sig,X-Vault-Pubkey", + "Access-Control-Max-Age": "86400", +}; +function withCors(res: Response): Response { + const h = new Headers(res.headers); + for (const k in CORS) h.set(k, CORS[k]); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h }); +} + export default { async fetch(req: Request, env: Env): Promise { - const url = new URL(req.url); - if (url.pathname === "/health") return new Response("OK"); - - // vaultId: HTTP はヘッダ、WS はブラウザがヘッダを付けられないのでクエリで受ける。 + if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS }); const isWs = req.headers.get("Upgrade") === "websocket"; - const vaultId = isWs ? url.searchParams.get("vault") : req.headers.get("X-Vault-Id"); - if (!vaultId) return new Response("Missing vault", { status: 400 }); - - // IP レート制限(粗い網。vault 単位の制限は DO 内で認証後に掛ける)。 - if (env.RATELIMIT_IP) { - const ip = req.headers.get("CF-Connecting-IP") || "unknown"; - const { success } = await env.RATELIMIT_IP.limit({ key: ip }); - if (!success) return new Response("Rate limited", { status: 429 }); - } - - // 生 vaultId を idFromName へ直入れすると漏洩時に第三者が同じ vault へ到達できるため、 - // SALT 付き SHA-256 でハッシュ化してから DO を引く(ferry-relay と同方針)。 - const idStr = await hashVaultId(vaultId, env.SALT); - const stub = env.VAULT.get(env.VAULT.idFromName(idStr)); - return stub.fetch(req); + const res = await handle(req, env); + // WS upgrade(101)は webSocket を保持するため再構築しない(CORS も不要)。それ以外は CORS を付ける。 + return isWs && res.status === 101 ? res : withCors(res); }, }; +async function handle(req: Request, env: Env): Promise { + const url = new URL(req.url); + if (url.pathname === "/health") return new Response("OK"); + + // vaultId: HTTP はヘッダ、WS はブラウザがヘッダを付けられないのでクエリで受ける。 + const isWs = req.headers.get("Upgrade") === "websocket"; + const vaultId = isWs ? url.searchParams.get("vault") : req.headers.get("X-Vault-Id"); + if (!vaultId) return new Response("Missing vault", { status: 400 }); + + // IP レート制限(粗い網。vault 単位の制限は DO 内で認証後に掛ける)。 + if (env.RATELIMIT_IP) { + const ip = req.headers.get("CF-Connecting-IP") || "unknown"; + const { success } = await env.RATELIMIT_IP.limit({ key: ip }); + if (!success) return new Response("Rate limited", { status: 429 }); + } + + // 生 vaultId を idFromName へ直入れすると漏洩時に第三者が同じ vault へ到達できるため、 + // SALT 付き SHA-256 でハッシュ化してから DO を引く(ferry-relay と同方針)。 + const idStr = await hashVaultId(vaultId, env.SALT); + const stub = env.VAULT.get(env.VAULT.idFromName(idStr)); + return stub.fetch(req); +} + async function hashVaultId(vaultId: string, salt: string): Promise { const data = new TextEncoder().encode(vaultId + "|" + salt); const buf = await crypto.subtle.digest("SHA-256", data); From 90efdedb7b647b274681c48ba273544c69b7d573 Mon Sep 17 00:00:00 2001 From: IMT <1llum1n4t1@duck.com> Date: Tue, 23 Jun 2026 00:52:22 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat(sync):=20=E6=8B=A1=E5=BC=B5=E3=82=AF?= =?UTF-8?q?=E3=83=A9=E3=82=A4=E3=82=A2=E3=83=B3=E3=83=88=E3=81=AE=20cloud?= =?UTF-8?q?=20=E3=83=A2=E3=83=BC=E3=83=89=E9=85=8D=E7=B7=9A(B3=20=E5=9F=BA?= =?UTF-8?q?=E7=9B=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排他3モード(off/chrome/cloud)を syncEnabled + syncMode の二段で表現(sync.js の syncEnabled ゲートは不変)。transport 選択は background が syncMode で行い、cloud のとき relay-transport へ 差し替える(sync.js のマージ頭脳は無改造)。 - storage.js: DEFAULT_SETTINGS に syncMode 追加 + vault 鍵束の local 専用キー(petarin:sync:vault・ never sync)と get/save/clear ヘルパ。秘密(署名秘密鍵JWK)を含むため chrome.storage.local のみ。 - background.js: applyTransport() で cloud+ペアリング済みなら setSyncTransport(relay)。realtime WS (/sync・"ping"/"pong" keepalive・指数backoff 再接続)で変更ピン {t:changed} を受けて pull。SW 休止 対策に keepalive alarm(1分)で WS 張り直し+取りこぼし reconcile。cloud は巨大 budget で容量ロジック無効化。 - manifest(両): alarms 権限。 Co-Authored-By: Claude Opus 4.8 --- manifest.firefox.json | 3 +- manifest.json | 3 +- src/background/background.js | 221 ++++++++++++++++++++++++++++++++--- src/shared/storage.js | 18 +++ 4 files changed, 230 insertions(+), 15 deletions(-) diff --git a/manifest.firefox.json b/manifest.firefox.json index 1212324..fcc4d60 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -37,7 +37,8 @@ }, "permissions": [ "storage", - "activeTab" + "activeTab", + "alarms" ], "host_permissions": [ "http://*/*", diff --git a/manifest.json b/manifest.json index 646a420..9fcfcce 100644 --- a/manifest.json +++ b/manifest.json @@ -29,7 +29,8 @@ }, "permissions": [ "storage", - "activeTab" + "activeTab", + "alarms" ], "host_permissions": [ "http://*/*", diff --git a/src/background/background.js b/src/background/background.js index ec6b13b..8f6475d 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -5,35 +5,222 @@ // 「常駐ループ」は持たず、イベント発火(storage 変更が SW を起こす)と画面を開いた // タイミングで reconcile する設計。syncEnabled=false の間は reconcile が即 return する // ので、未操作ユーザーには一切の sync アクセスが発生しない。 +// - 排他3モード(off / chrome / cloud)。cloud のとき transport を relay に差し替え(sync.js は不変)、 +// realtime の WS(変更ピン受信)を保持し、容量ロジックは巨大 budget で無効化する。 -import { STORAGE_KEYS, DEFAULT_SETTINGS } from "../shared/storage.js"; -import { reconcile, purgeSyncProjection, wasJustPushed, SYNC_KEYS } from "../shared/sync.js"; +import { STORAGE_KEYS, DEFAULT_SETTINGS, VAULT_KEY, getVaultPairing } from "../shared/storage.js"; +import { reconcile, purgeSyncProjection, wasJustPushed, SYNC_KEYS, setSyncTransport } from "../shared/sync.js"; +import { createRelayTransport } from "../shared/relay-transport.js"; +import { importVault, signRequest } from "../shared/vault.js"; -// syncEnabled を SW メモリにキャッシュ。OFF が確定している間は付箋編集ごとの無駄な SW 起床+ +// 同期状態を SW メモリにキャッシュ。OFF が確定している間は付箋編集ごとの無駄な SW 起床+ // getSettings を避ける(reconcile は OFF なら即 return するが、起床自体のコストを省く)。 // null=未知=安全側に reconcile する。SW 起動時と settings 変更時に更新。 let _enabledCache = null; -chrome.storage.local.get(STORAGE_KEYS.settings).then((r) => { - const s = r[STORAGE_KEYS.settings]; - if (typeof s?.syncEnabled === "boolean") _enabledCache = s.syncEnabled; +let _syncMode = "chrome"; + +// cloud モードでは容量会計(chrome.storage.sync 固有の 100KB/8KB 上限)は無関係なので、巨大 budget を +// 渡してバイト gating を実質無効化する(item 数上限 MAX_ITEMS=512 はドメイン数上限として残るが個人用途で十分)。 +const CLOUD_OPTS = { totalBudget: Number.MAX_SAFE_INTEGER, perItemBudget: Number.MAX_SAFE_INTEGER }; +const KEEPALIVE_ALARM = "petarin:relay-keepalive"; + +function isCloudActive() { + return _enabledCache === true && _syncMode === "cloud"; +} + +// 設定キャッシュを最新化(syncEnabled / syncMode)。 +function applySettingsCache(s) { + if (s && typeof s.syncEnabled === "boolean") _enabledCache = s.syncEnabled; + if (s && typeof s.syncMode === "string") _syncMode = s.syncMode; +} + +chrome.storage.local.get(STORAGE_KEYS.settings).then(async (r) => { + applySettingsCache(r[STORAGE_KEYS.settings]); + await applyTransport(); }).catch(() => {}); +// ── transport 選択(cloud=relay / それ以外=chrome 既定)───────────────── +// vault(CryptoKey を含む)は serialize 不可なので、保存済み pairing から毎回 importVault で再構築する。 +// pairing の同一性で再構築をキャッシュ(鍵差し替え時のみ作り直す)。 +let _vault = null; +let _vaultStamp = ""; + +async function loadVault() { + const pairing = await getVaultPairing(); + if (!pairing) { + _vault = null; + _vaultStamp = ""; + return null; + } + const stamp = JSON.stringify([pairing.id, pairing.url, pairing.k]); + if (_vault && stamp === _vaultStamp) return _vault; + try { + _vault = await importVault(pairing); + _vaultStamp = stamp; + } catch (e) { + console.warn("[petarin] vault import 失敗:", e); + _vault = null; + _vaultStamp = ""; + } + return _vault; +} + +// 現在のモード/vault に応じて transport を差し替え、WS・keepalive alarm を起こす/畳む。 +async function applyTransport() { + if (isCloudActive()) { + const vault = await loadVault(); + if (vault) { + setSyncTransport(createRelayTransport(vault)); + chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 1 }); + connectRelaySocket(); + return; + } + // cloud 指定だが未ペアリング → 何も送れないので chrome 既定へフォールバック(reconcile は hasSync で空振り)。 + } + setSyncTransport(null); // chrome.storage.sync 既定へ戻す + closeRelaySocket(); + chrome.alarms.clear(KEEPALIVE_ALARM); +} + +// ── realtime WebSocket(変更ピン受信 + keepalive)───────────────────── +let _ws = null; +let _wsAttempt = 0; +let _wsReconnectTimer = 0; +let _heartbeatTimer = 0; + +async function connectRelaySocket() { + if (!isCloudActive()) return; + if (_ws && (_ws.readyState === WebSocket.OPEN || _ws.readyState === WebSocket.CONNECTING)) return; + const vault = await loadVault(); + if (!vault) return; + + // WS はブラウザがヘッダを付けられないので ts/sig/pubkey をクエリで渡す(relay の verify(fromQuery))。 + const ts = String(Date.now()); + let sig; + try { + sig = await signRequest(vault.signPrivKey, vault.vaultId, ts, "GET", "/sync", new Uint8Array()); + } catch (e) { + console.warn("[petarin] WS 署名失敗:", e); + return; + } + const wsUrl = + vault.relayUrl.replace(/\/+$/, "").replace(/^http/, "ws") + + "/sync?vault=" + encodeURIComponent(vault.vaultId) + + "&ts=" + ts + "&sig=" + encodeURIComponent(sig) + + "&pubkey=" + encodeURIComponent(vault.pubB64); + + let ws; + try { + ws = new WebSocket(wsUrl); + } catch (e) { + scheduleReconnect(); + return; + } + _ws = ws; + ws.onopen = () => { + _wsAttempt = 0; + startHeartbeat(); + }; + ws.onmessage = (e) => { + // 他端末の編集/削除 → 薄い変更ピン {t:changed,d,seq}。早めに pull(自エコーは reconcile 側の wasJustPushed で弾く)。 + // "pong" は keepalive の応答なので無視。 + if (e.data === "pong") return; + let m = null; + try { + m = JSON.parse(e.data); + } catch { + /* noop */ + } + if (m && m.t === "changed") scheduleReconcile(300); + }; + ws.onclose = () => { + stopHeartbeat(); + if (_ws === ws) _ws = null; + if (isCloudActive()) scheduleReconnect(); + }; + ws.onerror = () => { + try { + ws.close(); + } catch { + /* noop */ + } + }; +} + +function scheduleReconnect() { + clearTimeout(_wsReconnectTimer); + const delay = Math.min(30000, 1000 * 2 ** Math.min(_wsAttempt, 5)); // 1s→2→4→…→30s 上限 + _wsAttempt++; + _wsReconnectTimer = setTimeout(() => { + if (isCloudActive()) connectRelaySocket(); + }, delay); +} + +// 20s ごとの ping。WS 活動が MV3 SW のアイドルタイマをリセットし、接続セッション中の SW を延命する。 +// (SW が休止すると interval ごと止まるが、その場合は keepalive alarm が次の起床で WS を張り直す。) +function startHeartbeat() { + stopHeartbeat(); + _heartbeatTimer = setInterval(() => { + if (_ws && _ws.readyState === WebSocket.OPEN) { + try { + _ws.send("ping"); + } catch { + /* noop */ + } + } + }, 20000); +} +function stopHeartbeat() { + if (_heartbeatTimer) clearInterval(_heartbeatTimer); + _heartbeatTimer = 0; +} +function closeRelaySocket() { + clearTimeout(_wsReconnectTimer); + _wsAttempt = 0; + stopHeartbeat(); + if (_ws) { + try { + _ws.close(); + } catch { + /* noop */ + } + _ws = null; + } +} + chrome.runtime.onInstalled.addListener(async () => { const raw = await chrome.storage.local.get(STORAGE_KEYS.settings); if (!raw[STORAGE_KEYS.settings]) { await chrome.storage.local.set({ [STORAGE_KEYS.settings]: DEFAULT_SETTINGS }); } + applySettingsCache(raw[STORAGE_KEYS.settings]); + await applyTransport(); scheduleReconcile(); // 既存ユーザーが ON 済みなら起動時に追いつく }); -chrome.runtime.onStartup?.addListener(() => scheduleReconcile()); +chrome.runtime.onStartup?.addListener(async () => { + await applyTransport(); + scheduleReconcile(); +}); + +// keepalive alarm: SW が休止から復帰したタイミングで WS を張り直し、取りこぼしを reconcile で追いつく。 +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name !== KEEPALIVE_ALARM) return; + if (!isCloudActive()) { + chrome.alarms.clear(KEEPALIVE_ALARM); + return; + } + connectRelaySocket(); + scheduleReconcile(800); // 切断中の他端末変更を full dump reconcile で取り込む +}); // ── reconcile のデバウンス(短い間隔で来る変更をまとめる)── let _timer = 0; function scheduleReconcile(delay = 1200) { clearTimeout(_timer); _timer = setTimeout(() => { - reconcile().catch((e) => console.warn("[petarin] reconcile 失敗:", e)); + const opts = isCloudActive() ? CLOUD_OPTS : {}; + reconcile(opts).catch((e) => console.warn("[petarin] reconcile 失敗:", e)); }, delay); } @@ -43,11 +230,15 @@ const TRIGGER_LOCAL = new Set([STORAGE_KEYS.notes, STORAGE_KEYS.settings]); chrome.storage.onChanged.addListener((changes, area) => { const keys = Object.keys(changes); if (area === "local") { - // 設定変更は syncEnabled キャッシュを更新し、常に追従(ON↔OFF 切替・スコープ変更の反映)。 + // 設定変更は syncEnabled/syncMode キャッシュを更新し、transport を張り直してから reconcile。 if (changes[STORAGE_KEYS.settings]) { - const nv = changes[STORAGE_KEYS.settings].newValue; - if (nv && typeof nv.syncEnabled === "boolean") _enabledCache = nv.syncEnabled; - scheduleReconcile(); + applySettingsCache(changes[STORAGE_KEYS.settings].newValue); + applyTransport().finally(() => scheduleReconcile()); + return; + } + // vault(ペアリング鍵)の追加/削除 → cloud transport を張り直す。 + if (changes[VAULT_KEY]) { + applyTransport().finally(() => scheduleReconcile()); return; } // OFF が確定しているなら、付箋編集のたびに SW を起こさない(無駄起床の抑止)。未知(null)は安全側に reconcile。 @@ -58,6 +249,8 @@ chrome.storage.onChanged.addListener((changes, area) => { return; } if (area === "sync") { + // cloud モードでは chrome.storage.sync は使わない(relay が真実のミラー)ので無視。 + if (_syncMode === "cloud") return; // 自分が直前に push した「値と同一」のエコーだけ無視(往復ループ&書込レート枯渇を防ぐ)。 // 同一キーでも値が違えば他端末の変更なので pull する(キー名一致だけで切らない)。 if (wasJustPushed(changes)) return; @@ -69,7 +262,9 @@ chrome.storage.onChanged.addListener((changes, area) => { chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (!msg || typeof msg !== "object") return; if (msg.type === "petarin:reconcile") { - reconcile() + // 画面を開いた=前面復帰のタイミング。cloud なら transport/WS を確実に張ってから突合する。 + applyTransport() + .then(() => reconcile(isCloudActive() ? CLOUD_OPTS : {})) .then((report) => sendResponse({ ok: true, report })) .catch((e) => sendResponse({ ok: false, error: String(e) })); return true; // 非同期応答 diff --git a/src/shared/storage.js b/src/shared/storage.js index fe76849..0a75893 100644 --- a/src/shared/storage.js +++ b/src/shared/storage.js @@ -93,6 +93,7 @@ export const DEFAULT_SETTINGS = { // 勝手に有効化しない=インフォームドコンセントを維持するため。 // syncEnabled=false の間は sync API を一切呼ばず、現状と完全に同一の挙動。 syncEnabled: false, // 同期そのものの ON/OFF(既定 OFF=外部送信ゼロを維持) + syncMode: "chrome", // 同期 ON 時の経路(排他): "chrome"(ブラウザ標準同期)| "cloud"(relay)。OFF は syncEnabled=false。transport 選択は background が syncMode で行い sync.js は不変 syncSettings: false, // 見た目設定(side/色味/表示)も同期するか syncScope: "selected", // "selected"(選択ドメインのみ)| "all"(容量内で全部) syncDomains: [], // syncScope==="selected" のとき同期するドメイン配列 @@ -111,6 +112,23 @@ export async function getSettings() { return { ...DEFAULT_SETTINGS, ...(raw[STORAGE_KEYS.settings] || {}) }; } +// ── クラウド同期 vault(端末ローカル専用・never sync)───────────────── +// 同期グループの鍵束(pairing payload: vaultId/relayUrl/vaultKey/署名鍵 JWK)。秘密を含むため +// chrome.storage.local にのみ保存し、chrome.storage.sync には一切出さない(鍵は端末から出さない)。 +// 別端末への引き継ぎは QR/コード(exportPairingCode)で行う。SYNCABLE_SETTINGS にも含めない。 +export const VAULT_KEY = "petarin:sync:vault"; + +export async function getVaultPairing() { + const raw = await chrome.storage.local.get(VAULT_KEY); + return raw[VAULT_KEY] || null; +} +export async function saveVaultPairing(pairing) { + await chrome.storage.local.set({ [VAULT_KEY]: pairing }); +} +export async function clearVaultPairing() { + await chrome.storage.local.remove(VAULT_KEY); +} + // ── 書き込みの直列化(read-modify-write の競合=ロストアップデート防止)── // chrome.storage.local.set 単体は原子的だが、get→改変→set の間に別の更新が割り込むと // 片方が消える。同一コンテキスト内の更新を 1 本の Promise 連鎖に並べて直列に流す。 From be6615467331006f27378a0ff6cca7ff2dbbb281 Mon Sep 17 00:00:00 2001 From: IMT <1llum1n4t1@duck.com> Date: Tue, 23 Jun 2026 01:00:59 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat(manage):=20=E5=90=8C=E6=9C=9F?= =?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB=E3=82=92=E6=8E=92=E4=BB=963?= =?UTF-8?q?=E3=83=A2=E3=83=BC=E3=83=89=E5=8C=96=20+=20=E3=82=AF=E3=83=A9?= =?UTF-8?q?=E3=82=A6=E3=83=89=E3=81=AE=E3=83=9A=E3=82=A2=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=B0UI(B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 付箋デスクの同期パネルを「ON/OFF トグル」から「同期しない / ブラウザ標準同期 / クラウド同期」の 排他3ラジオへ。mode 別の同意文を出し分け、クラウド時だけペアリング section を表示する。 - 作成: generateVault → ローカル保存 → 引き継ぎコード(exportPairingCode)を表示。 - 参加: コードを貼り付け → importVault で検証 → 保存。 - 解除: clearVaultPairing + purgeSync(shadow撤去)。コピー/再表示も。 - 100KB ゲージは容量上限のある chrome 標準同期のみ表示(cloud はリレー側で広いので隠す)。 - relay-transport に DEFAULT_RELAY_URL を追加(新規作成の初期リレー。本番は custom domain へ)。 - background: cloud だが未ペアリングのとき chrome へ漏らさず止める UNAVAILABLE_TRANSPORT。 Co-Authored-By: Claude Opus 4.8 --- src/background/background.js | 17 +++- src/manage/manage.css | 32 +++++++ src/manage/manage.html | 61 ++++++++++--- src/manage/manage.js | 164 +++++++++++++++++++++++++++++++--- src/shared/relay-transport.js | 5 ++ 5 files changed, 251 insertions(+), 28 deletions(-) diff --git a/src/background/background.js b/src/background/background.js index 8f6475d..2023d62 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -65,6 +65,15 @@ async function loadVault() { return _vault; } +// cloud モードだが未ペアリングのときに使う「使用不可」transport。chrome へフォールバックすると +// 「cloud を選んだのに chrome.storage.sync へ送る」誤動作になるため、hasSync()=false で reconcile を空振りさせる。 +const UNAVAILABLE_TRANSPORT = { + isAvailable: () => false, + getAll: async () => ({}), + set: async () => {}, + remove: async () => {}, +}; + // 現在のモード/vault に応じて transport を差し替え、WS・keepalive alarm を起こす/畳む。 async function applyTransport() { if (isCloudActive()) { @@ -75,9 +84,13 @@ async function applyTransport() { connectRelaySocket(); return; } - // cloud 指定だが未ペアリング → 何も送れないので chrome 既定へフォールバック(reconcile は hasSync で空振り)。 + // cloud 指定だが未ペアリング → chrome へ漏らさず同期を止める(ペアリングするまで何もしない)。 + setSyncTransport(UNAVAILABLE_TRANSPORT); + closeRelaySocket(); + chrome.alarms.clear(KEEPALIVE_ALARM); + return; } - setSyncTransport(null); // chrome.storage.sync 既定へ戻す + setSyncTransport(null); // off / chrome → chrome.storage.sync 既定へ戻す closeRelaySocket(); chrome.alarms.clear(KEEPALIVE_ALARM); } diff --git a/src/manage/manage.css b/src/manage/manage.css index d406129..5023d79 100644 --- a/src/manage/manage.css +++ b/src/manage/manage.css @@ -474,6 +474,13 @@ body { 属性セレクタで明示的に打ち消す(これが無いと閉じる/段階表示が効かない)。 */ .syncpanel[hidden], .sp-body[hidden], +.sp-gauge[hidden], +.sp-consent[hidden], +.sp-pair[hidden], +.sp-pair-code[hidden], +.sp-pair-join[hidden], +#pairSetup[hidden], +#pairLinked[hidden], .memomodal[hidden] { display: none !important; } .syncpanel { @@ -547,6 +554,31 @@ body { .sp-note { font-size: 12px; color: var(--ink-soft); line-height: 1.6; } .sp-note.warn { color: var(--danger); } +/* ── 3モード選択 ─────────────────────────────────────────────── */ +.sp-modes { border: 1px solid var(--line); border-radius: 12px; padding: 8px 10px; margin: 14px 0 12px; display: flex; flex-direction: column; gap: 2px; } +.sp-modes legend { font-size: 12px; font-weight: 700; color: var(--ink-soft); padding: 0 6px; } +.sp-mode { display: flex; align-items: flex-start; gap: 10px; padding: 9px 8px; border-radius: 9px; cursor: pointer; transition: background 0.12s; } +.sp-mode:hover { background: #faf3e2; } +.sp-mode input { width: 17px; height: 17px; margin-top: 1px; accent-color: var(--accent-warm); flex-shrink: 0; } +.sp-mode-t { display: flex; flex-direction: column; gap: 2px; font-size: 14px; font-weight: 700; line-height: 1.35; } +.sp-mode-t small { font-weight: 400; font-size: 11.5px; color: var(--ink-soft); } + +/* ── クラウド同期ペアリング ───────────────────────────────────── */ +.sp-pair { margin-top: 14px; display: flex; flex-direction: column; gap: 12px; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: #fbf4e3; } +.sp-pair-status { font-size: 13px; font-weight: 700; color: var(--ink-soft); } +.sp-pair-status.linked { color: var(--note-green-i); } +.sp-pair-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.sp-btn { border: 1px solid var(--line); background: var(--paper-card); color: var(--ink); border-radius: 9px; padding: 8px 14px; font-size: 13px; font-weight: 700; cursor: pointer; transition: background 0.12s, border-color 0.12s, filter 0.12s; } +.sp-btn:hover { background: #f1e7d2; } +.sp-btn.primary { background: var(--accent-warm); border-color: var(--accent-warm); color: #fff; } +.sp-btn.primary:hover { filter: brightness(0.96); background: var(--accent-warm); } +.sp-btn.danger { color: var(--danger); border-color: #e7c3bd; } +.sp-btn.danger:hover { background: #fbe0dd; } +.sp-pair-join, .sp-pair-code { display: flex; flex-direction: column; gap: 8px; } +.sp-pair-label { font-size: 12px; color: var(--ink-soft); line-height: 1.5; } +.sp-pair-ta { width: 100%; box-sizing: border-box; font-family: ui-monospace, Consolas, monospace; font-size: 12px; line-height: 1.5; border: 1px solid var(--line); border-radius: 9px; padding: 9px 10px; resize: vertical; background: #fff; color: var(--ink); word-break: break-all; } +.sp-pair-ta:focus { outline: none; border-color: var(--accent-warm); box-shadow: 0 0 0 3px rgba(232,161,60,0.25); } + /* ── 付箋エディタ(WEBページ側の展開付箋と同じレイアウト・移動/リサイズ無し)────────────────── */ .memomodal { position: fixed; inset: 0; z-index: 60; diff --git a/src/manage/manage.html b/src/manage/manage.html index e79185b..10271d2 100644 --- a/src/manage/manage.html +++ b/src/manage/manage.html @@ -121,23 +121,58 @@

複数PCで同期

- - -
+ 同期方式(端末ごとに選べます) + + + +
+ + + + + +