diff --git a/.env.example b/.env.example index 378df15..693e5b1 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,7 @@ TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900 +TWITTER_LITE_ORIGIN=https://your-host.your-tailnet.ts.net +TWITTER_LITE_ALLOWED_LOGIN=your-tailscale-login +TWITTER_LITE_DB_PATH=/absolute/path/to/twitter-lite/.data/workspace.sqlite +# Required when connecting Mastodon; keep this runtime file outside Git. +# TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key +TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com diff --git a/.gitignore b/.gitignore index 138bca6..cc1f0d4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ node_modules/ playwright-report/ test-results/ +.data/ diff --git a/README.md b/README.md index 08fb7b7..b7ef08a 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,29 @@ # Twitter Lite -Twitter Lite is a read-only research deck for X. Create multiple named decks, -and arrange up to six columns per deck for searches, user timelines, and lists. -Each column is bound to an explicit relay profile, so different accounts can -be used side by side. +Twitter Lite is a personal research deck for Twitter and Mastodon. Create +multiple named decks and arrange up to six columns per deck. Each column is +bound to a connection account, so platforms and multiple accounts work side by side. ## Scope - Multiple deck profiles with creation, selection, renaming, and deletion -- Search, user timeline, and list columns with independent relay profiles +- Twitter search, user timeline, and list columns with independent relay accounts +- Mastodon OAuth, multiple accounts, search, user, list, and hashtag columns - Native X search syntax, Top/Latest ranking, and optional `filter:follows` - List discovery for the profile selected in the column editor - Column editing, ordering, deletion/undo, manual refresh, and cursor pagination -- Browser-local persistence of deck definitions and the active deck +- SQLite-backed shared decks, revision conflicts, and device-local active selection +- Temporary views for AI exploration, with explicit save and temporary copies +- Server-only encrypted Mastodon credentials and Tailscale owner access - Read-only cards with original-post links, text, media, and quotes - Experimental WebMCP tools to manage decks and read or paginate their columns Both `/` and `/deck` open the deck workspace. The separate reader, search, list, user-profile, and conversation routes have been removed. Original-post -links open X; conversations are not rendered inside the app. +links open their source site; conversations are not rendered inside the app. -Built-in AI planning, summaries, and the Mastodon/Bluesky/Threads/Nostr -connectors are not implemented yet. Twitter is the only supported platform. +Built-in AI planning, summaries, and Bluesky/Threads/Nostr connectors are not +implemented yet. An external browser agent can create and read decks through WebMCP. ## Requirements and setup @@ -34,8 +36,11 @@ cp .env.example .env.local ``` The Nix development shell installs Hallmark's agent skill for the supported -local agent targets. Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite -development, or export it before running the production server: +local agent targets. Set the runtime configuration in `.env.local` for Vite +development, or export it before running the production server. In addition to +the relay URL, configure the Serve origin, owner login, and an absolute SQLite +path from `.env.example`. Mastodon needs an allowed instance and a runtime +encryption key file; see [storage, OAuth and restore](docs/storage-and-oauth.md). ```bash export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900 @@ -46,7 +51,8 @@ endpoint. Select a profile for each column. There is no global account selector, profile cookie, or application-level `BIRD_PROFILE_NAME` default. Requests validate that the bound profile still exists before fetching posts or lists. Bird and relay credentials stay on the server; saved deck definitions include -the selected profile names. +stable connection IDs. The connection metadata resolves Twitter IDs to relay +profiles; Mastodon credentials never go to the browser. ## Commands @@ -60,6 +66,7 @@ nix develop -c pnpm test:e2e nix develop -c pnpm test:live nix develop -c pnpm build nix develop -c pnpm start +nix develop -c pnpm db:generate ``` `test:e2e` uses the system Chromium supplied by the Nix dev shell. Playwright @@ -83,26 +90,28 @@ dialogs; Escape closes them and returns focus. Column menus contain editing, ordering, and deletion; refresh stays available directly in each header. Create a deck for an investigation, then add columns for each perspective. -Choose a relay profile and a source: search, user timeline, or list. Profile -changes affect only the edited column. Matching source conditions and profiles +Choose a connected account and one of its supported sources. Account +changes affect only the edited column. Matching source conditions and accounts share the query cache; different profiles never share posts or cursors. -The workspace saves multiple decks and the active selection in localStorage. -Only the active deck is rendered and fetched. Posts and cursors are not saved; -reloading fetches first pages. Devices and tabs do not synchronize edits. +Saved decks live on the server and refresh across devices on focus and while +visible. Conflicting revisions are rejected. Only the active deck is rendered +and fetched; posts and cursors are not archived. The active selection stays +local to the device. -Old single-deck data is not migrated automatically because it has no explicit -column profile binding. Invalid or older saved data remains untouched while -the app shows an empty workspace and an explanation. Saving a new edit replaces -that saved data. See [the deck model and persistence contract](docs/research-decks.md). +AI-created decks start as temporary views in the current tab. Save a view to +share it across devices, or make a temporary copy of a saved deck to experiment. +Temporary views disappear on reload or navigation, including OAuth redirects. +Existing version-2 browser decks have an explicit import action; invalid older +data is left untouched. See [the deck model and persistence contract](docs/research-decks.md). ## WebMCP -A WebMCP-enabled browser exposes `list_decks`, `get_deck`, `set_deck`, -`select_deck`, `delete_deck`, `get_column_posts`, and `load_more_column` on -both deck routes. Start with `list_decks` to discover deck IDs and available -relay profiles. `set_deck` creates a new deck when `deckId` is omitted; supply -an existing ID to replace that deck's complete ordered columns and activate it. +A WebMCP-enabled browser exposes `list_connections`, `list_decks`, `get_deck`, +`set_deck`, `save_deck`, `select_deck`, `delete_deck`, `get_column_posts`, and +`load_more_column` on both deck routes. Discover connection IDs first. +`set_deck` creates a temporary view when `deckId` is omitted. `save_deck` +persists it. Replacing or deleting a saved deck requires its `expectedRevision`. Enable `chrome://flags/#enable-webmcp-testing`, restart Chrome, and open the local app. Use the @@ -111,12 +120,19 @@ to invoke tools. Registration uses native `document.modelContext` through `usewebmcp`; there is no polyfill or external MCP transport. Unsupported browsers retain the manual deck UI. See [tool contracts and verification](docs/webmcp-prototype.md). -Development binds to `127.0.0.1` by default. `dev:tailscale` binds to -`0.0.0.0`, including LAN interfaces. Native WebMCP needs a secure context; +Development and `dev:tailscale` both bind to `127.0.0.1`. Native WebMCP needs a secure context; use a Tailscale Serve HTTPS origin for remote access, and allow its exact hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process environment. HTTP and HTTPS origins have separate localStorage. +Set `TWITTER_LITE_ORIGIN` to the exact Serve HTTPS origin (no trailing slash) +and `TWITTER_LITE_ALLOWED_LOGIN` to your Tailscale login. The app requires +Serve's `Tailscale-User-Login` header and rejects other users. Keep the backend +on localhost: the trusted Serve proxy supplies identity. Tagged clients do not +provide user identity. Missing configuration fails closed; direct browser access +to localhost does not supply the required identity. Playwright supplies an +explicit fixture identity to its isolated test server. + ## NixOS service The flake provides a production package and a NixOS module: @@ -135,6 +151,10 @@ The flake provides a production package and a NixOS module: services.twitter-lite = { enable = true; relayBaseUrl = "http://127.0.0.1:6900"; + publicOrigin = "https://home.example-tailnet.ts.net"; + allowedLogin = "your-tailscale-login"; + mastodonOrigins = [ "https://fedi.yutakobayashi.com" ]; + credentialKeyFile = "/var/lib/secrets/twitter-lite-key"; }; } ]; @@ -143,9 +163,12 @@ The flake provides a production package and a NixOS module: } ``` -The service listens on `127.0.0.1:3000` by default. Set -`services.twitter-lite.host` or `services.twitter-lite.port` to change the -listener. Profiles are selected in column definitions, not service options. +The service listens on `127.0.0.1:3000`. Set +`services.twitter-lite.port` to change the port; the host remains loopback. +The module reserves `/var/lib/twitter-lite` with mode 0700 for persistent state. +`credentialKeyFile` can reference a runtime secret file for SNS credentials; +systemd loads it as a credential, separate from the database and Nix store. +Profiles are selected in column definitions, not service options. The package can also be built directly with `nix build`. ## Reliability diff --git a/docs/plans/2026-09-24-mastodon-and-shared-decks.md b/docs/plans/2026-09-24-mastodon-and-shared-decks.md new file mode 100644 index 0000000..f8e1973 --- /dev/null +++ b/docs/plans/2026-09-24-mastodon-and-shared-decks.md @@ -0,0 +1,141 @@ +# Mastodon複数アカウント・デッキ端末間共有の実装計画 + +状態: 機能実装・検証済み。2026-09-24に現行コード・公式仕様・Elkを調査。Drizzle ORM 0.45.3+Drizzle Kit 0.31.11+better-sqlite3 13.0.3と、Tailscale Serveの本人情報による利用制限を採用。実機確認の範囲と運用上の引き継ぎは以下に記録する。 + +## 実装・検証記録(2026-09-24) + +- T0〜T6の機能を実装。ユーザーのブラウザでTailscale経由のMastodon認可・callback復帰を確認。 +- サーバーはタグ付き端末なので、本人loginはSelf.UserIDから推測せず実際のユーザー情報で設定。タグ付き端末からの本人情報なしアクセスは引き続き拒否。 +- 単体・統合202件、Playwrightの共有デッキ/WebMCP/アクセス制限38件と接続管理/callback4件を検証。 +- 別ブラウザから保存済み認可を再利用し、実Mastodonを20件→40件へページ送り。同じ一時ビューでTwitter20件を取得。ブラウザエラーなし。確認用ビューは永続保存していない。 +- Nixパッケージ単体で起動・3マイグレーション・DB書込・バックアップを確認。依存hash更新済み。 +- 実アカウントでの接続はMastodon1件。同一インスタンスの複数アカウント・別インスタンス・再接続・解除・401競合・429はテストで検証し、実アカウントを増減させる破壊的試験は行っていない。 +- NixOSサービスの実機デプロイは行わず、モジュール・永続ディレクトリ・credential設定と運用手順を用意。試用用devサーバーを稼働。 + +## 前提と到達点 + +- 自分専用。自宅の1台でアプリを稼働し、複数の自分の端末からTailscale ServeのHTTPSで利用する。 +- 同じデッキにTwitterとMastodonを並べ、各カラムに接続アカウントを固定できる。 +- 端末AでOAuth接続とデッキ作成を済ませれば、端末Bでも同じ接続・デッキを使える。 +- デッキ定義・カラム順序・接続一覧を共有する。開いているデッキ、スクロール位置、編集中のフォームは端末ローカルとする案。 +- AIが調査ごとに作るデッキは一時ビューとして開き、必要なものだけ保存済みデッキにする。一時ビューは端末間共有の対象外。 +- 投稿の収集アーカイブ、AIサマリー、オフライン編集、他人との共同編集はこの増分に含めない。 + +## 推奨構成 + +```mermaid +flowchart LR + A[PC / スマホ] --> B[Tailscale Serve HTTPS] + B --> C[Twitter Lite / localhost] + C --> D[(SQLite / 自宅のローカルディスク)] + C --> E[Twitter Safe Relay] + C --> F[Mastodon各インスタンス] +``` + +SQLiteを採用する案。端末が増えても、各端末がSQLを実行するのではなく、同じアプリサーバーへアクセスする。デッキ設定とOAuth情報の小さな更新には単一サーバーのSQLiteで始められると判断する。DBファイルを端末間コピーしたり、NAS上のファイルを複数サーバーから直接開いたりしない。 + +| 候補 | 今回の評価 | +| --- | --- | +| SQLite | 推奨。別DBサービス不要。ローカルディスク、短いトランザクション、マイグレーション、復元試験を用意する | +| PostgreSQL | 複数アプリサーバーや大量の並列収集へ進む時に再評価。現時点では運用対象が増える | +| ブラウザDB+同期基盤 | 採用しない。今回必要なのはオンラインで同じサーバー状態を読むこと。オフライン同期エンジンを導入する必要はない | + +SQLiteは同時書き込みが1つという制約がある。複数サーバー・高い書き込み並列度ではclient/server DBを検討する。[SQLiteの用途](https://www.sqlite.org/whentouse.html) + +## 認証と保存の境界 + +アプリの利用許可とSNSアカウントのOAuthを分ける。最初は単一の私用ワークスペースとし、アプリ内のユーザー登録・パスワード管理は作らない。 + +Tailscale Serveの本人情報を利用する場合は、自分のloginを許可し、バックエンドをlocalhostに束縛する。現在の試用用 `0.0.0.0` 起動を、そのまま本人情報ヘッダーを信頼する本番構成に持ち込まない。タグ付き端末のアクセスには通常のユーザー情報ヘッダーが付かないため、最初の実機確認に含める。Serveからの経路、利用許可、変更リクエストのOrigin検証を共通化する。[Tailscale Serve identity headers](https://tailscale.com/docs/features/tailscale-serve#identity-headers) + +OAuth callbackは固定のHTTPS URLにする。認可後に戻る主体はブラウザなので、その端末がtailnetへ到達できる構成を実機検証する。callbackのためにFunnelでアプリ全体を公開する方針は取らない。 + +## 保存モデル案 + +| 保存対象 | 主な内容 | +| --- | --- | +| `decks` | ID、名前、revision、更新日時 | +| `deck_columns` | ID、deck ID、並び順、connection ID、名前、platform固有のsource JSON | +| `connections` | ID、platform、接続先origin、外部アカウントIDまたはrelay profile参照、表示名、接続状態 | +| `connection_credentials` | connection ID、暗号化したアクセストークン、鍵の識別子。通常の接続一覧とは分離 | +| `oauth_apps` | インスタンスorigin、callback・scope構成、client ID、暗号化したclient secret | +| `oauth_attempts` | 短時間有効なstate、開始ブラウザとの束縛、接続先、PKCE verifier、期限、一度限りの消費状態 | + +単一利用者なので、この段階ではusers/organizations/roles等のテーブルを作らない。 + +カラムの現行 `profileName` を `connectionId` へ変更する。Twitterはconnectionにrelay profile名を保持し、トークンは引き続きrelay側で管理する。Mastodonは接続インスタンスoriginと `verify_credentials` のaccount IDで重複を判定する。表示handleを主キーにしない。同じインスタンスに別アカウントを追加でき、再接続時には既存のconnection IDを維持する。 + +DBには秘密を暗号化して保存し、暗号鍵はDB外の実行時credentialファイルから読む。鍵やtokenをNix store・Git・ブラウザ・SSR payload・WebMCPに含めない。DBと鍵の両方が揃って復元できる手順を用意する。 + +## PR単位のタスク + +| ID | タスク | 依存 | 完了条件 | +| --- | --- | --- | --- | +| T0 | 実行環境とアクセス経路を固定 | なし | 本番HTTPS origin/callback候補を決め、PC・スマホから同じ利用者として接続。Serve以外からのヘッダー偽装を許さない構成を確認。対象Mastodonのバージョン・OAuthメタデータも調べる | +| T1 | SQLiteと永続ディレクトリを導入 | T0 | 現行Node/Nixで動くdriver・migration方式を実証。`StateDirectory`等でDBを永続化。再起動・アプリ更新後も残り、バックアップから復元できる。依存追加時はflakeのpnpm hash更新まで行う | +| T2 | connectionモデルへTwitterを移行 | T1 | relay profile一覧から接続を作り、カラム・取得・キャッシュ・ページ送りがconnection IDを使う。2つのTwitter接続を混ぜずに並列表示。不明・削除済みの接続はエラーとして残す | +| T3 | デッキをサーバー保存し端末間共有 | T1,T2 | PCで作ったデッキが別ブラウザコンテキストに表示。revision競合で上書きを拒否。WebMCPも同じ保存処理を使う。既存localStorageからの明示インポートを提供し、重複取り込みと既存DBの破壊を防ぐ | +| T4 | Mastodon OAuthとトークン保管 | T0,T1,T2 | 接続先登録→認可→callback→本人確認→暗号化保存。同一インスタンス2アカウント・別インスタンス・再接続・解除が動く。拒否/state不一致・期限切れ・再利用を検証 | +| T5 | Mastodon取得と投稿正規化 | T4 | ユーザー投稿・リスト・ハッシュタグのカラムを実装し、全文検索を別の能力として扱う。CW・sensitiveメディア・boost・HTML本文を安全に表示。元投稿URLと取得インスタンスのローカルIDを区別 | +| T6 | 複数接続UIとWebMCPを統合 | T3,T5 | 接続管理から追加・再接続・解除。カラムは接続に応じたsourceを選べる。Twitter/Mastodonを同じデッキに並べ、AIも接続一覧を発見して作成・取得できる | +| T7 | 2端末・障害・運用の通し検証 | T3,T4,T6 | 端末AのOAuth接続を端末Bで再認可せず利用。編集競合・接続失効・429・再起動・DB復元の検証。UI/API/WebMCP/ログにトークンが出ないことを確認 | + +各PRに必要な単体・統合テストを含める。T7までテストを先送りしない。 + +推奨順序: `T0 → T1 → T2 → T3` で既存Twitterの端末間共有を先に完成。T3とT4は保存モデル確定後に並列作業可能。その後 `T4 → T5 → T6 → T7`。 + +### T3: 同期の具体的な範囲 + +- 保存済みデッキはDBを正本にし、クライアントキャッシュへ読み込む。保存中・保存失敗を区別し、サーバーが受理していない編集を「保存済み」と表示しない。 +- デッキ単位のrevisionを比較して、名前・カラム・順序を1トランザクションで更新。同じrevisionへの2回目の更新は競合として返す。 +- 初期案はフォーカス復帰時と表示中の軽い定期再取得。SSE/WebSocket/CRDTは導入しない。編集中の内容は自動更新で消さず、競合時に再読み込みを案内する。 +- 選択中デッキIDは端末ローカル。PCで別デッキを開いたためにスマホの画面まで勝手に切り替わる挙動を避ける。別端末から削除されたデッキを表示中なら、残りのデッキへ移動し通知する。 +- `get_deck`等はrevisionを返し、既存デッキを置換する `set_deck` は期待revisionを指定する。投稿読取ツールにはDB内の秘密を含めない。 +- localStorageインポートは一度限りの移行機能。DB完成後にlocalStorageへ書き戻す二重運用はしない。 + +### T3/T6: AIが作る一時ビューと保存 + +- 一時ビューと保存済みデッキは同じカラム定義・描画・接続参照を使う。別のプラットフォーム抽象や投稿取得経路は作らない。 +- AIによる新規作成は既定で一時ビュー。タブ内のメモリで保持し、DBへは書かない。複数の一時ビューを切り替えられ、UIに「一時」と「デッキとして保存」を表示する。再読み込みやタブを閉じると失われることを画面で明示する。 +- 保存操作は新規デッキをサーバーに作成する。成功後にその一時ビューを保存済みデッキへ置き換え、他の端末にも見えるようにする。失敗時は一時ビューを残し、再試行できる。二重クリック・同じ保存要求の再送で重複デッキを作らない。 +- 保存済みデッキから一時コピーを作って、AIが検索語・カラムを組み替えて試せる。元デッキは変更しない。初期実装では保存先は新規デッキとし、元デッキへのマージ機能は作らない。 +- WebMCPは一時ビューの作成・読取・編集・破棄と、明示的な保存を扱う。既存の保存済みデッキ更新には引き続き期待revisionを要求する。ツールの結果は一時か保存済みかを明示し、保存先を推測させない。 +- 一時ビューにも通常のカラム数制限と接続の検証を適用する。投稿内容や認証情報の保存は行わず、保存するのは名前・カラム・検索条件等の定義のみ。 +- 検証: AIの一時作成でDB件数が増えない、ページ送りできる、複数ビューを切り替えられる、保存後は別ブラウザから取得できる、保存失敗で内容を失わない、保存再送が重複しない、一時コピーの編集で元デッキを変更しない。 + +### T4: OAuthの具体的な範囲 + +- 初期対象はMastodon 4.3以降を提案。Authorization Code+PKCE S256とstateを使い、旧版互換の分岐は作らない。Mastodonはconfidential clientを前提にしているためclient secretも必要。[OAuth仕様](https://docs.joinmastodon.org/spec/oauth/) +- `POST /api/v1/apps`でインスタンスごとに登録。callback・scope構成と合わせて再利用する。[アプリ登録API](https://docs.joinmastodon.org/methods/apps/) +- 閲覧用scopeに絞る。候補は `read:accounts read:statuses read:lists read:search`。実装したカラムのAPI要件と照合する。同一インスタンスへの別アカウント追加は `force_login=true` を使う。[OAuth API](https://docs.joinmastodon.org/methods/oauth/) +- state/verifierは開始ブラウザに束縛し、期限付き・一度限りで検証。token交換と `verify_credentials` はサーバーで実施し、戻り先URLにtokenを含めない。[本人確認API](https://docs.joinmastodon.org/methods/accounts/#verify-account-credentials) +- 接続解除・401からの再接続を実装。通常のMastodon tokenは自動失効しないため、汎用refresh token基盤を先に作らない。[OAuth tokens](https://docs.joinmastodon.org/api/oauth-tokens/) +- 接続先入力をサーバーがfetchするため、origin・DNS解決先・redirectを検証する。初期は設定したインスタンスの許可リストに限定する案。OAuthやページ送りを家のLANへの汎用HTTP転送にしない。 + +### T5/T6: プラットフォーム抽象化 + +- 共通化するのはconnection参照、取得結果、エラー、継続ページの境界。全SNSへTwitterのTop/Latestや検索構文を要求しない。 +- source schemaはplatformとkindで分岐。MastodonのリストID・アカウントIDは接続インスタンスの文脈を持つ。接続変更時はリストを選び直す。 +- 投稿の同一性にはcanonical URIを使い、取得先のローカルstatus IDはAPI操作用に保持する。boostのwrapperと元投稿を区別する。 +- HTML本文は許可する要素・URLを制限して処理。CW・sensitiveを初期表示で尊重し、全文を単に既存のtextへ詰めない。 +- Mastodonの全文検索はインスタンスの検索環境次第。明示的な検索エラーと正常な空レスポンスを区別する。ただし4.5.3では検索バックエンド無効時も空配列を返すため、0件だけで全文検索への対応可否は判定できない。画面にも検索範囲が接続先の設定に依存することを表示する。[検索API](https://docs.joinmastodon.org/methods/search/)、[4.5.3の検索処理](https://github.com/mastodon/mastodon/blob/v4.5.3/app/services/search_service.rb) +- ホームタイムライン、通知、Streamingは最初のカラムが動いてから別タスク。既存の調査カラムと手動ページ送りを先に完成する。 + +## Elkから参考にする範囲 + +調査対象commit: `8a90074fca9f316a0c71f7249b1a31f21829a987`。 + +- インスタンス別のOAuth app登録・再利用: [server/utils/shared.ts](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/server/utils/shared.ts) +- 認可URLと複数アカウント追加: [server/api/[server]/login.ts](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/server/api/%5Bserver%5D/login.ts) +- アカウント本人確認・インスタンスと表示ドメインの区別: [app/composables/users.ts](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/app/composables/users.ts) + +ElkはOAuth app情報をサーバーに持つが、ユーザーtokenはcallback URLを経由してブラウザへ渡し、IndexedDBの `elk-users` に保存する。今回のサーバーtoken保管・端末間同期は別実装にする。[callback](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/server/api/%5Bserver%5D/oauth/%5Borigin%5D.ts)、[ユーザー保存](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/app/plugins/0.setup-users.ts) + +Elkのグローバルなcurrent accountの切り替えも、そのままカラムごとの並列取得には使わない。各カラムはconnection IDから独立したリクエスト文脈を得る。[Mastodonクライアント](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/app/composables/masto/masto.ts) + +## 後続タスク + +- Bluesky: 接続・認可方式を別途調査し、MastodonのOAuth仕様を流用しない。 +- Threads: アプリ登録・利用可能な読み取り権限・検索範囲を実機確認してからスコープを決める。 +- Nostr: relay集合・署名/鍵の扱いを別に設計し、OAuthに無理に合わせない。 +- AIサマリー/並列収集: 投稿snapshotと取得日時・クエリ・接続文脈を保存する別のデータモデルを追加する。今回は設定同期のDBに投稿アーカイブを混ぜない。 diff --git a/docs/research-decks.md b/docs/research-decks.md index be61bac..25948ea 100644 --- a/docs/research-decks.md +++ b/docs/research-decks.md @@ -1,140 +1,130 @@ # Research decks -## Workspace interface +## Workspace -The deck occupies the viewport with a dark sidebar and horizontally arranged, -independently scrolling columns. A compact toolbar names the active deck. -The sidebar switches decks, adds columns, and jumps to a column; on mobile, -it becomes a compact top bar. Column header menus expose editing, ordering, -and deletion. Creation and editing use native modal dialogs with Escape and -focus restoration. Tokens use a navy/blue palette and a system sans font. -This replaces the original spacious Garden reader layout. Layout inspiration: -[Twitter's TweetDeck design notes](https://blog.x.com/en_us/a/2012/designing-the-new-tweetdeck). +Both `/` and `/deck` render the deck-only application. A dark sidebar switches +decks, adds columns, and jumps to a column. Up to six ordered columns scroll +independently; only the active deck mounts and fetches its columns. On mobile, +the sidebar becomes a compact top bar. Native dialogs handle creation and +editing, with Escape and focus restoration. Column menus offer editing, +ordering, and deletion; refresh and pagination are manual. -## Product direction +Twitter and Mastodon can appear together, with different accounts in each +column. Original-post links open their source site. Separate reader, search, +list, user-profile, and conversation pages are not provided. -A research topic becomes a TweetDeck-style workspace: columns represent -questions or perspectives and will eventually collect posts across Twitter, -Mastodon, Bluesky, Threads, and Nostr for AI summaries with source references. -The current implementation supports Twitter only, with manually or -WebMCP-authored decks. Built-in planning, summaries, and other connectors -remain future work. +## Definitions and connections -## Workspace and column model +`src/features/decks/model.ts` validates deck IDs, titles, ordered columns, and +unique column IDs within each deck. Each column contains `id`, `title`, +`connectionId`, and a platform-specific `source`. -Both `/` and `/deck` render the same deck-only application. Separate reader, -search, list, user-profile, and conversation pages have been removed. -Original-post links open X. +| Platform | Source kind | Conditions | +| --- | --- | --- | +| Twitter | `search` | Native `query`, `product` (`Top`/`Latest`), `following` | +| Twitter | `user` | `target`: handle or X/Twitter profile URL | +| Twitter | `list` | `target`: numeric list ID or X/Twitter list URL | +| Mastodon | `search` | `query`; results depend on the instance's search configuration | +| Mastodon | `user` | `target`: account handle or the connected instance's numeric account ID | +| Mastodon | `list` | `target`: numeric list ID for the connected account | +| Mastodon | `hashtag` | `target`: tag without `#`, containing letters, numbers, or underscores | -`src/features/decks/model.ts` validates a version-2 workspace containing -`activeDeckId` and one or more named decks. Each deck has a stable ID and up to -six ordered columns. Only the active deck mounts its columns. The UI supports -creating, selecting, renaming, and deleting deck profiles; the last deck cannot -be deleted. Deleting the active deck selects the first remaining deck. +`connectionId` is the account binding, distinct from a deck's name. Twitter +connections resolve to profiles discovered from the configured relay. +Mastodon connections identify accounts by instance origin and remote account +ID; OAuth tokens stay encrypted on the server. The connection manager supports +adding, reconnecting, and disconnecting Mastodon accounts. See +[storage and OAuth](storage-and-oauth.md) for configuration and recovery. -Each column has a stable ID, title, required `profileName`, and one source: +The editor discovers lists for the selected account. Changing accounts clears +target-based conditions so an instance-local ID is not reused accidentally. +Searches can retain conditions between accounts on the same platform. There is +no global account selector or fallback to another account. Missing or +disconnected bindings produce errors. -| Source kind | Conditions | -| --- | --- | -| `search` | Native Twitter `query`, `product` (`Top`/`Latest`), and `following` | -| `user` | `target`: handle or X/Twitter profile URL, normalized to a handle | -| `list` | `target`: numeric ID or X/Twitter list URL, normalized to an ID | +The complete source and connection ID form query-cache identity. Equal +conditions on the same connection share pages; different connections retain +separate results and cursors. Deck synchronization does not poll post feeds. -All sources currently require `platform: "twitter"`. Unsupported definitions -fail validation. Column IDs must be unique within a deck, and deck IDs within -the workspace. Manual edits and agent tools use the same final schema. +## Implementation boundaries -`profileName` is the relay account binding, distinct from a named deck profile. -The editor discovers names through `/profiles` and lists through the selected -profile. A column's profile can be changed independently. Every feed and list -request carries an explicit profile name; the server confirms it still exists. -Deleted profiles and unavailable discovery produce errors instead of falling -back to another account. There is no browser-wide profile selection. +- `column-editor.tsx` owns the title, connection binding, and final submission. +- `column-source-editor.tsx` dispatches by platform and defines rebinding rules. +- `twitter-source-editor.tsx` and `mastodon-source-editor.tsx` own each + platform's source selection, fields, and account-specific list discovery. +- `use-research-feed.ts` dispatches requests and keys the cache by connection + and source. Server functions resolve credentials and fetch upstream pages. +- `platforms/types.ts` defines normalized `ResearchPost` and `ResearchPage` + records consumed by cards and column tools, without raw provider responses + or credentials. -The complete source and profile participate in query-cache identity. Equal -conditions on the same profile share loaded pages; distinct profiles retain -separate results and cursors. Columns have independent refresh, pagination, -and error state. Pagination and refresh are manual, with no polling. +Twitter posts use `twitter:` keys. Mastodon posts use canonical status URIs +for keys and retain the fetched instance's native status ID separately. Boosts +keep the wrapper identity and identify the boosting account. Mastodon HTML is +sanitized on the server; cards honor content warnings and sensitive media. -## Platform boundary +## Saved decks and temporary views -`src/features/platforms/types.ts` defines the display/evidence record without -Bird imports: stable key, platform, native identity, original URL, text, -author, optional publication time, media, and quoted post. The Twitter mapper -uses `twitter:` keys rather than mutable author handles. Raw responses -and credentials do not enter this record. Cards consume the normalized record; -engagement metrics and Twitter article previews are not normalized yet. +SQLite is authoritative for saved definitions. Creating a named deck through +the UI saves it; subsequent saved-deck edits and deletions use a revision check +in a transaction. A stale revision is rejected. Save failures remain visible +and do not report unsaved edits as persisted. -The source union is the extension point for future connectors. Twitter search -syntax and ranking controls are provider-specific. When implementing another -connector, add its real schema and server operation, normalize stable identity, -and bind connection details into cache identity. Multiple sources in one -column should wait until a second connector exercises that need; each source -must retain its own opaque continuation and error state. +Saved decks refresh on focus and every five seconds while visible. Refresh is +held while an editor is open. Active selection is a local preference under +`twitter-lite-active-deck`; selecting a deck does not switch another device's +view. Deleting the active deck selects a remaining one. When none remain, the +app opens an empty temporary view. -## Persistence +WebMCP-created views are temporary by default. Temporary copies of saved decks +also remain in this tab's memory. Editing them does not write to SQLite. The +explicit save action replaces a temporary view with a shared saved deck, +preserving its ID for idempotent retries. Failed saves retain temporary +content. Temporary views disappear on reload or navigation, including OAuth +redirects. Only definitions are saved: posts, cursors, and scroll positions +are not archived. -The workspace is stored under `twitter-lite-research-deck` in localStorage, -including all deck definitions and the active selection. It stores conditions -and relay profile names, not credentials, posts, summaries, or cursors. -Reloading fetches first pages of the selected deck. SSR and the first browser -render show a loading state until storage has been read. +The UI offers an explicit, one-time import of valid version-2 data from +`twitter-lite-research-deck` in localStorage. It resolves old relay profile +names to connection IDs and creates new saved deck IDs transactionally. A +server marker prevents repeated imports; different content after the first +import is rejected. The browser copy is removed only after success. Invalid +or unsupported data is left untouched. HTTP and HTTPS have separate browser +storage, but authorized devices read the same server-backed decks. -There is no automatic migration of the former single-deck format, which did -not pin profiles to columns. Invalid or older saved data remains untouched -while the UI presents an empty workspace and an error. An explicit saved edit -replaces it. Storage failures are visible: changes still apply in the current -tab, but persistence failures mean they will be lost on reload. Tabs and -devices do not synchronize; the last write to an origin's localStorage wins. -HTTP and HTTPS origins maintain separate workspaces. +## WebMCP -## Deck WebMCP tools - -Both deck routes expose workspace management and active-column reading. -`list_decks` discovers definitions and available relay profiles; `get_deck` -reads a specific or active deck. `set_deck` creates or replaces and activates a -deck; `select_deck` and `delete_deck` operate by ID. `get_column_posts` and -`load_more_column` read or paginate columns in the active deck. See the -[full tool contracts](webmcp-prototype.md). - -For example, after discovering a relay profile named `main`, create a deck: +Use `list_connections` to discover bindings, then `set_deck` to create a +temporary view. This example uses IDs returned by discovery: ```json { "title": "WebMCPの反応", "columns": [ { - "title": "日本語", - "profileName": "main", - "source": { "kind": "search", "query": "WebMCP lang:ja" } + "title": "Twitter", + "connectionId": "twitter-connection-id", + "source": { "platform": "twitter", "kind": "search", "query": "WebMCP lang:ja" } }, { - "title": "開発者", - "profileName": "main", - "source": { "kind": "user", "target": "@example" } + "title": "Mastodon", + "connectionId": "mastodon-connection-id", + "source": { "platform": "mastodon", "kind": "hashtag", "target": "WebMCP" } } ] } ``` -Omitting `deckId` creates a deck. To edit, read first and include its `deckId` -and every column to retain; keep existing column IDs. Omitted columns are -removed and omitted column IDs are generated. Post loading is asynchronous, -so a successful save does not mean the upstream requests succeeded. - -## Future AI work - -A planner can generate definitions through the existing schema and tools. -Summaries will need persisted collection snapshots: post identity and URL, -retrieval time, source conditions, and profile context. Saved conditions alone -do not preserve the evidence behind a summary. ACP or Codex app-server may -connect a future planner, but neither is part of this implementation. +Read the returned deck ID and call `save_deck` only when the view should be +shared. Replacing a saved deck requires its `deckId` and `expectedRevision`; +include every column to retain. Post loading is asynchronous and can fail +independently of saving. See [all tool contracts](webmcp-prototype.md). ## Verification -Unit tests cover normalization, source/workspace validation, ordering, -profile-specific caching and pagination, profile discovery failures, and -storage behavior. Playwright uses a standalone mock relay through real server -functions to exercise deck switching, column/profile editing, list selection, -pagination, persistence, and native WebMCP. Accessibility checks run on the -workspace. Automated tests do not need a live SNS search. +Unit tests exercise source normalization, account-specific caches, OAuth, +credential storage, revisions, import, and temporary-view behavior. Playwright +uses an isolated SQLite database and mock relay through real server functions +to exercise shared decks across browser contexts, editing, persistence, +pagination, and native WebMCP. Deterministic automated tests do not require +live SNS credentials. diff --git a/docs/storage-and-oauth.md b/docs/storage-and-oauth.md new file mode 100644 index 0000000..d8e15e6 --- /dev/null +++ b/docs/storage-and-oauth.md @@ -0,0 +1,102 @@ +# Shared storage and Mastodon OAuth + +Twitter Lite runs as a single personal server behind Tailscale Serve. The +backend binds to loopback and accepts only the configured Tailscale login. +Browser requests that change state must have the configured Origin. OAuth +callbacks also pass the owner check; the browser must be able to reach the +tailnet HTTPS address after Mastodon authorization. + +## Runtime configuration + +| Variable | Value | +| --- | --- | +| `TWITTER_LITE_ORIGIN` | Exact Serve HTTPS origin, without a trailing slash | +| `TWITTER_LITE_ALLOWED_LOGIN` | Owner's Tailscale login | +| `TWITTER_LITE_DB_PATH` | Absolute path to the SQLite database on local disk | +| `TWITTER_LITE_MASTODON_ORIGINS` | Comma-separated approved HTTPS instance origins | +| `TWITTER_LITE_CREDENTIAL_KEY_FILE` | Runtime file containing 32 random bytes encoded as base64 | + +The credential key is required for Mastodon, but not for Twitter-only use. +Generate it once, keep it outside Git and the Nix store, and retain it when +updating the application. For example, with an existing private directory: + +```sh +umask 077 +nix develop -c node --input-type=module -e 'import {randomBytes} from "node:crypto"; import {writeFileSync} from "node:fs"; writeFileSync("/absolute/private/credential-key", randomBytes(32).toString("base64") + "\n", {flag: "wx", mode: 0o600})' +``` + +The command refuses to replace an existing file. Losing the key makes saved +SNS credentials unreadable. A separate protected backup of the key is needed +alongside database backups. + +## Database and migrations + +Drizzle ORM 0.45.3, Drizzle Kit 0.31.11 and better-sqlite3 13.0.3 are pinned. +The driver ships native prebuilds; dependency install scripts remain disabled. +Both the actual Nix Node runtime and the built Nix package have been exercised +with SQLite operations and the packaged backup command. + +`drizzle/` contains generated SQL and metadata. `pnpm db:generate` generates +SQL and bundles it into TypeScript for the server. Commit both artifacts with +schema changes. The server applies pending migrations under an immediate +transaction before serving an authorized application request. It enables WAL, +foreign keys and a five-second busy timeout. Deployment does not depend on a +particular working directory or a separate migration command. + +Deck definitions, ordered columns, connection metadata, OAuth applications +and short-lived OAuth attempts live in SQLite. Tokens, client secrets and +PKCE verifiers are authenticated encrypted envelopes in separate fields. +Their associated data binds each secret to its record and purpose. Public +connection responses never select those fields. + +## Backups and restore + +Use the live SQLite backup API instead of copying only the main file while WAL +is active. The destination must be an absolute path that does not already +exist; backups are mode 0600 and pass a SQLite integrity check. + +```sh +TWITTER_LITE_DB_PATH=/absolute/workspace.sqlite \ + nix develop -c pnpm db:backup /absolute/backups/workspace-2026-09-24.sqlite +``` + +The Nix package exposes the same operation as `twitter-lite-backup`: + +```sh +TWITTER_LITE_DB_PATH=/var/lib/twitter-lite/workspace.sqlite \ + twitter-lite-backup /absolute/backups/workspace-2026-09-24.sqlite +``` + +Run it as an identity that can read the database and write the backup directory. +On NixOS the application uses `DynamicUser`, `StateDirectory=twitter-lite` and +mode 0700. The runtime key is passed with systemd `LoadCredential`. + +For restore, stop the service first. Preserve the current state directory as +a separate recovery copy, then restore the verified backup as +`workspace.sqlite` in a clean state directory with the service's ownership and +permissions. Do not leave old `-wal` or `-shm` sidecars beside a restored main +database. Restore the matching credential key separately, then start the +service and verify decks and account access. Do not attempt to restore a newer +schema into an older application version. + +## OAuth and instance support + +Initial support targets Mastodon 4.3+ with PKCE S256. The first configured +instance, `https://fedi.yutakobayashi.com`, reported 4.5.3 and S256 on 2026-09-24. +Only configured HTTPS origins with public DNS addresses are accepted; server +requests pin the resolved address and refuse redirects. + +Each instance/callback/scope combination has an OAuth application. Authorization +uses `read:accounts read:statuses read:lists read:search`, PKCE, a ten-minute +one-use state and an HttpOnly browser-binding cookie. Tokens are exchanged and +account identity is verified on the server. The callback URL never contains an +access token. Same-instance accounts remain separate; reconnecting preserves +the connection ID only for the same account. + +An expired token marks its connection unavailable until reconnected. Disconnect +first revokes the token using the original OAuth application, then removes the +local credential while retaining the connection reference used by saved decks. + +Search results depend on the instance's backend and indexing. A successful +empty response does not prove full-text search is enabled: Mastodon 4.5.3 also +returns empty status results when its search backend is disabled. diff --git a/docs/webmcp-prototype.md b/docs/webmcp-prototype.md index c3e2ccc..5870f37 100644 --- a/docs/webmcp-prototype.md +++ b/docs/webmcp-prototype.md @@ -1,88 +1,108 @@ # WebMCP prototype -## Scope and registration +## Registration -The deck workspace exposes seven React-owned tools on `/` and `/deck`. -`usewebmcp` owns native browser registration and cleanup. There is no polyfill -or external MCP transport. Unsupported browsers retain the manual UI. Tools -are enabled after local storage loads; relay-profile discovery may still be -pending, which `list_decks` reports as `profiles: null`. +The deck workspace exposes nine React-owned tools on `/` and `/deck`. +`usewebmcp` handles native browser registration and cleanup. Tools become +available after saved decks load. Connection discovery may still be pending; +`list_connections` then returns `connections: null`. -The former `search_posts`, `get_loaded_posts`, and `load_more_posts` tools and -standalone reader routes have been removed. Agents manage named decks and -address columns explicitly, including their bound relay profiles. +Registration uses `document.modelContext`; there is no polyfill or external +MCP transport. Unsupported browsers retain the manual interface. All server +operations use the same access controls and persistence rules as the UI. ## Workspace tools | Tool | Input | Behavior | | --- | --- | --- | -| `list_decks` | `{}` | Return all deck definitions, `activeDeckId`, available `profiles`, and `storageError` | -| `get_deck` | Optional `deckId` | Read a saved deck; omitted ID selects the active deck | -| `set_deck` | Optional `deckId`, required `title` and `columns` | Create when ID is omitted; otherwise replace an existing deck, then activate it | -| `select_deck` | `deckId` | Activate a saved deck and persist the selection | -| `delete_deck` | `deckId` | Permanently remove the definition; cannot delete the last deck | +| `list_connections` | `{}` | Return connection IDs, platforms, origins, account IDs, display names, and states; never credentials | +| `list_decks` | `{}` | Return saved decks and this tab's temporary views, `activeDeckId`, and `storageError` | +| `get_deck` | Optional `deckId` | Read the named or active view, including `persisted` and saved `revision` | +| `set_deck` | Optional `deckId` and `expectedRevision`, required `title` and `columns` | Without an ID, create a temporary view; with an ID, replace and activate that existing view | +| `save_deck` | `deckId` | Explicitly persist a temporary view; an already saved deck is unchanged | +| `select_deck` | `deckId` | Activate a view; selection remains device-local | +| `delete_deck` | `deckId`, optional `expectedRevision` | Discard a temporary view or delete a saved deck for all devices | -`set_deck` accepts at most six columns. Each requires `title`, `profileName` -from `list_decks`, and a discriminated `source`. Its `kind` is `search`, `user`, -or `list`; `platform` defaults to `twitter`. Searches require `query`, with -`product` defaulting to `Latest` and `following` to false. User and list sources -require `target` (handle/profile URL or list ID/URL). Unknown source fields, -invalid targets, duplicate column IDs, and unknown profiles fail before saving. +Read tools return the workspace's current client snapshot, not a fresh server +request. Saved definitions refresh on focus and while visible, except during +editing. Replacing or deleting a saved deck requires `expectedRevision` from +the definition being edited. A stale revision fails without overwriting the +server. Use the UI's reload action after a conflict when a fresh definition +is needed. -Read before editing. Include every column to keep; omitted columns are removed. -Preserve IDs for retained columns and omit IDs for new ones. An empty columns -array clears a deck. A supplied deck ID must already exist. Successful mutation -closes unsaved editor forms. `set_deck` returns the applied definition, -`persisted: true`, and `posts: "loading-asynchronously"`; searches can fail -independently after the save succeeds. +`set_deck` accepts at most six columns, each with a title, `connectionId` from +discovery, and a source. Connections must be connected and match the source +platform. Optional column IDs preserve identity; omitted IDs are generated. +Include every retained column: omitted columns are removed, and an empty array +clears the view. A supplied deck ID must already exist. -Deleting the active deck selects the first remaining one. Deletion has no -workspace-tool undo. Storage failures return `isError: true` explaining that -the mutation applied in memory but could not be persisted. Saving replaces -invalid or legacy saved data; there is no automatic legacy migration. +Twitter sources support `search`, `user`, and `list`. `platform` defaults to +`twitter`; search requires `query` and defaults `product` to `Latest` and +`following` to false. User/list targets accept handles or profile URLs and +numeric list IDs or list URLs, respectively. + +Mastodon requires `platform: "mastodon"` and supports `search` (`query`), +`user` (handle or instance-local account ID), `list` (account-local numeric +list ID), and `hashtag` (tag without `#`). It does not accept Twitter ranking +or following controls. Search availability and coverage depend on the +instance; an empty successful result does not prove full-text support. + +## Mutation results and persistence + +`set_deck` returns `deck`, its actual `persisted` flag, and +`posts: "loading-asynchronously"`. A new view exists only in tab memory until +`save_deck` succeeds. Temporary views disappear on reload or navigation, +including OAuth redirects. Saving keeps the ID, allowing an identical create +request to be retried without duplicate decks. Failed saves keep temporary +content; rejected updates do not replace the accepted saved definition. + +Successful mutations close unsaved editor forms. Deleting the active view +selects a remaining one; deleting the last opens an empty temporary view. +There is no tool-level deletion undo. Column requests may fail independently +after a definition is applied or saved. Existing browser decks are imported +only through the explicit UI import action, not through a tool side effect. ## Column tools `get_column_posts` accepts `columnId`, `offset` (default 0, nonnegative integer), and `limit` (default 20, integer 1–50). It reads already loaded posts without a -network request. Only columns mounted in the active deck are available; select -the deck and allow it to render first. +network request. Only columns mounted in the active view are available; +select the view and allow it to render first. -`load_more_column` accepts `columnId`. It loads or retries one continuation -using that column's bound profile. Wait for its initial load or refresh before -calling. Concurrent pagination joins the existing request. If the deck or -column changes during the request, the tool reports an error instead of -returning results under the new identity. At the end, it returns no appended -posts and `hasMore: false`. +`load_more_column` accepts `columnId` and fetches or retries one continuation +using the column's connection. Wait for initial loading or refresh to finish. +Concurrent pagination joins the existing request. If the view or column +changes during pagination, execution fails rather than returning results under +the changed identity. At the end, no posts are appended and `hasMore` is false. Both return: -- `column`: ID, title, bound profile, and source definition -- `status`: `loading`, `ready`, or `error`, plus `loading` and `error` details -- `posts`: normalized records with original URLs, identity, text, author, - and available media/quotes -- `loadedCount`, `offset`, and `nextOffset` for slicing deduplicated cached posts -- `hasMore`: whether the current feed has an upstream continuation +- `column`: ID, title, connection ID, and source +- `status`: `loading`, `ready`, or `error`, with `loading` and `error` details +- `posts`: normalized records with original URLs, text, author, and available + media, quotes, content warnings, or boost information +- `loadedCount`, `offset`, and `nextOffset` for slices of deduplicated posts +- `hasMore`: whether the feed has an upstream continuation Continuation returns up to 20 newly appended posts. Use `nextOffset` with -`get_column_posts` to read additional already loaded records, and -`load_more_column` for an upstream page. These are cache snapshots; manual UI -refreshes and pagination can change the available records. +`get_column_posts` for additional already loaded records; use +`load_more_column` for another upstream page. These are cache snapshots, not +archived evidence. -## Results and errors +## Errors and annotations -Tools return JSON in an MCP text content block. Execution failures set -`isError: true` with `code`, `message`, and `retryable`. Schema failures use -`invalid-input`; other tool failures use `tool-error`. Column snapshots report -underlying relay failures through their `error` field. An empty successful -query is not an error. +Results are JSON in an MCP text content block. Execution failures set +`isError: true` with `code`, `message`, and `retryable: false`. Schema and +connection-validation failures use `invalid-input`; other execution failures +use `tool-error`. Column snapshots expose upstream errors separately in their +`error` field. Empty successful queries are not errors. -`list_decks`, `get_deck`, and `get_column_posts` carry `readOnlyHint: true`. -The other tools change local UI or storage. `delete_deck` carries -`destructiveHint: true`; tools returning external posts mark them untrusted. -Annotations are metadata, not authorization controls. No tool writes to X. +`list_connections`, `list_decks`, `get_deck`, and `get_column_posts` have +`readOnlyHint: true`. `delete_deck` has `destructiveHint: true`; tools returning +external posts mark them untrusted. Annotations describe behavior and do not +grant authorization. No tool posts to or modifies an SNS account. -## Verification and browser setup +## Browser setup and verification ```sh nix develop -c pnpm test @@ -90,19 +110,17 @@ nix develop -c pnpm typecheck nix develop -c pnpm test:e2e e2e/integrations/webmcp.test.ts ``` -The E2E tests enable native Chromium WebMCP/testing flags and use -`navigator.modelContextTesting` to invoke actual registered tools. A mock -relay supplies deterministic responses through real server functions. +E2E tests enable Chromium WebMCP/testing flags and invoke actual registered +tools through `navigator.modelContextTesting`. Mock relay responses pass +through real server functions and isolated database state. For interactive testing, enable `chrome://flags/#enable-webmcp-testing`, -restart Chrome, and use Model Context Tool Inspector on the app. Registration -uses `document.modelContext`; reload after changing browser support. Native -WebMCP requires a secure context: local loopback works for development; remote -Tailscale access should use an HTTPS Serve origin. Allow the exact hostname -through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process environment. -HTTP and HTTPS have separate browser-local workspaces. +restart Chrome, and use Model Context Tool Inspector. Reload after changing +browser support. Native WebMCP requires a secure context; use the configured +Tailscale Serve HTTPS origin for remote access. Development also requires +allowing its exact hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS`. +The app's owner check still applies; direct localhost access does not supply +Serve identity. See [runtime access configuration](storage-and-oauth.md). No production origin-trial token or external MCP-client bridge is configured. Browser cancellation does not guarantee cancellation of a shared feed request. -Agent task-selection quality still needs evaluation with the consuming agent; -automated browser tests verify contracts and UI behavior. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..a05cbba --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'sqlite', + schema: './src/features/storage/schema.ts', + out: './drizzle', +}) diff --git a/drizzle/0000_aspiring_bloodstorm.sql b/drizzle/0000_aspiring_bloodstorm.sql new file mode 100644 index 0000000..8e3c020 --- /dev/null +++ b/drizzle/0000_aspiring_bloodstorm.sql @@ -0,0 +1,66 @@ +CREATE TABLE `connection_credentials` ( + `connection_id` text PRIMARY KEY NOT NULL, + `encrypted_token` text NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `connections` ( + `id` text PRIMARY KEY NOT NULL, + `platform` text NOT NULL, + `origin` text NOT NULL, + `account_id` text, + `relay_profile` text, + `display_name` text NOT NULL, + `status` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `connections_account` ON `connections` (`platform`,`origin`,`account_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `connections_relay_profile` ON `connections` (`origin`,`relay_profile`);--> statement-breakpoint +CREATE TABLE `deck_columns` ( + `id` text NOT NULL, + `deck_id` text NOT NULL, + `position` integer NOT NULL, + `connection_id` text NOT NULL, + `title` text NOT NULL, + `source` text NOT NULL, + PRIMARY KEY(`deck_id`, `id`), + FOREIGN KEY (`deck_id`) REFERENCES `decks`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE restrict, + CONSTRAINT "deck_columns_valid_position" CHECK("deck_columns"."position" >= 0) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `deck_columns_position` ON `deck_columns` (`deck_id`,`position`);--> statement-breakpoint +CREATE TABLE `decks` ( + `id` text PRIMARY KEY NOT NULL, + `title` text NOT NULL, + `revision` integer DEFAULT 1 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + CONSTRAINT "decks_positive_revision" CHECK("decks"."revision" >= 1) +); +--> statement-breakpoint +CREATE TABLE `oauth_apps` ( + `id` text PRIMARY KEY NOT NULL, + `origin` text NOT NULL, + `redirect_uri` text NOT NULL, + `scopes` text NOT NULL, + `client_id` text NOT NULL, + `encrypted_client_secret` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_apps_configuration` ON `oauth_apps` (`origin`,`redirect_uri`,`scopes`);--> statement-breakpoint +CREATE TABLE `oauth_attempts` ( + `state_hash` text PRIMARY KEY NOT NULL, + `browser_hash` text NOT NULL, + `app_id` text NOT NULL, + `encrypted_verifier` text NOT NULL, + `connection_id` text, + `expires_at` integer NOT NULL, + `consumed_at` integer, + FOREIGN KEY (`app_id`) REFERENCES `oauth_apps`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/0001_many_fat_cobra.sql b/drizzle/0001_many_fat_cobra.sql new file mode 100644 index 0000000..e76ae87 --- /dev/null +++ b/drizzle/0001_many_fat_cobra.sql @@ -0,0 +1 @@ +ALTER TABLE `connection_credentials` ADD `app_id` text REFERENCES oauth_apps(id); \ No newline at end of file diff --git a/drizzle/0002_fine_master_chief.sql b/drizzle/0002_fine_master_chief.sql new file mode 100644 index 0000000..b2a2a78 --- /dev/null +++ b/drizzle/0002_fine_master_chief.sql @@ -0,0 +1,6 @@ +CREATE TABLE `legacy_imports` ( + `id` text PRIMARY KEY NOT NULL, + `payload_hash` text NOT NULL, + `deck_ids` text NOT NULL, + `created_at` integer NOT NULL +); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..7c9708c --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,424 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "42951f9a-6025-4e73-9155-764d25514ba8", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "connection_credentials": { + "name": "connection_credentials", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "connection_credentials_connection_id_connections_id_fk": { + "name": "connection_credentials_connection_id_connections_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connections": { + "name": "connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "relay_profile": { + "name": "relay_profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connections_account": { + "name": "connections_account", + "columns": ["platform", "origin", "account_id"], + "isUnique": true + }, + "connections_relay_profile": { + "name": "connections_relay_profile", + "columns": ["origin", "relay_profile"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deck_columns": { + "name": "deck_columns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "deck_columns_position": { + "name": "deck_columns_position", + "columns": ["deck_id", "position"], + "isUnique": true + } + }, + "foreignKeys": { + "deck_columns_deck_id_decks_id_fk": { + "name": "deck_columns_deck_id_decks_id_fk", + "tableFrom": "deck_columns", + "tableTo": "decks", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_columns_connection_id_connections_id_fk": { + "name": "deck_columns_connection_id_connections_id_fk", + "tableFrom": "deck_columns", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deck_columns_deck_id_id_pk": { + "columns": ["deck_id", "id"], + "name": "deck_columns_deck_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "deck_columns_valid_position": { + "name": "deck_columns_valid_position", + "value": "\"deck_columns\".\"position\" >= 0" + } + } + }, + "decks": { + "name": "decks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "decks_positive_revision": { + "name": "decks_positive_revision", + "value": "\"decks\".\"revision\" >= 1" + } + } + }, + "oauth_apps": { + "name": "oauth_apps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_apps_configuration": { + "name": "oauth_apps_configuration", + "columns": ["origin", "redirect_uri", "scopes"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_attempts": { + "name": "oauth_attempts", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_verifier": { + "name": "encrypted_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_attempts_app_id_oauth_apps_id_fk": { + "name": "oauth_attempts_app_id_oauth_apps_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_attempts_connection_id_connections_id_fk": { + "name": "oauth_attempts_connection_id_connections_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..d47bce3 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,440 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1455820b-8aec-4fcb-9790-c7d9f1fd908c", + "prevId": "42951f9a-6025-4e73-9155-764d25514ba8", + "tables": { + "connection_credentials": { + "name": "connection_credentials", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "connection_credentials_connection_id_connections_id_fk": { + "name": "connection_credentials_connection_id_connections_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_credentials_app_id_oauth_apps_id_fk": { + "name": "connection_credentials_app_id_oauth_apps_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connections": { + "name": "connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "relay_profile": { + "name": "relay_profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connections_account": { + "name": "connections_account", + "columns": ["platform", "origin", "account_id"], + "isUnique": true + }, + "connections_relay_profile": { + "name": "connections_relay_profile", + "columns": ["origin", "relay_profile"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deck_columns": { + "name": "deck_columns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "deck_columns_position": { + "name": "deck_columns_position", + "columns": ["deck_id", "position"], + "isUnique": true + } + }, + "foreignKeys": { + "deck_columns_deck_id_decks_id_fk": { + "name": "deck_columns_deck_id_decks_id_fk", + "tableFrom": "deck_columns", + "tableTo": "decks", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_columns_connection_id_connections_id_fk": { + "name": "deck_columns_connection_id_connections_id_fk", + "tableFrom": "deck_columns", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deck_columns_deck_id_id_pk": { + "columns": ["deck_id", "id"], + "name": "deck_columns_deck_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "deck_columns_valid_position": { + "name": "deck_columns_valid_position", + "value": "\"deck_columns\".\"position\" >= 0" + } + } + }, + "decks": { + "name": "decks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "decks_positive_revision": { + "name": "decks_positive_revision", + "value": "\"decks\".\"revision\" >= 1" + } + } + }, + "oauth_apps": { + "name": "oauth_apps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_apps_configuration": { + "name": "oauth_apps_configuration", + "columns": ["origin", "redirect_uri", "scopes"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_attempts": { + "name": "oauth_attempts", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_verifier": { + "name": "encrypted_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_attempts_app_id_oauth_apps_id_fk": { + "name": "oauth_attempts_app_id_oauth_apps_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_attempts_connection_id_connections_id_fk": { + "name": "oauth_attempts_connection_id_connections_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..531cd33 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,478 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ebfb67e2-a014-4d40-89e3-30c9b55cf047", + "prevId": "1455820b-8aec-4fcb-9790-c7d9f1fd908c", + "tables": { + "connection_credentials": { + "name": "connection_credentials", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "connection_credentials_connection_id_connections_id_fk": { + "name": "connection_credentials_connection_id_connections_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_credentials_app_id_oauth_apps_id_fk": { + "name": "connection_credentials_app_id_oauth_apps_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connections": { + "name": "connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "relay_profile": { + "name": "relay_profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connections_account": { + "name": "connections_account", + "columns": ["platform", "origin", "account_id"], + "isUnique": true + }, + "connections_relay_profile": { + "name": "connections_relay_profile", + "columns": ["origin", "relay_profile"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deck_columns": { + "name": "deck_columns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "deck_columns_position": { + "name": "deck_columns_position", + "columns": ["deck_id", "position"], + "isUnique": true + } + }, + "foreignKeys": { + "deck_columns_deck_id_decks_id_fk": { + "name": "deck_columns_deck_id_decks_id_fk", + "tableFrom": "deck_columns", + "tableTo": "decks", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_columns_connection_id_connections_id_fk": { + "name": "deck_columns_connection_id_connections_id_fk", + "tableFrom": "deck_columns", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deck_columns_deck_id_id_pk": { + "columns": ["deck_id", "id"], + "name": "deck_columns_deck_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "deck_columns_valid_position": { + "name": "deck_columns_valid_position", + "value": "\"deck_columns\".\"position\" >= 0" + } + } + }, + "decks": { + "name": "decks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "decks_positive_revision": { + "name": "decks_positive_revision", + "value": "\"decks\".\"revision\" >= 1" + } + } + }, + "legacy_imports": { + "name": "legacy_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_ids": { + "name": "deck_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_apps": { + "name": "oauth_apps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_apps_configuration": { + "name": "oauth_apps_configuration", + "columns": ["origin", "redirect_uri", "scopes"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_attempts": { + "name": "oauth_attempts", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_verifier": { + "name": "encrypted_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_attempts_app_id_oauth_apps_id_fk": { + "name": "oauth_attempts_app_id_oauth_apps_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_attempts_connection_id_connections_id_fk": { + "name": "oauth_attempts_connection_id_connections_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..61cb3c3 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1790234301369, + "tag": "0000_aspiring_bloodstorm", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1790234518415, + "tag": "0001_many_fat_cobra", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1790234736939, + "tag": "0002_fine_master_chief", + "breakpoints": true + } + ] +} diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index e2151fa..fdc57a7 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,7 +1,31 @@ +import { basename, dirname, isAbsolute } from 'node:path' import AxeBuilder from '@axe-core/playwright' import { test as base } from '@playwright/test' +import { openDatabase } from '../src/features/storage/database.server' -export const test = base.extend<{ a11y: () => AxeBuilder }>({ +export const test = base.extend<{ + a11y: () => AxeBuilder + resetDecks: undefined +}>({ + resetDecks: [ + // biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments. + async ({}, use) => { + const path = process.env.TWITTER_LITE_E2E_DB_PATH + if ( + !path || + !isAbsolute(path) || + !basename(dirname(path)).startsWith('twitter-lite-e2e-') || + basename(path) !== 'workspace.sqlite' + ) + throw new Error('Isolated E2E database is required.') + const database = openDatabase(path).$client + database.pragma('foreign_keys = ON') + database.exec('DELETE FROM decks; DELETE FROM legacy_imports;') + database.close() + await use(undefined) + }, + { auto: true }, + ], a11y: async ({ page }, use) => { await use(() => new AxeBuilder({ page }) diff --git a/e2e/integrations/access.test.ts b/e2e/integrations/access.test.ts new file mode 100644 index 0000000..d7d54e6 --- /dev/null +++ b/e2e/integrations/access.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '../fixtures' + +test('owner can open the app but missing or foreign identity is forbidden', async ({ + request, +}) => { + expect((await request.get('/')).status()).toBe(200) + expect( + ( + await request.get('/', { headers: { 'Tailscale-User-Login': '' } }) + ).status(), + ).toBe(403) + expect( + ( + await request.get('/', { + headers: { 'Tailscale-User-Login': 'other@twitter-lite.invalid' }, + }) + ).status(), + ).toBe(403) +}) + +test('mutations require exact Origin even with same-origin Fetch Metadata', async ({ + request, +}) => { + expect((await request.post('/')).status()).toBe(403) + expect( + ( + await request.post('/', { + headers: { + Origin: 'https://other.invalid', + 'Sec-Fetch-Site': 'same-origin', + }, + }) + ).status(), + ).toBe(403) +}) + +test('server functions also reject foreign identity and cross-site calls', async ({ + page, + request, +}) => { + const serverRequest = page.waitForRequest((request) => + request.url().includes('/_serverFn/'), + ) + await page.goto('/') + const url = (await serverRequest).url() + expect( + ( + await request.get(url, { + headers: { + 'Tailscale-User-Login': 'other@twitter-lite.invalid', + 'Sec-Fetch-Site': 'same-origin', + }, + }) + ).status(), + ).toBe(403) + expect( + ( + await request.get(url, { + headers: { 'Sec-Fetch-Site': 'cross-site' }, + }) + ).status(), + ).toBe(403) +}) diff --git a/e2e/integrations/connections.test.ts b/e2e/integrations/connections.test.ts new file mode 100644 index 0000000..1a54f54 --- /dev/null +++ b/e2e/integrations/connections.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '../fixtures' + +test('opens account management after hydration without inheriting real OAuth configuration', async ({ + page, +}) => { + await page.goto('/') + const manage = page.getByRole('button', { name: '接続アカウントを管理' }) + await expect(manage).toBeEnabled() + await manage.click() + const dialog = page.getByRole('dialog', { name: '接続アカウント' }) + await expect(dialog).toBeVisible() + await expect( + dialog.getByText('利用するMastodonサーバーがまだ設定されていません。'), + ).toBeVisible() + await expect( + dialog.getByRole('button', { name: 'Mastodonで認可する' }), + ).toHaveCount(0) + await page.keyboard.press('Escape') + await expect(dialog).toHaveCount(0) + await expect(manage).toBeFocused() +}) + +test('rejects an unbound OAuth callback without contacting an instance', async ({ + page, +}) => { + await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code') + await expect(page).toHaveURL(/\/\?mastodon=failed$/) + await expect(page.getByRole('alert')).toContainText( + 'Mastodonの認可を完了できませんでした。', + ) + await expect( + page.getByRole('button', { name: '接続アカウントを管理' }), + ).toBeEnabled() +}) diff --git a/e2e/integrations/deck.test.ts b/e2e/integrations/deck.test.ts index 3e0326c..00022bb 100644 --- a/e2e/integrations/deck.test.ts +++ b/e2e/integrations/deck.test.ts @@ -13,7 +13,7 @@ async function addColumn( await editor.getByLabel('カラム名', { exact: true }).fill(title) await editor .getByRole('combobox', { name: '接続プロファイル', exact: true }) - .selectOption(profile) + .selectOption({ label: profile }) await editor .getByRole('combobox', { name: 'カラムの種類', exact: true }) .selectOption(kind) @@ -39,6 +39,10 @@ async function addColumn( test.beforeEach(async ({ page }) => { await page.goto('/') await page.waitForLoadState('networkidle') + await page + .getByRole('button', { name: 'デッキとして保存', exact: true }) + .click() + await expect(page.getByText('サーバーに保存・端末間で共有')).toHaveCount(1) }) test('binds identical searches to independent profiles through paging, editing and reload', async ({ @@ -65,7 +69,7 @@ test('binds identical searches to independent profiles through paging, editing a await second.getByRole('button', { name: '編集', exact: true }).click() await page .getByRole('combobox', { name: '接続プロファイル', exact: true }) - .selectOption('e2e') + .selectOption({ label: 'e2e' }) await page.getByRole('button', { name: '変更を保存' }).click() await expect( second.getByText('Latest · all page 1', { exact: true }), @@ -240,6 +244,9 @@ test('selects a list from the column profile catalog and refreshes it on profile }) => { await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByLabel('カラム名', { exact: true }).fill('購読リスト') + await page + .getByRole('combobox', { name: '接続プロファイル', exact: true }) + .selectOption({ label: 'e2e' }) await page .getByRole('combobox', { name: 'カラムの種類', exact: true }) .selectOption('list') @@ -248,7 +255,7 @@ test('selects a list from the column profile catalog and refreshes it on profile ).toBeVisible() await page .getByRole('combobox', { name: '接続プロファイル', exact: true }) - .selectOption('alternate') + .selectOption({ label: 'alternate' }) await expect( page.getByRole('button', { name: 'My E2E List', exact: true }), ).toHaveCount(0) @@ -265,3 +272,104 @@ test('selects a list from the column profile catalog and refreshes it on profile .getByText('list page 1', { exact: true }), ).toBeVisible() }) + +test('keeps an open draft through remote edits and rejects its stale save', async ({ + page, + browser, + baseURL, +}) => { + const other = await browser.newContext({ + baseURL, + extraHTTPHeaders: { 'Tailscale-User-Login': 'owner@twitter-lite.invalid' }, + }) + try { + const device = await other.newPage() + await device.goto('/') + await page.getByRole('button', { name: '名前を変更' }).click() + await page.getByLabel('調査テーマ・デッキ名').fill('編集中の名前') + await device.getByRole('button', { name: '名前を変更' }).click() + await device.getByLabel('調査テーマ・デッキ名').fill('別の端末の変更') + await device.getByRole('button', { name: '名前を保存' }).click() + await expect(device.getByRole('heading', { level: 1 })).toHaveText( + '別の端末の変更', + ) + await page.evaluate(() => window.dispatchEvent(new Event('focus'))) + await expect(page.getByLabel('調査テーマ・デッキ名')).toHaveValue( + '編集中の名前', + ) + await page.getByRole('button', { name: '名前を保存' }).click() + await expect(page.getByRole('alert')).toContainText( + '別の端末で変更されました', + ) + await expect(page.getByLabel('調査テーマ・デッキ名')).toHaveValue( + '編集中の名前', + ) + await page.keyboard.press('Escape') + await page.getByRole('button', { name: '最新のデッキを再読み込み' }).click() + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + '別の端末の変更', + ) + } finally { + await other.close() + } +}) + +test('imports old browser decks explicitly and avoids duplicate imports after retry', async ({ + page, +}) => { + const legacy = { + version: 2, + activeDeckId: 'old', + decks: [ + { + id: 'old', + title: '以前の調査', + columns: [ + { + id: 'a', + title: '以前の観点', + profileName: 'e2e', + source: { + platform: 'twitter', + kind: 'search', + query: 'WebMCP', + product: 'Latest', + following: false, + }, + }, + ], + }, + ], + } + await page.evaluate( + (legacy) => + localStorage.setItem( + 'twitter-lite-research-deck', + JSON.stringify(legacy), + ), + legacy, + ) + await page.reload() + const selector = page.getByLabel('デッキプロファイル', { exact: true }) + await expect(selector.locator('option')).toHaveCount(1) + await page.getByRole('button', { name: '旧デッキを取り込む' }).click() + await expect(selector.locator('option')).toHaveCount(2) + await selector.selectOption({ label: '以前の調査' }) + await expect( + page.getByText('Latest · all page 1', { exact: true }), + ).toBeVisible() + await page.evaluate( + (legacy) => + localStorage.setItem( + 'twitter-lite-research-deck', + JSON.stringify(legacy), + ), + legacy, + ) + await page.reload() + await page.getByRole('button', { name: '旧デッキを取り込む' }).click() + await expect( + page.getByRole('button', { name: '旧デッキを取り込む' }), + ).toHaveCount(0) + await expect(selector.locator('option')).toHaveCount(2) +}) diff --git a/e2e/integrations/webmcp.test.ts b/e2e/integrations/webmcp.test.ts index 81cc6e7..0b6a381 100644 --- a/e2e/integrations/webmcp.test.ts +++ b/e2e/integrations/webmcp.test.ts @@ -1,4 +1,5 @@ import type { Page } from '@playwright/test' +import Database from 'better-sqlite3' import { expect, test } from '../fixtures' type NativeTesting = { @@ -9,14 +10,12 @@ type ToolResult = { content: { type: string; text: string }[] isError?: boolean } - test.use({ launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, args: ['--enable-blink-features=WebMCP,WebMCPTesting'], }, }) - async function toolNames(page: Page) { return page.evaluate(() => ( @@ -39,212 +38,286 @@ async function executeTool( ).modelContextTesting.executeTool(name, JSON.stringify(input)), { name, input }, ) - expect(result, `${name} returned a result`).not.toBeNull() - if (result === null) throw new Error(`${name} returned no result`) - return JSON.parse(result) + expect(result).not.toBeNull() + return JSON.parse(result ?? '{}') } -const column = (title: string, profileName = 'e2e') => ({ +function decoded(result: ToolResult) { + return JSON.parse(result.content[0]?.text ?? '{}') +} +const column = (title: string, connectionId: string) => ({ title, - profileName, + connectionId, source: { kind: 'search', query: 'WebMCP' }, }) - +function savedCount() { + const db = new Database(process.env.TWITTER_LITE_E2E_DB_PATH ?? '', { + readonly: true, + }) + try { + return db.prepare('SELECT count(*) AS count FROM decks').get() + } finally { + db.close() + } +} +let accounts: Record let pageErrors: string[] test.beforeEach(async ({ page }) => { pageErrors = [] page.on('pageerror', (error) => pageErrors.push(error.message)) - await page.goto('/deck') + await page.goto('/') await expect .poll(() => toolNames(page)) .toEqual([ 'delete_deck', 'get_column_posts', 'get_deck', + 'list_connections', 'list_decks', 'load_more_column', + 'save_deck', 'select_deck', 'set_deck', ]) + await expect + .poll( + async () => + decoded(await executeTool(page, 'list_connections')).connections + ?.length, + ) + .toBe(2) + accounts = Object.fromEntries( + decoded(await executeTool(page, 'list_connections')).connections.map( + (connection: { displayName: string; id: string }) => [ + connection.displayName, + connection.id, + ], + ), + ) }) -test.afterEach(() => expect(pageErrors, 'uncaught browser errors').toEqual([])) +test.afterEach(() => expect(pageErrors).toEqual([])) -test('creates, replaces, switches and deletes persisted decks through native WebMCP', async ({ +test('creates temporary research, edits it, persists explicitly and reopens it on another device', async ({ page, + browser, + baseURL, }) => { - const inventory = await executeTool(page, 'list_decks') - expect(JSON.parse(inventory.content[0]?.text ?? '').profiles).toEqual([ - 'e2e', - 'alternate', - ]) const created = await executeTool(page, 'set_deck', { title: 'WebMCPの反応', columns: [ - { ...column('日本語'), id: 'new' }, - column('別の接続', 'alternate'), + { ...column('日本語', accounts.e2e ?? ''), id: 'new' }, + column('別の接続', accounts.alternate ?? ''), ], }) expect(created.isError).not.toBe(true) - const { deck } = JSON.parse(created.content[0]?.text ?? '') - await expect(page.locator('.deck-column h2')).toHaveText([ - '日本語', - '別の接続', - ]) + const deck = decoded(created).deck + expect(deck.persisted).toBe(false) + expect(savedCount()).toEqual({ count: 0 }) await expect( page.getByText('alternate · Latest · all page 1', { exact: true }), ).toBeVisible() await page .getByRole('region', { name: '日本語', exact: true }) - .locator('summary[aria-label="日本語の操作"]') + .locator('summary') .click() await page .getByRole('region', { name: '日本語', exact: true }) .getByRole('button', { name: '編集', exact: true }) .click() - await expect(page.getByLabel('Twitterの検索条件')).toHaveValue('WebMCP') const replaced = await executeTool(page, 'set_deck', { deckId: deck.id, title: '更新した調査', columns: [deck.columns[1]], }) expect(replaced.isError).not.toBe(true) - await expect(page.getByRole('form', { name: 'カラムを編集' })).toHaveCount(0) - await expect(page.locator('.deck-column h2')).toHaveText(['別の接続']) - const another = await executeTool(page, 'set_deck', { - title: '別の調査', - columns: [], - }) - const anotherId = JSON.parse(another.content[0]?.text ?? '').deck.id + await expect(page.getByRole('dialog')).toHaveCount(0) + expect(savedCount()).toEqual({ count: 0 }) expect( - (await executeTool(page, 'select_deck', { deckId: deck.id })).isError, + (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, ).not.toBe(true) + expect( + (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, + ).not.toBe(true) + expect(savedCount()).toEqual({ count: 1 }) + const other = await browser.newContext({ + baseURL, + extraHTTPHeaders: { 'Tailscale-User-Login': 'owner@twitter-lite.invalid' }, + }) + try { + const device = await other.newPage() + await device.goto('/') + await expect(device.getByRole('heading', { level: 1 })).toHaveText( + '更新した調査', + ) + await expect( + device.getByText('alternate · Latest · all page 1', { exact: true }), + ).toBeVisible() + } finally { + await other.close() + } await page.reload() - await expect.poll(() => toolNames(page)).toContain('get_deck') - const restored = JSON.parse( - (await executeTool(page, 'get_deck')).content[0]?.text ?? '', - ).deck - expect(restored).toEqual({ - ...deck, - title: '更新した調査', - columns: [deck.columns[1]], - }) - expect( - (await executeTool(page, 'delete_deck', { deckId: anotherId })).isError, - ).not.toBe(true) - const after = JSON.parse( - (await executeTool(page, 'list_decks')).content[0]?.text ?? '', + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + '更新した調査', ) - expect( - after.decks.some((item: { id: string }) => item.id === anotherId), - ).toBe(false) - expect(after.activeDeckId).toBe(deck.id) }) -test('rejects duplicate IDs, unavailable profiles and invalid sources without mutations', async ({ +test('requires revisions for saved mutations and rejects stale overwrites', async ({ + page, +}) => { + const deck = decoded( + await executeTool(page, 'set_deck', { title: '保存済み', columns: [] }), + ).deck + await executeTool(page, 'save_deck', { deckId: deck.id }) + expect( + ( + await executeTool(page, 'set_deck', { + deckId: deck.id, + title: '変更', + columns: [], + }) + ).isError, + ).toBe(true) + expect( + ( + await executeTool(page, 'set_deck', { + deckId: deck.id, + expectedRevision: 1, + title: '変更', + columns: [], + }) + ).isError, + ).not.toBe(true) + expect( + ( + await executeTool(page, 'set_deck', { + deckId: deck.id, + expectedRevision: 1, + title: '古い変更', + columns: [], + }) + ).isError, + ).toBe(true) + await expect(page.getByRole('heading', { level: 1 })).toHaveText('変更') + expect( + ( + await executeTool(page, 'delete_deck', { + deckId: deck.id, + expectedRevision: 1, + }) + ).isError, + ).toBe(true) + expect( + ( + await executeTool(page, 'delete_deck', { + deckId: deck.id, + expectedRevision: 2, + }) + ).isError, + ).not.toBe(true) + expect(savedCount()).toEqual({ count: 0 }) +}) + +test('keeps temporary views independent and loses only unsaved views on reload', async ({ + page, +}) => { + const first = decoded( + await executeTool(page, 'set_deck', { title: '一時の調査A', columns: [] }), + ).deck + const second = decoded( + await executeTool(page, 'set_deck', { title: '一時の調査B', columns: [] }), + ).deck + await executeTool(page, 'select_deck', { deckId: first.id }) + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + '一時の調査A', + ) + await executeTool(page, 'delete_deck', { deckId: second.id }) + expect( + decoded(await executeTool(page, 'list_decks')).decks.some( + (deck: { id: string }) => deck.id === second.id, + ), + ).toBe(false) + expect(savedCount()).toEqual({ count: 0 }) + await page.reload() + await expect(page.getByRole('heading', { level: 1 })).toHaveText('新しい調査') +}) + +test('rejects invalid input without changing the active view', async ({ page, }) => { await executeTool(page, 'set_deck', { title: '残す調査', - columns: [column('残す観点')], + columns: [column('残す観点', accounts.e2e ?? '')], }) - const saved = await page.evaluate(() => - localStorage.getItem('twitter-lite-research-deck'), - ) - const duplicate = { ...column('不正'), id: 'duplicate' } + const duplicate = { ...column('不正', accounts.e2e ?? ''), id: 'duplicate' } for (const columns of [ [duplicate, duplicate], [column('不明', 'missing')], - [{ ...column('不正'), source: { kind: 'list', target: 'not-a-list' } }], + [ + { + ...column('不正', accounts.e2e ?? ''), + source: { kind: 'list', target: 'invalid' }, + }, + ], ]) { expect( (await executeTool(page, 'set_deck', { title: '変更しない', columns })) .isError, ).toBe(true) await expect(page.locator('.deck-column h2')).toHaveText(['残す観点']) - expect( - await page.evaluate(() => - localStorage.getItem('twitter-lite-research-deck'), - ), - ).toBe(saved) } + expect(savedCount()).toEqual({ count: 0 }) }) -test('reports persistence failure while keeping the applied deck visible', async ({ +test('keeps failed temporary saves visible and allows retry without duplicate decks', async ({ page, }) => { - await page.evaluate(() => { - const setItem = Storage.prototype.setItem - Storage.prototype.setItem = function (key, value) { - if (this === localStorage) throw new Error('QuotaExceededError') - setItem.call(this, key, value) - } - }) - const result = await executeTool(page, 'set_deck', { - title: '未保存の調査', - columns: [], - }) - expect(result.isError).toBe(true) - expect(result.content[0]?.text).toContain( - 'applied in this tab but could not be saved', + const deck = decoded( + await executeTool(page, 'set_deck', { title: '未保存の調査', columns: [] }), + ).deck + await page.route('**/_serverFn/**', (route) => + route.request().method() === 'POST' ? route.abort() : route.continue(), ) + expect( + (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, + ).toBe(true) await expect(page.getByRole('heading', { level: 1 })).toHaveText( '未保存の調査', ) - await expect(page.getByRole('alert')).toContainText('保存できません') + expect(decoded(await executeTool(page, 'get_deck')).deck.persisted).toBe( + false, + ) + await page.unroute('**/_serverFn/**') + expect( + (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, + ).not.toBe(true) + expect(savedCount()).toEqual({ count: 1 }) }) -test('keeps manual deck creation functional when WebMCP is disabled', async ({ - playwright, - baseURL, -}) => { - const browser = await playwright.chromium.launch({ - executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, - args: ['--disable-blink-features=WebMCP,WebMCPTesting'], - }) - try { - const page = await browser.newPage({ baseURL }) - await page.goto('/') - await page - .getByRole('button', { name: 'カラムを追加', exact: true }) - .click() - await page.getByLabel('カラム名', { exact: true }).fill('手動で調査') - await page - .getByRole('combobox', { name: '接続プロファイル', exact: true }) - .selectOption('e2e') - await page.getByLabel('Twitterの検索条件').fill('ordinary-search') - await page.getByRole('button', { name: '追加して検索' }).click() - await expect( - page.getByText('Latest · all page 1', { exact: true }), - ).toBeVisible() - } finally { - await browser.close() - } -}) - -test('reads and pages the bound column profile without mixing sibling feeds', async ({ +test('reads and pages bound accounts without mixing sibling columns', async ({ page, }) => { - const result = await executeTool(page, 'set_deck', { - title: '接続を比較', - columns: [column('通常'), column('別の接続', 'alternate')], - }) - const { deck } = JSON.parse(result.content[0]?.text ?? '') + const deck = decoded( + await executeTool(page, 'set_deck', { + title: '接続を比較', + columns: [ + column('通常', accounts.e2e ?? ''), + column('別の接続', accounts.alternate ?? ''), + ], + }), + ).deck await expect( page.getByText('alternate · Latest · all page 1', { exact: true }), ).toBeVisible() - const boundId = deck.columns[1].id - const loaded = JSON.parse( - (await executeTool(page, 'get_column_posts', { columnId: boundId })) - .content[0]?.text ?? '', + const id = deck.columns[1].id + const loaded = decoded( + await executeTool(page, 'get_column_posts', { columnId: id }), ) - expect(loaded.column.profileName).toBe('alternate') + expect(loaded.column.connectionId).toBe(accounts.alternate) expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([ 'alternate · Latest · all page 1', ]) - const moreResult = await executeTool(page, 'load_more_column', { - columnId: boundId, - }) - expect(moreResult.isError).not.toBe(true) - const more = JSON.parse(moreResult.content[0]?.text ?? '') + const more = decoded( + await executeTool(page, 'load_more_column', { columnId: id }), + ) expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) expect(more.posts.map((post: { text: string }) => post.text)).toEqual([ 'alternate · Latest · all page 2', @@ -252,24 +325,8 @@ test('reads and pages the bound column profile without mixing sibling feeds', as await expect( page.getByRole('region', { name: '通常', exact: true }).locator('article'), ).toHaveCount(1) - await expect( - page - .getByRole('region', { name: '別の接続', exact: true }) - .locator('article'), - ).toHaveCount(2) - const slice = JSON.parse( - ( - await executeTool(page, 'get_column_posts', { - columnId: boundId, - offset: 1, - limit: 1, - }) - ).content[0]?.text ?? '', - ) - expect(slice.posts).toEqual(more.posts) - await executeTool(page, 'set_deck', { title: '別デッキ', columns: [] }) + await executeTool(page, 'set_deck', { title: '別の一時ビュー', columns: [] }) expect( - (await executeTool(page, 'get_column_posts', { columnId: boundId })) - .isError, + (await executeTool(page, 'get_column_posts', { columnId: id })).isError, ).toBe(true) }) diff --git a/flake.nix b/flake.nix index b215fc7..68a7825 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,7 @@ inherit (finalAttrs) pname version src; pnpm = pkgs.pnpm_11; fetcherVersion = 4; - hash = "sha256-rUszJwsou2NXbVwyPQQ0bk+pRFzRQIzrWs+JXBX/Q9Y="; + hash = "sha256-3mfbk9jA/3j/WbgokZnHpExSNVbOxSD5tYiFgvP/LI8="; }; nativeBuildInputs = with pkgs; [ @@ -44,6 +44,8 @@ cp -r .output/. $out/lib/twitter-lite/ makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite \ --add-flags "$out/lib/twitter-lite/server/index.mjs" + makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-backup \ + --add-flags "$out/lib/twitter-lite/server/tools/backup-database.js" runHook postInstall ''; @@ -113,10 +115,29 @@ description = "Base URL of the Twitter relay."; }; - host = lib.mkOption { + publicOrigin = lib.mkOption { type = lib.types.nonEmptyStr; - default = "127.0.0.1"; - description = "Address on which Twitter Lite listens."; + example = "https://home.example-tailnet.ts.net"; + description = "Exact Tailscale Serve HTTPS origin, without a trailing slash."; + }; + + allowedLogin = lib.mkOption { + type = lib.types.nonEmptyStr; + description = "Tailscale login allowed to access this personal workspace."; + }; + + credentialKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/var/lib/secrets/twitter-lite-key"; + description = "Runtime file containing the base64-encoded 32-byte credential encryption key. Never put this key in the Nix store."; + }; + + mastodonOrigins = lib.mkOption { + type = lib.types.listOf lib.types.nonEmptyStr; + default = [ ]; + example = [ "https://mastodon.social" ]; + description = "Allowed Mastodon HTTPS origins, without trailing slashes."; }; port = lib.mkOption { @@ -132,12 +153,23 @@ after = [ "network.target" ]; wantedBy = [ "multi-user.target" ]; environment = { - HOST = cfg.host; + HOST = "127.0.0.1"; PORT = toString cfg.port; TWITTER_RELAY_BASE_URL = cfg.relayBaseUrl; + TWITTER_LITE_ORIGIN = cfg.publicOrigin; + TWITTER_LITE_ALLOWED_LOGIN = cfg.allowedLogin; + TWITTER_LITE_DB_PATH = "/var/lib/twitter-lite/workspace.sqlite"; + TWITTER_LITE_MASTODON_ORIGINS = lib.concatStringsSep "," cfg.mastodonOrigins; + } // lib.optionalAttrs (cfg.credentialKeyFile != null) { + TWITTER_LITE_CREDENTIAL_KEY_FILE = "%d/credential-key"; }; serviceConfig = { DynamicUser = true; + StateDirectory = "twitter-lite"; + StateDirectoryMode = "0700"; + UMask = "0077"; + LoadCredential = lib.optional (cfg.credentialKeyFile != null) + "credential-key:${cfg.credentialKeyFile}"; ExecStart = lib.getExe cfg.package; Restart = "on-failure"; }; diff --git a/package.json b/package.json index 8202933..39a631a 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,14 @@ "#/*": "./src/*" }, "scripts": { + "db:generate": "drizzle-kit generate && node scripts/bundle-migrations.mjs && biome format --write src/features/storage/migrations.generated.ts", + "db:backup": "tsx scripts/backup-database.ts", "dev": "vite dev --host 127.0.0.1 --port 3000", - "dev:tailscale": "vite dev --host 0.0.0.0 --port 3000", + "dev:tailscale": "vite dev --host 127.0.0.1 --port 3000", "generate:e2e-openapi": "orval --config orval.config.ts --formatter biome --fail-on-warnings", "generate-routes": "node scripts/generate-routes.mjs", "check:routes": "pnpm generate-routes && git diff --exit-code -- src/routeTree.gen.ts", - "build": "vite build", + "build": "vite build && node scripts/build-tools.mjs", "start": "HOST=127.0.0.1 node .output/server/index.mjs", "typecheck": "tsc --noEmit", "lint": "biome check .", @@ -37,9 +39,12 @@ "@tanstack/react-router-ssr-query": "1.167.1", "@tanstack/react-start": "1.168.27", "@yuta/bird": "0.10.1", + "better-sqlite3": "13.0.3", + "drizzle-orm": "0.45.3", "nitro": "3.0.260610-beta", "react": "19.2.7", "react-dom": "19.2.7", + "sanitize-html": "2.17.7", "usewebmcp": "5.1.0", "zod": "4.4.3" }, @@ -50,10 +55,13 @@ "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", + "@types/better-sqlite3": "9.6.0", "@types/node": "26.1.1", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", + "@types/sanitize-html": "2.16.1", "@vitejs/plugin-react": "6.0.3", + "drizzle-kit": "0.31.11", "jsdom": "29.1.1", "knip": "6.27.0", "orval": "8.24.0", diff --git a/playwright.config.ts b/playwright.config.ts index 9cd8ad2..07814d9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,8 +1,15 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { defineConfig, devices } from '@playwright/test' const appPort = 4173 const relayPort = 6911 const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE +const databasePath = + process.env.TWITTER_LITE_E2E_DB_PATH ?? + join(mkdtempSync(join(tmpdir(), 'twitter-lite-e2e-')), 'workspace.sqlite') +process.env.TWITTER_LITE_E2E_DB_PATH = databasePath if (!chromiumExecutable) { throw new Error( @@ -16,6 +23,7 @@ export default defineConfig({ workers: 1, use: { baseURL: `http://127.0.0.1:${appPort}`, + extraHTTPHeaders: { 'Tailscale-User-Login': 'owner@twitter-lite.invalid' }, launchOptions: { executablePath: chromiumExecutable }, screenshot: 'only-on-failure', trace: 'retain-on-failure', @@ -28,8 +36,8 @@ export default defineConfig({ timeout: 120_000, }, { - command: `TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`, - url: `http://127.0.0.1:${appPort}`, + command: `TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} TWITTER_LITE_ALLOWED_LOGIN=owner@twitter-lite.invalid TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`, + port: appPort, reuseExistingServer: false, timeout: 120_000, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c3b25f..58060cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,15 +23,24 @@ importers: '@yuta/bird': specifier: 0.10.1 version: 0.10.1 + better-sqlite3: + specifier: 13.0.3 + version: 13.0.3 + drizzle-orm: + specifier: 0.45.3 + version: 0.45.3(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.2)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 3.0.260610-beta(better-sqlite3@13.0.3)(chokidar@5.0.0)(drizzle-orm@0.45.3(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3))(jiti@2.7.0)(lru-cache@11.5.2)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) react: specifier: 19.2.7 version: 19.2.7 react-dom: specifier: 19.2.7 version: 19.2.7(react@19.2.7) + sanitize-html: + specifier: 2.17.7 + version: 2.17.7 usewebmcp: specifier: 5.1.0 version: 5.1.0(react@19.2.7) @@ -57,6 +66,9 @@ importers: '@testing-library/react': specifier: 16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/better-sqlite3': + specifier: 9.6.0 + version: 9.6.0 '@types/node': specifier: 26.1.1 version: 26.1.1 @@ -66,9 +78,15 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.17) + '@types/sanitize-html': + specifier: 2.16.1 + version: 2.16.1 '@vitejs/plugin-react': specifier: 6.0.3 version: 6.0.3(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + drizzle-kit: + specifier: 0.31.11 + version: 0.31.11 jsdom: specifier: 29.1.1 version: 29.1.1 @@ -293,6 +311,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.11.0': resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} @@ -308,156 +329,452 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -1151,6 +1468,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/better-sqlite3@9.6.0': + resolution: {integrity: sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1174,6 +1494,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1409,6 +1732,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + better-sqlite3@13.0.3: + resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} + engines: {node: '>=22'} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -1421,6 +1748,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + caniuse-lite@1.0.30001805: resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} @@ -1479,6 +1809,9 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + db0@0.3.4: resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==} peerDependencies: @@ -1514,6 +1847,10 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1532,6 +1869,134 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + + drizzle-kit@0.31.11: + resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==} + hasBin: true + + drizzle-orm@0.45.3: + resolution: {integrity: sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@netlify/db': '>=0.4.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@netlify/db': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + electron-to-chromium@1.5.389: resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} @@ -1539,6 +2004,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -1564,6 +2033,16 @@ packages: es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -1573,6 +2052,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1680,6 +2163,13 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + httpxy@0.5.5: resolution: {integrity: sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==} @@ -1695,6 +2185,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@5.1.0: + resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -1766,6 +2260,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + launder@1.7.1: + resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} + leven@4.1.0: resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1928,6 +2425,10 @@ packages: zephyr-agent: optional: true + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + node-releases@2.0.51: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} @@ -1978,6 +2479,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -2073,6 +2577,10 @@ packages: rou3@0.8.1: resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + sanitize-html@2.17.7: + resolution: {integrity: sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==} + engines: {node: '>=22.12.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -2117,6 +2625,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -2724,6 +3239,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.11.0': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -2751,81 +3268,235 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.28.2': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.28.2': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.28.2': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.28.2': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.28.2': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.28.2': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.28.2': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.28.2': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.28.2': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.28.2': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.28.2': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.28.2': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.28.2': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.28.2': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.28.2': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.28.2': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.28.2': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.28.2': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.28.2': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.28.2': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.28.2': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.28.2': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.28.2': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.28.2': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.28.2': optional: true @@ -3543,6 +4214,10 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/better-sqlite3@9.6.0': + dependencies: + '@types/node': 26.1.1 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3568,6 +4243,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + '@types/unist@3.0.3': {} '@typescript/typescript-aix-ppc64@7.0.2': @@ -3730,6 +4409,10 @@ snapshots: baseline-browser-mapping@2.10.43: {} + better-sqlite3@13.0.3: + dependencies: + node-addon-api: 8.9.2 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -3746,6 +4429,8 @@ snapshots: node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.6) + buffer-from@1.1.2: {} + caniuse-lite@1.0.30001805: {} chai@6.2.2: {} @@ -3792,7 +4477,12 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - db0@0.3.4: {} + dayjs@1.11.23: {} + + db0@0.3.4(better-sqlite3@13.0.3)(drizzle-orm@0.45.3(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)): + optionalDependencies: + better-sqlite3: 13.0.3 + drizzle-orm: 0.45.3(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3) debug@4.4.3: dependencies: @@ -3800,6 +4490,8 @@ snapshots: decimal.js@10.6.0: {} + deepmerge@4.3.1: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -3810,10 +4502,60 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + + drizzle-kit@0.31.11: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.12 + + drizzle-orm@0.45.3(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3): + optionalDependencies: + '@types/better-sqlite3': 9.6.0 + better-sqlite3: 13.0.3 + electron-to-chromium@1.5.389: {} entities@4.5.0: {} + entities@7.0.1: {} + entities@8.0.0: {} env-runner@0.1.16: @@ -3825,6 +4567,60 @@ snapshots: es-module-lexer@2.3.0: {} + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -3856,6 +4652,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -3955,6 +4753,20 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.0.0 + httpxy@0.5.5: {} human-signals@8.0.1: {} @@ -3963,6 +4775,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-plain-object@5.1.0: {} + is-potential-custom-element-name@1.0.1: {} is-stream@4.0.1: {} @@ -4043,6 +4857,10 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 + launder@1.7.1: + dependencies: + dayjs: 1.11.23 + leven@4.1.0: {} lightningcss-android-arm64@1.32.0: @@ -4141,11 +4959,11 @@ snapshots: nf3@0.3.22: {} - nitro@3.0.260610-beta(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.2)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + nitro@3.0.260610-beta(better-sqlite3@13.0.3)(chokidar@5.0.0)(drizzle-orm@0.45.3(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3))(jiti@2.7.0)(lru-cache@11.5.2)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: consola: 3.4.2 crossws: 0.4.10(srvx@0.11.22) - db0: 0.3.4 + db0: 0.3.4(better-sqlite3@13.0.3)(drizzle-orm@0.45.3(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)) env-runner: 0.1.16 h3: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) hookable: 6.1.1 @@ -4156,7 +4974,7 @@ snapshots: rolldown: 1.1.5 srvx: 0.11.22 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4)(lru-cache@11.5.2)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(better-sqlite3@13.0.3)(drizzle-orm@0.45.3(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)))(lru-cache@11.5.2)(ofetch@2.0.0-alpha.3) optionalDependencies: jiti: 2.7.0 vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) @@ -4192,6 +5010,8 @@ snapshots: - uploadthing - wrangler + node-addon-api@8.9.2: {} + node-releases@2.0.51: {} npm-run-path@6.0.0: @@ -4304,6 +5124,8 @@ snapshots: parse-ms@4.0.0: {} + parse-srcset@1.0.2: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -4393,6 +5215,16 @@ snapshots: rou3@0.8.1: {} + sanitize-html@2.17.7: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 12.0.0 + is-plain-object: 5.1.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.17 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -4421,6 +5253,13 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + source-map@0.7.6: {} srvx@0.11.22: {} @@ -4543,10 +5382,10 @@ snapshots: rolldown: 1.1.5 vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) - unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4)(lru-cache@11.5.2)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(better-sqlite3@13.0.3)(drizzle-orm@0.45.3(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)))(lru-cache@11.5.2)(ofetch@2.0.0-alpha.3): optionalDependencies: chokidar: 5.0.0 - db0: 0.3.4 + db0: 0.3.4(better-sqlite3@13.0.3)(drizzle-orm@0.45.3(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)) lru-cache: 11.5.2 ofetch: 2.0.0-alpha.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ed0b5a..dab6e51 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ allowBuilds: + better-sqlite3: false esbuild: true diff --git a/scripts/backup-database.ts b/scripts/backup-database.ts new file mode 100644 index 0000000..95e2946 --- /dev/null +++ b/scripts/backup-database.ts @@ -0,0 +1,13 @@ +import { backupDatabase } from '../src/features/storage/backup.server' + +const source = process.env.TWITTER_LITE_DB_PATH +const destination = process.argv[2] +if (!source || !destination || process.argv.length !== 3) { + console.error( + 'Usage: TWITTER_LITE_DB_PATH=/absolute/app.db pnpm db:backup /absolute/backup.db', + ) + process.exitCode = 1 +} else { + await backupDatabase(source, destination) + console.log(`Backup verified: ${destination}`) +} diff --git a/scripts/build-tools.mjs b/scripts/build-tools.mjs new file mode 100644 index 0000000..82c8f28 --- /dev/null +++ b/scripts/build-tools.mjs @@ -0,0 +1,10 @@ +import { build } from 'vite' + +await build({ + configFile: false, + build: { + ssr: 'scripts/backup-database.ts', + outDir: '.output/server/tools', + rollupOptions: { external: ['better-sqlite3'] }, + }, +}) diff --git a/scripts/bundle-migrations.mjs b/scripts/bundle-migrations.mjs new file mode 100644 index 0000000..f8f7dee --- /dev/null +++ b/scripts/bundle-migrations.mjs @@ -0,0 +1,10 @@ +import { writeFileSync } from 'node:fs' +import { readMigrationFiles } from 'drizzle-orm/migrator' + +const migrations = readMigrationFiles({ + migrationsFolder: new URL('../drizzle', import.meta.url).pathname, +}) +writeFileSync( + new URL('../src/features/storage/migrations.generated.ts', import.meta.url), + `// Generated by pnpm db:generate. Do not edit.\nexport const migrations = ${JSON.stringify(migrations, null, 2)}\n`, +) diff --git a/src/components/icon.tsx b/src/components/icon.tsx index 31b133d..b2d7cb9 100644 --- a/src/components/icon.tsx +++ b/src/components/icon.tsx @@ -1,5 +1,6 @@ const paths = { columns: 'M3 4h7v16H3z M14 4h7v16h-7z', + hashtag: 'M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16', search: 'M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0', user: 'M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0', list: 'M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01', diff --git a/src/features/access/policy.server.ts b/src/features/access/policy.server.ts new file mode 100644 index 0000000..61926eb --- /dev/null +++ b/src/features/access/policy.server.ts @@ -0,0 +1,36 @@ +type AccessConfig = { origin: string; allowedLogin: string } + +export function readAccessConfig(): AccessConfig | null { + const origin = process.env.TWITTER_LITE_ORIGIN + const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN + if (!origin || !allowedLogin?.trim()) return null + try { + const url = new URL(origin) + const secure = url.protocol === 'https:' + const local = url.protocol === 'http:' && url.hostname === '127.0.0.1' + if ((!secure && !local) || url.origin !== origin) return null + return { origin, allowedLogin } + } catch { + return null + } +} + +/** The backend must bind to loopback; only Serve may supply identity headers. */ +export function checkAccess( + request: Request, + config: AccessConfig | null, +): Response | null { + if (!config) { + return new Response('Access configuration is required.', { status: 503 }) + } + if (request.headers.get('Tailscale-User-Login') !== config.allowedLogin) { + return new Response('Forbidden', { status: 403 }) + } + if ( + !['GET', 'HEAD', 'OPTIONS'].includes(request.method) && + request.headers.get('Origin') !== config.origin + ) { + return new Response('Forbidden', { status: 403 }) + } + return null +} diff --git a/src/features/access/policy.test.ts b/src/features/access/policy.test.ts new file mode 100644 index 0000000..f8446a8 --- /dev/null +++ b/src/features/access/policy.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest' +import { checkAccess, readAccessConfig } from './policy.server' + +const config = { + origin: 'https://deck.invalid', + allowedLogin: 'owner@deck.invalid', +} + +afterEach(() => vi.unstubAllEnvs()) + +describe('Serve access boundary', () => { + it('fails closed when the deployment is not configured', () => { + vi.stubEnv('TWITTER_LITE_ORIGIN', '') + vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '') + expect(readAccessConfig()).toBeNull() + expect(checkAccess(new Request(config.origin), null)?.status).toBe(503) + }) + + it.each([ + '', + 'https://deck.invalid/path', + 'http://deck.invalid', + 'not a URL', + ])('rejects an invalid configured origin: %s', (origin) => { + vi.stubEnv('TWITTER_LITE_ORIGIN', origin) + vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin) + expect(readAccessConfig()).toBeNull() + }) + + it.each([ + 'https://deck.invalid', + 'http://127.0.0.1:4173', + ])('accepts an explicit deployment origin: %s', (origin) => { + vi.stubEnv('TWITTER_LITE_ORIGIN', origin) + vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin) + expect(readAccessConfig()).toEqual({ ...config, origin }) + }) + + it.each([ + undefined, + 'other@deck.invalid', + 'owner@deck.invalid, other@deck.invalid', + ])('rejects absent, foreign, or ambiguous identities: %s', (login) => { + const headers = new Headers() + if (login) headers.set('Tailscale-User-Login', login) + expect( + checkAccess(new Request(config.origin, { headers }), config)?.status, + ).toBe(403) + }) + + it('permits owner navigation back from an OAuth provider without Origin', () => { + const request = new Request( + `${config.origin}/oauth/mastodon/callback?code=code`, + { + headers: { + 'Tailscale-User-Login': config.allowedLogin, + 'Sec-Fetch-Site': 'cross-site', + }, + }, + ) + expect(checkAccess(request, config)).toBeNull() + }) + + it.each([ + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + ])('requires exact Origin for %s even with same-origin Fetch Metadata', (method) => { + const headers = { + 'Tailscale-User-Login': config.allowedLogin, + 'Sec-Fetch-Site': 'same-origin', + } + expect( + checkAccess(new Request(config.origin, { method, headers }), config) + ?.status, + ).toBe(403) + expect( + checkAccess( + new Request(config.origin, { + method, + headers: { ...headers, Origin: 'https://other.invalid' }, + }), + config, + )?.status, + ).toBe(403) + expect( + checkAccess( + new Request(config.origin, { + method, + headers: { ...headers, Origin: config.origin }, + }), + config, + ), + ).toBeNull() + }) +}) diff --git a/src/features/connections/connection-manager.css b/src/features/connections/connection-manager.css new file mode 100644 index 0000000..97b3d27 --- /dev/null +++ b/src/features/connections/connection-manager.css @@ -0,0 +1,73 @@ +.connection-manager { + display: grid; + gap: 1rem; +} +.connection-manager-description, +.connection-manager-note { + margin: 0; + color: #aab8c2; + font-size: 0.875rem; +} +.connection-manager-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.75rem; +} +.connection-manager-list > li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding-block: 0.75rem; + border-bottom: 1px solid #38444d; +} +.connection-manager-account { + display: grid; + gap: 0.25rem; + min-width: 0; + overflow-wrap: anywhere; +} +.connection-manager-account > span { + font-size: 0.8rem; + color: #aab8c2; +} +.connection-manager-account > .connection-manager-status-connected { + color: #71d6ad; +} +.connection-manager-account > .connection-manager-status-expired { + color: #f2c66d; +} +.connection-manager-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; +} +.connection-manager-add { + display: grid; + gap: 0.75rem; +} +.connection-manager-add h3 { + margin: 0; + font-size: 1rem; +} +.connection-manager select { + width: 100%; + min-width: 0; + background: #15202b; + color: #e7e9ea; + border: 1px solid #536471; + border-radius: 0.25rem; + padding: 0.6rem; +} +.connection-manager button { + min-height: 2.5rem; +} +@media (max-width: 480px) { + .connection-manager-list > li { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/features/connections/connection-manager.test.tsx b/src/features/connections/connection-manager.test.tsx new file mode 100644 index 0000000..c692881 --- /dev/null +++ b/src/features/connections/connection-manager.test.tsx @@ -0,0 +1,139 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { ConnectionManager } from './connection-manager' +import type { Connection } from './model' + +const actions = vi.hoisted(() => ({ + load: vi.fn(), + authorize: vi.fn(), + disconnect: vi.fn(), +})) +vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn })) +vi.mock('../mastodon/server-functions', () => ({ + loadMastodonInstances: actions.load, + startMastodonOAuth: actions.authorize, + disconnectMastodonAccount: actions.disconnect, +})) + +const account: Connection = { + id: 'mastodon-first', + platform: 'mastodon', + origin: 'https://mastodon.invalid', + accountId: '1', + displayName: '@first', + status: 'connected', +} +beforeEach(() => { + vi.resetAllMocks() + actions.load.mockResolvedValue({ + origins: ['https://mastodon.invalid', 'https://second.invalid'], + }) +}) + +function show( + connections: Connection[] = [account], + onChanged = vi.fn(), + hasTemporaryDecks = false, +) { + return render( + + + , + ) +} + +it('shows per-account status and warns about temporary decks without blocking actions', async () => { + show( + [ + account, + { ...account, id: 'expired', displayName: '@expired', status: 'expired' }, + ], + vi.fn(), + true, + ) + expect(screen.getByText('接続済み')).toBeVisible() + expect(screen.getByText('再接続が必要')).toBeVisible() + expect(screen.getByText(/一時デッキは消えます/)).toBeVisible() + expect( + await screen.findByRole('button', { name: 'Mastodonで認可する' }), + ).toBeEnabled() +}) + +it('uses the selected instance and reports a safe error when authorization fails', async () => { + actions.authorize.mockRejectedValue( + new Error('private server credential detail'), + ) + show() + fireEvent.change(await screen.findByLabelText('Mastodonサーバー'), { + target: { value: 'https://second.invalid' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Mastodonで認可する' })) + await waitFor(() => + expect(actions.authorize).toHaveBeenCalledWith({ + data: { origin: 'https://second.invalid' }, + }), + ) + expect(await screen.findByRole('alert')).toHaveTextContent( + '認可を開始できませんでした', + ) + expect(screen.queryByText(/private server/)).toBeNull() + expect( + screen.getByRole('button', { name: 'Mastodonで認可する' }), + ).toBeEnabled() +}) + +it('binds reconnect to the selected account rather than whichever instance is selected for addition', async () => { + actions.authorize.mockRejectedValue(new Error('offline')) + show() + await screen.findByLabelText('Mastodonサーバー') + fireEvent.click(screen.getByRole('button', { name: '@firstを再接続' })) + await waitFor(() => + expect(actions.authorize).toHaveBeenCalledWith({ + data: { origin: account.origin, connectionId: account.id }, + }), + ) + expect(await screen.findByRole('alert')).toBeVisible() +}) + +it('refreshes the account list after successful disconnect', async () => { + const onChanged = vi.fn().mockResolvedValue(undefined) + actions.disconnect.mockResolvedValue({ disconnected: true }) + show([account], onChanged) + fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' })) + await waitFor(() => expect(onChanged).toHaveBeenCalledOnce()) + expect(actions.disconnect).toHaveBeenCalledWith({ + data: { connectionId: account.id }, + }) +}) + +it('keeps account controls available after a failed disconnect', async () => { + actions.disconnect.mockRejectedValue(new Error('private token')) + const onChanged = vi.fn() + show([account], onChanged) + fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' })) + expect(await screen.findByRole('alert')).toHaveTextContent( + '接続を解除できませんでした', + ) + expect(onChanged).not.toHaveBeenCalled() + expect( + screen.getByRole('button', { name: '@firstの接続を解除' }), + ).toBeEnabled() +}) + +it('does not offer Mastodon reconnect or revoke for Twitter relay accounts', async () => { + show([{ ...account, platform: 'twitter', id: 'twitter-first' }]) + await screen.findByLabelText('Mastodonサーバー') + expect(screen.queryByRole('button', { name: '@firstを再接続' })).toBeNull() + expect( + screen.queryByRole('button', { name: '@firstの接続を解除' }), + ).toBeNull() +}) diff --git a/src/features/connections/connection-manager.tsx b/src/features/connections/connection-manager.tsx new file mode 100644 index 0000000..ea20b88 --- /dev/null +++ b/src/features/connections/connection-manager.tsx @@ -0,0 +1,177 @@ +import { useQuery } from '@tanstack/react-query' +import { useServerFn } from '@tanstack/react-start' +import { useId, useState } from 'react' +import { + disconnectMastodonAccount, + loadMastodonInstances, + startMastodonOAuth, +} from '../mastodon/server-functions' +import type { Connection } from './model' +import './connection-manager.css' + +const statusNames = { + connected: '接続済み', + disconnected: '未接続', + expired: '再接続が必要', +} + +export function ConnectionManager({ + connections, + onChanged, + hasTemporaryDecks = false, +}: { + connections: Connection[] + onChanged: () => unknown + hasTemporaryDecks?: boolean +}) { + const fetchInstances = useServerFn(loadMastodonInstances) + const authorize = useServerFn(startMastodonOAuth) + const disconnect = useServerFn(disconnectMastodonAccount) + const instances = useQuery({ + queryKey: ['mastodon-instances'], + queryFn: () => fetchInstances(), + retry: false, + }) + const [selected, setSelected] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + const selectId = useId() + const origin = selected || instances.data?.origins[0] || '' + + async function connect(origin: string, connectionId?: string) { + setBusy(true) + setError(undefined) + try { + const result = await authorize({ + data: { origin, ...(connectionId ? { connectionId } : {}) }, + }) + window.location.assign(result.authorizationUrl) + } catch { + setError( + 'Mastodonの認可を開始できませんでした。接続先を確認して再試行してください。', + ) + } finally { + setBusy(false) + } + } + + async function remove(connectionId: string) { + setBusy(true) + setError(undefined) + try { + await disconnect({ data: { connectionId } }) + try { + await onChanged() + } catch { + setError( + '接続は解除しましたが、一覧を更新できませんでした。画面を再読み込みしてください。', + ) + } + } catch { + setError('接続を解除できませんでした。時間をおいて再試行してください。') + } finally { + setBusy(false) + } + } + + return ( +
+

