feat: add shared decks and multi-account Mastodon OAuth

This commit is contained in:
2026-09-24 16:52:55 +09:00
parent d2cbf4dbd3
commit c47f58f065
100 changed files with 9215 additions and 1027 deletions
+6
View File
@@ -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
+1
View File
@@ -8,3 +8,4 @@ dist/
node_modules/
playwright-report/
test-results/
.data/
+54 -31
View File
@@ -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
@@ -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に投稿アーカイブを混ぜない。
+94 -104
View File
@@ -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:<id>` 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:<id>` 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.
+102
View File
@@ -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.
+87 -69
View File
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'sqlite',
schema: './src/features/storage/schema.ts',
out: './drizzle',
})
+66
View File
@@ -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
);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `connection_credentials` ADD `app_id` text REFERENCES oauth_apps(id);
+6
View File
@@ -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
);
+424
View File
@@ -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": {}
}
}
+440
View File
@@ -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": {}
}
}
+478
View File
@@ -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": {}
}
}
+27
View File
@@ -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
}
]
}
+25 -1
View File
@@ -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 })
+63
View File
@@ -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': '[email protected]' },
})
).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': '[email protected]',
'Sec-Fetch-Site': 'same-origin',
},
})
).status(),
).toBe(403)
expect(
(
await request.get(url, {
headers: { 'Sec-Fetch-Site': 'cross-site' },
})
).status(),
).toBe(403)
})
+34
View File
@@ -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()
})
+111 -3
View File
@@ -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': '[email protected]' },
})
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)
})
+196 -139
View File
@@ -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<string, string>
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)
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,
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
).not.toBe(true)
const after = JSON.parse(
(await executeTool(page, 'list_decks')).content[0]?.text ?? '',
expect(savedCount()).toEqual({ count: 1 })
const other = await browser.newContext({
baseURL,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
})
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(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', {
const deck = decoded(
await executeTool(page, 'set_deck', {
title: '接続を比較',
columns: [column('通常'), column('別の接続', 'alternate')],
})
const { deck } = JSON.parse(result.content[0]?.text ?? '')
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)
})
+37 -5
View File
@@ -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";
};
+10 -2
View File
@@ -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",
+10 -2
View File
@@ -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': '[email protected]' },
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} [email protected] 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,
},
+846 -7
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,2 +1,3 @@
allowBuilds:
better-sqlite3: false
esbuild: true
+13
View File
@@ -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}`)
}
+10
View File
@@ -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'] },
},
})
+10
View File
@@ -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`,
)
+1
View File
@@ -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',
+36
View File
@@ -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
}
+98
View File
@@ -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: '[email protected]',
}
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,
'[email protected]',
'[email protected], [email protected]',
])('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()
})
})
@@ -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;
}
}
@@ -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(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<ConnectionManager
connections={connections}
onChanged={onChanged}
hasTemporaryDecks={hasTemporaryDecks}
/>
</QueryClientProvider>,
)
}
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()
})
@@ -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<string>()
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 (
<section className="connection-manager" aria-label="接続アカウント管理">
<p className="connection-manager-description">
追加したアカウントは、どの端末のカラムからも選べます。
</p>
{error && <p role="alert">{error}</p>}
{connections.length === 0 ? (
<p>接続アカウントはまだありません。</p>
) : (
<ul className="connection-manager-list">
{connections.map((connection) => (
<li key={connection.id}>
<div className="connection-manager-account">
<strong>{connection.displayName}</strong>
<span>
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
{new URL(connection.origin).hostname}
</span>
<span
className={`connection-manager-status connection-manager-status-${connection.status}`}
>
{statusNames[connection.status]}
</span>
</div>
{connection.platform === 'mastodon' && (
<div className="connection-manager-actions">
<button
type="button"
disabled={busy}
aria-label={`${connection.displayName}を再接続`}
onClick={() =>
void connect(connection.origin, connection.id)
}
>
再接続
</button>
{connection.status !== 'disconnected' && (
<button
type="button"
disabled={busy}
aria-label={`${connection.displayName}の接続を解除`}
onClick={() => void remove(connection.id)}
>
接続解除
</button>
)}
</div>
)}
</li>
))}
</ul>
)}
<form
className="connection-manager-add"
onSubmit={(event) => {
event.preventDefault()
if (origin) void connect(origin)
}}
>
<h3>Mastodonアカウントを追加</h3>
{instances.isPending ? (
<p role="status">接続先を確認しています…</p>
) : instances.isError ? (
<p role="alert">
接続先を取得できませんでした。
<button type="button" onClick={() => void instances.refetch()}>
再試行
</button>
</p>
) : instances.data.origins.length === 0 ? (
<p>利用するMastodonサーバーがまだ設定されていません。</p>
) : (
<>
<label htmlFor={selectId}>Mastodonサーバー</label>
<select
id={selectId}
value={origin}
onChange={(event) => setSelected(event.target.value)}
disabled={busy}
>
{instances.data.origins.map((value) => (
<option key={value} value={value}>
{new URL(value).hostname}
</option>
))}
</select>
<button type="submit" disabled={busy || !origin}>
Mastodonで認可する
</button>
</>
)}
{hasTemporaryDecks && (
<p className="connection-manager-note">
認可画面へ移動すると、一時デッキは消えます。残したいデッキは先に保存してください。
</p>
)}
{busy && <p role="status">処理しています…</p>}
</form>
</section>
)
}
@@ -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.',
)
}
}
@@ -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.',
)
})
+12
View File
@@ -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<typeof connectionSchema>
@@ -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<string> {
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
}
+157
View File
@@ -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('この接続は利用できません')
})
@@ -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()
},
)
+177
View File
@@ -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(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<ColumnEditor
connections={connections}
onSave={onSave}
onCancel={vi.fn()}
/>
</QueryClientProvider>,
)
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)
})
+48 -157
View File
@@ -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 <p role="status">リストを取得しています…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
再試行
</button>
</p>
)
return (
<section
className="deck-list-choices"
aria-label="このプロファイルのリスト"
>
{lists.data.length === 0 ? (
<p>リストはありません。URLまたはIDでも指定できます。</p>
) : (
lists.data.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
{list.isPrivate ? ' · 非公開' : ''}
</button>
))
)}
</section>
)
}
export function ColumnEditor({
column,
profiles,
connections,
onSave,
onCancel,
}: {
column?: DeckColumn
profiles: string[]
onSave: (column: DeckColumn) => void
connections: Connection[]
onSave: (column: DeckColumn) => Promise<boolean>
onCancel: () => void
}) {
const available = connections.filter(
(connection) => connection.status === 'connected',
)
const formId = useId()
const [error, setError] = useState<string>()
const [kind, setKind] = useState<DeckColumn['source']['kind']>(
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 (
<form
className="deck-editor"
aria-label={column ? 'カラムを編集' : 'カラムを追加'}
onSubmit={(event) => {
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)
}}
>
<div className="deck-editor-fields">
@@ -112,124 +75,52 @@ export function ColumnEditor({
接続プロファイル
<select
id={`${formId}-profile`}
value={profileName}
onChange={(event) => setProfileName(event.target.value)}
value={connectionId}
required
onChange={(event) => {
const connection = available.find(
(value) => value.id === event.target.value,
)
if (!connection) return
setConnectionId(connection.id)
setSource(rebindColumnSource(source, connection.platform))
}}
>
<option value="" disabled>
選択してください
</option>
{profileName && !profiles.includes(profileName) ? (
<option value={profileName} disabled>
{profileName}(利用不可)
{connectionId && !validConnection && (
<option value={connectionId} disabled>
{connectionId}(利用不可)
</option>
) : null}
{profiles.map((name) => (
<option key={name} value={name}>
{name}
)}
{available.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.displayName}
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
</option>
))}
</select>
</label>
<label htmlFor={`${formId}-kind`}>
カラムの種類
<select
id={`${formId}-kind`}
value={kind}
onChange={(event) => {
setKind(event.target.value as typeof kind)
setTarget('')
}}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
</select>
</label>
</div>
{kind === 'search' ? (
<div className="deck-source-fields" key="search">
<label htmlFor={`${formId}-query`}>
Twitterの検索条件
<input
id={`${formId}-query`}
name="query"
defaultValue={
column?.source.kind === 'search' ? column.source.query : ''
}
maxLength={512}
placeholder="例:WebMCP lang:ja"
required
<ColumnSourceEditor
source={source}
connectionId={validConnection ? connectionId : ''}
onChange={setSource}
/>
</label>
<label htmlFor={`${formId}-product`}>
表示順
<select
id={`${formId}-product`}
name="product"
defaultValue={
column?.source.kind === 'search'
? column.source.product
: 'Latest'
}
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
</select>
</label>
<p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
name="following"
defaultChecked={
column?.source.kind === 'search' && column.source.following
}
/>
フォロー中のみ
</label>
</div>
) : (
<div className="deck-source-fields" key={kind}>
<label htmlFor={`${formId}-target`}>
{kind === 'user' ? 'ユーザー' : 'リスト'}
<input
id={`${formId}-target`}
value={target}
onChange={(event) => setTarget(event.target.value)}
placeholder={
kind === 'user'
? '@handle またはプロフィールURL'
: 'リストのURLまたはID'
}
maxLength={256}
required
/>
</label>
{kind === 'list' && profiles.includes(profileName) ? (
<ListChoices
key={profileName}
profileName={profileName}
onSelect={setTarget}
/>
) : null}
</div>
)}
<p className="deck-help">
このカラムは選択した接続プロファイルで取得します。
</p>
{error ? (
{error && (
<p role="alert" className="deck-error">
{error}
</p>
) : null}
)}
<div className="deck-actions">
<button
type="submit"
className="deck-primary"
disabled={!profiles.includes(profileName)}
disabled={!validConnection}
>
{column ? '変更を保存' : '追加して検索'}
</button>
-7
View File
@@ -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 }
}
@@ -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 (
<TwitterSourceEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case 'mastodon':
return (
<MastodonSourceEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
}
}
+13 -8
View File
@@ -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<typeof usePostFeed> }
type ColumnFeed = {
column: DeckColumn
query: ReturnType<typeof useResearchFeed>
}
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
export function useRegisterColumn(
registry: ColumnRegistry,
column: DeckColumn,
query: ReturnType<typeof usePostFeed>,
query: ReturnType<typeof useResearchFeed>,
) {
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 (
+17 -20
View File
@@ -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 (
<section className="deck-column" aria-labelledby={headingId}>
<header className="deck-column-header">
@@ -98,22 +103,14 @@ export function ResearchColumn({
</details>
</div>
<div className="deck-column-label">
<span title={`接続プロファイル: ${column.profileName}`}>
{column.profileName}
</span>
<span>
{column.source.kind === 'search'
? `${column.source.product === 'Latest' ? '最新' : '話題'}${column.source.following ? ' · フォロー中' : ''}`
: column.source.kind === 'user'
? 'ユーザー投稿'
: 'リスト'}
<span
title={`接続プロファイル: ${connectionLabel ?? '接続が見つかりません'}`}
>
{connectionLabel ?? '接続が見つかりません'}
</span>
<span>{sourceDescription.label}</span>
</div>
<p className="deck-query">
{column.source.kind === 'search'
? column.source.query
: column.source.target}
</p>
<p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status">
{query.isFetching
? '投稿を取得しています…'
+145 -31
View File
@@ -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<ColumnRegistry>(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
}}
/>
<button
@@ -98,7 +119,7 @@ export function DeckPage() {
aria-label="カラムを追加"
title="カラムを追加"
type="button"
disabled={!ready || deck.columns.length >= MAX_COLUMNS}
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
onClick={() => setEditing('new')}
>
<Icon name="plus" />
@@ -124,7 +145,18 @@ export function DeckPage() {
</button>
))}
</nav>
<p className="sidebar-note">このブラウザに保存</p>
<p className="sidebar-note">
{deck.persisted
? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'}
</p>
<button
type="button"
disabled={!ready}
onClick={() => setManagingConnections(true)}
>
接続アカウントを管理
</button>
</>
}
>
@@ -136,18 +168,60 @@ export function DeckPage() {
</span>
</div>
<div className="deck-actions">
{deck.persisted ? (
<button
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title}のコピー`, deck.columns)
clearEditors()
}}
>
一時コピーを作成
</button>
) : (
<button
type="button"
disabled={!ready || saving}
onClick={() => void persist(deck.id)}
>
デッキとして保存
</button>
)}
<button
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
type="button"
disabled={!ready}
disabled={!ready || saving}
onClick={() => setRenaming(!renaming)}
>
<Icon name="edit" />
</button>
</div>
</div>
{!deck.persisted && ready ? (
<p className="deck-save-status">
一時ビューです。再読み込みやタブを閉じると失われます。
</p>
) : null}
{saving ? (
<p className="deck-save-status" role="status">
保存しています…
</p>
) : null}
{legacyAvailable ? (
<p className="deck-save-status">
このブラウザに旧デッキがあります。
<button
type="button"
disabled={saving || !profiles.data}
onClick={() => void importLegacy()}
>
旧デッキを取り込む
</button>
</p>
) : null}
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
@@ -161,26 +235,62 @@ export function DeckPage() {
</button>
</p>
) : null}
{oauthResult === 'failed' ? (
<p className="deck-error" role="alert">
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
</p>
) : null}
{oauthResult === 'connected' ? (
<p className="deck-save-status" role="status">
Mastodonアカウントを接続しました。カラムの接続先に選べます。
</p>
) : null}
{profiles.data?.relayError ? (
<p className="deck-error" role="alert">
{profiles.data.relayError}
</p>
) : null}
{managingConnections ? (
<Dialog
title="接続アカウント"
onClose={() => setManagingConnections(false)}
>
<ConnectionManager
connections={profiles.data?.connections ?? []}
onChanged={() => profiles.refetch()}
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
/>
</Dialog>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
{storageError ? (
<p className="deck-error" role="alert">
{storageError}
<button
type="button"
disabled={saving}
onClick={() => {
clearEditors()
void refresh(true)
}}
>
最新のデッキを再読み込み
</button>
</p>
) : null}
{renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={(event) => {
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)
}}
>
<label htmlFor="deck-title">
@@ -203,8 +313,8 @@ export function DeckPage() {
onClose={() => setEditing(null)}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}-${profiles.data?.profiles.join(',')}`}
profiles={profiles.data?.profiles ?? []}
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
connections={profiles.data?.connections ?? []}
column={
editing === 'new'
? undefined
@@ -221,11 +331,10 @@ export function DeckPage() {
<button
type="button"
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
onClick={() => {
onClick={async () => {
const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column)
save({ ...deck, columns })
setRemoved(undefined)
if (await save({ ...deck, columns })) setRemoved(undefined)
}}
>
元に戻す
@@ -256,20 +365,25 @@ export function DeckPage() {
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
connectionLabel={
profiles.data?.connections.find(
(connection) => connection.id === column.connectionId,
)?.displayName
}
first={index === 0}
last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })}
onMove={(direction) =>
save(moveColumn(deck, column.id, direction))
}
onRemove={() => {
setRemoved({ column, index })
save({
onRemove={async () => {
const saved = await save({
...deck,
columns: deck.columns.filter(
(current) => current.id !== column.id,
),
})
if (saved) setRemoved({ column, index })
if (typeof editing === 'object' && editing?.id === column.id)
setEditing(null)
}}
+17 -12
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import type { Deck } from './model'
import type { DeckView } from './model'
export function DeckSwitcher({
decks,
@@ -10,16 +10,22 @@ export function DeckSwitcher({
onSelect,
onCreate,
onDelete,
onEditingChange,
}: {
decks: Deck[]
decks: DeckView[]
activeId: string
ready: boolean
onSelect: (id: string) => void
onCreate: (title: string) => void
onDelete: () => void
onCreate: (title: string) => Promise<boolean>
onDelete: () => Promise<boolean>
onEditingChange: (editing: boolean) => void
}) {
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
onEditingChange(creating || deleting)
return () => onEditingChange(false)
}, [creating, deleting, onEditingChange])
return (
<div className="deck-workspaces">
<div className="deck-actions">
@@ -37,6 +43,7 @@ export function DeckSwitcher({
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.title}
{deck.persisted ? '' : '(一時)'}
</option>
))}
</select>
@@ -59,7 +66,7 @@ export function DeckSwitcher({
aria-label="デッキを削除"
title="デッキを削除"
type="button"
disabled={!ready || decks.length <= 1}
disabled={!ready}
onClick={() => {
setDeleting(!deleting)
setCreating(false)
@@ -73,14 +80,13 @@ export function DeckSwitcher({
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
<form
className="deck-rename"
onSubmit={(event) => {
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
onCreate(title)
setCreating(false)
if (await onCreate(title)) setCreating(false)
}}
>
<label htmlFor="new-deck-title">
@@ -105,9 +111,8 @@ export function DeckSwitcher({
<p>このデッキとカラムの設定を削除します。</p>
<button
type="button"
onClick={() => {
onDelete()
setDeleting(false)
onClick={async () => {
if (await onDelete()) setDeleting(false)
}}
>
削除する
+80
View File
@@ -0,0 +1,80 @@
import { createHash, randomUUID } from 'node:crypto'
import { and, eq } from 'drizzle-orm'
import { getDatabase } from '../storage/database.server'
import { connections, legacyImports } from '../storage/schema'
import { legacyWorkspaceSchema } from './legacy-import'
import {
createDeck,
DeckPersistenceError,
listDecks,
} from './repository.server'
export function importLegacyWorkspace(
input: unknown,
database = getDatabase(),
relayOrigin = new URL(process.env.TWITTER_RELAY_BASE_URL ?? '').origin,
) {
const legacy = legacyWorkspaceSchema.parse(input)
const payloadHash = createHash('sha256')
.update(JSON.stringify(legacy.decks))
.digest('hex')
return database.transaction(
() => {
const marker = database
.select()
.from(legacyImports)
.where(eq(legacyImports.id, 'local-storage-v2'))
.get()
if (marker) {
if (marker.payloadHash !== payloadHash)
throw new DeckPersistenceError(
'conflict',
'旧デッキはすでに取り込み済みです。別の内容で上書きできません。',
)
return listDecks(database)
}
const available = database
.select()
.from(connections)
.where(
and(
eq(connections.platform, 'twitter'),
eq(connections.origin, relayOrigin),
eq(connections.status, 'connected'),
),
)
.all()
const imported = legacy.decks.map((deck) =>
createDeck(
{
...deck,
id: randomUUID(),
columns: deck.columns.map(({ profileName, ...column }) => {
const connection = available.find(
(connection) => connection.relayProfile === profileName,
)
if (!connection)
throw new DeckPersistenceError(
'connection-unavailable',
`旧プロファイル「${profileName}」に対応する接続がありません。接続一覧を更新してください。`,
)
return { ...column, connectionId: connection.id }
}),
},
database,
),
)
database
.insert(legacyImports)
.values({
id: 'local-storage-v2',
payloadHash,
deckIds: imported.map((deck) => deck.id),
createdAt: Date.now(),
})
.run()
return listDecks(database)
},
{ behavior: 'immediate' },
)
}
+98
View File
@@ -0,0 +1,98 @@
// @vitest-environment node
import { afterEach, beforeEach, expect, it } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connections, legacyImports } from '../storage/schema'
import { importLegacyWorkspace } from './import.server'
import { createDeck, listDecks } from './repository.server'
let database: AppDatabase
const legacy = {
version: 2,
activeDeckId: 'same-id',
decks: [
{
id: 'same-id',
title: 'Legacy',
columns: [
{
id: 'a',
title: 'Account',
profileName: 'old-profile',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
},
],
},
],
}
beforeEach(() => {
database = openDatabase(':memory:')
database
.insert(connections)
.values({
id: 'new-connection-id',
platform: 'twitter',
origin: 'https://relay.invalid',
relayProfile: 'old-profile',
displayName: 'Old account',
status: 'connected',
createdAt: 1,
updatedAt: 1,
})
.run()
})
afterEach(() => database.$client.close())
it('imports legacy profiles as connection bindings without overwriting existing same-ID decks', () => {
const existing = createDeck(
{ id: 'same-id', title: 'Existing server deck', columns: [] },
database,
)
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
expect(result).toHaveLength(2)
expect(result.find((deck) => deck.id === existing.id)).toEqual(existing)
const imported = result.find((deck) => deck.title === 'Legacy')
expect(imported?.id).not.toBe('same-id')
expect(imported?.columns[0]).toEqual({
id: 'a',
title: 'Account',
connectionId: 'new-connection-id',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
})
expect(
importLegacyWorkspace(legacy, database, 'https://relay.invalid'),
).toEqual(result)
})
it('blocks a different browser snapshot after the one-time import', () => {
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
expect(() =>
importLegacyWorkspace(
{ ...legacy, decks: [{ ...legacy.decks[0], title: 'Different' }] },
database,
'https://relay.invalid',
),
).toThrow('旧デッキはすでに取り込み済みです')
expect(listDecks(database)).toEqual(result)
})
it('rolls back all imported decks and marker when any profile is unavailable', () => {
const second = {
...legacy.decks[0],
id: 'second',
columns: [{ ...legacy.decks[0]?.columns[0], profileName: 'missing' }],
}
expect(() =>
importLegacyWorkspace(
{ ...legacy, decks: [...legacy.decks, second] },
database,
'https://relay.invalid',
),
).toThrow('対応する接続がありません')
expect(listDecks(database)).toEqual([])
expect(database.select().from(legacyImports).all()).toEqual([])
})
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod'
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
export const LEGACY_STORAGE_KEY = 'twitter-lite-research-deck'
const legacyColumn = columnSchema
.omit({ connectionId: true })
.extend({ profileName: z.string().min(1) })
const legacyDeck = z
.object({
id: deckSchema.shape.id,
title: deckSchema.shape.title,
columns: z.array(legacyColumn).max(MAX_COLUMNS),
})
.strict()
.refine(
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'カラム ID が重複しています。',
)
export const legacyWorkspaceSchema = z
.object({
version: z.literal(2),
activeDeckId: z.string(),
decks: z.array(legacyDeck).min(1).max(100),
})
.strict()
.refine(
(value) =>
new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
'デッキ ID が重複しています。',
)
@@ -0,0 +1,230 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { type ReactElement, useId } from 'react'
import { loadMastodonLists } from '../platforms/mastodon-server-functions'
import type { MastodonSource } from '../platforms/mastodon-source'
type EditorProps<T extends MastodonSource = MastodonSource> = {
source: T
onChange: (source: MastodonSource) => void
}
export function defaultMastodonSource(
kind: MastodonSource['kind'] = 'search',
): MastodonSource {
switch (kind) {
case 'search':
return { platform: 'mastodon', kind, query: '' }
case 'user':
return { platform: 'mastodon', kind, target: '' }
case 'list':
return { platform: 'mastodon', kind, target: '' }
case 'hashtag':
return { platform: 'mastodon', kind, target: '' }
}
}
export function MastodonSourceEditor({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }) {
const id = useId()
return (
<>
<label htmlFor={id}>
カラムの種類
<select
id={id}
value={source.kind}
onChange={(event) =>
onChange(
defaultMastodonSource(
event.target.value as MastodonSource['kind'],
),
)
}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
<option value="hashtag">ハッシュタグ</option>
</select>
</label>
<MastodonSourceFields
source={source}
connectionId={connectionId}
onChange={onChange}
/>
</>
)
}
function MastodonSourceFields({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) {
case 'search':
return <MastodonSearchEditor source={source} onChange={onChange} />
case 'user':
return <MastodonUserEditor source={source} onChange={onChange} />
case 'list':
return (
<MastodonListEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case 'hashtag':
return <MastodonHashtagEditor source={source} onChange={onChange} />
}
}
function MastodonSearchEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'search' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
Mastodonの検索条件
<input
id={id}
value={source.query}
onChange={(event) =>
onChange({ ...source, query: event.target.value })
}
maxLength={512}
placeholder="例:WebMCP"
required
/>
</label>
<p className="deck-help">
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
</p>
</div>
)
}
function MastodonUserEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'user' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
ユーザー
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="user@server またはアカウントID"
maxLength={256}
required
/>
</label>
</div>
)
}
function MastodonHashtagEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'hashtag' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
ハッシュタグ
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="# を除いたタグ名"
maxLength={128}
required
/>
</label>
</div>
)
}
function MastodonListEditor({
source,
connectionId,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'list' }>> & {
connectionId: string
}) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
リスト
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="このアカウントのリストID"
maxLength={32}
required
/>
</label>
{connectionId && (
<MastodonListChoices
connectionId={connectionId}
onSelect={(target) => onChange({ ...source, target })}
/>
)}
</div>
)
}
function MastodonListChoices({
connectionId,
onSelect,
}: {
connectionId: string
onSelect: (target: string) => void
}) {
const fetchLists = useServerFn(loadMastodonLists)
const lists = useQuery({
queryKey: ['mastodon-lists', connectionId],
queryFn: () => fetchLists({ data: { connectionId } }),
retry: false,
})
if (lists.isPending) return <p role="status">リストを取得しています…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
再試行
</button>
</p>
)
return (
<section className="deck-list-choices" aria-label="このアカウントのリスト">
{lists.data.lists.length === 0 ? (
<p>このアカウントのリストはありません。</p>
) : (
lists.data.lists.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
</button>
))
)}
</section>
)
}
+3 -17
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { type Deck, deckSchema, moveColumn, workspaceSchema } from './model'
import { type Deck, deckSchema, moveColumn } from './model'
const deck: Deck = {
id: 'research',
@@ -7,7 +7,7 @@ const deck: Deck = {
columns: ['a', 'b'].map((id) => ({
id,
title: id,
profileName: 'main',
connectionId: 'main',
source: {
platform: 'twitter',
kind: 'search',
@@ -71,21 +71,7 @@ describe('deck definitions', () => {
expect(
deckSchema.safeParse({
...deck,
columns: [{ ...deck.columns[0], profileName: '' }],
}).success,
).toBe(false)
expect(
workspaceSchema.safeParse({
version: 2,
activeDeckId: 'missing',
decks: [deck],
}).success,
).toBe(false)
expect(
workspaceSchema.safeParse({
version: 2,
activeDeckId: deck.id,
decks: [deck, deck],
columns: [{ ...deck.columns[0], connectionId: '' }],
}).success,
).toBe(false)
})
+9 -24
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
import {
buildSearchQuery,
normalizeListTarget,
@@ -69,12 +70,15 @@ export const columnSchema = z
.object({
id: z.string().min(1).max(128),
title: z.string().trim().min(1).max(80),
profileName: z.string().min(1),
source: z.discriminatedUnion('kind', [
connectionId: z.string().min(1),
source: z.union([
z.discriminatedUnion('kind', [
searchSourceSchema,
userSourceSchema,
listSourceSchema,
]),
mastodonSourceSchema,
]),
})
.strict()
@@ -92,37 +96,18 @@ export const deckSchema = z
'カラム ID が重複しています。',
)
export const workspaceSchema = z
.object({
version: z.literal(2),
activeDeckId: deckSchema.shape.id,
decks: z.array(deckSchema).min(1),
})
.strict()
.refine(
(workspace) =>
new Set(workspace.decks.map((deck) => deck.id)).size ===
workspace.decks.length,
'デッキ ID が重複しています。',
)
.refine(
(workspace) =>
workspace.decks.some((deck) => deck.id === workspace.activeDeckId),
'選択したデッキが見つかりません。',
)
export type Deck = z.infer<typeof deckSchema>
export type DeckColumn = z.infer<typeof columnSchema>
export type Workspace = z.infer<typeof workspaceSchema>
export type DeckView = Deck & { persisted: boolean; revision?: number }
export type Workspace = { activeDeckId: string; decks: DeckView[] }
export const EMPTY_DECK: Deck = {
id: 'initial',
title: '新しい調査',
columns: [],
}
export const EMPTY_WORKSPACE: Workspace = {
version: 2,
activeDeckId: EMPTY_DECK.id,
decks: [EMPTY_DECK],
decks: [{ ...EMPTY_DECK, persisted: false }],
}
export function newId() {
return crypto.getRandomValues(new Uint32Array(4)).join('-')
+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod'
import { deckSchema } from './model'
export const savedDeckDefinitionSchema = deckSchema
export const savedDeckSchema = savedDeckDefinitionSchema.safeExtend({
revision: z.number().int().positive(),
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative(),
})
export const replaceDeckInputSchema = z
.object({
deck: savedDeckDefinitionSchema,
expectedRevision: z.number().int().positive(),
})
.strict()
export const deleteDeckInputSchema = z
.object({
id: deckSchema.shape.id,
expectedRevision: z.number().int().positive(),
})
.strict()
export type SavedDeckDefinition = z.infer<typeof savedDeckDefinitionSchema>
export type SavedDeck = z.infer<typeof savedDeckSchema>
export type DeckPersistenceFailure = {
ok: false
error: {
code:
| 'conflict'
| 'not-found'
| 'connection-unavailable'
| 'invalid-input'
| 'storage-unavailable'
message: string
}
}
+184
View File
@@ -0,0 +1,184 @@
import { and, asc, eq } from 'drizzle-orm'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { connections, deckColumns, decks } from '../storage/schema'
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
replaceDeckInputSchema,
type SavedDeck,
type SavedDeckDefinition,
savedDeckDefinitionSchema,
savedDeckSchema,
} from './persistence-model'
export class DeckPersistenceError extends Error {
constructor(
readonly code: DeckPersistenceFailure['error']['code'],
message: string,
) {
super(message)
}
}
const conflict = () =>
new DeckPersistenceError(
'conflict',
'別の端末で変更されました。最新のデッキを読み直してください。',
)
const missing = () =>
new DeckPersistenceError(
'not-found',
'このデッキは削除されたか、見つかりません。',
)
export function loadDeck(
id: string,
database = getDatabase(),
): SavedDeck | null {
const record = database.select().from(decks).where(eq(decks.id, id)).get()
if (!record) return null
const columns = database
.select({
id: deckColumns.id,
title: deckColumns.title,
connectionId: deckColumns.connectionId,
source: deckColumns.source,
})
.from(deckColumns)
.where(eq(deckColumns.deckId, id))
.orderBy(asc(deckColumns.position))
.all()
return savedDeckSchema.parse({ ...record, columns })
}
export function listDecks(database = getDatabase()): SavedDeck[] {
return database.transaction(() =>
database
.select({ id: decks.id })
.from(decks)
.orderBy(asc(decks.createdAt), asc(decks.id))
.all()
.map(({ id }) => {
const deck = loadDeck(id, database)
if (!deck) throw missing()
return deck
}),
)
}
function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
for (const column of deck.columns) {
const connection = database
.select({ platform: connections.platform, status: connections.status })
.from(connections)
.where(eq(connections.id, column.connectionId))
.get()
if (
!connection ||
connection.platform !== column.source.platform ||
connection.status !== 'connected'
) {
throw new DeckPersistenceError(
'connection-unavailable',
'カラムの接続アカウントが利用できません。接続を確認してください。',
)
}
}
}
function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run()
if (deck.columns.length)
database
.insert(deckColumns)
.values(
deck.columns.map((column, position) => ({
...column,
deckId: deck.id,
position,
})),
)
.run()
}
export function createDeck(
input: unknown,
database = getDatabase(),
): SavedDeck {
const deck = savedDeckDefinitionSchema.parse(input)
return database.transaction(
() => {
const existing = loadDeck(deck.id, database)
if (existing) {
const original = savedDeckDefinitionSchema.parse({
id: existing.id,
title: existing.title,
columns: existing.columns,
})
if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict()
return existing
}
validateConnections(deck, database)
const now = Date.now()
const result = { ...deck, revision: 1, createdAt: now, updatedAt: now }
database
.insert(decks)
.values({
id: deck.id,
title: deck.title,
revision: 1,
createdAt: now,
updatedAt: now,
})
.run()
writeColumns(deck, database)
return result
},
{ behavior: 'immediate' },
)
}
export function replaceDeck(
input: unknown,
database = getDatabase(),
): SavedDeck {
const { deck, expectedRevision } = replaceDeckInputSchema.parse(input)
return database.transaction(
() => {
const existing = loadDeck(deck.id, database)
if (!existing) throw missing()
if (existing.revision !== expectedRevision) throw conflict()
validateConnections(deck, database)
const updatedAt = Date.now()
const result = database
.update(decks)
.set({ title: deck.title, revision: expectedRevision + 1, updatedAt })
.where(and(eq(decks.id, deck.id), eq(decks.revision, expectedRevision)))
.run()
if (result.changes !== 1) throw conflict()
writeColumns(deck, database)
return {
...deck,
revision: expectedRevision + 1,
createdAt: existing.createdAt,
updatedAt,
}
},
{ behavior: 'immediate' },
)
}
export function deleteDeck(input: unknown, database = getDatabase()): string {
const { id, expectedRevision } = deleteDeckInputSchema.parse(input)
return database.transaction(
() => {
if (!loadDeck(id, database)) throw missing()
const result = database
.delete(decks)
.where(and(eq(decks.id, id), eq(decks.revision, expectedRevision)))
.run()
if (result.changes !== 1) throw conflict()
return id
},
{ behavior: 'immediate' },
)
}
+214
View File
@@ -0,0 +1,214 @@
// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
deckColumns,
} from '../storage/schema'
import type { SavedDeckDefinition } from './persistence-model'
import {
createDeck,
deleteDeck,
listDecks,
loadDeck,
replaceDeck,
} from './repository.server'
let directory: string
let database: AppDatabase
let second: AppDatabase
const deck: SavedDeckDefinition = {
id: 'request-123',
title: 'Research',
columns: [
{
id: 'first',
title: 'First',
connectionId: 'twitter-one',
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
following: false,
},
},
{
id: 'second',
title: 'Second',
connectionId: 'twitter-two',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
},
],
}
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-deck-repository-'))
database = openDatabase(join(directory, 'app.db'))
second = openDatabase(join(directory, 'app.db'))
database
.insert(connections)
.values(
['twitter-one', 'twitter-two'].map((id) => ({
id,
platform: 'twitter' as const,
origin: 'https://relay.invalid',
relayProfile: id,
displayName: id,
status: 'connected' as const,
createdAt: 1,
updatedAt: 1,
})),
)
.run()
})
afterEach(() => {
second.$client.close()
database.$client.close()
rmSync(directory, { recursive: true })
})
it('shares deck order and per-column account bindings with a separate database client', () => {
const saved = createDeck(deck, database)
expect(saved).toMatchObject({ ...deck, revision: 1 })
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(listDecks(second)).toEqual([saved])
})
it('makes retried creation idempotent but rejects different content using the same ID', () => {
const saved = createDeck(deck, database)
expect(createDeck(deck, second)).toEqual(saved)
expect(() =>
createDeck({ ...deck, title: 'Different request' }, second),
).toThrow('別の端末で変更されました')
expect(listDecks(database)).toEqual([saved])
})
it('replaces title, ordered columns and bindings atomically, then rejects stale edits', () => {
const initial = createDeck(deck, database)
const reversed = {
...deck,
title: 'Changed',
columns: [...deck.columns].reverse(),
}
const saved = replaceDeck(
{ deck: reversed, expectedRevision: initial.revision },
database,
)
expect(saved).toMatchObject({
...reversed,
revision: 2,
createdAt: initial.createdAt,
})
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'別の端末で変更されました',
)
expect(loadDeck(deck.id, second)).toEqual(saved)
})
it('clears all columns and prevents stale deletion from another device', () => {
createDeck(deck, database)
const saved = replaceDeck(
{ deck: { ...deck, columns: [] }, expectedRevision: 1 },
database,
)
expect(loadDeck(deck.id, second)?.columns).toEqual([])
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('別の端末で変更されました')
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id)
expect(loadDeck(deck.id, database)).toBeNull()
})
it('cascades deleted deck columns and reports already-deleted decks', () => {
createDeck(deck, database)
deleteDeck({ id: deck.id, expectedRevision: 1 }, database)
expect(database.select().from(deckColumns).all()).toEqual([])
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'このデッキは削除されたか',
)
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('このデッキは削除されたか')
})
it.each([
'disconnected',
'expired',
] as const)('rejects %s connections without partially saving a deck', (status) => {
const initial = createDeck(deck, database)
database
.update(connections)
.set({ status })
.where(eq(connections.id, 'twitter-two'))
.run()
expect(() =>
replaceDeck(
{ deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 },
database,
),
).toThrow('カラムの接続アカウントが利用できません')
expect(loadDeck(deck.id, second)).toEqual(initial)
})
it('rejects a connection from another platform', () => {
database
.update(connections)
.set({ platform: 'mastodon' })
.where(eq(connections.id, 'twitter-one'))
.run()
expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません',
)
expect(listDecks(database)).toEqual([])
})
it('rejects unknown connections without creating rows', () => {
database.delete(connections).where(eq(connections.id, 'twitter-two')).run()
expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません',
)
expect(listDecks(database)).toEqual([])
})
it('rejects duplicate column IDs without changing the saved version', () => {
const saved = createDeck(deck, database)
expect(() =>
replaceDeck(
{
deck: { ...deck, columns: [deck.columns[0], deck.columns[0]] },
expectedRevision: 1,
},
database,
),
).toThrow('カラム ID が重複しています')
expect(loadDeck(deck.id, database)).toEqual(saved)
})
it('returns only deck definitions and revision metadata, never credential data', () => {
database
.insert(connectionCredentials)
.values({
connectionId: 'twitter-one',
encryptedToken: 'secret-envelope',
updatedAt: 1,
})
.run()
createDeck(deck, database)
const loaded = listDecks(database)
expect(Object.keys(loaded[0] ?? {}).sort()).toEqual([
'columns',
'createdAt',
'id',
'revision',
'title',
'updatedAt',
])
expect(JSON.stringify(loaded)).not.toContain('secret-envelope')
expect(JSON.stringify(loaded)).not.toContain('relayProfile')
})
@@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, it } from 'vitest'
import type { ResearchPost } from '../platforms/types'
import { ResearchPostCard } from './research-post-card'
it('waits for reveal before mounting CW content or sensitive media', () => {
const post: ResearchPost = {
key: 'mastodon:1',
platform: 'mastodon',
nativeId: '1',
url: 'https://social.invalid/@a/1',
text: 'Hidden content',
html: '<p>Hidden <strong>content</strong></p>',
author: { name: 'Alice', handle: '[email protected]' },
contentWarning: 'Spoiler',
sensitive: true,
boostedBy: { name: 'Bob', handle: '[email protected]' },
media: [
{
type: 'photo',
url: 'https://social.invalid/image.png',
alt: 'Hidden image',
},
],
}
render(<ResearchPostCard post={post} />)
expect(screen.getByText('Bob がブースト')).toBeInTheDocument()
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '内容を表示' }))
expect(screen.getByAltText('Hidden image')).toBeInTheDocument()
expect(screen.getByText('content').tagName).toBe('STRONG')
fireEvent.click(screen.getByRole('button', { name: '内容を隠す' }))
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
})
+30
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Icon } from '#/components/icon'
import { PostText } from '#/components/post-text'
import type { ResearchPost } from '#/features/platforms/types'
@@ -11,8 +12,13 @@ const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
})
export function ResearchPostCard({ post }: { post: ResearchPost }) {
const [revealed, setRevealed] = useState(false)
const concealed = Boolean(post.contentWarning || post.sensitive)
return (
<article className="research-post">
{post.boostedBy ? (
<p className="handle">{post.boostedBy.name} がブースト</p>
) : null}
<header>
{post.author.avatarUrl ? (
<img
@@ -35,9 +41,31 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
</time>
) : null}
</header>
{concealed ? (
<div>
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
<button
type="button"
aria-expanded={revealed}
onClick={() => setRevealed(!revealed)}
>
{revealed ? '内容を隠す' : '内容を表示'}
</button>
</div>
) : null}
{!concealed || revealed ? (
<>
{post.html !== undefined ? (
<div
className="research-post-text"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Mastodon HTML is sanitized by the server allowlist before normalization.
dangerouslySetInnerHTML={{ __html: post.html }}
/>
) : (
<p className="research-post-text">
<PostText text={post.text} />
</p>
)}
{post.media?.map((media) =>
media.type === 'photo' ? (
<img
@@ -73,6 +101,8 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
</a>
</blockquote>
) : null}
</>
) : null}
<footer>
<a href={post.url} target="_blank" rel="noreferrer noopener">
元の投稿を開く <Icon name="arrow" />
+60
View File
@@ -0,0 +1,60 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { legacyWorkspaceSchema } from './legacy-import'
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
replaceDeckInputSchema,
savedDeckDefinitionSchema,
} from './persistence-model'
async function run<T>(
operation: (
repository: typeof import('./repository.server'),
) => T | Promise<T>,
): Promise<{ ok: true; data: T } | DeckPersistenceFailure> {
const repository = await import('./repository.server')
try {
return { ok: true, data: await operation(repository) }
} catch (error) {
if (error instanceof repository.DeckPersistenceError)
return { ok: false, error: { code: error.code, message: error.message } }
if (error instanceof z.ZodError)
return {
ok: false,
error: {
code: 'invalid-input',
message: 'デッキの入力内容を確認してください。',
},
}
return {
ok: false,
error: {
code: 'storage-unavailable',
message: 'デッキを保存・取得できませんでした。再試行してください。',
},
}
}
}
export const loadSavedDecks = createServerFn({ method: 'GET' }).handler(() =>
run((repository) => repository.listDecks()),
)
export const createSavedDeck = createServerFn({ method: 'POST' })
.validator(savedDeckDefinitionSchema)
.handler(({ data }) => run((repository) => repository.createDeck(data)))
export const replaceSavedDeck = createServerFn({ method: 'POST' })
.validator(replaceDeckInputSchema)
.handler(({ data }) => run((repository) => repository.replaceDeck(data)))
export const deleteSavedDeck = createServerFn({ method: 'POST' })
.validator(deleteDeckInputSchema)
.handler(({ data }) => run((repository) => repository.deleteDeck(data)))
export const importBrowserDecks = createServerFn({ method: 'POST' })
.validator(legacyWorkspaceSchema)
.handler(({ data }) =>
run(async () => {
const { importLegacyWorkspace } = await import('./import.server')
return importLegacyWorkspace(data)
}),
)
+37
View File
@@ -0,0 +1,37 @@
import type { DeckColumn } from './model'
/** Describe the bound source once, keeping platform/kind decisions out of the column layout. */
export function describeSource(source: DeckColumn['source']): {
label: string
query: string
} {
switch (source.platform) {
case 'twitter':
switch (source.kind) {
case 'search':
return {
label: `${source.product === 'Latest' ? '最新' : '話題'}${source.following ? ' · フォロー中' : ''}`,
query: source.query,
}
case 'user':
return { label: 'ユーザー投稿', query: source.target }
case 'list':
return { label: 'リスト', query: source.target }
}
break
case 'mastodon':
switch (source.kind) {
case 'search':
return { label: '全文検索', query: source.query }
case 'user':
return { label: 'ユーザー投稿', query: source.target }
case 'list':
return { label: 'リスト', query: source.target }
case 'hashtag':
return { label: 'ハッシュタグ', query: `#${source.target}` }
}
break
}
const exhaustive: never = source
return exhaustive
}
@@ -0,0 +1,223 @@
import { type ReactElement, useId } from 'react'
import { useListChoices } from '../posts/use-list-choices'
import type { DeckColumn } from './model'
type Source = Extract<DeckColumn['source'], { platform: 'twitter' }>
type EditorProps<T extends Source = Source> = {
source: T
onChange: (source: Source) => void
}
export function defaultTwitterSource(kind: Source['kind'] = 'search'): Source {
switch (kind) {
case 'search':
return {
platform: 'twitter',
kind,
query: '',
product: 'Latest',
following: false,
}
case 'user':
return { platform: 'twitter', kind, target: '' }
case 'list':
return { platform: 'twitter', kind, target: '' }
}
}
export function TwitterSourceEditor({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }) {
const id = useId()
return (
<>
<label htmlFor={id}>
カラムの種類
<select
id={id}
value={source.kind}
onChange={(event) =>
onChange(defaultTwitterSource(event.target.value as Source['kind']))
}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
</select>
</label>
<TwitterSourceFields
source={source}
connectionId={connectionId}
onChange={onChange}
/>
</>
)
}
function TwitterSourceFields({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) {
case 'search':
return <TwitterSearchEditor source={source} onChange={onChange} />
case 'user':
return <TwitterUserEditor source={source} onChange={onChange} />
case 'list':
return (
<TwitterListEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
}
}
function TwitterSearchEditor({
source,
onChange,
}: EditorProps<Extract<Source, { kind: 'search' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={`${id}-query`}>
Twitterの検索条件
<input
id={`${id}-query`}
value={source.query}
onChange={(event) =>
onChange({ ...source, query: event.target.value })
}
maxLength={512}
placeholder="例:WebMCP lang:ja"
required
/>
</label>
<label htmlFor={`${id}-product`}>
表示順
<select
id={`${id}-product`}
value={source.product}
onChange={(event) =>
onChange({
...source,
product: event.target.value as 'Latest' | 'Top',
})
}
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
</select>
</label>
<p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
checked={source.following}
onChange={(event) =>
onChange({ ...source, following: event.target.checked })
}
/>
フォロー中のみ
</label>
</div>
)
}
function TwitterUserEditor({
source,
onChange,
}: EditorProps<Extract<Source, { kind: 'user' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
ユーザー
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="@handle またはプロフィールURL"
maxLength={256}
required
/>
</label>
</div>
)
}
function TwitterListEditor({
source,
connectionId,
onChange,
}: EditorProps<Extract<Source, { kind: 'list' }>> & { connectionId: string }) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
リスト
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="リストのURLまたはID"
maxLength={256}
required
/>
</label>
{connectionId && (
<TwitterListChoices
connectionId={connectionId}
onSelect={(target) => onChange({ ...source, target })}
/>
)}
</div>
)
}
function TwitterListChoices({
connectionId,
onSelect,
}: {
connectionId: string
onSelect: (target: string) => void
}) {
const lists = useListChoices(connectionId)
if (lists.isPending) return <p role="status">リストを取得しています…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
再試行
</button>
</p>
)
return (
<section
className="deck-list-choices"
aria-label="このプロファイルのリスト"
>
{lists.data.length === 0 ? (
<p>リストはありません。URLまたはIDでも指定できます。</p>
) : (
lists.data.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
{list.isPrivate ? ' · 非公開' : ''}
</button>
))
)}
</section>
)
}
+295 -58
View File
@@ -1,66 +1,303 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EMPTY_DECK, EMPTY_WORKSPACE } from './model'
import { DECK_STORAGE_KEY, useDeck } from './use-deck'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { LEGACY_STORAGE_KEY } from './legacy-import'
import type { SavedDeck } from './persistence-model'
import { ACTIVE_DECK_KEY, useDeck } from './use-deck'
const server = vi.hoisted(() => ({
load: vi.fn(),
create: vi.fn(),
replace: vi.fn(),
remove: vi.fn(),
import: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('./server-functions', () => ({
loadSavedDecks: server.load,
createSavedDeck: server.create,
replaceSavedDeck: server.replace,
deleteSavedDeck: server.remove,
importBrowserDecks: server.import,
}))
const saved: SavedDeck = {
id: 'saved',
title: 'Shared research',
columns: [],
revision: 1,
createdAt: 1,
updatedAt: 1,
}
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
vi.clearAllMocks()
server.load.mockResolvedValue({ ok: true, data: [] })
server.create.mockImplementation(async ({ data }) => ({
ok: true,
data: { ...data, revision: 1, createdAt: 1, updatedAt: 1 },
}))
})
describe('saved decks', () => {
it('restores saved conditions without overwriting them during mount', () => {
const deck = { ...EMPTY_DECK, title: 'WebMCP調査' }
const saved = { ...EMPTY_WORKSPACE, decks: [deck] }
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(saved))
const { result } = renderHook(useDeck)
expect(result.current.ready).toBe(true)
expect(result.current.deck).toEqual(deck)
expect(JSON.parse(localStorage.getItem(DECK_STORAGE_KEY) ?? '')).toEqual(
saved,
it('creates and edits temporary views without writing shared decks', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('AI research')
})
const id = result.current.deck.id
await act(async () => {
await result.current.save({ id, title: 'Revised research', columns: [] })
})
expect(result.current.deck).toMatchObject({
title: 'Revised research',
persisted: false,
})
expect(server.create).not.toHaveBeenCalled()
expect(server.replace).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
await act(async () => {
await result.current.persist(id)
})
expect(result.current.deck).toMatchObject({
id,
persisted: true,
revision: 1,
})
expect(server.create).toHaveBeenCalledTimes(1)
await act(async () => {
await result.current.persist(id)
})
expect(server.create).toHaveBeenCalledTimes(1)
})
it('keeps a temporary view on failed save and retries with the same identity', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Keep this')
})
const id = result.current.deck.id
server.create.mockResolvedValueOnce({
ok: false,
error: { message: 'Server disk unavailable' },
})
await act(async () => {
expect(await result.current.persist(id)).toBe(false)
})
expect(result.current.deck).toMatchObject({
id,
title: 'Keep this',
persisted: false,
})
expect(result.current.storageError).toBe('Server disk unavailable')
await act(async () => {
expect(await result.current.persist(id)).toBe(true)
})
expect(server.create.mock.calls.map((call) => call[0].data.id)).toEqual([
id,
id,
])
})
it('retains the accepted revision and surfaces conflicts instead of claiming failed edits were saved', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
server.replace.mockResolvedValue({
ok: false,
error: { message: 'Revision conflict' },
})
await act(async () => {
expect(await result.current.save({ ...saved, title: 'My draft' })).toBe(
false,
)
})
it('keeps invalid saved data untouched and explains the failure', () => {
localStorage.setItem(DECK_STORAGE_KEY, '{broken')
const { result } = renderHook(useDeck)
expect(result.current.storageError).toContain('読み込めません')
expect(localStorage.getItem(DECK_STORAGE_KEY)).toBe('{broken')
})
it('reports failed writes while keeping changes usable in memory', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError')
})
const { result } = renderHook(useDeck)
let persisted = true
act(() => {
persisted = result.current.save({ ...EMPTY_DECK, title: '未保存' })
})
expect(persisted).toBe(false)
expect(result.current.deck.title).toBe('未保存')
expect(result.current.storageError).toContain('保存できません')
})
it('keeps decks isolated through immediate create, edit, switch and reload', () => {
const { result, unmount } = renderHook(useDeck)
const firstId = result.current.deck.id
let secondId = ''
act(() => {
result.current.create('第二の調査')
secondId = result.current.getWorkspace().activeDeckId
result.current.save({
id: secondId,
title: '変更した第二の調査',
columns: [],
})
result.current.select(firstId)
})
expect(result.current.deck.title).toBe('新しい調査')
expect(result.current.workspace.decks).toHaveLength(2)
unmount()
const restored = renderHook(useDeck)
act(() => restored.result.current.select(secondId))
expect(restored.result.current.deck.title).toBe('変更した第二の調査')
act(() => restored.result.current.remove(secondId))
expect(restored.result.current.deck.id).toBe(firstId)
expect(() => restored.result.current.remove(firstId)).toThrow('最後')
expect(() => restored.result.current.select('missing')).toThrow()
expect(result.current.deck).toMatchObject({ title: saved.title, revision: 1 })
expect(result.current.storageError).toBe('Revision conflict')
expect(server.replace).toHaveBeenCalledWith({
data: {
deck: { id: saved.id, title: 'My draft', columns: [] },
expectedRevision: 1,
},
})
})
it('refreshes on focus but holds remote updates while a form is being edited', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result, rerender } = renderHook(({ editing }) => useDeck(editing), {
initialProps: { editing: false },
})
await waitFor(() => expect(result.current.ready).toBe(true))
rerender({ editing: true })
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Remote edit', revision: 2 }],
})
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(server.load).toHaveBeenCalledTimes(1)
expect(result.current.deck.title).toBe(saved.title)
rerender({ editing: false })
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(result.current.deck).toMatchObject({
title: 'Remote edit',
revision: 2,
})
})
it('keeps the active deck local and copies saved content into a separate temporary view', async () => {
server.load.mockResolvedValue({
ok: true,
data: [saved, { ...saved, id: 'second', title: 'Second' }],
})
localStorage.setItem(ACTIVE_DECK_KEY, 'second')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.deck.id).toBe('second'))
act(() => {
result.current.createTemporary('A copy', result.current.deck.columns)
})
const copy = result.current.deck
expect(copy.persisted).toBe(false)
expect(copy.id).not.toBe('second')
await act(async () => {
await result.current.save({ ...copy, title: 'Changed copy' })
})
expect(
result.current.workspace.decks.find((deck) => deck.id === 'second')?.title,
).toBe('Second')
expect(server.replace).not.toHaveBeenCalled()
})
it('does not import old browser data without an explicit action', async () => {
const legacy = {
version: 2,
activeDeckId: 'old',
decks: [{ id: 'old', title: 'Old deck', columns: [] }],
}
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(legacy))
server.import.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.legacyAvailable).toBe(true)
expect(server.import).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe(JSON.stringify(legacy))
await act(async () => {
await result.current.importLegacy()
})
expect(server.import).toHaveBeenCalledWith({ data: legacy })
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
})
it('leaves unreadable legacy data untouched', async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, '{broken')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.storageError).toContain('読み込めませんでした')
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe('{broken')
})
it('recognizes a committed save after a lost response without duplicating its temporary view', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Lost response')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
await act(async () => {
await result.current.persist(view.id)
})
server.load.mockResolvedValue({
ok: true,
data: [
{
id: view.id,
title: view.title,
columns: view.columns,
revision: 1,
createdAt: 1,
updatedAt: 1,
},
],
})
await act(async () => {
await result.current.refresh()
})
expect(
result.current.workspace.decks.filter((deck) => deck.id === view.id),
).toHaveLength(1)
expect(result.current.deck.persisted).toBe(true)
})
it('preserves a divergent temporary edit when a lost-response save appears remotely', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Before request')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
await act(async () => {
await result.current.persist(view.id)
})
await act(async () => {
await result.current.save({ ...view, title: 'Edited after failure' })
})
server.load.mockResolvedValue({
ok: true,
data: [
{
id: view.id,
title: view.title,
columns: view.columns,
revision: 1,
createdAt: 1,
updatedAt: 1,
},
],
})
await act(async () => {
await result.current.refresh()
})
expect(result.current.deck).toMatchObject({
title: 'Edited after failure',
persisted: false,
})
expect(result.current.deck.id).not.toBe(view.id)
expect(
result.current.workspace.decks.find((deck) => deck.id === view.id),
).toMatchObject({ title: 'Before request', persisted: true })
})
it('ignores an older refresh response after a newer request has completed', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
let resolveOlder: (value: unknown) => void = () => {}
server.load.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOlder = resolve
}),
)
act(() => {
void result.current.refresh()
})
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Newest', revision: 3 }],
})
await act(async () => {
await result.current.refresh()
})
expect(result.current.deck.revision).toBe(3)
await act(async () => {
resolveOlder({
ok: true,
data: [{ ...saved, title: 'Older', revision: 2 }],
})
})
expect(result.current.deck).toMatchObject({ title: 'Newest', revision: 3 })
})
+285 -54
View File
@@ -1,104 +1,335 @@
import { useEffect, useRef, useState } from 'react'
import { useServerFn } from '@tanstack/react-start'
import { useCallback, useEffect, useRef, useState } from 'react'
import { LEGACY_STORAGE_KEY, legacyWorkspaceSchema } from './legacy-import'
import {
type Deck,
type DeckView,
deckSchema,
EMPTY_DECK,
EMPTY_WORKSPACE,
newId,
type Workspace,
workspaceSchema,
} from './model'
import type { SavedDeck } from './persistence-model'
import {
createSavedDeck,
deleteSavedDeck,
importBrowserDecks,
loadSavedDecks,
replaceSavedDeck,
} from './server-functions'
export const DECK_STORAGE_KEY = 'twitter-lite-research-deck'
export const ACTIVE_DECK_KEY = 'twitter-lite-active-deck'
function asView(deck: SavedDeck): DeckView {
return {
id: deck.id,
title: deck.title,
columns: deck.columns,
revision: deck.revision,
persisted: true,
}
}
function definition(deck: Deck): Deck {
return deckSchema.parse({
id: deck.id,
title: deck.title,
columns: deck.columns,
})
}
export function useDeck() {
export function useDeck(editing = false) {
const services = {
load: useServerFn(loadSavedDecks),
create: useServerFn(createSavedDeck),
replace: useServerFn(replaceSavedDeck),
remove: useServerFn(deleteSavedDeck),
import: useServerFn(importBrowserDecks),
}
const servicesRef = useRef(services)
servicesRef.current = services
const editingRef = useRef(editing)
editingRef.current = editing
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE)
const current = useRef(workspace)
const [ready, setReady] = useState(false)
const initialized = useRef(false)
const [storageError, setStorageError] = useState<string>()
const [saving, setSaving] = useState(false)
const busy = useRef(false)
const generation = useRef(0)
const refreshSequence = useRef(0)
const [legacyAvailable, setLegacyAvailable] = useState(false)
useEffect(() => {
const apply = useCallback((next: Workspace) => {
current.current = next
setWorkspace(next)
try {
const saved = localStorage.getItem(DECK_STORAGE_KEY)
if (saved !== null) {
const restored = workspaceSchema.parse(JSON.parse(saved))
current.current = restored
setWorkspace(restored)
}
localStorage.setItem(ACTIVE_DECK_KEY, next.activeDeckId)
} catch {
setStorageError(
'保存したデッキを読み込めませんでした。編集内容を保存すると、このブラウザのデッキを置き換えます。',
)
/* Selection is a device-local preference, not saved deck data. */
}
setReady(true)
}, [])
function persist(next: Workspace) {
const validated = workspaceSchema.parse(next)
current.current = validated
setWorkspace(validated)
const reconcile = useCallback(
(saved: SavedDeck[]) => {
let preferred = current.current.activeDeckId
let temporary = current.current.decks.filter((deck) => !deck.persisted)
if (!initialized.current)
temporary = saved.length
? []
: [{ ...EMPTY_DECK, id: newId(), persisted: false }]
temporary = temporary.flatMap((view) => {
const accepted = saved.find((deck) => deck.id === view.id)
if (!accepted) return [view]
if (
JSON.stringify(definition(accepted)) ===
JSON.stringify(definition(view))
)
return []
const id = newId()
if (preferred === view.id) preferred = id
setStorageError(
'保存済みの内容と異なる編集を、一時コピーとして残しました。',
)
return [{ ...view, id }]
})
const all = [...saved.map(asView), ...temporary]
if (!all.length)
all.push({ ...EMPTY_DECK, id: newId(), persisted: false })
if (!initialized.current) {
try {
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(validated))
setStorageError(undefined)
preferred = localStorage.getItem(ACTIVE_DECK_KEY) ?? preferred
} catch {
/* Optional local preference. */
}
}
const activeDeckId = all.some((deck) => deck.id === preferred)
? preferred
: (all[0]?.id ?? '')
if (initialized.current && activeDeckId !== preferred)
setStorageError('表示中のデッキが別の端末で削除されました。')
initialized.current = true
apply({ activeDeckId, decks: all })
},
[apply],
)
const refresh = useCallback(
async (force = false) => {
if (busy.current || (!force && editingRef.current)) return false
const requestGeneration = generation.current
const requestSequence = ++refreshSequence.current
try {
const result = await servicesRef.current.load()
if (
requestGeneration !== generation.current ||
requestSequence !== refreshSequence.current ||
busy.current ||
(!force && editingRef.current)
)
return false
if (!result.ok) throw new Error(result.error.message)
if (force) setStorageError(undefined)
reconcile(result.data)
setReady(true)
return true
} catch {
if (
requestSequence !== refreshSequence.current ||
requestGeneration !== generation.current
)
return false
setStorageError(
'このブラウザに保存できません。現在の変更は、ページを閉じると失われます。',
'保存済みデッキを読み込めませんでした。再試行してください。',
)
return false
}
}
function save(next: Deck) {
const validated = deckSchema.parse(next)
const previous = current.current
const exists = previous.decks.some((deck) => deck.id === validated.id)
return persist({
...previous,
activeDeckId: validated.id,
decks: exists
? previous.decks.map((deck) =>
deck.id === validated.id ? validated : deck,
},
[reconcile],
)
: [...previous.decks, validated],
useEffect(() => {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
if (legacy) {
legacyWorkspaceSchema.parse(JSON.parse(legacy))
setLegacyAvailable(true)
}
} catch {
setStorageError(
'旧デッキを読み込めませんでした。ブラウザの保存内容は変更していません。',
)
}
void refresh()
const onFocus = () => {
if (document.visibilityState === 'visible') void refresh()
}
const timer = setInterval(onFocus, 5000)
window.addEventListener('focus', onFocus)
return () => {
clearInterval(timer)
window.removeEventListener('focus', onFocus)
}
}, [refresh])
async function mutate<T>(
operation: () => Promise<T>,
accept: (value: T) => void,
) {
if (busy.current) {
setStorageError('保存処理が完了するまでお待ちください。')
return false
}
busy.current = true
generation.current += 1
setSaving(true)
try {
const result = await operation()
accept(result)
setStorageError(undefined)
return true
} catch (error) {
setStorageError(
error instanceof Error
? error.message
: '保存できませんでした。再試行してください。',
)
return false
} finally {
busy.current = false
setSaving(false)
}
}
function upsert(view: DeckView) {
const previous = current.current
apply({
activeDeckId: view.id,
decks: previous.decks.some((deck) => deck.id === view.id)
? previous.decks.map((deck) => (deck.id === view.id ? view : deck))
: [...previous.decks, view],
})
}
function create(title: string) {
return save({ id: newId(), title, columns: [] })
async function save(next: Deck, expectedRevision?: number) {
const parsed = definition(next)
const existing = current.current.decks.find((deck) => deck.id === parsed.id)
if (!existing?.persisted) {
if (busy.current) return false
generation.current += 1
upsert({ ...parsed, persisted: false })
return true
}
return mutate(
async () => {
const result = await servicesRef.current.replace({
data: {
deck: parsed,
expectedRevision: expectedRevision ?? existing.revision ?? 0,
},
})
if (!result.ok) throw new Error(result.error.message)
return result.data
},
(result) => upsert(asView(result)),
)
}
function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
return persist({ ...current.current, activeDeckId: id })
apply({ ...current.current, activeDeckId: id })
return true
}
function remove(id: string) {
function createTemporary(title: string, columns: Deck['columns'] = []) {
const view = {
...definition({ id: newId(), title, columns }),
persisted: false,
}
upsert(view)
return view
}
async function persist(id: string) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('デッキが見つかりません。')
if (view.persisted) return true
return mutate(
async () => {
const result = await servicesRef.current.create({
data: definition(view),
})
if (!result.ok) throw new Error(result.error.message)
return result.data
},
(result) => upsert(asView(result)),
)
}
async function create(title: string) {
const view = createTemporary(title)
return persist(view.id)
}
async function remove(id: string, expectedRevision?: number) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('デッキが見つかりません。')
const accept = () => {
const previous = current.current
if (!previous.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
const decks = previous.decks.filter((deck) => deck.id !== id)
const first = decks[0]
if (!first) throw new Error('最後のデッキは削除できません。')
return persist({
...previous,
decks,
if (!decks.length)
decks.push({ ...EMPTY_DECK, id: newId(), persisted: false })
apply({
activeDeckId:
previous.activeDeckId === id ? first.id : previous.activeDeckId,
previous.activeDeckId === id
? (decks[0]?.id ?? '')
: previous.activeDeckId,
decks,
})
}
const deck =
workspace.decks.find((deck) => deck.id === workspace.activeDeckId) ??
EMPTY_DECK
if (!view.persisted) {
if (busy.current) return false
accept()
return true
}
return mutate(async () => {
const result = await servicesRef.current.remove({
data: { id, expectedRevision: expectedRevision ?? view.revision ?? 0 },
})
if (!result.ok) throw new Error(result.error.message)
}, accept)
}
async function importLegacy() {
return mutate(
async () => {
const data = legacyWorkspaceSchema.parse(
JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) ?? 'null'),
)
const result = await servicesRef.current.import({ data })
if (!result.ok) throw new Error(result.error.message)
return result.data
},
(result) => {
reconcile(result)
setLegacyAvailable(false)
try {
localStorage.removeItem(LEGACY_STORAGE_KEY)
} catch {
/* Server marker prevents duplicate imports on retry. */
}
},
)
}
return {
deck,
deck: workspace.decks.find(
(deck) => deck.id === workspace.activeDeckId,
) ?? { ...EMPTY_DECK, persisted: false },
workspace,
ready,
saving,
storageError,
legacyAvailable,
save,
create,
createTemporary,
persist,
select,
remove,
refresh,
importLegacy,
getWorkspace: () => current.current,
}
}
+44 -12
View File
@@ -1,15 +1,24 @@
import { describe, expect, it } from 'vitest'
import type { Connection } from '#/features/connections/model'
import { prepareDeck } from './webmcp-contracts'
const account = (id: string): Connection => ({
id,
platform: 'twitter',
origin: 'https://relay.invalid',
accountId: null,
displayName: id,
status: 'connected',
})
const column = {
title: '観点',
profileName: 'main',
connectionId: 'main',
source: { kind: 'search', query: 'AI' },
}
describe('deck tool input', () => {
it('fills Twitter defaults and assigns independent deck and column IDs', () => {
const deck = prepareDeck({ title: '調査', columns: [column, column] }, [
'main',
account('main'),
])
expect(deck.columns[0]?.source).toEqual({
platform: 'twitter',
@@ -27,19 +36,19 @@ describe('deck tool input', () => {
expect(
prepareDeck(
{ deckId: 'research', title: '調査', columns: [identified] },
['main'],
[account('main')],
),
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
expect(() =>
prepareDeck({ title: '調査', columns: [identified, identified] }, [
'main',
account('main'),
]),
).toThrow()
})
it('rejects unavailable profiles before applying a definition', () => {
expect(() =>
prepareDeck({ title: '調査', columns: [column] }, ['work']),
).toThrow('Unknown relay profile')
prepareDeck({ title: '調査', columns: [column] }, [account('work')]),
).toThrow('Unavailable connection')
})
it('accepts user and list columns bound to separate profiles', () => {
const deck = prepareDeck(
@@ -49,14 +58,14 @@ describe('deck tool input', () => {
{ ...column, source: { kind: 'user', target: '@alice' } },
{
...column,
profileName: 'work',
connectionId: 'work',
source: { kind: 'list', target: '123' },
},
],
},
['main', 'work'],
[account('main'), account('work')],
)
expect(deck.columns.map((column) => column.profileName)).toEqual([
expect(deck.columns.map((column) => column.connectionId)).toEqual([
'main',
'work',
])
@@ -77,7 +86,7 @@ describe('deck tool input', () => {
},
],
},
['main'],
[account('main')],
),
).toThrow()
expect(() =>
@@ -95,14 +104,37 @@ describe('deck tool input', () => {
},
],
},
['main'],
[account('main')],
),
).toThrow()
expect(() =>
prepareDeck(
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
['main'],
[account('main')],
),
).toThrow()
})
})
it('builds a mixed-platform deck with explicit account bindings', () => {
const deck = prepareDeck(
{
title: 'Mixed',
columns: [
column,
{
title: 'Mastodon tag',
connectionId: 'masto',
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' },
},
],
},
[account('main'), { ...account('masto'), platform: 'mastodon' }],
)
expect(
deck.columns.map((column) => [column.connectionId, column.source.platform]),
).toEqual([
['main', 'twitter'],
['masto', 'mastodon'],
])
})
+32 -6
View File
@@ -1,4 +1,6 @@
import { z } from 'zod'
import type { Connection } from '#/features/connections/model'
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
import { InputError } from '#/features/posts/inputs'
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model'
@@ -8,7 +10,9 @@ const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
.describe(
'Keep an existing column ID when editing; omit for a new column.',
),
source: z.discriminatedUnion('kind', [
source: z.union([
mastodonSourceSchema,
z.discriminatedUnion('kind', [
z
.object({
platform: z.literal('twitter').default('twitter'),
@@ -33,9 +37,13 @@ const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
})
.strict(),
]),
]),
})
export const emptyToolInput = z.object({}).strict()
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict()
export const deleteDeckToolInput = deckIdInput
.extend({ expectedRevision: z.number().int().positive().optional() })
.strict()
export const getDeckInput = z
.object({ deckId: deckSchema.shape.id.optional() })
.strict()
@@ -44,19 +52,30 @@ export const setDeckInput = z
deckId: deckSchema.shape.id
.optional()
.describe(
'Omit to create a deck; supply an existing deck ID to replace and activate it.',
'Omit to create a temporary view in this tab; supply an existing ID to replace and activate it.',
),
expectedRevision: z
.number()
.int()
.positive()
.optional()
.describe(
'Required when replacing a saved deck; omit for a temporary view.',
),
title: deckSchema.shape.title,
columns: z
.array(toolColumn)
.max(MAX_COLUMNS)
.describe(
'Complete ordered columns, each bound to a relay profile from list_decks. Omitted existing columns are removed.',
'Complete ordered columns, each bound to a connection ID from list_connections. Omitted existing columns are removed.',
),
})
.strict()
export function prepareDeck(input: unknown, profiles: readonly string[]) {
export function prepareDeck(
input: unknown,
connections: readonly Connection[],
) {
const parsed = setDeckInput.parse(input)
const deck = deckSchema.parse({
id: parsed.deckId ?? newId(),
@@ -67,8 +86,15 @@ export function prepareDeck(input: unknown, profiles: readonly string[]) {
})),
})
for (const column of deck.columns) {
if (!profiles.includes(column.profileName))
throw new InputError(`Unknown relay profile: ${column.profileName}`)
if (
!connections.some(
(connection) =>
connection.id === column.connectionId &&
connection.platform === column.source.platform &&
connection.status === 'connected',
)
)
throw new InputError(`Unavailable connection: ${column.connectionId}`)
}
return deck
}
+101 -59
View File
@@ -1,8 +1,10 @@
import { useWebMCP } from 'usewebmcp'
import type { Deck, Workspace } from './model'
import type { Connection } from '#/features/connections/model'
import type { Deck, DeckView, Workspace } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import {
deckIdInput,
deleteDeckToolInput,
emptyToolInput,
getDeckInput,
prepareDeck,
@@ -12,126 +14,166 @@ import {
export function useDeckTools({
ready,
saving,
storageError,
save,
select,
remove,
getWorkspace,
profiles,
connections,
onApplied,
createTemporary,
persist,
}: {
ready: boolean
saving: boolean
storageError?: string
save: (deck: Deck) => boolean
save: (deck: Deck, expectedRevision?: number) => Promise<boolean>
select: (id: string) => boolean
remove: (id: string) => boolean
remove: (id: string, expectedRevision?: number) => Promise<boolean>
getWorkspace: () => Workspace
profiles: string[] | undefined
connections: Connection[] | undefined
onApplied: () => void
createTemporary: (title: string, columns?: Deck['columns']) => DeckView
persist: (id: string) => Promise<boolean>
}) {
const supported = useWebMCPSupported()
const enabled = supported && ready
function applied(persisted: boolean) {
onApplied()
if (!persisted)
throw new Error(
'The deck was applied in this tab but could not be saved. Reloading will lose these changes.',
const enabled = useWebMCPSupported() && ready
function requireDeck(id?: string) {
const workspace = getWorkspace()
const deck = workspace.decks.find(
(deck) => deck.id === (id ?? workspace.activeDeckId),
)
if (!deck) throw new Error('Deck not found.')
return deck
}
function requireIdle() {
if (saving) throw new Error('Wait for the current save to finish.')
}
function applied(success: boolean) {
if (!success)
throw new Error(
'The operation was not saved. Read the deck and latest revision before retrying. Temporary views and unsaved forms remain available.',
)
onApplied()
}
useWebMCP({
name: 'list_decks',
name: 'list_connections',
description:
'List saved decks and their columns, activeDeckId and available relay profile names. Conditions are stored in this browser only; posts are not included.',
'List available connected accounts and stable connection IDs for each platform. Credentials are never returned.',
inputSchema: emptyToolInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
profiles: profiles ?? null,
storageError: storageError ?? null,
}
return { connections: connections ?? null }
}),
})
useWebMCP({
name: 'list_decks',
description:
'List shared saved decks and this tab’s temporary views, with persistence flags, revisions and activeDeckId. Active selection is local to this device.',
inputSchema: emptyToolInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
return { ...getWorkspace(), storageError: storageError ?? null }
}),
})
useWebMCP({
name: 'get_deck',
description:
'Read one saved deck with column IDs, bound relay profiles and source definitions. Omit deckId for the active deck. No post fetch.',
'Read the definition, persistence flag and revision of a saved deck or temporary view. Omit deckId for the active view. Does not fetch posts.',
inputSchema: getDeckInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
const { deckId } = getDeckInput.parse(input)
const workspace = getWorkspace()
const deck = workspace.decks.find(
(deck) => deck.id === (deckId ?? workspace.activeDeckId),
)
if (!deck) throw new Error('Deck not found.')
return { deck, storageError: storageError ?? null }
}),
toolResult(() => ({
deck: requireDeck(getDeckInput.parse(input).deckId),
storageError: storageError ?? null,
})),
})
useWebMCP({
name: 'set_deck',
description:
'Create a saved deck when deckId is omitted, or replace an existing deck by ID, then activate it. Up to six ordered columns: search, user timeline or list, each with an explicit relay profile from list_decks. Omitted columns are removed. Closes unsaved forms. Posts load asynchronously and can fail independently.',
'Create a TEMPORARY view when deckId is omitted. It exists only in this tab until save_deck is explicitly called. To replace an existing view supply its deckId; saved decks additionally require expectedRevision from get_deck. Complete ordered columns replace all previous columns, at most six, bound to connection IDs from list_connections. Activates the view and closes unsaved forms only on success; posts load asynchronously.',
inputSchema: setDeckInput,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
toolResult(async () => {
requireIdle()
const parsed = setDeckInput.parse(input)
if (
parsed.deckId &&
!getWorkspace().decks.some((deck) => deck.id === parsed.deckId)
if (!connections) throw new Error('Connections are not available yet.')
const next = prepareDeck(parsed, connections)
if (!parsed.deckId) {
const deck = createTemporary(next.title, next.columns)
onApplied()
return { deck, persisted: false, posts: 'loading-asynchronously' }
}
const previous = requireDeck(parsed.deckId)
if (previous.persisted && parsed.expectedRevision === undefined)
throw new Error(
'expectedRevision is required to update a saved deck.',
)
throw new Error('Deck not found.')
if (!profiles) throw new Error('Relay profiles are not available yet.')
const next = prepareDeck(parsed, profiles)
applied(save(next))
return { deck: next, persisted: true, posts: 'loading-asynchronously' }
applied(await save(next, parsed.expectedRevision))
const deck = requireDeck(next.id)
return {
deck,
persisted: deck.persisted,
posts: 'loading-asynchronously',
}
}),
})
useWebMCP({
name: 'save_deck',
description:
'Explicitly persist a temporary view as a new shared deck. Keeps its ID to make retries idempotent. Success makes it available to other devices; failure keeps the temporary view intact. Already saved decks are unchanged.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(async () => {
requireIdle()
const { deckId } = deckIdInput.parse(input)
applied(await persist(deckId))
return { deck: requireDeck(deckId), persisted: true }
}),
})
useWebMCP({
name: 'select_deck',
description:
'Activate a saved deck by ID and save the selection in this browser. Closes unsaved forms; its columns load asynchronously.',
'Select a saved deck or temporary view in this tab. Active selection is stored only on this device. Closes unsaved forms and loads columns asynchronously.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(() => {
requireIdle()
const { deckId } = deckIdInput.parse(input)
applied(select(deckId))
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
persisted: true,
}
return getWorkspace()
}),
})
useWebMCP({
name: 'delete_deck',
description:
'Permanently delete one saved deck and its column definitions from this browser. The last remaining deck cannot be deleted. Deleting the active deck selects the first remaining deck.',
inputSchema: deckIdInput,
'Discard a temporary view or permanently delete a saved deck from the server for all devices. Saved deletion requires expectedRevision. Temporary deletion does not change server data.',
inputSchema: deleteDeckToolInput,
enabled,
annotations: { readOnlyHint: false, destructiveHint: true },
execute: (input) =>
toolResult(() => {
const { deckId } = deckIdInput.parse(input)
applied(remove(deckId))
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
persisted: true,
}
toolResult(async () => {
requireIdle()
const { deckId, expectedRevision } = deleteDeckToolInput.parse(input)
if (requireDeck(deckId).persisted && expectedRevision === undefined)
throw new Error(
'expectedRevision is required to delete a saved deck.',
)
applied(await remove(deckId, expectedRevision))
return getWorkspace()
}),
})
}
+65
View File
@@ -0,0 +1,65 @@
import { eq } from 'drizzle-orm'
import { decryptCredential } from '../connections/credentials.server'
import { getDatabase } from '../storage/database.server'
import { connectionCredentials, connections } from '../storage/schema'
import { requireMastodonOrigin, safeMastodonRequest } from './transport.server'
export async function requestMastodon(
connectionId: string,
path: string,
query?: URLSearchParams,
): Promise<Response> {
if (
!path.startsWith('/api/') ||
path.startsWith('//') ||
path.includes('\\') ||
path.includes('?') ||
path.includes('#')
) {
throw new Error('Invalid Mastodon API path.')
}
const database = getDatabase()
const connection = database
.select()
.from(connections)
.where(eq(connections.id, connectionId))
.get()
if (connection?.platform !== 'mastodon' || connection.status !== 'connected')
throw new Error(
'Mastodon connection is unavailable. Reconnect this account.',
)
const credential = database
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
if (!credential)
throw new Error(
'Mastodon credentials are unavailable. Reconnect this account.',
)
const url = new URL(path, requireMastodonOrigin(connection.origin))
if (query) url.search = query.toString()
const response = await safeMastodonRequest(url, {
headers: {
authorization: `Bearer ${decryptCredential(credential.encryptedToken, `connection:${connectionId}:access-token`)}`,
},
})
if (response.status === 401) {
database.transaction(
(tx) => {
const current = tx
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
if (current?.encryptedToken !== credential.encryptedToken) return
tx.update(connections)
.set({ status: 'expired', updatedAt: Date.now() })
.where(eq(connections.id, connectionId))
.run()
},
{ behavior: 'immediate' },
)
}
return response
}
+100
View File
@@ -0,0 +1,100 @@
// @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 { requestMastodon } from './client.server'
let database: AppDatabase
const send = vi.hoisted(() => vi.fn())
vi.mock('../storage/database.server', async (original) => ({
...(await original<typeof import('../storage/database.server')>()),
getDatabase: () => database,
}))
vi.mock('../connections/credentials.server', () => ({
decryptCredential: (value: string) => value,
}))
vi.mock('./transport.server', () => ({
requireMastodonOrigin: (origin: string) => origin,
safeMastodonRequest: send,
}))
beforeEach(() => {
send.mockReset()
database = openDatabase(':memory:')
database
.insert(connections)
.values({
id: 'account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: 'Account',
status: 'connected',
createdAt: 1,
updatedAt: 1,
})
.run()
database
.insert(connectionCredentials)
.values({
connectionId: 'account',
encryptedToken: 'old-token',
updatedAt: 1,
})
.run()
})
afterEach(() => database.$client.close())
it('marks the current rejected credential expired', async () => {
send.mockResolvedValue(new Response(null, { status: 401 }))
expect((await requestMastodon('account', '/api/v1/lists')).status).toBe(401)
expect(database.select().from(connections).get()?.status).toBe('expired')
})
it('does not expire a reconnected account when an old request returns 401 later', async () => {
let resolveResponse!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
resolveResponse = resolve
})
send.mockReturnValueOnce(response)
const request = requestMastodon('account', '/api/v1/lists')
expect(send).toHaveBeenCalledWith(
new URL('https://mastodon.invalid/api/v1/lists'),
{ headers: { authorization: 'Bearer old-token' } },
)
database
.update(connectionCredentials)
.set({
encryptedToken: 'new-token',
updatedAt: 2,
})
.where(eq(connectionCredentials.connectionId, 'account'))
.run()
resolveResponse(new Response(null, { status: 401 }))
expect((await request).status).toBe(401)
expect(database.select().from(connections).get()?.status).toBe('connected')
expect(
database.select().from(connectionCredentials).get()?.encryptedToken,
).toBe('new-token')
send.mockResolvedValueOnce(Response.json([]))
expect((await requestMastodon('account', '/api/v1/lists')).ok).toBe(true)
expect(send).toHaveBeenLastCalledWith(
new URL('https://mastodon.invalid/api/v1/lists'),
{ headers: { authorization: 'Bearer new-token' } },
)
})
it('does not change a disconnected account when an old request returns 401 later', async () => {
let resolveResponse!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
resolveResponse = resolve
})
send.mockReturnValueOnce(response)
const request = requestMastodon('account', '/api/v1/lists')
database.delete(connectionCredentials).run()
database.update(connections).set({ status: 'disconnected' }).run()
resolveResponse(new Response(null, { status: 401 }))
await request
expect(database.select().from(connections).get()?.status).toBe('disconnected')
})
+364
View File
@@ -0,0 +1,364 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { and, eq, gt, isNull, lt } from 'drizzle-orm'
import { z } from 'zod'
import { readAccessConfig } from '../access/policy.server'
import {
decryptCredential,
encryptCredential,
} from '../connections/credentials.server'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
oauthApps,
oauthAttempts,
} from '../storage/schema'
import { requireMastodonOrigin, safeMastodonRequest } from './transport.server'
export const oauthLifetimeSeconds = 600
const scopes = 'read:accounts read:statuses read:lists read:search'
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
const random = () => randomBytes(32).toString('base64url')
const appSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
})
const tokenSchema = z.object({
access_token: z.string().min(1),
token_type: z.string().regex(/^bearer$/i),
scope: z.string(),
})
const accountSchema = z.object({
id: z.string().min(1),
acct: z.string().min(1),
})
type Send = typeof safeMastodonRequest
export function oauthCookieName(state: string) {
return `mastodon_oauth_${hash(state).slice(0, 24)}`
}
function callbackUri() {
const config = readAccessConfig()
if (!config) throw new Error('Access configuration is required.')
return `${config.origin}/oauth/mastodon/callback`
}
async function formRequest(
send: Send,
origin: string,
path: string,
fields: Record<string, string>,
) {
const response = await send(new URL(path, origin), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields),
})
if (!response.ok)
throw new Error(`Mastodon request failed with HTTP ${response.status}.`)
return response
}
async function getApp(origin: string, database: AppDatabase, send: Send) {
const redirectUri = callbackUri()
const existing = database
.select()
.from(oauthApps)
.where(
and(
eq(oauthApps.origin, origin),
eq(oauthApps.redirectUri, redirectUri),
eq(oauthApps.scopes, scopes),
),
)
.get()
if (existing) return existing
const metadataResponse = await send(
new URL('/.well-known/oauth-authorization-server', origin),
)
if (!metadataResponse.ok)
throw new Error('Mastodon OAuth metadata is unavailable.')
const metadata = z
.object({ code_challenge_methods_supported: z.array(z.string()) })
.parse(await metadataResponse.json())
if (!metadata.code_challenge_methods_supported.includes('S256'))
throw new Error('This instance does not support PKCE S256.')
const registered = appSchema.parse(
await (
await formRequest(send, origin, '/api/v1/apps', {
client_name: 'Twitter Lite',
redirect_uris: redirectUri,
scopes,
})
).json(),
)
const id = randomUUID()
const app = {
id,
origin,
redirectUri,
scopes,
clientId: registered.client_id,
encryptedClientSecret: encryptCredential(
registered.client_secret,
`oauth-app:${id}:client-secret`,
),
createdAt: Date.now(),
}
database.insert(oauthApps).values(app).onConflictDoNothing().run()
return (
database
.select()
.from(oauthApps)
.where(
and(
eq(oauthApps.origin, origin),
eq(oauthApps.redirectUri, redirectUri),
eq(oauthApps.scopes, scopes),
),
)
.get() ?? app
)
}
export async function beginMastodonOAuth(
input: { origin: string; connectionId?: string },
database = getDatabase(),
send: Send = safeMastodonRequest,
) {
database
.delete(oauthAttempts)
.where(lt(oauthAttempts.expiresAt, Date.now()))
.run()
const origin = requireMastodonOrigin(input.origin)
if (input.connectionId) {
const connection = database
.select()
.from(connections)
.where(eq(connections.id, input.connectionId))
.get()
if (connection?.platform !== 'mastodon' || connection.origin !== origin)
throw new Error('The reconnect account does not match this instance.')
}
const app = await getApp(origin, database, send)
const state = random()
const browserCookie = random()
const verifier = random()
const stateHash = hash(state)
database
.insert(oauthAttempts)
.values({
stateHash,
browserHash: hash(browserCookie),
appId: app.id,
encryptedVerifier: encryptCredential(
verifier,
`oauth-attempt:${stateHash}:verifier`,
),
connectionId: input.connectionId ?? null,
expiresAt: Date.now() + oauthLifetimeSeconds * 1000,
})
.run()
const authorize = new URL('/oauth/authorize', origin)
authorize.search = new URLSearchParams({
response_type: 'code',
client_id: app.clientId,
redirect_uri: app.redirectUri,
scope: app.scopes,
state,
force_login: 'true',
code_challenge_method: 'S256',
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
}).toString()
return { authorizationUrl: authorize.toString(), state, browserCookie }
}
export async function completeMastodonOAuth(
input: {
state: string
browserCookie?: string
code?: string
error?: string
},
database = getDatabase(),
send: Send = safeMastodonRequest,
): Promise<string> {
if (!input.state || !input.browserCookie)
throw new Error('OAuth state or browser binding is missing.')
const stateHash = hash(input.state)
const now = Date.now()
const attempt = database
.update(oauthAttempts)
.set({ consumedAt: now })
.where(
and(
eq(oauthAttempts.stateHash, stateHash),
eq(oauthAttempts.browserHash, hash(input.browserCookie)),
isNull(oauthAttempts.consumedAt),
gt(oauthAttempts.expiresAt, now),
),
)
.returning()
.get()
if (!attempt)
throw new Error('OAuth attempt is invalid, expired, or already used.')
if (input.error || !input.code)
throw new Error('Mastodon authorization was not completed.')
const app = database
.select()
.from(oauthApps)
.where(eq(oauthApps.id, attempt.appId))
.get()
if (!app) throw new Error('OAuth app is unavailable.')
requireMastodonOrigin(app.origin)
const token = tokenSchema.parse(
await (
await formRequest(send, app.origin, '/oauth/token', {
grant_type: 'authorization_code',
code: input.code,
client_id: app.clientId,
client_secret: decryptCredential(
app.encryptedClientSecret,
`oauth-app:${app.id}:client-secret`,
),
redirect_uri: app.redirectUri,
code_verifier: decryptCredential(
attempt.encryptedVerifier,
`oauth-attempt:${stateHash}:verifier`,
),
})
).json(),
)
const granted = new Set(token.scope.split(' '))
if (!scopes.split(' ').every((scope) => granted.has(scope)))
throw new Error('Required Mastodon read permissions were not granted.')
const verified = await send(
new URL('/api/v1/accounts/verify_credentials', app.origin),
{
headers: { authorization: `Bearer ${token.access_token}` },
},
)
if (!verified.ok) throw new Error('Mastodon account verification failed.')
const account = accountSchema.parse(await verified.json())
return database.transaction((tx) => {
const existing = tx
.select()
.from(connections)
.where(
and(
eq(connections.platform, 'mastodon'),
eq(connections.origin, app.origin),
eq(connections.accountId, account.id),
),
)
.get()
if (attempt.connectionId && existing?.id !== attempt.connectionId)
throw new Error(
'The authorized account does not match the account being reconnected.',
)
const id = existing?.id ?? randomUUID()
tx.insert(connections)
.values({
id,
platform: 'mastodon',
origin: app.origin,
accountId: account.id,
displayName: `@${account.acct}`,
status: 'connected',
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: connections.id,
set: {
displayName: `@${account.acct}`,
status: 'connected',
updatedAt: now,
},
})
.run()
const credential = {
connectionId: id,
appId: app.id,
encryptedToken: encryptCredential(
token.access_token,
`connection:${id}:access-token`,
),
updatedAt: now,
}
tx.insert(connectionCredentials)
.values(credential)
.onConflictDoUpdate({
target: connectionCredentials.connectionId,
set: credential,
})
.run()
return id
})
}
export async function disconnectMastodon(
connectionId: string,
database = getDatabase(),
send: Send = safeMastodonRequest,
) {
const connection = database
.select()
.from(connections)
.where(eq(connections.id, connectionId))
.get()
if (connection?.platform !== 'mastodon')
throw new Error('Mastodon connection was not found.')
const credential = database
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
if (credential) {
const app = credential.appId
? database
.select()
.from(oauthApps)
.where(eq(oauthApps.id, credential.appId))
.get()
: undefined
if (!app)
throw new Error(
'The OAuth app required to revoke this connection is missing.',
)
requireMastodonOrigin(connection.origin)
await formRequest(send, connection.origin, '/oauth/revoke', {
client_id: app.clientId,
client_secret: decryptCredential(
app.encryptedClientSecret,
`oauth-app:${app.id}:client-secret`,
),
token: decryptCredential(
credential.encryptedToken,
`connection:${connectionId}:access-token`,
),
})
}
database.transaction(
(tx) => {
const current = tx
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
if (current?.encryptedToken !== credential?.encryptedToken)
throw new Error(
'This account was reconnected during revocation. Its new connection has been kept. Refresh the account list before trying again.',
)
tx.delete(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.run()
tx.update(connections)
.set({ status: 'disconnected', updatedAt: Date.now() })
.where(eq(connections.id, connectionId))
.run()
},
{ behavior: 'immediate' },
)
}
+357
View File
@@ -0,0 +1,357 @@
// @vitest-environment node
import { createHash, randomBytes } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { decryptCredential } from '../connections/credentials.server'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
oauthApps,
oauthAttempts,
} from '../storage/schema'
import {
beginMastodonOAuth,
completeMastodonOAuth,
disconnectMastodon,
} from './oauth.server'
let database: AppDatabase
let directory: string
let accountId: string
const origin = 'https://mastodon.invalid'
const token = 'private-access-token'
const clientSecret = 'private-client-secret'
const send = vi.fn(async (url: URL, _init?: RequestInit): Promise<Response> => {
if (url.pathname === '/.well-known/oauth-authorization-server')
return Response.json({ code_challenge_methods_supported: ['S256'] })
if (url.pathname === '/api/v1/apps')
return Response.json({ client_id: 'client', client_secret: clientSecret })
if (url.pathname === '/oauth/token')
return Response.json({
access_token: token,
token_type: 'Bearer',
scope: 'read:accounts read:statuses read:lists read:search',
})
if (url.pathname === '/api/v1/accounts/verify_credentials')
return Response.json({ id: accountId, acct: `user${accountId}` })
if (url.pathname === '/oauth/revoke') return Response.json({})
throw new Error('Unexpected mock endpoint')
})
beforeEach(() => {
database = openDatabase(':memory:')
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-oauth-'))
const key = join(directory, 'key')
writeFileSync(key, randomBytes(32).toString('base64'), { mode: 0o600 })
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', key)
vi.stubEnv('TWITTER_LITE_ORIGIN', 'https://deck.invalid')
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '[email protected]')
vi.stubEnv(
'TWITTER_LITE_MASTODON_ORIGINS',
`${origin},https://second.invalid`,
)
accountId = '1'
send.mockClear()
})
afterEach(() => {
database.$client.close()
rmSync(directory, { force: true, recursive: true })
vi.unstubAllEnvs()
})
it('registers once and uses force-login, state and PKCE while storing only encrypted secrets', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const url = new URL(attempt.authorizationUrl)
expect(url.origin).toBe(origin)
expect(url.searchParams.get('redirect_uri')).toBe(
'https://deck.invalid/oauth/mastodon/callback',
)
expect(url.searchParams.get('force_login')).toBe('true')
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
expect(url.searchParams.get('state')).toBe(attempt.state)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'authorization-code' },
database,
send,
)
const credentials = database.select().from(connectionCredentials).get()
expect(credentials?.connectionId).toBe(id)
expect(
decryptCredential(
credentials?.encryptedToken ?? '',
`connection:${id}:access-token`,
),
).toBe(token)
const saved = JSON.stringify({
apps: database.select().from(oauthApps).all(),
attempts: database.select().from(oauthAttempts).all(),
credentials,
})
expect(saved).not.toContain(token)
expect(saved).not.toContain(clientSecret)
expect(saved).not.toContain(attempt.state)
expect(saved).not.toContain(attempt.browserCookie)
const exchange = send.mock.calls.find(
([request]) => request.pathname === '/oauth/token',
)
const fields = exchange?.[1]?.body as URLSearchParams
expect(
createHash('sha256')
.update(fields.get('code_verifier') ?? '')
.digest('base64url'),
).toBe(url.searchParams.get('code_challenge'))
await beginMastodonOAuth({ origin }, database, send)
expect(
send.mock.calls.filter(([request]) => request.pathname === '/api/v1/apps'),
).toHaveLength(1)
})
it('supports two accounts per instance and accounts on another instance without changing reconnect IDs', async () => {
const first = await beginMastodonOAuth({ origin }, database, send)
const firstId = await completeMastodonOAuth(
{ ...first, code: 'first' },
database,
send,
)
accountId = '2'
const second = await beginMastodonOAuth({ origin }, database, send)
const secondId = await completeMastodonOAuth(
{ ...second, code: 'second' },
database,
send,
)
expect(secondId).not.toBe(firstId)
const another = await beginMastodonOAuth(
{ origin: 'https://second.invalid' },
database,
send,
)
const anotherId = await completeMastodonOAuth(
{ ...another, code: 'another' },
database,
send,
)
expect(anotherId).not.toBe(secondId)
const reconnect = await beginMastodonOAuth(
{ origin, connectionId: secondId },
database,
send,
)
expect(
await completeMastodonOAuth(
{ ...reconnect, code: 'reconnect' },
database,
send,
),
).toBe(secondId)
expect(database.select().from(connections).all()).toHaveLength(3)
})
it('rejects mismatched reconnect accounts without replacing the existing credential', async () => {
const first = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...first, code: 'first' },
database,
send,
)
const before = database.select().from(connectionCredentials).get()
const reconnect = await beginMastodonOAuth(
{ origin, connectionId: id },
database,
send,
)
accountId = 'different'
await expect(
completeMastodonOAuth({ ...reconnect, code: 'other' }, database, send),
).rejects.toThrow('does not match')
expect(database.select().from(connectionCredentials).get()).toEqual(before)
})
it('rejects browser mismatch and unknown state before exchanging a code', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const calls = send.mock.calls.length
await expect(
completeMastodonOAuth(
{ ...attempt, browserCookie: 'another-browser', code: 'code' },
database,
send,
),
).rejects.toThrow('invalid, expired, or already used')
await expect(
completeMastodonOAuth(
{ ...attempt, state: 'unknown-state', code: 'code' },
database,
send,
),
).rejects.toThrow('invalid, expired, or already used')
expect(send).toHaveBeenCalledTimes(calls)
})
it('consumes rejected authorization attempts so they cannot be retried', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
await expect(
completeMastodonOAuth(
{ ...attempt, error: 'access_denied' },
database,
send,
),
).rejects.toThrow('not completed')
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
).rejects.toThrow('already used')
expect(database.select().from(connections).all()).toHaveLength(0)
})
it('rejects expired attempts without exchanging the code', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
database
.update(oauthAttempts)
.set({ expiresAt: Date.now() - 1 })
.run()
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
).rejects.toThrow('expired')
expect(
send.mock.calls.filter(([request]) => request.pathname === '/oauth/token'),
).toHaveLength(0)
})
it('allows exactly one exchange when callbacks race', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const results = await Promise.allSettled([
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
])
expect(results.map((result) => result.status).sort()).toEqual([
'fulfilled',
'rejected',
])
expect(
send.mock.calls.filter(([request]) => request.pathname === '/oauth/token'),
).toHaveLength(1)
})
it('rejects instances outside the configured allowlist without network requests', async () => {
await expect(
beginMastodonOAuth(
{ origin: 'https://unconfigured.invalid' },
database,
send,
),
).rejects.toThrow('not configured')
expect(send).not.toHaveBeenCalled()
})
it('revokes with the original app, erases credentials, and retains the column binding ID', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
send,
)
vi.stubEnv('TWITTER_LITE_ORIGIN', 'https://new-deck.invalid')
await disconnectMastodon(id, database, send)
expect(database.select().from(connectionCredentials).all()).toHaveLength(0)
expect(
database.select().from(connections).where(eq(connections.id, id)).get()
?.status,
).toBe('disconnected')
const revoke = send.mock.calls.find(
([request]) => request.pathname === '/oauth/revoke',
)
const fields = revoke?.[1]?.body as URLSearchParams
expect(fields.get('client_secret')).toBe(clientSecret)
expect(fields.get('token')).toBe(token)
})
it('keeps credentials available for another revoke attempt when revocation fails', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
send,
)
const failedRevoke = vi.fn(async () => new Response(null, { status: 503 }))
await expect(disconnectMastodon(id, database, failedRevoke)).rejects.toThrow(
'HTTP 503',
)
expect(database.select().from(connectionCredentials).all()).toHaveLength(1)
expect(database.select().from(connections).get()?.status).toBe('connected')
})
it('keeps a new credential when an older disconnect completes after reconnection', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
send,
)
let resolveResponse!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
resolveResponse = resolve
})
const pendingRevoke = vi.fn(() => response)
const disconnect = disconnectMastodon(id, database, pendingRevoke)
expect(pendingRevoke).toHaveBeenCalledTimes(1)
const reconnect = await beginMastodonOAuth(
{ origin, connectionId: id },
database,
send,
)
await completeMastodonOAuth(
{ ...reconnect, code: 'reconnect' },
database,
async (url, init) =>
url.pathname === '/oauth/token'
? Response.json({
access_token: 'new-access-token',
token_type: 'Bearer',
scope: 'read:accounts read:statuses read:lists read:search',
})
: send(url, init),
)
resolveResponse(Response.json({}))
await expect(disconnect).rejects.toThrow('reconnected during revocation')
const credential = database.select().from(connectionCredentials).get()
expect(
decryptCredential(
credential?.encryptedToken ?? '',
`connection:${id}:access-token`,
),
).toBe('new-access-token')
expect(database.select().from(connections).get()?.status).toBe('connected')
})
it('rejects incomplete scopes without saving the token', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const limitedGrant = vi.fn(async () =>
Response.json({
access_token: token,
token_type: 'Bearer',
scope: 'read:accounts',
}),
)
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, limitedGrant),
).rejects.toThrow('permissions were not granted')
expect(database.select().from(connectionCredentials).all()).toHaveLength(0)
})
it('consumes an attempt before a failing token exchange', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const failedExchange = vi.fn(async () => new Response(null, { status: 400 }))
await expect(
completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
failedExchange,
),
).rejects.toThrow('HTTP 400')
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
).rejects.toThrow('already used')
})
+42
View File
@@ -0,0 +1,42 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const loadMastodonInstances = createServerFn({ method: 'GET' }).handler(
async () => {
const { mastodonOrigins } = await import('./transport.server')
return { origins: mastodonOrigins() }
},
)
export const startMastodonOAuth = createServerFn({ method: 'POST' })
.validator(
z
.object({
origin: z.string(),
connectionId: z.string().min(1).optional(),
})
.strict(),
)
.handler(async ({ data }) => {
const { setCookie } = await import('@tanstack/react-start/server')
const { beginMastodonOAuth, oauthCookieName, oauthLifetimeSeconds } =
await import('./oauth.server')
const { readAccessConfig } = await import('../access/policy.server')
const attempt = await beginMastodonOAuth(data)
setCookie(oauthCookieName(attempt.state), attempt.browserCookie, {
httpOnly: true,
secure: readAccessConfig()?.origin.startsWith('https:') === true,
sameSite: 'lax',
path: '/oauth/mastodon/callback',
maxAge: oauthLifetimeSeconds,
})
return { authorizationUrl: attempt.authorizationUrl }
})
export const disconnectMastodonAccount = createServerFn({ method: 'POST' })
.validator(z.object({ connectionId: z.string().min(1) }).strict())
.handler(async ({ data }) => {
const { disconnectMastodon } = await import('./oauth.server')
await disconnectMastodon(data.connectionId)
return { disconnected: true }
})
+128
View File
@@ -0,0 +1,128 @@
import { lookup } from 'node:dns/promises'
import { request } from 'node:https'
import { isIP } from 'node:net'
export function mastodonOrigins(): string[] {
return (process.env.TWITTER_LITE_MASTODON_ORIGINS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.map((value) => {
const url = new URL(value)
if (
url.protocol !== 'https:' ||
url.origin !== value ||
url.port ||
isIP(url.hostname)
) {
throw new Error(
'Mastodon origins must be HTTPS host origins without paths or custom ports.',
)
}
return url.origin
})
}
export function requireMastodonOrigin(origin: string): string {
if (!mastodonOrigins().includes(origin))
throw new Error('This Mastodon instance is not configured.')
return origin
}
export function isPublicAddress(address: string): boolean {
if (isIP(address) === 4) {
const [a = 0, b = 0, c = 0] = address.split('.').map(Number)
return !(
a === 0 ||
a === 10 ||
a === 127 ||
a >= 224 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 192 && b === 0) ||
(a === 192 && b === 88 && c === 99) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
(a === 203 && b === 0 && c === 113)
)
}
if (isIP(address) === 6) {
const normalized = address.toLowerCase()
return (
/^[23]/.test(normalized) &&
!/^2001:(0*:|db8:)/.test(normalized) &&
!normalized.startsWith('2002:')
)
}
return false
}
/** Fixed approved HTTPS origins, pinned public DNS results, no redirects. */
export async function safeMastodonRequest(
url: URL,
init: RequestInit = {},
): Promise<Response> {
requireMastodonOrigin(url.origin)
if (url.username || url.password) throw new Error('Invalid Mastodon URL.')
const addresses = await lookup(url.hostname, { all: true, verbatim: true })
const address = addresses[0]
if (!address || addresses.some((value) => !isPublicAddress(value.address))) {
throw new Error('Mastodon must resolve to public network addresses.')
}
const headers = Object.fromEntries(new Headers(init.headers))
const body =
init.body instanceof URLSearchParams ? init.body.toString() : init.body
if (body !== undefined && body !== null && typeof body !== 'string')
throw new Error('Unsupported Mastodon request body.')
return new Promise((resolve, reject) => {
const outgoing = request(
url,
{
method: init.method ?? 'GET',
headers,
family: address.family,
lookup: (_hostname, _options, callback) =>
callback(null, address.address, address.family),
},
(incoming) => {
const chunks: Buffer[] = []
let bytes = 0
incoming.on('data', (chunk: Buffer) => {
bytes += chunk.length
if (bytes > 5 * 1024 * 1024)
incoming.destroy(new Error('Mastodon response is too large.'))
else chunks.push(chunk)
})
incoming.on('error', () =>
reject(new Error('Mastodon response failed.')),
)
incoming.on('end', () => {
const status = incoming.statusCode ?? 502
if (status >= 300 && status < 400)
return reject(new Error('Mastodon redirects are not allowed.'))
const responseHeaders = new Headers()
for (const [name, value] of Object.entries(incoming.headers)) {
if (value !== undefined)
responseHeaders.set(
name,
Array.isArray(value) ? value.join(', ') : value,
)
}
resolve(
new Response(status === 204 ? null : Buffer.concat(chunks), {
status,
headers: responseHeaders,
}),
)
})
},
)
const timeout = setTimeout(() => {
outgoing.destroy(new Error('Mastodon request timed out.'))
}, 20_000)
outgoing.on('close', () => clearTimeout(timeout))
outgoing.on('error', () => reject(new Error('Mastodon request failed.')))
outgoing.end(body)
})
}
+54
View File
@@ -0,0 +1,54 @@
// @vitest-environment node
import { afterEach, expect, it, vi } from 'vitest'
import {
isPublicAddress,
mastodonOrigins,
requireMastodonOrigin,
} from './transport.server'
afterEach(() => vi.unstubAllEnvs())
it.each([
'127.0.0.1',
'10.0.0.1',
'172.16.0.1',
'192.168.1.1',
'100.91.91.87',
'169.254.169.254',
'0.0.0.0',
'224.0.0.1',
'198.18.0.1',
'192.0.2.1',
'::1',
'::ffff:127.0.0.1',
'fe80::1',
'fd00::1',
'2001:db8::1',
'2002:7f00:1::1',
])('rejects non-public destination %s', (address) =>
expect(isPublicAddress(address)).toBe(false))
it.each([
'1.1.1.1',
'8.8.8.8',
'2606:4700:4700::1111',
])('accepts public destination %s', (address) =>
expect(isPublicAddress(address)).toBe(true))
it.each([
'http://mastodon.invalid',
'https://mastodon.invalid/path',
'https://mastodon.invalid:8443',
'https://127.0.0.1',
'https://user:[email protected]',
])('rejects unsafe configured origin %s', (origin) => {
vi.stubEnv('TWITTER_LITE_MASTODON_ORIGINS', origin)
expect(() => mastodonOrigins()).toThrow()
})
it('requires exact configured origin equality', () => {
vi.stubEnv('TWITTER_LITE_MASTODON_ORIGINS', 'https://mastodon.invalid')
expect(requireMastodonOrigin('https://mastodon.invalid')).toBe(
'https://mastodon.invalid',
)
expect(() =>
requireMastodonOrigin('https://mastodon.invalid.attacker.invalid'),
).toThrow('not configured')
})
@@ -0,0 +1,154 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { requestMastodon } from '../mastodon/client.server'
import { getDatabase } from '../storage/database.server'
import { connections } from '../storage/schema'
import { mapMastodonPost } from './mastodon.server'
import { mastodonFeedInput } from './mastodon-source'
import type { ResearchPage } from './types'
export class MastodonFeedError extends Error {
constructor(
readonly code:
| 'account-unavailable'
| 'rate-limit'
| 'search-unavailable'
| 'request-failed',
message: string,
) {
super(message)
}
}
const PAGE_SIZE = 20
async function requireOk(response: Response, search = false) {
if (response.ok) return response
if (response.status === 401)
throw new MastodonFeedError(
'account-unavailable',
'アカウントを再接続してください。',
)
if (response.status === 429)
throw new MastodonFeedError(
'rate-limit',
'取得回数の制限に達しました。しばらく待ってから再試行してください。',
)
if (search && [400, 422, 501, 503].includes(response.status))
throw new MastodonFeedError(
'search-unavailable',
'検索を実行できません。接続先の対応状況と検索条件を確認してください。',
)
throw new MastodonFeedError(
'request-failed',
'Mastodonから取得できませんでした。接続と対象を確認してください。',
)
}
/** Extract only a numeric max_id; never follow an upstream-provided URL. */
export function nextMastodonCursor(
link: string | null,
origin: string,
path: string,
) {
for (const part of link?.split(',') ?? []) {
const match = part.match(/<([^>]+)>;\s*rel="next"/)
if (!match?.[1]) continue
try {
const url = new URL(match[1])
const cursor = url.searchParams.get('max_id')
if (
url.origin === origin &&
url.pathname === path &&
cursor &&
/^\d{1,32}$/.test(cursor)
)
return cursor
} catch {
/* A malformed continuation must not become a request target. */
}
}
return undefined
}
export async function fetchMastodonPage(
raw: unknown,
request = requestMastodon,
): Promise<ResearchPage> {
const { connectionId, source, cursor } = mastodonFeedInput.parse(raw)
const connection = getDatabase()
.select()
.from(connections)
.where(eq(connections.id, connectionId))
.get()
if (connection?.platform !== 'mastodon' || connection.status !== 'connected')
throw new MastodonFeedError(
'account-unavailable',
'Mastodonアカウントを接続してください。',
)
const params = new URLSearchParams({ limit: String(PAGE_SIZE) })
let path: string
if (source.kind === 'search') {
if (cursor && !/^\d{1,8}$/.test(cursor))
throw new Error('Invalid search cursor.')
path = '/api/v2/search'
params.set('q', source.query)
params.set('type', 'statuses')
params.set('resolve', 'false')
params.set('offset', cursor ?? '0')
} else {
if (cursor && !/^\d{1,32}$/.test(cursor))
throw new Error('Invalid timeline cursor.')
if (cursor) params.set('max_id', cursor)
if (source.kind === 'user') {
let id = source.target
if (!/^\d{1,32}$/.test(id)) {
const lookup = await requireOk(
await request(
connectionId,
'/api/v1/accounts/lookup',
new URLSearchParams({ acct: id.replace(/^@/, '') }),
),
)
id = z
.object({ id: z.string().regex(/^\d{1,32}$/) })
.parse(await lookup.json()).id
}
path = `/api/v1/accounts/${id}/statuses`
} else if (source.kind === 'list')
path = `/api/v1/timelines/list/${source.target}`
else path = `/api/v1/timelines/tag/${encodeURIComponent(source.target)}`
}
const response = await requireOk(
await request(connectionId, path, params),
source.kind === 'search',
)
const data: unknown = await response.json()
const statuses =
source.kind === 'search'
? z.object({ statuses: z.array(z.unknown()) }).parse(data).statuses
: z.array(z.unknown()).parse(data)
const nextCursor =
source.kind === 'search'
? statuses.length === PAGE_SIZE
? String(Number(cursor ?? '0') + PAGE_SIZE)
: undefined
: nextMastodonCursor(
response.headers.get('link'),
connection.origin,
path,
)
return {
posts: statuses.map((post) => mapMastodonPost(post, connection.origin)),
...(nextCursor ? { nextCursor } : {}),
}
}
export async function fetchMastodonLists(connectionId: string) {
const response = await requireOk(
await requestMastodon(connectionId, '/api/v1/lists'),
)
return z
.array(z.object({ id: z.string(), title: z.string() }))
.parse(await response.json())
.map((list) => ({ id: list.id, name: list.title, isPrivate: true }))
}
@@ -0,0 +1,112 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connections } from '../storage/schema'
import { fetchMastodonPage, nextMastodonCursor } from './mastodon-feed.server'
let database: AppDatabase
vi.mock('../storage/database.server', async (original) => ({
...(await original<typeof import('../storage/database.server')>()),
getDatabase: () => database,
}))
beforeEach(() => {
database = openDatabase(':memory:')
database
.insert(connections)
.values({
id: 'account-a',
platform: 'mastodon',
origin: 'https://social.invalid',
accountId: '1',
displayName: 'A',
status: 'connected',
createdAt: 1,
updatedAt: 1,
})
.run()
})
afterEach(() => database.$client.close())
const source = { platform: 'mastodon', kind: 'hashtag', target: '猫' }
describe('Mastodon feed requests', () => {
it('uses the column connection and numeric continuation without following links', async () => {
const request = vi.fn().mockResolvedValue(
new Response('[]', {
headers: {
link: '<https://social.invalid/api/v1/timelines/tag/%E7%8C%AB?max_id=100>; rel="next"',
},
}),
)
const result = await fetchMastodonPage(
{ connectionId: 'account-a', source, cursor: '200' },
request,
)
expect(request).toHaveBeenCalledWith(
'account-a',
'/api/v1/timelines/tag/%E7%8C%AB',
new URLSearchParams({ limit: '20', max_id: '200' }),
)
expect(result.nextCursor).toBe('100')
expect(
nextMastodonCursor(
'<https://evil.invalid/api/v1/timelines/tag/cat?max_id=10>; rel="next"',
'https://social.invalid',
'/api/v1/timelines/tag/cat',
),
).toBeUndefined()
})
it('resolves remote account handles in the selected instance before reading statuses', async () => {
const request = vi
.fn()
.mockResolvedValueOnce(Response.json({ id: '42' }))
.mockResolvedValueOnce(Response.json([]))
await fetchMastodonPage(
{
connectionId: 'account-a',
source: {
platform: 'mastodon',
kind: 'user',
target: '@[email protected]',
},
},
request,
)
expect(request.mock.calls[0]).toEqual([
'account-a',
'/api/v1/accounts/lookup',
new URLSearchParams({ acct: '[email protected]' }),
])
expect(request.mock.calls[1]?.[1]).toBe('/api/v1/accounts/42/statuses')
})
it('distinguishes rate limits, expired accounts, and unavailable search', async () => {
for (const [status, code] of [
[429, 'rate-limit'],
[401, 'account-unavailable'],
[422, 'search-unavailable'],
] as const) {
const request = vi.fn().mockResolvedValue(new Response('{}', { status }))
await expect(
fetchMastodonPage(
{
connectionId: 'account-a',
source: { platform: 'mastodon', kind: 'search', query: 'test' },
},
request,
),
).rejects.toMatchObject({ code })
}
})
it('rejects invalid cursors and missing accounts before sending a request', async () => {
const request = vi.fn()
await expect(
fetchMastodonPage(
{ connectionId: 'account-a', source, cursor: 'https://evil.invalid' },
request,
),
).rejects.toThrow()
await expect(
fetchMastodonPage({ connectionId: 'missing', source }, request),
).rejects.toMatchObject({ code: 'account-unavailable' })
expect(request).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,35 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { mastodonFeedInput } from './mastodon-source'
export const loadMastodonPosts = createServerFn({ method: 'GET' })
.validator(mastodonFeedInput)
.handler(async ({ data }) => {
const { fetchMastodonPage, MastodonFeedError } = await import(
'./mastodon-feed.server'
)
try {
return { ok: true as const, page: await fetchMastodonPage(data) }
} catch (error) {
return {
ok: false as const,
error: {
code:
error instanceof MastodonFeedError ? error.code : 'request-failed',
message:
error instanceof MastodonFeedError
? error.message
: 'Mastodonから取得できませんでした。',
retryable:
!(error instanceof MastodonFeedError) ||
error.code !== 'account-unavailable',
},
}
}
})
export const loadMastodonLists = createServerFn({ method: 'GET' })
.validator(z.object({ connectionId: z.string().min(1) }).strict())
.handler(async ({ data }) => {
const { fetchMastodonLists } = await import('./mastodon-feed.server')
return { lists: await fetchMastodonLists(data.connectionId) }
})
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod'
export const mastodonSourceSchema = z.discriminatedUnion('kind', [
z
.object({
platform: z.literal('mastodon'),
kind: z.literal('user'),
target: z.string().trim().min(1).max(256),
})
.strict(),
z
.object({
platform: z.literal('mastodon'),
kind: z.literal('list'),
target: z.string().regex(/^\d{1,32}$/),
})
.strict(),
z
.object({
platform: z.literal('mastodon'),
kind: z.literal('hashtag'),
target: z
.string()
.trim()
.regex(/^[\p{L}\p{N}_]+$/u)
.max(128),
})
.strict(),
z
.object({
platform: z.literal('mastodon'),
kind: z.literal('search'),
query: z.string().trim().min(1).max(512),
})
.strict(),
])
export type MastodonSource = z.infer<typeof mastodonSourceSchema>
export const mastodonFeedInput = z
.object({
connectionId: z.string().min(1),
source: mastodonSourceSchema,
cursor: z.string().max(128).optional(),
})
.strict()
+134
View File
@@ -0,0 +1,134 @@
import sanitizeHtml from 'sanitize-html'
import { z } from 'zod'
import type { ResearchPost } from './types'
const webUrl = z
.string()
.url()
.refine((value) => {
const url = new URL(value)
return (
['https:', 'http:'].includes(url.protocol) &&
!url.username &&
!url.password
)
})
const accountSchema = z.object({
id: z.string(),
acct: z.string(),
display_name: z.string(),
username: z.string(),
avatar: z.string().optional(),
})
const baseStatusSchema = z.object({
id: z.string(),
uri: webUrl,
url: webUrl.nullable(),
content: z.string(),
created_at: z.string(),
spoiler_text: z.string(),
sensitive: z.boolean(),
account: accountSchema,
media_attachments: z.array(
z.object({
type: z.string(),
url: z.string().nullable(),
preview_url: z.string().nullable(),
description: z.string().nullable(),
}),
),
})
const statusSchema = baseStatusSchema.extend({
reblog: baseStatusSchema.nullable().optional(),
})
export function cleanMastodonContent(content: string) {
const html = sanitizeHtml(content, {
allowedTags: [
'p',
'br',
'a',
'span',
'strong',
'em',
'b',
'i',
'code',
'pre',
'blockquote',
],
allowedAttributes: { a: ['href', 'rel', 'target'] },
allowedSchemes: ['https', 'http'],
allowProtocolRelative: false,
transformTags: {
a: sanitizeHtml.simpleTransform('a', {
target: '_blank',
rel: 'noreferrer noopener',
}),
},
})
const escapedText = sanitizeHtml(html.replace(/<\/p>|<br\s*\/?>/g, '\n'), {
allowedTags: [],
allowedAttributes: {},
})
const entities: Record<string, string> = { amp: '&', lt: '<', gt: '>' }
const text = escapedText
.replace(/&(amp|lt|gt);/g, (_, name: string) => entities[name] ?? '')
.trim()
return { html, text }
}
export function mapMastodonPost(raw: unknown, origin: string): ResearchPost {
const wrapper = statusSchema.parse(raw)
const post = wrapper.reblog ?? wrapper
const author = (account: z.infer<typeof accountSchema>) => ({
name: account.display_name || account.username,
handle: account.acct.includes('@')
? account.acct
: `${account.acct}@${new URL(origin).host}`,
})
const timestamp = Date.parse(post.created_at)
const media: NonNullable<ResearchPost['media']> =
post.media_attachments.flatMap((item) => {
if (
!['image', 'video', 'gifv'].includes(item.type) ||
!webUrl.safeParse(item.url).success
)
return []
return [
{
type:
item.type === 'image'
? ('photo' as const)
: item.type === 'gifv'
? ('gif' as const)
: ('video' as const),
url: item.url as string,
...(webUrl.safeParse(item.preview_url).success
? { previewUrl: item.preview_url as string }
: {}),
...(item.description ? { alt: item.description } : {}),
},
]
})
return {
key: `mastodon:${wrapper.uri}`,
platform: 'mastodon',
nativeId: post.id,
url: post.url ?? post.uri,
...cleanMastodonContent(post.content),
author: {
...author(post.account),
...(webUrl.safeParse(post.account.avatar).success
? { avatarUrl: post.account.avatar }
: {}),
},
...(Number.isFinite(timestamp)
? { createdAt: new Date(timestamp).toISOString() }
: {}),
...(post.spoiler_text ? { contentWarning: post.spoiler_text } : {}),
sensitive: post.sensitive,
...(wrapper.reblog ? { boostedBy: author(wrapper.account) } : {}),
media,
}
}
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { cleanMastodonContent, mapMastodonPost } from './mastodon.server'
const status = {
id: 'local-123',
uri: 'https://remote.invalid/users/a/statuses/1',
url: 'https://remote.invalid/@a/1',
content: '<p>Hello <strong>world</strong> &amp; &#x1f600;</p>',
created_at: '2026-09-24T00:00:00Z',
spoiler_text: 'CW',
sensitive: true,
account: {
id: '7',
acct: '[email protected]',
username: 'a',
display_name: 'Alice',
avatar: 'javascript:alert(1)',
},
media_attachments: [
{
type: 'image',
url: 'https://remote.invalid/image.png',
preview_url: null,
description: 'An image',
},
],
}
describe('Mastodon normalization', () => {
it('preserves canonical identity, local API id, formatting, CW, and media descriptions', () => {
const post = mapMastodonPost(status, 'https://local.invalid')
expect(post).toMatchObject({
key: `mastodon:${status.uri}`,
nativeId: 'local-123',
text: 'Hello world & 😀',
contentWarning: 'CW',
sensitive: true,
author: { name: 'Alice', handle: '[email protected]' },
media: [{ type: 'photo', alt: 'An image' }],
})
expect(post.html).toContain('<strong>world</strong>')
expect(post.author.avatarUrl).toBeUndefined()
})
it('distinguishes a boost from the original post', () => {
const boost = mapMastodonPost(
{
...status,
id: 'boost-99',
uri: 'https://local.invalid/boost/99',
account: {
...status.account,
acct: 'booster',
display_name: 'Booster',
},
reblog: status,
},
'https://local.invalid',
)
expect(boost.key).toBe('mastodon:https://local.invalid/boost/99')
expect(boost.nativeId).toBe(status.id)
expect(boost.author.name).toBe('Alice')
expect(boost.boostedBy).toEqual({
name: 'Booster',
handle: '[email protected]',
})
})
it('strips active markup, dangerous links, styling and remote inline images', () => {
const { html, text } = cleanMastodonContent(
'<p onclick="alert(1)">Hi<script>alert(1)</script><img src=x onerror=alert(1)><a href="javascript:alert(1)">bad</a><a href="https://safe.invalid/" style="color:red">safe</a></p>',
)
expect(html).not.toMatch(/script|onclick|onerror|<img|javascript|style=/)
expect(html).toContain('href="https://safe.invalid/"')
expect(html).toContain('rel="noreferrer noopener"')
expect(text).toBe('Hibadsafe')
})
it('returns decoded plain text without interpreting escaped markup', () => {
expect(
cleanMastodonContent('<p>&lt;script&gt; &amp;lt;</p><p>next<br>end</p>')
.text,
).toBe('<script> &lt;\nnext\nend')
})
})
+7
View File
@@ -1,11 +1,18 @@
type Platform = 'twitter' | 'mastodon' | 'bluesky' | 'threads' | 'nostr'
export type ResearchPage = { posts: ResearchPost[]; nextCursor?: string }
export type ResearchPost = {
key: string
platform: Platform
nativeId: string
url: string
text: string
/** Only HTML sanitized on the server with the platform allowlist. */
html?: string
contentWarning?: string
sensitive?: boolean
boostedBy?: { name: string; handle: string }
author: {
name: string
handle: string
@@ -0,0 +1,60 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import type { DeckColumn } from '../decks/model'
import {
loadListPosts,
loadUserPosts,
searchPosts,
} from '../posts/server-functions'
import { createPostFeedOptions, PostLoadError } from '../posts/use-post-feed'
import { loadMastodonPosts } from './mastodon-server-functions'
import { mapTwitterPost } from './twitter'
import type { ResearchPage, ResearchPost } from './types'
export function flattenResearchPages(pages: ResearchPage[]): ResearchPost[] {
const seen = new Set<string>()
return pages.flatMap((page) =>
page.posts.filter((post) => {
if (seen.has(post.key)) return false
seen.add(post.key)
return true
}),
)
}
export function useResearchFeed(column: DeckColumn) {
const mastodon = useServerFn(loadMastodonPosts)
const loadUser = useServerFn(loadUserPosts)
const loadList = useServerFn(loadListPosts)
const search = useServerFn(searchPosts)
return useInfiniteQuery({
queryKey: ['research-posts', column.connectionId, column.source],
initialPageParam: undefined as string | undefined,
retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
queryFn: async ({ pageParam }): Promise<ResearchPage> => {
if (column.source.platform === 'mastodon') {
const result = await mastodon({
data: {
connectionId: column.connectionId,
source: column.source,
cursor: pageParam,
},
})
if (!result.ok) throw new PostLoadError(result.error)
return result.page
}
const { platform: _, ...source } = column.source
const page = await createPostFeedOptions(
{ ...source, connectionId: column.connectionId },
{ loadUser, loadList, search },
).queryFn({ pageParam })
return {
posts: page.tweets.map(mapTwitterPost),
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
}
},
getNextPageParam: (page) => page.nextCursor,
})
}
+4 -2
View File
@@ -20,8 +20,10 @@ describe('explicit relay profile binding', () => {
[listChoicesInputSchema, {}],
])('rejects missing or empty bindings for loader %#', (schema, input) => {
expect(schema.safeParse(input).success).toBe(false)
expect(schema.safeParse({ ...input, profileName: ' ' }).success).toBe(false)
expect(schema.safeParse({ ...input, profileName: 'work' }).success).toBe(
expect(schema.safeParse({ ...input, connectionId: ' ' }).success).toBe(
false,
)
expect(schema.safeParse({ ...input, connectionId: 'work' }).success).toBe(
true,
)
})
+5 -5
View File
@@ -3,28 +3,28 @@ import { z } from 'zod'
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
const LIST_ID = /^\d{1,32}$/
const profileNameSchema = z.string().trim().min(1).max(256)
const connectionIdSchema = z.string().trim().min(1).max(256)
export class InputError extends Error {}
export const userPageInputSchema = z.object({
profileName: profileNameSchema,
connectionId: connectionIdSchema,
target: z.string().trim().min(1).max(256),
cursor: z.string().min(1).optional(),
})
export const listPageInputSchema = z.object({
profileName: profileNameSchema,
connectionId: connectionIdSchema,
target: z.string().trim().min(1).max(256),
cursor: z.string().min(1).optional(),
})
export const listChoicesInputSchema = z.object({
profileName: profileNameSchema,
connectionId: connectionIdSchema,
})
export const searchPageInputSchema = z.object({
profileName: profileNameSchema,
connectionId: connectionIdSchema,
query: z.string().trim().min(1).max(512),
product: z.enum(['Top', 'Latest']),
following: z.boolean(),
+3 -3
View File
@@ -164,7 +164,7 @@ function resultPage(
export async function loadUserPage(
reader: BirdReader,
input: Omit<UserPageInput, 'profileName'>,
input: Omit<UserPageInput, 'connectionId'>,
): Promise<LoadResult> {
try {
const handle = normalizeUserTarget(input.target)
@@ -192,7 +192,7 @@ export async function loadUserPage(
export async function loadListPage(
reader: BirdReader,
input: Omit<ListPageInput, 'profileName'>,
input: Omit<ListPageInput, 'connectionId'>,
): Promise<LoadResult> {
try {
const listId = normalizeListTarget(input.target)
@@ -216,7 +216,7 @@ export async function loadListPage(
export async function searchPage(
reader: BirdReader,
input: Omit<SearchPageInput, 'profileName'>,
input: Omit<SearchPageInput, 'connectionId'>,
): Promise<LoadResult> {
try {
return resultPage(
+8 -8
View File
@@ -36,12 +36,12 @@ const configFailure = (diagnostic: unknown): LoadFailure => {
}
}
async function reader(profileName: string) {
const [{ getBirdReader }, { requireProfileName }] = await Promise.all([
async function reader(connectionId: string) {
const [{ getBirdReader }, { requireTwitterConnection }] = await Promise.all([
import('./bird-client.server'),
import('../profiles/profile-service.server'),
import('../connections/repository.server'),
])
await requireProfileName(profileName)
const profileName = await requireTwitterConnection(connectionId)
return getBirdReader(profileName)
}
@@ -49,7 +49,7 @@ export const loadUserPosts = createServerFn({ method: 'GET' })
.validator(userPageInputSchema)
.handler(async ({ data }) => {
try {
return await loadUserPage(await reader(data.profileName), data)
return await loadUserPage(await reader(data.connectionId), data)
} catch (error) {
return configFailure(error)
}
@@ -59,7 +59,7 @@ export const loadListPosts = createServerFn({ method: 'GET' })
.validator(listPageInputSchema)
.handler(async ({ data }) => {
try {
return await loadListPage(await reader(data.profileName), data)
return await loadListPage(await reader(data.connectionId), data)
} catch (error) {
return configFailure(error)
}
@@ -69,7 +69,7 @@ export const loadLists = createServerFn({ method: 'GET' })
.validator(listChoicesInputSchema)
.handler(async ({ data }) => {
try {
return await loadListChoices(await reader(data.profileName))
return await loadListChoices(await reader(data.connectionId))
} catch (error) {
return configFailure(error)
}
@@ -79,7 +79,7 @@ export const searchPosts = createServerFn({ method: 'GET' })
.validator(searchPageInputSchema)
.handler(async ({ data }) => {
try {
return await searchPage(await reader(data.profileName), data)
return await searchPage(await reader(data.connectionId), data)
} catch (error) {
return configFailure(error)
}
+7 -3
View File
@@ -19,6 +19,10 @@ type LoadErrorCode =
| 'relay-config'
| 'timeout'
| 'upstream'
| 'account-unavailable'
| 'rate-limit'
| 'search-unavailable'
| 'request-failed'
export type LoadError = {
code: LoadErrorCode
@@ -43,19 +47,19 @@ export type ListChoice = {
export type ListChoicesResult = { ok: true; lists: ListChoice[] } | LoadFailure
export type UserPageInput = {
profileName: string
connectionId: string
target: string
cursor?: string
}
export type ListPageInput = {
profileName: string
connectionId: string
target: string
cursor?: string
}
export type SearchPageInput = {
profileName: string
connectionId: string
query: string
product: SearchProduct
following: boolean
+1 -1
View File
@@ -15,7 +15,7 @@ describe('createListChoicesOptions', () => {
await expect(options.queryFn()).resolves.toEqual([
{ id: '123', name: 'My List' },
])
expect(loader).toHaveBeenCalledWith({ data: { profileName: 'main' } })
expect(loader).toHaveBeenCalledWith({ data: { connectionId: 'main' } })
expect(options.queryKey).toEqual(['list-choices', 'main'])
expect(options.retry).toBe(false)
expect(options.refetchOnWindowFocus).toBe(false)
+7 -7
View File
@@ -4,7 +4,7 @@ import { loadLists } from './server-functions'
import type { ListChoice, ListChoicesResult, LoadError } from './types'
type ListChoicesLoader = (options: {
data: { profileName: string }
data: { connectionId: string }
}) => Promise<ListChoicesResult>
export class ListChoicesLoadError extends Error {
@@ -14,13 +14,13 @@ export class ListChoicesLoadError extends Error {
}
export function createListChoicesOptions(
profileName: string,
connectionId: string,
loader: ListChoicesLoader,
) {
return {
queryKey: ['list-choices', profileName] as const,
queryKey: ['list-choices', connectionId] as const,
queryFn: async (): Promise<ListChoice[]> => {
const result = await loader({ data: { profileName } })
const result = await loader({ data: { connectionId } })
if (!result.ok) throw new ListChoicesLoadError(result.error)
return result.lists
},
@@ -30,10 +30,10 @@ export function createListChoicesOptions(
}
}
export function useListChoices(profileName: string) {
export function useListChoices(connectionId: string) {
const loader = useServerFn(loadLists)
return useQuery({
...createListChoicesOptions(profileName, loader),
enabled: Boolean(profileName),
...createListChoicesOptions(connectionId, loader),
enabled: Boolean(connectionId),
})
}
+21 -56
View File
@@ -1,20 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook } from '@testing-library/react'
import { createElement, type ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createPostFeedOptions,
PostLoadError,
usePostFeed,
} from './use-post-feed'
const useServerFn = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-start', async (importOriginal) => {
const original =
await importOriginal<typeof import('@tanstack/react-start')>()
return { ...original, useServerFn }
})
import { QueryClient } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import { createPostFeedOptions, PostLoadError } from './use-post-feed'
type FeedLoaders = Parameters<typeof createPostFeedOptions>[1]
@@ -32,7 +18,7 @@ describe('createPostFeedOptions', () => {
page: { tweets: [], nextCursor: 'next' },
})
const options = createPostFeedOptions(
{ profileName: 'main', kind: 'user', target: '@yuta' },
{ connectionId: 'main', kind: 'user', target: '@yuta' },
loaders({ loadUser }),
)
@@ -40,7 +26,7 @@ describe('createPostFeedOptions', () => {
expect(loadUser).toHaveBeenCalledOnce()
expect(loadUser).toHaveBeenCalledWith({
data: { profileName: 'main', target: '@yuta', cursor: 'cursor' },
data: { connectionId: 'main', target: '@yuta', cursor: 'cursor' },
})
})
@@ -50,7 +36,7 @@ describe('createPostFeedOptions', () => {
page: { tweets: [], nextCursor: 'next' },
})
const options = createPostFeedOptions(
{ profileName: 'main', kind: 'list', target: '1234567890' },
{ connectionId: 'main', kind: 'list', target: '1234567890' },
loaders({ loadList }),
)
@@ -58,17 +44,17 @@ describe('createPostFeedOptions', () => {
expect(loadList).toHaveBeenCalledOnce()
expect(loadList).toHaveBeenCalledWith({
data: { profileName: 'main', target: '1234567890', cursor: 'cursor' },
data: { connectionId: 'main', target: '1234567890', cursor: 'cursor' },
})
expect(options.queryKey).toEqual([
'posts',
{ profileName: 'main', kind: 'list', target: '1234567890' },
{ connectionId: 'main', kind: 'list', target: '1234567890' },
])
})
it('does not retry requests automatically', () => {
const options = createPostFeedOptions(
{ profileName: 'main', kind: 'user', target: 'yuta' },
{ connectionId: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -77,7 +63,7 @@ describe('createPostFeedOptions', () => {
it('does not refetch automatically when the window regains focus', () => {
const options = createPostFeedOptions(
{ profileName: 'main', kind: 'user', target: 'yuta' },
{ connectionId: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -86,7 +72,7 @@ describe('createPostFeedOptions', () => {
it('does not refetch automatically when the connection returns', () => {
const options = createPostFeedOptions(
{ profileName: 'main', kind: 'user', target: 'yuta' },
{ connectionId: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -95,7 +81,7 @@ describe('createPostFeedOptions', () => {
it('takes the next cursor only from the resolved page', () => {
const options = createPostFeedOptions(
{ profileName: 'main', kind: 'user', target: 'yuta' },
{ connectionId: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -108,7 +94,7 @@ describe('createPostFeedOptions', () => {
it('includes every search control in the query key', () => {
const options = createPostFeedOptions(
{
profileName: 'main',
connectionId: 'main',
kind: 'search',
query: 'AI',
product: 'Top',
@@ -120,7 +106,7 @@ describe('createPostFeedOptions', () => {
expect(options.queryKey).toEqual([
'posts',
{
profileName: 'main',
connectionId: 'main',
kind: 'search',
query: 'AI',
product: 'Top',
@@ -136,7 +122,7 @@ describe('createPostFeedOptions', () => {
})
const options = createPostFeedOptions(
{
profileName: 'main',
connectionId: 'main',
kind: 'search',
query: 'AI lang:ja',
product: 'Top',
@@ -150,7 +136,7 @@ describe('createPostFeedOptions', () => {
expect(search).toHaveBeenCalledOnce()
expect(search).toHaveBeenCalledWith({
data: {
profileName: 'main',
connectionId: 'main',
query: 'AI lang:ja',
product: 'Top',
following: true,
@@ -166,7 +152,7 @@ describe('createPostFeedOptions', () => {
retryable: true,
}
const options = createPostFeedOptions(
{ profileName: 'main', kind: 'user', target: 'yuta' },
{ connectionId: 'main', kind: 'user', target: 'yuta' },
loaders({
loadUser: vi.fn().mockResolvedValue({ ok: false, error }),
}),
@@ -186,14 +172,14 @@ describe('profile-bound feed cache', () => {
it('keeps cached posts and pagination isolated between bound profiles', async () => {
const loadUser = vi.fn(async ({ data }) => ({
ok: true as const,
page: { tweets: [], nextCursor: `${data.profileName}-next` },
page: { tweets: [], nextCursor: `${data.connectionId}-next` },
}))
const main = createPostFeedOptions(
{ kind: 'user', target: 'yuta', profileName: 'main' },
{ kind: 'user', target: 'yuta', connectionId: 'main' },
loaders({ loadUser }),
)
const work = createPostFeedOptions(
{ kind: 'user', target: 'yuta', profileName: 'work' },
{ kind: 'user', target: 'yuta', connectionId: 'work' },
loaders({ loadUser }),
)
const client = new QueryClient()
@@ -207,29 +193,8 @@ describe('profile-bound feed cache', () => {
expect(loadUser).toHaveBeenCalledTimes(2)
await work.queryFn({ pageParam: 'work-next' })
expect(loadUser).toHaveBeenLastCalledWith({
data: { target: 'yuta', profileName: 'work', cursor: 'work-next' },
data: { target: 'yuta', connectionId: 'work', cursor: 'work-next' },
})
client.clear()
})
})
describe('usePostFeed', () => {
beforeEach(() => {
useServerFn.mockReset()
})
it('keeps an absent request idle without calling a loader', () => {
const loader = vi.fn()
useServerFn.mockReturnValue(loader)
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children)
const { result } = renderHook(() => usePostFeed(undefined), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
expect(loader).not.toHaveBeenCalled()
})
})
+4 -33
View File
@@ -1,7 +1,4 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import type { SearchProduct } from '@yuta/bird'
import { loadListPosts, loadUserPosts, searchPosts } from './server-functions'
import type {
ListPageInput,
LoadError,
@@ -11,7 +8,7 @@ import type {
UserPageInput,
} from './types'
export type FeedRequest = { profileName: string } & (
export type FeedRequest = { connectionId: string } & (
| { kind: 'user'; target: string }
| { kind: 'list'; target: string }
| {
@@ -51,7 +48,7 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
return unwrap(
await loaders.loadUser({
data: {
profileName: request.profileName,
connectionId: request.connectionId,
target: request.target,
cursor: pageParam,
},
@@ -62,7 +59,7 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
return unwrap(
await loaders.loadList({
data: {
profileName: request.profileName,
connectionId: request.connectionId,
target: request.target,
cursor: pageParam,
},
@@ -72,7 +69,7 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
return unwrap(
await loaders.search({
data: {
profileName: request.profileName,
connectionId: request.connectionId,
query: request.query,
product: request.product,
following: request.following,
@@ -84,29 +81,3 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
getNextPageParam: (page: PostPage) => page.nextCursor,
}
}
function usePostFeedOptions() {
const loadUser = useServerFn(loadUserPosts)
const loadList = useServerFn(loadListPosts)
const search = useServerFn(searchPosts)
return (request: FeedRequest) =>
createPostFeedOptions(request, {
loadUser,
loadList,
search,
})
}
export function usePostFeed(request: FeedRequest | undefined) {
const options = usePostFeedOptions()
const disabled = {
kind: 'user',
target: '',
profileName: '',
} satisfies FeedRequest
return useInfiniteQuery({
...options(request ?? disabled),
enabled: request !== undefined,
})
}
@@ -1,8 +0,0 @@
import { createServerFn } from '@tanstack/react-start'
export const loadProfiles = createServerFn({ method: 'GET' }).handler(
async () => {
const { fetchProfileNames } = await import('./profile-service.server')
return { profiles: await fetchProfileNames() }
},
)
+33
View File
@@ -0,0 +1,33 @@
import { closeSync, openSync, unlinkSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path'
import Database from 'better-sqlite3'
export async function backupDatabase(source: string, destination: string) {
if (!isAbsolute(source) || !isAbsolute(destination))
throw new Error('Database and backup paths must be absolute.')
if (resolve(source) === resolve(destination))
throw new Error('Backup must use a different file.')
const client = new Database(source, { readonly: true, fileMustExist: true })
try {
// Reserve the destination without overwriting an existing backup; restrict permissions.
closeSync(openSync(destination, 'wx', 0o600))
try {
await client.backup(destination)
const restored = new Database(destination, {
readonly: true,
fileMustExist: true,
})
try {
if (restored.pragma('quick_check', { simple: true }) !== 'ok')
throw new Error('Backup integrity verification failed.')
} finally {
restored.close()
}
} catch (error) {
unlinkSync(destination)
throw error
}
} finally {
client.close()
}
}
+58
View File
@@ -0,0 +1,58 @@
import { mkdirSync } from 'node:fs'
import { dirname, isAbsolute } from 'node:path'
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { migrations } from './migrations.generated'
import * as schema from './schema'
function applyMigrations(client: Database.Database) {
// Drizzle Kit SQL is bundled into the server so deployment never depends on cwd.
client
.transaction(() => {
client.exec(
'CREATE TABLE IF NOT EXISTS __drizzle_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at NUMERIC)',
)
const latest = client
.prepare(
'SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1',
)
.get() as { created_at: number } | undefined
for (const migration of migrations) {
if (latest && latest.created_at >= migration.folderMillis) continue
for (const statement of migration.sql) client.exec(statement)
client
.prepare(
'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
)
.run(migration.hash, migration.folderMillis)
}
})
.immediate()
}
export function openDatabase(path: string) {
if (path !== ':memory:' && !isAbsolute(path))
throw new Error('TWITTER_LITE_DB_PATH must be an absolute path.')
if (path !== ':memory:')
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
const client = new Database(path, { timeout: 5000 })
try {
client.pragma('foreign_keys = ON')
client.pragma('journal_mode = WAL')
applyMigrations(client)
return drizzle(client, { schema })
} catch (error) {
client.close()
throw error
}
}
export type AppDatabase = ReturnType<typeof openDatabase>
let database: AppDatabase | undefined
export function getDatabase() {
const path = process.env.TWITTER_LITE_DB_PATH
if (!path) throw new Error('TWITTER_LITE_DB_PATH is required.')
database ??= openDatabase(path)
return database
}
+142
View File
@@ -0,0 +1,142 @@
// @vitest-environment node
import { mkdtempSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { and, eq, sql } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { backupDatabase } from './backup.server'
import { type AppDatabase, openDatabase } from './database.server'
import { migrations } from './migrations.generated'
import { connections, deckColumns, decks } from './schema'
let directory: string
let databases: AppDatabase[]
function open(name = 'app.db') {
const db = openDatabase(join(directory, name))
databases.push(db)
return db
}
const deck = {
id: 'research',
title: 'Research',
revision: 1,
createdAt: 1,
updatedAt: 1,
}
const connection = {
id: 'twitter-work',
platform: 'twitter' as const,
origin: 'https://relay.invalid',
relayProfile: 'work',
displayName: 'Work',
status: 'connected' as const,
createdAt: 1,
updatedAt: 1,
}
const column = {
id: 'column',
deckId: 'research',
position: 0,
connectionId: 'twitter-work',
title: 'Query',
source: { platform: 'twitter', kind: 'search', query: 'WebMCP' },
}
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-database-'))
databases = []
})
afterEach(() => {
for (const db of databases) db.$client.close()
rmSync(directory, { recursive: true })
})
describe('persistent database', () => {
it('reopens stored decks and applies migrations only once', () => {
const first = open()
first.insert(decks).values(deck).run()
const second = open()
expect(second.select().from(decks).all()).toEqual([deck])
expect(
second.$client
.prepare('SELECT count(*) AS count FROM __drizzle_migrations')
.get(),
).toEqual({ count: migrations.length })
expect(second.$client.pragma('journal_mode', { simple: true })).toBe('wal')
expect(second.$client.pragma('foreign_keys', { simple: true })).toBe(1)
})
it('rejects a stale revision from a second database connection', () => {
const first = open()
const second = open()
first.insert(decks).values(deck).run()
const update = (db: AppDatabase, title: string) =>
db
.update(decks)
.set({ title, revision: sql`${decks.revision} + 1` })
.where(and(eq(decks.id, deck.id), eq(decks.revision, 1)))
.run()
expect(update(first, 'PC changes').changes).toBe(1)
expect(update(second, 'Stale phone changes').changes).toBe(0)
expect(second.select().from(decks).get()).toMatchObject({
title: 'PC changes',
revision: 2,
})
})
it('rolls back deck edits when a column references a missing connection', () => {
const db = open()
db.insert(decks).values(deck).run()
expect(() =>
db.transaction((tx) => {
tx.update(decks)
.set({ title: 'Not committed', revision: 2 })
.where(eq(decks.id, deck.id))
.run()
tx.insert(deckColumns).values(column).run()
}),
).toThrow()
expect(db.select().from(decks).get()).toEqual(deck)
expect(db.select().from(deckColumns).all()).toEqual([])
})
it('retains referenced connections and removes columns when their deck is deleted', () => {
const db = open()
db.insert(connections).values(connection).run()
db.insert(decks).values(deck).run()
db.insert(deckColumns).values(column).run()
expect(() =>
db.delete(connections).where(eq(connections.id, connection.id)).run(),
).toThrow()
db.delete(decks).where(eq(decks.id, deck.id)).run()
expect(db.select().from(deckColumns).all()).toEqual([])
expect(db.select().from(connections).all()).toHaveLength(1)
})
it('restores a live WAL backup with deck and column state intact', async () => {
const db = open()
db.insert(connections).values(connection).run()
db.insert(decks).values(deck).run()
db.insert(deckColumns).values(column).run()
await backupDatabase(
join(directory, 'app.db'),
join(directory, 'backup.db'),
)
const restored = open('backup.db')
expect(restored.select().from(decks).all()).toEqual([deck])
expect(restored.select().from(deckColumns).all()).toEqual([column])
expect(statSync(join(directory, 'backup.db')).mode & 0o777).toBe(0o600)
})
it('refuses to overwrite an existing backup', async () => {
const db = open()
db.insert(decks).values(deck).run()
const destination = join(directory, 'backup.db')
await backupDatabase(join(directory, 'app.db'), destination)
db.update(decks).set({ title: 'Later state' }).run()
await expect(
backupDatabase(join(directory, 'app.db'), destination),
).rejects.toThrow()
expect(open('backup.db').select().from(decks).get()?.title).toBe('Research')
})
})
@@ -0,0 +1,36 @@
// Generated by pnpm db:generate. Do not edit.
export const migrations = [
{
sql: [
'CREATE TABLE `connection_credentials` (\n\t`connection_id` text PRIMARY KEY NOT NULL,\n\t`encrypted_token` text NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tFOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE cascade\n);\n',
'\nCREATE TABLE `connections` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`platform` text NOT NULL,\n\t`origin` text NOT NULL,\n\t`account_id` text,\n\t`relay_profile` text,\n\t`display_name` text NOT NULL,\n\t`status` text NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL\n);\n',
'\nCREATE UNIQUE INDEX `connections_account` ON `connections` (`platform`,`origin`,`account_id`);',
'\nCREATE UNIQUE INDEX `connections_relay_profile` ON `connections` (`origin`,`relay_profile`);',
'\nCREATE TABLE `deck_columns` (\n\t`id` text NOT NULL,\n\t`deck_id` text NOT NULL,\n\t`position` integer NOT NULL,\n\t`connection_id` text NOT NULL,\n\t`title` text NOT NULL,\n\t`source` text NOT NULL,\n\tPRIMARY KEY(`deck_id`, `id`),\n\tFOREIGN KEY (`deck_id`) REFERENCES `decks`(`id`) ON UPDATE no action ON DELETE cascade,\n\tFOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE restrict,\n\tCONSTRAINT "deck_columns_valid_position" CHECK("deck_columns"."position" >= 0)\n);\n',
'\nCREATE UNIQUE INDEX `deck_columns_position` ON `deck_columns` (`deck_id`,`position`);',
'\nCREATE TABLE `decks` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`title` text NOT NULL,\n\t`revision` integer DEFAULT 1 NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT "decks_positive_revision" CHECK("decks"."revision" >= 1)\n);\n',
'\nCREATE TABLE `oauth_apps` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`origin` text NOT NULL,\n\t`redirect_uri` text NOT NULL,\n\t`scopes` text NOT NULL,\n\t`client_id` text NOT NULL,\n\t`encrypted_client_secret` text NOT NULL,\n\t`created_at` integer NOT NULL\n);\n',
'\nCREATE UNIQUE INDEX `oauth_apps_configuration` ON `oauth_apps` (`origin`,`redirect_uri`,`scopes`);',
'\nCREATE TABLE `oauth_attempts` (\n\t`state_hash` text PRIMARY KEY NOT NULL,\n\t`browser_hash` text NOT NULL,\n\t`app_id` text NOT NULL,\n\t`encrypted_verifier` text NOT NULL,\n\t`connection_id` text,\n\t`expires_at` integer NOT NULL,\n\t`consumed_at` integer,\n\tFOREIGN KEY (`app_id`) REFERENCES `oauth_apps`(`id`) ON UPDATE no action ON DELETE cascade,\n\tFOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE cascade\n);\n',
],
bps: true,
folderMillis: 1790234301369,
hash: 'f5bc7f4f379560b9495e9702a2bd57b27740a596fbacb76792208069db312bd2',
},
{
sql: [
'ALTER TABLE `connection_credentials` ADD `app_id` text REFERENCES oauth_apps(id);',
],
bps: true,
folderMillis: 1790234518415,
hash: '659051351ad2d857118f39b1cffedb99f227d0ace58025ceec978d1f653ea599',
},
{
sql: [
'CREATE TABLE `legacy_imports` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`payload_hash` text NOT NULL,\n\t`deck_ids` text NOT NULL,\n\t`created_at` integer NOT NULL\n);\n',
],
bps: true,
folderMillis: 1790234736939,
hash: 'b5e37346de551e36b3803ce756f9fb449c451ad9e120bdfa584b7db34037f805',
},
]
+127
View File
@@ -0,0 +1,127 @@
import { sql } from 'drizzle-orm'
import {
check,
integer,
primaryKey,
sqliteTable,
text,
uniqueIndex,
} from 'drizzle-orm/sqlite-core'
const timestamps = {
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
}
export const connections = sqliteTable(
'connections',
{
id: text('id').primaryKey(),
platform: text('platform', { enum: ['twitter', 'mastodon'] }).notNull(),
origin: text('origin').notNull(),
accountId: text('account_id'),
relayProfile: text('relay_profile'),
displayName: text('display_name').notNull(),
status: text('status', {
enum: ['connected', 'disconnected', 'expired'],
}).notNull(),
...timestamps,
},
(table) => [
uniqueIndex('connections_account').on(
table.platform,
table.origin,
table.accountId,
),
uniqueIndex('connections_relay_profile').on(
table.origin,
table.relayProfile,
),
],
)
export const decks = sqliteTable(
'decks',
{
id: text('id').primaryKey(),
title: text('title').notNull(),
revision: integer('revision').notNull().default(1),
...timestamps,
},
(table) => [check('decks_positive_revision', sql`${table.revision} >= 1`)],
)
export const deckColumns = sqliteTable(
'deck_columns',
{
id: text('id').notNull(),
deckId: text('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
position: integer('position').notNull(),
connectionId: text('connection_id')
.notNull()
.references(() => connections.id, { onDelete: 'restrict' }),
title: text('title').notNull(),
source: text('source', { mode: 'json' })
.$type<Record<string, unknown>>()
.notNull(),
},
(table) => [
primaryKey({ columns: [table.deckId, table.id] }),
uniqueIndex('deck_columns_position').on(table.deckId, table.position),
check('deck_columns_valid_position', sql`${table.position} >= 0`),
],
)
// Credential fields contain opaque authenticated-encryption envelopes, never plaintext.
export const connectionCredentials = sqliteTable('connection_credentials', {
connectionId: text('connection_id')
.primaryKey()
.references(() => connections.id, { onDelete: 'cascade' }),
encryptedToken: text('encrypted_token').notNull(),
appId: text('app_id').references(() => oauthApps.id, {
onDelete: 'restrict',
}),
updatedAt: integer('updated_at').notNull(),
})
export const oauthApps = sqliteTable(
'oauth_apps',
{
id: text('id').primaryKey(),
origin: text('origin').notNull(),
redirectUri: text('redirect_uri').notNull(),
scopes: text('scopes').notNull(),
clientId: text('client_id').notNull(),
encryptedClientSecret: text('encrypted_client_secret').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
uniqueIndex('oauth_apps_configuration').on(
table.origin,
table.redirectUri,
table.scopes,
),
],
)
export const oauthAttempts = sqliteTable('oauth_attempts', {
stateHash: text('state_hash').primaryKey(),
browserHash: text('browser_hash').notNull(),
appId: text('app_id')
.notNull()
.references(() => oauthApps.id, { onDelete: 'cascade' }),
encryptedVerifier: text('encrypted_verifier').notNull(),
connectionId: text('connection_id').references(() => connections.id, {
onDelete: 'cascade',
}),
expiresAt: integer('expires_at').notNull(),
consumedAt: integer('consumed_at'),
})
export const legacyImports = sqliteTable('legacy_imports', {
id: text('id').primaryKey(),
payloadHash: text('payload_hash').notNull(),
deckIds: text('deck_ids', { mode: 'json' }).$type<string[]>().notNull(),
createdAt: integer('created_at').notNull(),
})
+23 -4
View File
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as DeckRouteImport } from './routes/deck'
import { Route as IndexRouteImport } from './routes/index'
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
const DeckRoute = DeckRouteImport.update({
id: '/deck',
@@ -22,31 +23,40 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const OauthMastodonCallbackRoute = OauthMastodonCallbackRouteImport.update({
id: '/oauth/mastodon/callback',
path: '/oauth/mastodon/callback',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/deck': typeof DeckRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/deck': typeof DeckRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/deck': typeof DeckRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/deck'
fullPaths: '/' | '/deck' | '/oauth/mastodon/callback'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/deck'
id: '__root__' | '/' | '/deck'
to: '/' | '/deck' | '/oauth/mastodon/callback'
id: '__root__' | '/' | '/deck' | '/oauth/mastodon/callback'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DeckRoute: typeof DeckRoute
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
}
declare module '@tanstack/react-router' {
@@ -65,22 +75,31 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/oauth/mastodon/callback': {
id: '/oauth/mastodon/callback'
path: '/oauth/mastodon/callback'
fullPath: '/oauth/mastodon/callback'
preLoaderRoute: typeof OauthMastodonCallbackRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DeckRoute: DeckRoute,
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
import type { startInstance } from './start.ts'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
}
}
+40
View File
@@ -0,0 +1,40 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/oauth/mastodon/callback')({
server: {
handlers: {
GET: async ({ request }) => {
const { getCookie, deleteCookie } = await import(
'@tanstack/react-start/server'
)
const { completeMastodonOAuth, oauthCookieName } = await import(
'../../../features/mastodon/oauth.server'
)
const params = new URL(request.url).searchParams
const state = params.get('state') ?? ''
const cookieName = oauthCookieName(state)
const browserCookie = getCookie(cookieName)
deleteCookie(cookieName, { path: '/oauth/mastodon/callback' })
let outcome = 'connected'
try {
await completeMastodonOAuth({
state,
browserCookie,
code: params.get('code') ?? undefined,
error: params.get('error') ?? undefined,
})
} catch {
outcome = 'failed'
}
return new Response(null, {
status: 303,
headers: {
location: `/?mastodon=${outcome}`,
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
},
})
},
},
},
})
+26
View File
@@ -0,0 +1,26 @@
import {
createCsrfMiddleware,
createMiddleware,
createStart,
} from '@tanstack/react-start'
const ownerAccess = createMiddleware().server(async ({ request, next }) => {
const { checkAccess, readAccessConfig } = await import(
'./features/access/policy.server'
)
return checkAccess(request, readAccessConfig()) ?? next()
})
const csrf = createCsrfMiddleware({
filter: (context) => context.handlerType === 'serverFn',
})
const storage = createMiddleware().server(async ({ next }) => {
const { getDatabase } = await import('./features/storage/database.server')
getDatabase()
return next()
})
export const startInstance = createStart(() => ({
requestMiddleware: [ownerAccess, csrf, storage],
}))