feat: add shared decks and multi-account Mastodon OAuth
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user