+ 追加したアカウントは、どの端末のカラムからも選べます。 +

+ {error &&

{error}

} + {connections.length === 0 ? ( +

接続アカウントはまだありません。

+ ) : ( +
    + {connections.map((connection) => ( +
  • +
    + {connection.displayName} + + {connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '} + {new URL(connection.origin).hostname} + + + {statusNames[connection.status]} + +
    + {connection.platform === 'mastodon' && ( +
    + + {connection.status !== 'disconnected' && ( + + )} +
    + )} +
  • + ))} +
+ )} +
{ + event.preventDefault() + if (origin) void connect(origin) + }} + > +

Mastodonアカウントを追加

+ {instances.isPending ? ( +

接続先を確認しています…

+ ) : instances.isError ? ( +

+ 接続先を取得できませんでした。 + +

+ ) : instances.data.origins.length === 0 ? ( +

利用するMastodonサーバーがまだ設定されていません。

+ ) : ( + <> + + + + + )} + {hasTemporaryDecks && ( +

+ 認可画面へ移動すると、一時デッキは消えます。残したいデッキは先に保存してください。 +

+ )} + {busy &&

処理しています…

} +
+
+ ) +} diff --git a/src/features/connections/credentials.server.ts b/src/features/connections/credentials.server.ts new file mode 100644 index 0000000..7e08db8 --- /dev/null +++ b/src/features/connections/credentials.server.ts @@ -0,0 +1,97 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'node:crypto' +import { readFileSync } from 'node:fs' +import { z } from 'zod' + +const envelopeSchema = z + .object({ + version: z.literal(1), + keyId: z.string(), + iv: z.string(), + tag: z.string(), + ciphertext: z.string(), + }) + .strict() + +function decodeBase64(value: string): Buffer { + const result = Buffer.from(value, 'base64') + if (result.toString('base64') !== value) throw new Error('Invalid encoding') + return result +} + +function loadKey() { + const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE + if (!path) throw new Error('TWITTER_LITE_CREDENTIAL_KEY_FILE is required.') + let encoded: string + try { + encoded = readFileSync(path, 'utf8').trim() + } catch { + throw new Error('Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.') + } + let key: Buffer + try { + key = decodeBase64(encoded) + if (key.length !== 32) throw new Error('Invalid key length') + } catch { + throw new Error( + 'TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.', + ) + } + return { + key, + keyId: createHash('sha256').update(key).digest('hex').slice(0, 32), + } +} + +function associatedData(context: string, keyId: string) { + if (!context) throw new Error('A credential record and purpose are required.') + return Buffer.from( + JSON.stringify(['twitter-lite-credential', 1, keyId, context]), + ) +} + +/** Context must identify both record and purpose; persist the returned opaque JSON. */ +export function encryptCredential(plaintext: string, context: string): string { + const { key, keyId } = loadKey() + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', key, iv) + cipher.setAAD(associatedData(context, keyId)) + const ciphertext = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]) + return JSON.stringify({ + version: 1, + keyId, + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + ciphertext: ciphertext.toString('base64'), + }) +} + +export function decryptCredential(serialized: string, context: string): string { + const { key, keyId } = loadKey() + try { + const envelope = envelopeSchema.parse(JSON.parse(serialized)) + if (envelope.keyId !== keyId) throw new Error('Different key') + const iv = decodeBase64(envelope.iv) + const tag = decodeBase64(envelope.tag) + if (iv.length !== 12 || tag.length !== 16) + throw new Error('Invalid envelope') + const decipher = createDecipheriv('aes-256-gcm', key, iv) + decipher.setAAD(associatedData(context, keyId)) + decipher.setAuthTag(tag) + return Buffer.concat([ + decipher.update(decodeBase64(envelope.ciphertext)), + decipher.final(), + ]).toString('utf8') + } catch { + throw new Error( + 'Credential could not be decrypted. Check the stored credential and encryption key.', + ) + } +} diff --git a/src/features/connections/credentials.test.ts b/src/features/connections/credentials.test.ts new file mode 100644 index 0000000..67b304c --- /dev/null +++ b/src/features/connections/credentials.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment node +import { randomBytes } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { decryptCredential, encryptCredential } from './credentials.server' + +let directory: string +let keyPath: string +const context = 'connection:first:access-token' +const secret = 'sensitive-token-秘密' + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'twitter-lite-credentials-')) + keyPath = join(directory, 'key') + writeFileSync(keyPath, `${randomBytes(32).toString('base64')}\n`, { + mode: 0o600, + }) + vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', keyPath) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(directory, { recursive: true, force: true }) +}) + +it('round-trips credentials with distinct randomized ciphertext and no plaintext', () => { + const first = encryptCredential(secret, context) + const second = encryptCredential(secret, context) + expect(first).not.toBe(second) + expect(first).not.toContain(secret) + expect(first).not.toContain(Buffer.from(secret).toString('base64')) + expect(JSON.parse(first)).toMatchObject({ + version: 1, + keyId: expect.stringMatching(/^[a-f0-9]{32}$/), + }) + expect(decryptCredential(first, context)).toBe(secret) + expect(decryptCredential(second, context)).toBe(secret) +}) + +it.each([ + 'connection:second:access-token', + 'oauth-app:first:client-secret', +])('rejects ciphertext moved to another record or purpose: %s', (otherContext) => { + const stored = encryptCredential(secret, context) + expect(() => decryptCredential(stored, otherContext)).toThrow( + 'Credential could not be decrypted.', + ) +}) + +it.each([ + 'iv', + 'tag', + 'ciphertext', + 'keyId', + 'version', +])('rejects tampering with %s without disclosing the secret', (field) => { + const envelope = JSON.parse(encryptCredential(secret, context)) + envelope[field] = 'tampered' + expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow( + 'Credential could not be decrypted. Check the stored credential and encryption key.', + ) +}) + +it('rejects a validly encoded altered authentication tag', () => { + const envelope = JSON.parse(encryptCredential(secret, context)) + const tag = Buffer.from(envelope.tag, 'base64') + tag[0] = (tag[0] ?? 0) ^ 1 + envelope.tag = tag.toString('base64') + expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow( + 'Credential could not be decrypted.', + ) +}) + +it('requires the original key after restart or restore', () => { + const originalKey = readFileSync(keyPath) + const stored = encryptCredential(secret, context) + writeFileSync(keyPath, randomBytes(32).toString('base64')) + expect(() => decryptCredential(stored, context)).toThrow( + 'Credential could not be decrypted.', + ) + writeFileSync(keyPath, originalKey) + expect(decryptCredential(stored, context)).toBe(secret) +}) + +it.each([ + 'not-json', + '{}', + '{"version":2}', +])('rejects malformed or unsupported stored credentials: %s', (serialized) => { + expect(() => decryptCredential(serialized, context)).toThrow( + 'Credential could not be decrypted.', + ) +}) + +it('requires an explicitly configured credential key', () => { + vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', '') + expect(() => encryptCredential(secret, context)).toThrow( + 'TWITTER_LITE_CREDENTIAL_KEY_FILE is required.', + ) +}) + +it('reports an unreadable key without including the path or plaintext', () => { + rmSync(keyPath) + expect(() => encryptCredential(secret, context)).toThrow( + 'Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.', + ) +}) + +it.each([ + '', + 'this is not base64', + Buffer.alloc(16).toString('base64'), +])('rejects invalid key material', (encoded) => { + writeFileSync(keyPath, encoded) + expect(() => encryptCredential(secret, context)).toThrow( + 'must contain 32 random bytes encoded as base64.', + ) +}) + +it('refuses encryption without record binding', () => { + expect(() => encryptCredential(secret, '')).toThrow( + 'A credential record and purpose are required.', + ) +}) diff --git a/src/features/connections/model.ts b/src/features/connections/model.ts new file mode 100644 index 0000000..b575d05 --- /dev/null +++ b/src/features/connections/model.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +const connectionSchema = z.object({ + id: z.string().min(1), + platform: z.enum(['twitter', 'mastodon']), + origin: z.string().url(), + accountId: z.string().nullable(), + displayName: z.string(), + status: z.enum(['connected', 'disconnected', 'expired']), +}) + +export type Connection = z.infer diff --git a/src/features/connections/repository.server.ts b/src/features/connections/repository.server.ts new file mode 100644 index 0000000..74f38dc --- /dev/null +++ b/src/features/connections/repository.server.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto' +import { asc, eq } from 'drizzle-orm' +import { ProfileUnavailableError } from '../profiles/errors' +import { fetchProfileNames } from '../profiles/profile-service.server' +import { type AppDatabase, getDatabase } from '../storage/database.server' +import { connections } from '../storage/schema' +import type { Connection } from './model' + +function relayOrigin(): string { + const configured = process.env.TWITTER_RELAY_BASE_URL + if (!configured) + throw new ProfileUnavailableError( + 'TWITTER_RELAY_BASE_URL を設定してください。', + ) + return new URL(configured).origin +} + +async function syncTwitterConnections( + database: AppDatabase, + fetchProfiles: typeof fetchProfileNames, +) { + const origin = relayOrigin() + const profiles = new Set( + await fetchProfiles(origin).catch(() => { + throw new ProfileUnavailableError( + '接続プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。', + ) + }), + ) + database.transaction((tx) => { + const existing = tx + .select() + .from(connections) + .where(eq(connections.platform, 'twitter')) + .all() + const now = Date.now() + for (const connection of existing) { + const status = + connection.origin === origin && + connection.relayProfile !== null && + profiles.has(connection.relayProfile) + ? 'connected' + : 'disconnected' + if (connection.status !== status) { + tx.update(connections) + .set({ status, updatedAt: now }) + .where(eq(connections.id, connection.id)) + .run() + } + } + const known = new Set( + existing + .filter((connection) => connection.origin === origin) + .map((connection) => connection.relayProfile), + ) + for (const profile of profiles) { + if (known.has(profile)) continue + tx.insert(connections) + .values({ + id: randomUUID(), + platform: 'twitter', + origin, + relayProfile: profile, + displayName: profile, + status: 'connected', + createdAt: now, + updatedAt: now, + }) + .run() + } + }) + return origin +} + +const publicFields = { + id: connections.id, + platform: connections.platform, + origin: connections.origin, + accountId: connections.accountId, + displayName: connections.displayName, + status: connections.status, +} + +export async function listConnections( + database = getDatabase(), + fetchProfiles = fetchProfileNames, +): Promise<{ connections: Connection[]; relayError?: string }> { + let relayError: string | undefined + try { + await syncTwitterConnections(database, fetchProfiles) + } catch (error) { + if (!(error instanceof ProfileUnavailableError)) throw error + relayError = error.message + } + const saved = database + .select(publicFields) + .from(connections) + .orderBy(asc(connections.createdAt), asc(connections.id)) + .all() + return { connections: saved, ...(relayError ? { relayError } : {}) } +} + +export async function requireTwitterConnection( + id: string, + database = getDatabase(), + fetchProfiles = fetchProfileNames, +): Promise { + const origin = await syncTwitterConnections(database, fetchProfiles) + const connection = database + .select() + .from(connections) + .where(eq(connections.id, id)) + .get() + if ( + connection?.platform !== 'twitter' || + connection.origin !== origin || + connection.status !== 'connected' || + !connection.relayProfile + ) { + throw new ProfileUnavailableError( + 'この接続は利用できません。カラムの接続アカウントを確認してください。', + ) + } + return connection.relayProfile +} diff --git a/src/features/connections/repository.test.ts b/src/features/connections/repository.test.ts new file mode 100644 index 0000000..46343d1 --- /dev/null +++ b/src/features/connections/repository.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment node + +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { type AppDatabase, openDatabase } from '../storage/database.server' +import { connectionCredentials, connections } from '../storage/schema' +import { listConnections, requireTwitterConnection } from './repository.server' + +let database: AppDatabase +beforeEach(() => { + database = openDatabase(':memory:') + vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid/') +}) +afterEach(() => { + database.$client.close() + vi.unstubAllEnvs() +}) + +it('retains stable distinct IDs for multiple relay profiles across repeated discovery', async () => { + const fetchProfiles = vi.fn().mockResolvedValue(['first', 'second', 'first']) + const { connections: first } = await listConnections(database, fetchProfiles) + const { connections: second } = await listConnections(database, fetchProfiles) + expect(first).toHaveLength(2) + expect(new Set(first.map((connection) => connection.id)).size).toBe(2) + expect(second).toEqual(first) + expect( + await requireTwitterConnection(first[0]?.id ?? '', database, fetchProfiles), + ).toBe(first[0]?.displayName) + expect(fetchProfiles).toHaveBeenCalledWith('https://relay.invalid') +}) + +it('preserves unavailable bindings and reconnects the original ID when a profile returns', async () => { + const { connections: original } = await listConnections( + database, + async () => ['first'], + ) + const id = original[0]?.id ?? '' + expect((await listConnections(database, async () => [])).connections).toEqual( + [expect.objectContaining({ id, status: 'disconnected' })], + ) + await expect( + requireTwitterConnection(id, database, async () => []), + ).rejects.toThrow('この接続は利用できません') + expect( + (await listConnections(database, async () => ['first'])).connections, + ).toEqual(original) +}) + +it('does not reuse an old relay binding for the same profile at another origin', async () => { + const { connections: original } = await listConnections( + database, + async () => ['first'], + ) + vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://another-relay.invalid') + const { connections: current } = await listConnections(database, async () => [ + 'first', + ]) + expect(current).toHaveLength(2) + expect(current).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: original[0]?.id, status: 'disconnected' }), + expect.objectContaining({ + origin: 'https://another-relay.invalid', + status: 'connected', + }), + ]), + ) + await expect( + requireTwitterConnection(original[0]?.id ?? '', database, async () => [ + 'first', + ]), + ).rejects.toThrow('この接続は利用できません') +}) + +it('does not convert a relay outage into persisted account removal', async () => { + const original = await listConnections(database, async () => ['first']) + const unavailable = vi + .fn() + .mockRejectedValue(new Error('private network detail')) + const failed = await listConnections(database, unavailable) + expect(failed.connections).toEqual(original.connections) + expect(failed.relayError).toContain( + 'Relay への接続を確認して再試行してください。', + ) + expect(database.select().from(connections).get()?.status).toBe('connected') + expect(await listConnections(database, async () => ['first'])).toEqual( + original, + ) +}) + +it('returns public metadata for all platforms without joining or exposing credentials', async () => { + database + .insert(connections) + .values({ + id: 'mastodon-account', + platform: 'mastodon', + origin: 'https://mastodon.invalid', + accountId: '42', + displayName: '@owner', + status: 'connected', + createdAt: 1, + updatedAt: 1, + }) + .run() + database + .insert(connectionCredentials) + .values({ + connectionId: 'mastodon-account', + encryptedToken: 'stored-secret-envelope', + updatedAt: 1, + }) + .run() + const { connections: discovered } = await listConnections( + database, + async () => ['first'], + ) + expect(discovered[0]).toEqual({ + id: 'mastodon-account', + platform: 'mastodon', + origin: 'https://mastodon.invalid', + accountId: '42', + displayName: '@owner', + status: 'connected', + }) + expect(Object.keys(discovered[1] ?? {}).sort()).toEqual([ + 'accountId', + 'displayName', + 'id', + 'origin', + 'platform', + 'status', + ]) + expect(JSON.stringify(discovered)).not.toContain('stored-secret-envelope') + const outage = await listConnections(database, async () => { + throw new Error('offline') + }) + expect(outage.connections).toEqual(discovered) + expect(outage.relayError).toBeDefined() + await expect( + requireTwitterConnection('mastodon-account', database, async () => [ + 'first', + ]), + ).rejects.toThrow('この接続は利用できません') + expect( + database + .select() + .from(connections) + .where(eq(connections.id, 'mastodon-account')) + .get()?.status, + ).toBe('connected') +}) + +it('rejects an unknown connection without treating its ID as a relay profile', async () => { + await expect( + requireTwitterConnection('first', database, async () => ['first']), + ).rejects.toThrow('この接続は利用できません') +}) diff --git a/src/features/connections/server-functions.ts b/src/features/connections/server-functions.ts new file mode 100644 index 0000000..7317a43 --- /dev/null +++ b/src/features/connections/server-functions.ts @@ -0,0 +1,8 @@ +import { createServerFn } from '@tanstack/react-start' + +export const loadConnections = createServerFn({ method: 'GET' }).handler( + async () => { + const { listConnections } = await import('./repository.server') + return listConnections() + }, +) diff --git a/src/features/decks/column-editor.test.tsx b/src/features/decks/column-editor.test.tsx new file mode 100644 index 0000000..0e3e4b0 --- /dev/null +++ b/src/features/decks/column-editor.test.tsx @@ -0,0 +1,177 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import type { Connection } from '../connections/model' +import { ColumnEditor } from './column-editor' + +const lists = vi.hoisted(() => vi.fn()) +vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn })) +vi.mock('#/features/platforms/mastodon-server-functions', () => ({ + loadMastodonLists: lists, +})) +vi.mock('#/features/posts/use-list-choices', () => ({ + useListChoices: () => ({ + isPending: false, + isError: false, + data: [], + refetch: vi.fn(), + }), +})) +const connections: Connection[] = [ + { + id: 'twitter', + platform: 'twitter', + origin: 'https://relay.invalid', + accountId: null, + displayName: 'Twitter account', + status: 'connected', + }, + { + id: 'mastodon-first', + platform: 'mastodon', + origin: 'https://mastodon.invalid', + accountId: '1', + displayName: '@first', + status: 'connected', + }, + { + id: 'mastodon-second', + platform: 'mastodon', + origin: 'https://mastodon.invalid', + accountId: '2', + displayName: '@second', + status: 'connected', + }, +] + +beforeEach(() => { + lists + .mockReset() + .mockImplementation( + async ({ data }: { data: { connectionId: string } }) => ({ + lists: [ + { + id: data.connectionId === 'mastodon-first' ? '10' : '20', + name: data.connectionId, + isPrivate: true, + }, + ], + }), + ) +}) + +function show() { + const onSave = vi.fn().mockResolvedValue(true) + render( + + + , + ) + fireEvent.change(screen.getByLabelText('カラム名'), { + target: { value: '調査' }, + }) + return onSave +} + +it('creates a Mastodon search without Twitter ordering or following fields', async () => { + const save = show() + fireEvent.change(screen.getByLabelText('接続プロファイル'), { + target: { value: 'mastodon-first' }, + }) + expect(screen.queryByLabelText('表示順')).toBeNull() + expect(screen.queryByLabelText('フォロー中のみ')).toBeNull() + expect(screen.getByText(/結果が0件でも/)).toBeVisible() + fireEvent.change(screen.getByLabelText('Mastodonの検索条件'), { + target: { value: 'WebMCP' }, + }) + fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' })) + await waitFor(() => + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: 'mastodon-first', + source: { platform: 'mastodon', kind: 'search', query: 'WebMCP' }, + }), + ), + ) +}) + +it('creates hashtags and resets unsupported source kind when changing to Twitter', async () => { + const save = show() + fireEvent.change(screen.getByLabelText('接続プロファイル'), { + target: { value: 'mastodon-first' }, + }) + fireEvent.change(screen.getByLabelText('カラムの種類'), { + target: { value: 'hashtag' }, + }) + fireEvent.change(screen.getByLabelText('ハッシュタグ'), { + target: { value: 'WebMCP' }, + }) + fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' })) + await waitFor(() => + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' }, + }), + ), + ) + fireEvent.change(screen.getByLabelText('接続プロファイル'), { + target: { value: 'twitter' }, + }) + expect(screen.getByLabelText('カラムの種類')).toHaveValue('search') + expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue('') + expect(screen.queryByRole('option', { name: 'ハッシュタグ' })).toBeNull() +}) + +it('loads lists for each account and clears the selected list when the binding changes', async () => { + show() + fireEvent.change(screen.getByLabelText('接続プロファイル'), { + target: { value: 'mastodon-first' }, + }) + fireEvent.change(screen.getByLabelText('カラムの種類'), { + target: { value: 'list' }, + }) + fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' })) + expect(screen.getByLabelText('リスト')).toHaveValue('10') + fireEvent.change(screen.getByLabelText('接続プロファイル'), { + target: { value: 'mastodon-second' }, + }) + expect(screen.getByLabelText('リスト')).toHaveValue('') + fireEvent.click( + await screen.findByRole('button', { name: 'mastodon-second' }), + ) + expect(screen.getByLabelText('リスト')).toHaveValue('20') + expect(lists).toHaveBeenCalledWith({ + data: { connectionId: 'mastodon-second' }, + }) +}) + +it('preserves title and Twitter search options after a rejected save', async () => { + const save = show() + save.mockResolvedValue(false) + fireEvent.change(screen.getByLabelText('Twitterの検索条件'), { + target: { value: 'from:owner WebMCP' }, + }) + fireEvent.change(screen.getByLabelText('表示順'), { + target: { value: 'Top' }, + }) + fireEvent.click(screen.getByLabelText('フォロー中のみ')) + fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' })) + await waitFor(() => expect(save).toHaveBeenCalledOnce()) + expect(screen.getByLabelText('カラム名')).toHaveValue('調査') + expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue( + 'from:owner WebMCP', + ) + expect(screen.getByLabelText('表示順')).toHaveValue('Top') + expect(screen.getByLabelText('フォロー中のみ')).toBeChecked() + fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' })) + await waitFor(() => expect(save).toHaveBeenCalledTimes(2)) + expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source) +}) diff --git a/src/features/decks/column-editor.tsx b/src/features/decks/column-editor.tsx index 391a321..23b4022 100644 --- a/src/features/decks/column-editor.tsx +++ b/src/features/decks/column-editor.tsx @@ -1,91 +1,54 @@ import { useId, useState } from 'react' -import { useListChoices } from '#/features/posts/use-list-choices' +import type { Connection } from '#/features/connections/model' +import { + ColumnSourceEditor, + defaultColumnSource, + rebindColumnSource, +} from './column-source-editor' import { columnSchema, type DeckColumn } from './model' -function ListChoices({ - profileName, - onSelect, -}: { - profileName: string - onSelect: (id: string) => void -}) { - const lists = useListChoices(profileName) - if (lists.isPending) return

リストを取得しています…

- if (lists.isError) - return ( -

- リストを取得できませんでした。 - -

- ) - return ( -
- {lists.data.length === 0 ? ( -

リストはありません。URLまたはIDでも指定できます。

- ) : ( - lists.data.map((list) => ( - - )) - )} -
- ) -} - export function ColumnEditor({ column, - profiles, + connections, onSave, onCancel, }: { column?: DeckColumn - profiles: string[] - onSave: (column: DeckColumn) => void + connections: Connection[] + onSave: (column: DeckColumn) => Promise onCancel: () => void }) { + const available = connections.filter( + (connection) => connection.status === 'connected', + ) const formId = useId() const [error, setError] = useState() - const [kind, setKind] = useState( - column?.source.kind ?? 'search', + const [connectionId, setConnectionId] = useState( + column?.connectionId ?? available[0]?.id ?? '', ) - const [profileName, setProfileName] = useState( - column?.profileName ?? profiles[0] ?? '', + const [source, setSource] = useState( + column?.source ?? defaultColumnSource(available[0]?.platform ?? 'twitter'), ) - const [target, setTarget] = useState( - column && column.source.kind !== 'search' ? column.source.target : '', + const validConnection = available.some( + (connection) => connection.id === connectionId, ) + return (
{ + onSubmit={async (event) => { event.preventDefault() + setError(undefined) const data = new FormData(event.currentTarget) - const source = - kind === 'search' - ? { - platform: 'twitter', - kind, - query: data.get('query'), - product: data.get('product'), - following: data.get('following') === 'on', - } - : { platform: 'twitter', kind, target } const parsed = columnSchema.safeParse({ id: column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'), title: data.get('title'), - profileName, + connectionId, source, }) - if (!parsed.success || !profiles.includes(profileName)) { + if (!parsed.success || !validConnection) { setError( parsed.success ? '接続プロファイルを選択してください。' @@ -93,7 +56,7 @@ export function ColumnEditor({ ) return } - onSave(parsed.data) + await onSave(parsed.data) }} >
@@ -112,124 +75,52 @@ export function ColumnEditor({ 接続プロファイル -
- {kind === 'search' ? ( -
- - -

- from:、lang:、since: など、Twitterの検索構文を使えます。 -

- -
- ) : ( -
- - {kind === 'list' && profiles.includes(profileName) ? ( - - ) : null} -
- )} +

このカラムは選択した接続プロファイルで取得します。

- {error ? ( + {error && (

{error}

- ) : null} + )}
diff --git a/src/features/decks/column-request.ts b/src/features/decks/column-request.ts deleted file mode 100644 index 158c442..0000000 --- a/src/features/decks/column-request.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { FeedRequest } from '#/features/posts/use-post-feed' -import type { DeckColumn } from './model' - -export function columnRequest(column: DeckColumn): FeedRequest { - const { platform: _, ...source } = column.source - return { ...source, profileName: column.profileName } -} diff --git a/src/features/decks/column-source-editor.tsx b/src/features/decks/column-source-editor.tsx new file mode 100644 index 0000000..2a8a4d0 --- /dev/null +++ b/src/features/decks/column-source-editor.tsx @@ -0,0 +1,69 @@ +import type { ReactElement } from 'react' +import type { Connection } from '../connections/model' +import { + defaultMastodonSource, + MastodonSourceEditor, +} from './mastodon-source-editor' +import type { DeckColumn } from './model' +import { + defaultTwitterSource, + TwitterSourceEditor, +} from './twitter-source-editor' + +type Source = DeckColumn['source'] + +export function defaultColumnSource(platform: Connection['platform']): Source { + switch (platform) { + case 'twitter': + return defaultTwitterSource() + case 'mastodon': + return defaultMastodonSource() + } +} + +/** Account-local targets never carry across bindings; compatible searches may. */ +export function rebindColumnSource( + source: Source, + platform: Connection['platform'], +): Source { + if (source.platform === platform) { + return source.kind === 'search' ? source : { ...source, target: '' } + } + switch (platform) { + case 'twitter': + return defaultTwitterSource( + source.kind === 'hashtag' ? 'search' : source.kind, + ) + case 'mastodon': + return defaultMastodonSource(source.kind) + } +} + +export function ColumnSourceEditor({ + source, + connectionId, + onChange, +}: { + source: Source + connectionId: string + onChange: (source: Source) => void +}): ReactElement { + switch (source.platform) { + case 'twitter': + return ( + + ) + case 'mastodon': + return ( + + ) + } +} diff --git a/src/features/decks/column-tools.ts b/src/features/decks/column-tools.ts index 307bf4a..97c9b19 100644 --- a/src/features/decks/column-tools.ts +++ b/src/features/decks/column-tools.ts @@ -1,20 +1,25 @@ import { useEffect, useRef } from 'react' import { useWebMCP } from 'usewebmcp' import { z } from 'zod' -import { mapTwitterPost } from '#/features/platforms/twitter' -import { flattenPostPages } from '#/features/posts/page' -import { PostLoadError, type usePostFeed } from '#/features/posts/use-post-feed' +import { + flattenResearchPages, + type useResearchFeed, +} from '#/features/platforms/use-research-feed' +import { PostLoadError } from '#/features/posts/use-post-feed' import type { DeckColumn } from './model' import { useWebMCPSupported } from './use-webmcp-supported' import { toolResult } from './webmcp-contracts' -type ColumnFeed = { column: DeckColumn; query: ReturnType } +type ColumnFeed = { + column: DeckColumn + query: ReturnType +} export type ColumnRegistry = Map export function useRegisterColumn( registry: ColumnRegistry, column: DeckColumn, - query: ReturnType, + query: ReturnType, ) { const entry = useRef({ column, query }) entry.current = { column, query } @@ -32,7 +37,7 @@ const readInput = columnInput.extend({ }) function read(feed: ColumnFeed, offset = 0, limit = 20) { - const posts = flattenPostPages(feed.query.data?.pages ?? []) + const posts = flattenResearchPages(feed.query.data?.pages ?? []) const selected = posts.slice(offset, offset + limit) return { column: feed.column, @@ -46,7 +51,7 @@ function read(feed: ColumnFeed, offset = 0, limit = 20) { feed.query.error instanceof PostLoadError ? feed.query.error.detail : null, - posts: selected.map(mapTwitterPost), + posts: selected, loadedCount: posts.length, offset, nextOffset: @@ -95,7 +100,7 @@ export function useColumnTools(registry: ColumnRegistry, ready: boolean) { if (query.isPending || (query.isFetching && !query.isFetchingNextPage)) throw new Error('Wait for the column to finish loading.') if (query.isError && !query.isFetchNextPageError) throw query.error - const offset = flattenPostPages(query.data?.pages ?? []).length + const offset = flattenResearchPages(query.data?.pages ?? []).length if (!query.hasNextPage) return read(entry.current, offset) const result = await query.fetchNextPage({ cancelRefetch: false }) if ( diff --git a/src/features/decks/deck-column.tsx b/src/features/decks/deck-column.tsx index fe9fb06..852dfda 100644 --- a/src/features/decks/deck-column.tsx +++ b/src/features/decks/deck-column.tsx @@ -1,14 +1,17 @@ import { Icon } from '#/components/icon' -import { mapTwitterPost } from '#/features/platforms/twitter' -import { flattenPostPages } from '#/features/posts/page' -import { PostLoadError, usePostFeed } from '#/features/posts/use-post-feed' -import { columnRequest } from './column-request' +import { + flattenResearchPages, + useResearchFeed, +} from '#/features/platforms/use-research-feed' +import { PostLoadError } from '#/features/posts/use-post-feed' import { type ColumnRegistry, useRegisterColumn } from './column-tools' import type { DeckColumn } from './model' import { ResearchPostCard } from './research-post-card' +import { describeSource } from './source-description' export function ResearchColumn({ column, + connectionLabel, onEdit, onRemove, onMove, @@ -17,6 +20,7 @@ export function ResearchColumn({ registry, }: { column: DeckColumn + connectionLabel?: string onEdit: () => void onRemove: () => void onMove: (direction: -1 | 1) => void @@ -24,14 +28,15 @@ export function ResearchColumn({ last: boolean registry: ColumnRegistry }) { - const query = usePostFeed(columnRequest(column)) + const query = useResearchFeed(column) useRegisterColumn(registry, column, query) - const posts = flattenPostPages(query.data?.pages ?? []).map(mapTwitterPost) + const posts = flattenResearchPages(query.data?.pages ?? []) const error = query.error instanceof PostLoadError ? query.error.detail : { message: '投稿を取得できませんでした。', retryable: true } const headingId = `column-${column.id}` + const sourceDescription = describeSource(column.source) return (
@@ -98,22 +103,14 @@ export function ResearchColumn({
- - {column.profileName} - - - {column.source.kind === 'search' - ? `${column.source.product === 'Latest' ? '最新' : '話題'}${column.source.following ? ' · フォロー中' : ''}` - : column.source.kind === 'user' - ? 'ユーザー投稿' - : 'リスト'} + + {connectionLabel ?? '接続が見つかりません'} + {sourceDescription.label}
-

- {column.source.kind === 'search' - ? column.source.query - : column.source.target} -

+

{sourceDescription.query}

{query.isFetching ? '投稿を取得しています…' diff --git a/src/features/decks/deck-page.tsx b/src/features/decks/deck-page.tsx index 87c98df..10c4111 100644 --- a/src/features/decks/deck-page.tsx +++ b/src/features/decks/deck-page.tsx @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query' +import { useLocation } from '@tanstack/react-router' import { useServerFn } from '@tanstack/react-start' import { useRef, useState } from 'react' import { AppShell } from '#/components/app-shell' import { Dialog } from '#/components/dialog' import { Icon } from '#/components/icon' -import { loadProfiles } from '#/features/profiles/server-functions' +import { ConnectionManager } from '#/features/connections/connection-manager' +import { loadConnections } from '#/features/connections/server-functions' import { ColumnEditor } from './column-editor' import { type ColumnRegistry, useColumnTools } from './column-tools' import { ResearchColumn } from './deck-column' @@ -15,26 +17,38 @@ import { useDeckTools } from './webmcp-tools' import './decks.css' export function DeckPage() { + const oauthResult = useLocation({ + select: (location) => + new URLSearchParams(location.searchStr).get('mastodon'), + }) const registry = useRef(new Map()).current + const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null) + const [renaming, setRenaming] = useState(false) + const [managingConnections, setManagingConnections] = useState(false) + const [switcherEditing, setSwitcherEditing] = useState(false) const { deck, workspace, ready, + saving, + persist, + createTemporary, + refresh, + importLegacy, + legacyAvailable, storageError, save, create, select, remove, getWorkspace, - } = useDeck() - const fetchProfiles = useServerFn(loadProfiles) + } = useDeck(editing !== null || renaming || switcherEditing) + const fetchProfiles = useServerFn(loadConnections) const profiles = useQuery({ - queryKey: ['profiles'], + queryKey: ['connections'], queryFn: () => fetchProfiles(), retry: false, }) - const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null) - const [renaming, setRenaming] = useState(false) useColumnTools(registry, ready) const [removed, setRemoved] = useState<{ column: DeckColumn @@ -43,12 +57,15 @@ export function DeckPage() { useDeckTools({ ready, + saving, storageError, save, select, remove, getWorkspace, - profiles: profiles.data?.profiles, + createTemporary, + persist, + connections: profiles.data?.connections, onApplied: clearEditors, }) @@ -58,8 +75,8 @@ export function DeckPage() { setRemoved(undefined) } - function saveColumn(column: DeckColumn) { - save({ + async function saveColumn(column: DeckColumn) { + const saved = await save({ ...deck, columns: editing === 'new' @@ -68,7 +85,8 @@ export function DeckPage() { current.id === column.id ? column : current, ), }) - setEditing(null) + if (saved) setEditing(null) + return saved } return ( @@ -79,18 +97,21 @@ export function DeckPage() { key={deck.id} decks={workspace.decks} activeId={deck.id} - ready={ready} + ready={ready && !saving} + onEditingChange={setSwitcherEditing} onSelect={(id) => { select(id) clearEditors() }} - onCreate={(title) => { - create(title) - clearEditors() + onCreate={async (title) => { + const saved = await create(title) + if (saved) clearEditors() + return saved }} - onDelete={() => { - remove(deck.id) - clearEditors() + onDelete={async () => { + const saved = await remove(deck.id) + if (saved) clearEditors() + return saved }} /> ))} -

このブラウザに保存

+

+ {deck.persisted + ? 'サーバーに保存・端末間で共有' + : '一時ビュー · このタブのみ'} +

+ } > @@ -136,18 +168,60 @@ export function DeckPage() {
+ {deck.persisted ? ( + + ) : ( + + )}
+ {!deck.persisted && ready ? ( +

+ 一時ビューです。再読み込みやタブを閉じると失われます。 +

+ ) : null} + {saving ? ( +

+ 保存しています… +

+ ) : null} + {legacyAvailable ? ( +

+ このブラウザに旧デッキがあります。 + +

+ ) : null} {!ready ? (

デッキを読み込んでいます… @@ -161,26 +235,62 @@ export function DeckPage() {

) : null} + {oauthResult === 'failed' ? ( +

+ Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。 +

+ ) : null} + {oauthResult === 'connected' ? ( +

+ Mastodonアカウントを接続しました。カラムの接続先に選べます。 +

+ ) : null} + {profiles.data?.relayError ? ( +

+ {profiles.data.relayError} +

+ ) : null} + {managingConnections ? ( + setManagingConnections(false)} + > + profiles.refetch()} + hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)} + /> + + ) : null} {profiles.isPending ? (

接続プロファイルを取得しています…

) : null} {storageError ? (

{storageError} +

) : null} {renaming ? ( setRenaming(false)}> { + onSubmit={async (event) => { event.preventDefault() const title = String( new FormData(event.currentTarget).get('title') ?? '', ).trim() if (!title) return - save({ ...deck, title }) - setRenaming(false) + if (await save({ ...deck, title })) setRenaming(false) }} >