Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80cadf7242 | ||
|
|
ceab2e2384 | ||
|
|
e82a1187af | ||
|
|
bd9c1b77b7 | ||
|
|
9c3917541b | ||
|
|
b2db67ea1c | ||
|
|
c47f58f065 | ||
|
|
d2cbf4dbd3 | ||
|
|
da8f52e605 | ||
|
|
2bc052c354 | ||
|
|
9daf6e71dc | ||
|
|
5406855e1c | ||
|
|
8eb299f46d | ||
|
|
079aaa1664 | ||
|
|
67ee53c305 | ||
|
|
70cacc2c2d | ||
|
|
1794255ebb | ||
|
|
24939e6f00 | ||
|
|
f5274195b9 | ||
|
|
8679a850e5 |
+10
-1
@@ -1,2 +1,11 @@
|
||||
TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
|
||||
BIRD_PROFILE_NAME=
|
||||
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
|
||||
# Optional home Codex research prototype. Start pnpm codex:serve separately.
|
||||
# TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500
|
||||
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra
|
||||
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
/.*/skills/
|
||||
.env*
|
||||
!.env.example
|
||||
.output/
|
||||
@@ -7,3 +8,4 @@ dist/
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.data/
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-09-24",
|
||||
"macrostructure": "Workbench",
|
||||
"theme": "custom",
|
||||
"theme_axes": "dark navy / system sans / blue",
|
||||
"enrichment": "none",
|
||||
"brief": "Full-height TweetDeck-style research columns with compact sidebar and modal editors"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-26",
|
||||
"macrostructure": "Workbench",
|
||||
"theme": "Garden",
|
||||
"enrichment": "none",
|
||||
"brief": "Personal read-only research client with a soft, deliberate workspace"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"date": "2026-09-24",
|
||||
"font_stack": "System sans; tokens.css",
|
||||
"palette": "Dark navy and blue OKLCH tokens",
|
||||
"motion": "motion-cut",
|
||||
"spacing": "named token scale",
|
||||
"framework": "React 19 + TanStack Start + Vite 8",
|
||||
"preserve": ["deck data model", "profile binding", "WebMCP contracts"],
|
||||
"override": "User requested TweetDeck-like full UI redesign instead of Garden reader"
|
||||
}
|
||||
@@ -1,85 +1,222 @@
|
||||
# Twitter Lite
|
||||
|
||||
Twitter Lite is an intentional, read-only X reader. It shows content only after
|
||||
you enter a user handle, profile URL, search query, or list URL, follow a
|
||||
deliberate post detail link, or manually open a valid
|
||||
`/:handle/status/:tweetId` URL.
|
||||
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
|
||||
|
||||
- User timelines and raw X search syntax
|
||||
- Popular (`Top`) and chronological (`Latest`) search
|
||||
- Optional `filter:follows` search
|
||||
- Authenticated account list selection and list timelines from a deliberate URL or ID
|
||||
- Infinite cursor pagination with explicit retry
|
||||
- Deliberate post detail pages with the visible conversation
|
||||
- Infinite conversation loading with explicit continuation retry
|
||||
- Read-only cards for text, media, quotes, articles, and quiet engagement counts
|
||||
- Multiple deck profiles with creation, selection, renaming, and deletion
|
||||
- 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
|
||||
- Platform logos in column headers distinguish Twitter and Mastodon at a glance
|
||||
- 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
|
||||
- Prototype: chat with a resident home Codex beside a live deck, reuse existing
|
||||
decks and write cited Markdown research reports on the host
|
||||
- Saved chat history with deck, account and citation restoration across devices
|
||||
and backend restarts
|
||||
|
||||
It intentionally has no home feed, recommendations, trends, notifications,
|
||||
history, account directory, or write actions.
|
||||
Both `/` and `/deck` open the deck workspace. The separate reader, search,
|
||||
list, user-profile, and conversation routes have been removed. Original-post
|
||||
links on post cards open their source site; SNS reply threads are not rendered
|
||||
inside the app.
|
||||
|
||||
Post detail pages show only the selected post and its visible conversation;
|
||||
they do not add related-post recommendations or an account-discovery surface.
|
||||
|
||||
## Routes
|
||||
|
||||
- `/` shows the empty user input.
|
||||
- `/:handle` shows a user timeline.
|
||||
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` shows search results.
|
||||
- `/i/lists` shows the authenticated account's lists.
|
||||
- `/i/lists/:listId` shows a list timeline.
|
||||
- `/:handle/status/:tweetId` shows one selected post and its visible conversation.
|
||||
Bluesky/Threads/Nostr connectors are not implemented yet. An external browser
|
||||
agent can also create and read decks through WebMCP.
|
||||
|
||||
## Requirements and setup
|
||||
|
||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||||
routes the `@yuta` scope to the public Gitea Packages registry, where Bird
|
||||
`0.10.0` provides the required Top and Latest search interface.
|
||||
routes the `@yuta` scope to the public Gitea Packages registry.
|
||||
|
||||
```bash
|
||||
nix develop -c pnpm install --frozen-lockfile
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development, and set
|
||||
`BIRD_PROFILE_NAME` when the relay has multiple profiles. Export the same
|
||||
values in the process environment before `start` or `test:live`:
|
||||
The Nix development shell installs Hallmark's agent skill for the supported
|
||||
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
|
||||
export BIRD_PROFILE_NAME=
|
||||
```
|
||||
|
||||
Both values are read by server-only code at runtime; neither value nor Bird is
|
||||
sent to the browser.
|
||||
The app discovers available account profiles through the relay's `/profiles`
|
||||
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
|
||||
stable connection IDs. The connection metadata resolves Twitter IDs to relay
|
||||
profiles; Mastodon credentials never go to the browser.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
nix develop -c pnpm dev
|
||||
nix develop -c pnpm dev:tailscale
|
||||
nix develop -c pnpm generate:e2e-openapi
|
||||
nix develop -c pnpm knip
|
||||
nix develop -c pnpm test
|
||||
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
|
||||
nix develop -c pnpm codex:serve
|
||||
```
|
||||
|
||||
`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by
|
||||
the dev shell. `test:live` is an explicit, opt-in smoke test that performs
|
||||
exactly one read-only Top search using the configured relay.
|
||||
## Home Codex research prototype
|
||||
|
||||
Development binds to `127.0.0.1` by default. `dev:tailscale` binds to
|
||||
`0.0.0.0`, so it exposes the app on LAN interfaces as well as Tailscale. Use it
|
||||
only on a trusted network and obtain the Tailscale address with
|
||||
`tailscale ip -4`.
|
||||
Run `codex login` as the host user, then keep `pnpm codex:serve` running separately
|
||||
from the web server. It starts a resident app-server at `ws://127.0.0.1:4500`
|
||||
using that user's existing Codex login. The launcher passes a small environment
|
||||
allowlist, without the app's database, relay or SNS secret settings. Configured
|
||||
MCP servers are disabled for this process without changing the user's settings.
|
||||
The prototype was developed against Codex CLI 0.156.1; its WebSocket and dynamic
|
||||
tool APIs are experimental.
|
||||
|
||||
Configure the web app with these values in `.env.local` (absolute report path):
|
||||
|
||||
```dotenv
|
||||
TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500
|
||||
TWITTER_LITE_CODEX_MODEL=gpt-6-astra
|
||||
TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
|
||||
```
|
||||
|
||||
Use the chat on the left while browsing horizontally scrollable deck columns on
|
||||
the right. Messages continue the same Codex thread and include the currently
|
||||
open deck as context. The account selector controls which connections Codex can
|
||||
use. It can inspect saved deck definitions and reuse them as temporary views,
|
||||
as well as create new views. `list_lists(connectionId)` discovers existing
|
||||
Twitter and Mastodon lists for a selected connected account; returned list IDs
|
||||
can be reused in list columns. It uses the same catalog as the manual editor
|
||||
(Twitter requests up to 100 lists; list discovery is not paginated).
|
||||
The default prompt asks Codex to inspect existing decks and the selected
|
||||
accounts' lists before planning new searches, then read relevant list columns
|
||||
for evidence. Follow-ups can reuse catalogs already fetched in the conversation.
|
||||
Manual deck creation and column editing remain
|
||||
available. The backend handles tools even when the browser is closed.
|
||||
Use **新しいチャット** to leave the current conversation and start a fresh Codex
|
||||
thread with your next message. The open deck and host reports remain available.
|
||||
If a turn is running, stop it first. The conversation reset is shared across
|
||||
open devices. **チャット履歴** reopens saved conversations with their account
|
||||
selection, deck and citations; the next message resumes the same Codex thread.
|
||||
SQLite stores conversation snapshots and the active conversation selection.
|
||||
Each turn is limited to 6 columns, 12 upstream requests shared between list
|
||||
discovery and post retrieval (at most 20 posts per fetch), and 15 minutes.
|
||||
The prototype accepts up to 100 messages per backend process.
|
||||
SSE pushes conversation messages, tool activity and generated deck updates to
|
||||
open browsers, without polling.
|
||||
Codex replies render as Markdown, including tables, lists and code. Clicking a
|
||||
cited post link scrolls to its card and briefly highlights it. Loaded cards take
|
||||
priority; otherwise the app shows the post captured during research in a column
|
||||
with matching source and account settings, creating a temporary citation deck
|
||||
when needed. These captured posts are labeled and do not change feed pagination
|
||||
or reveal sensitive content automatically. Other links still open externally;
|
||||
Ctrl/Cmd-click on a citation also opens the original site.
|
||||
Citation metadata stays with the saved conversation across reconnects,
|
||||
follow-up messages and server restarts. Markdown HTML is not executed and
|
||||
Markdown images are rendered as links.
|
||||
Reopening the page receives the latest state and restores the temporary deck.
|
||||
Later updates modify the same temporary view; they do not overwrite saved decks
|
||||
or switch away from another deck you are reading. Use the existing save button
|
||||
to persist a deck. The agent and visible columns currently fetch posts separately.
|
||||
|
||||
Codex writes `<report root>/<run ID>/report.md` using its workspace file tools.
|
||||
The UI displays its host path after validating the file. A conversation or deck
|
||||
edit can finish without producing a report. Citation quality is model output,
|
||||
not independently verified. Report contents remain on the host filesystem;
|
||||
the app DB stores the conversation, thread ID, deck context and citation metadata.
|
||||
|
||||
Browser disconnection does not cancel research; reconnecting restores the latest
|
||||
state through `/api/research/events`, under the same owner access checks as the
|
||||
rest of the app. After a backend restart, unfinished turns appear as interrupted;
|
||||
sending another message resumes the same thread. Before starting that message,
|
||||
the backend checks the resumed thread and interrupts any orphaned active turn.
|
||||
It does not automatically rerun interrupted requests. Files already written remain.
|
||||
Cancellation is explicit. The launcher and report directory must
|
||||
run under a user able to write those files. NixOS DynamicUser service integration
|
||||
and dedicated service credentials are outside this prototype.
|
||||
|
||||
`test:e2e` uses the system Chromium supplied by the Nix dev shell. Playwright
|
||||
integration and Axe accessibility tests live under `e2e/`. A standalone mock
|
||||
HTTP relay exercises the production server-function boundary without contacting
|
||||
X or a personal relay. Orval generates API fixture types from a pinned
|
||||
[twitter-openapi specification](https://github.com/fa0311/twitter-openapi/commit/590dae5c9f8575abc91d3774946bfe6f23960aba).
|
||||
Generated files are committed; run `generate:e2e-openapi` only when changing
|
||||
the pin or selected operations. Runtime requests use Bird.
|
||||
|
||||
`test:live` is an explicit, opt-in smoke test that performs one read-only Top
|
||||
search. For this standalone probe only, `BIRD_PROFILE_NAME` selects the account
|
||||
and `TWITTER_LITE_LIVE_QUERY` overrides the search text.
|
||||
|
||||
## Deck workspace
|
||||
|
||||
The interface uses a full-height, dark TweetDeck-style workbench: a deck sidebar,
|
||||
compact column headers, and independently scrolling post columns. On narrow
|
||||
screens, the sidebar becomes a compact top bar. Add/edit forms open in native
|
||||
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 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.
|
||||
|
||||
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.
|
||||
|
||||
AI-created decks open as temporary views reconstructed from their saved chat
|
||||
on another device or after reload. Save a view to add it to the independent deck
|
||||
library, or make a temporary copy of a saved deck to experiment.
|
||||
Other unsaved temporary edits 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_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
|
||||
[Model Context Tool Inspector](https://developer.chrome.com/docs/ai/webmcp#imitate_agent_chat_with_the_inspector_extension)
|
||||
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 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 both a production package and a NixOS module. Import the
|
||||
module and configure the relay:
|
||||
The flake provides a production package and a NixOS module:
|
||||
|
||||
```nix
|
||||
{
|
||||
@@ -95,7 +232,10 @@ module and configure the relay:
|
||||
services.twitter-lite = {
|
||||
enable = true;
|
||||
relayBaseUrl = "http://127.0.0.1:6900";
|
||||
# profileName = "default";
|
||||
publicOrigin = "https://home.example-tailnet.ts.net";
|
||||
allowedLogin = "your-tailscale-login";
|
||||
mastodonOrigins = [ "https://fedi.yutakobayashi.com" ];
|
||||
credentialKeyFile = "/var/lib/secrets/twitter-lite-key";
|
||||
};
|
||||
}
|
||||
];
|
||||
@@ -104,15 +244,15 @@ module and configure the relay:
|
||||
}
|
||||
```
|
||||
|
||||
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. The package can also be built directly with `nix build`.
|
||||
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
|
||||
|
||||
Bird uses X's internal GraphQL operations through the configured relay. Query
|
||||
IDs and response shapes can change without notice.
|
||||
|
||||
Conversation pages use Bird's current per-page chronological ordering. Keeping
|
||||
X's original ranked branch order is deferred until Bird exposes that order
|
||||
without expanding the relay surface.
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
# Home-agent research
|
||||
|
||||
## Product boundary
|
||||
|
||||
The user starts a research task from the deck workspace. A home agent chooses
|
||||
sources, opens a temporary deck, reads posts and writes a cited Markdown report
|
||||
on the host. Twitter Lite provides the connected-account and deck tools; the
|
||||
agent owns research and report writing.
|
||||
|
||||
SQLite stores conversation snapshots and the selected conversation alongside
|
||||
saved deck definitions and account credentials. Reports remain host Markdown;
|
||||
their contents are not copied into the DB. Adding a temporary deck to the saved
|
||||
deck library remains an explicit user action.
|
||||
|
||||
## Proposed first implementation
|
||||
|
||||
1. Add a topic and connected-account selection form to the deck workspace.
|
||||
2. Start one home Codex task with a dedicated output directory.
|
||||
3. Supply tools for connection discovery, temporary deck creation and bounded
|
||||
post retrieval using the existing validators and platform services.
|
||||
4. Save conversation state and the latest generated deck in SQLite. SSE
|
||||
sends the current snapshot on connection and pushes subsequent changes.
|
||||
Reopened pages restore the temporary view; later definitions update the same
|
||||
view. Closing that view does not delete the report or stop the agent.
|
||||
5. Have the agent write `report.md` in its per-task directory with original post
|
||||
URLs, collection limits and failed sources. Show progress and the output
|
||||
location in the app. No report-history UI or scheduled execution yet.
|
||||
|
||||
Following the user's WebSocket question, the proposed transport is a persistent
|
||||
`codex app-server --listen ws://127.0.0.1:4500` connected to the Node backend,
|
||||
with dynamic tools handled by that backend. WebSocket transport is currently
|
||||
experimental in the official documentation. Native browser WebMCP is not a remote
|
||||
MCP endpoint: reuse application operations rather than assuming that host Codex
|
||||
can directly invoke a user's browser tools. No externally exposed Codex socket
|
||||
is necessary.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Confirmed: use home Codex, rather than a separate OpenAI API integration.
|
||||
- Confirmed: report artifacts are host Markdown files written by the agent.
|
||||
- Prototyping: resident app-server over loopback WebSocket, using the existing
|
||||
Codex login and model `gpt-6-astra`. No NixOS service migration in this step.
|
||||
- Configure a dedicated report root and Codex authentication store. Preserve
|
||||
refreshed authentication for the service; do not put credentials in prompts
|
||||
or copy application secrets into the child environment.
|
||||
- Keep manual execution bounded and cancellable. After restart, unfinished
|
||||
conversations restore as interrupted. The next user message resumes the same
|
||||
Codex thread after stopping any surviving orphan turn. No automatic rerun or
|
||||
durable job queue is added.
|
||||
|
||||
## Conversation workspace
|
||||
|
||||
The next iteration places a persistent chat beside the horizontally scrollable
|
||||
deck. The browser displays user messages, public Codex agent messages and tool
|
||||
activity from app-server events. Each follow-up continues the existing thread
|
||||
and supplies the deck currently open in that browser. Internal reasoning is not
|
||||
part of this transcript.
|
||||
|
||||
The agent can list and inspect saved decks within the selected account scope,
|
||||
then reuse their columns in a temporary view. Saved definitions remain unchanged
|
||||
until the user saves a view. Existing manual creation, selection, column editing
|
||||
and saving stay available alongside the chat.
|
||||
|
||||
`list_lists(connectionId)` also exposes the manual editor's Twitter and Mastodon
|
||||
list catalogs. Discovery is read-only and checks the selected connected account
|
||||
before contacting either provider. Returned list IDs can be used directly in
|
||||
list-column sources. Discovery and post retrieval share the per-turn budget of
|
||||
12 upstream calls, including failures. Twitter requests up to 100 entries through
|
||||
the existing catalog service; discovery has no pagination yet.
|
||||
For Twitter, the agent prefers the selected connected account named `account2`
|
||||
for new columns and retrieval, resolving its ID through `list_connections`.
|
||||
It uses another selected account when `account2` is unavailable, lacks access
|
||||
(for example to a private list), or the user explicitly requests it. Existing
|
||||
columns retain their account bindings.
|
||||
|
||||
The default agent instructions require this discovery at the start of research,
|
||||
before planning new searches. Relevant existing lists become candidate sources;
|
||||
their posts are retrieved through the regular column tool. Catalogs already read
|
||||
in the conversation can be reused, and explicit user instructions take priority.
|
||||
|
||||
A conversational turn need not produce a research report: discussing a query or
|
||||
editing a deck is a valid completed turn. Reports, when requested, remain host
|
||||
Markdown files. SSE snapshots include the conversation and latest generated
|
||||
deck so reopening a page restores both without restarting work or polling.
|
||||
|
||||
The chat header can reset an idle conversation. The next message starts a new
|
||||
Codex thread while retaining the browser's current deck as context. Reset pushes
|
||||
an empty conversation to every subscriber, preserves host reports and does not
|
||||
delete deck definitions. Active turns must be stopped first; a stale reset must
|
||||
not clear a newer conversation started on another device. The history selector
|
||||
can reopen previous conversations while idle, restoring the generated deck (or
|
||||
last supplied deck context), selected accounts and citations. Selection is shared
|
||||
through SSE. Stale selections are rejected just like stale resets.
|
||||
|
||||
`research_sessions` stores the full public conversation snapshot and summary
|
||||
metadata; `research_state` stores the selected conversation, including an explicit
|
||||
blank selection for new chat. State is written before starting Codex and before
|
||||
publishing updates. DB write failures stop research and surface a save error.
|
||||
On restore, formerly active sessions become interrupted. `thread/resume` restores
|
||||
Codex's persisted dynamic tools. Recovery suppresses old turn notifications and
|
||||
tool calls, interrupts a still-running old turn, then starts the new message.
|
||||
|
||||
## Markdown and source navigation
|
||||
|
||||
Assistant messages use react-markdown with remark-gfm. Public tool messages and
|
||||
user input remain plain text. Markdown links retain their original URLs; a
|
||||
regular click on a known post navigates within the deck, while modified clicks
|
||||
and unrelated URLs retain external navigation.
|
||||
|
||||
Successful agent post fetches capture the exact column definition and a public
|
||||
post projection in the saved conversation. The projection excludes raw
|
||||
provider responses, HTML, media and account credentials. References are retained
|
||||
across turns, updated per source/account/post identity and retained in chat history.
|
||||
The agent is instructed to cite exact returned post URLs in Markdown.
|
||||
|
||||
Navigation prefers a loaded card, then a workspace column with the same source
|
||||
and connection. Missing sources open a one-column temporary deck. An unloaded
|
||||
reference is displayed as a labeled research-time post without altering the
|
||||
query cache or pagination. Cards receive focus, scroll into view and briefly
|
||||
highlight; reduced-motion settings are respected and sensitive content stays
|
||||
collapsed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Protocol tests for dynamic-tool requests, failures, cancellation and process
|
||||
exit, with no real model usage in ordinary tests.
|
||||
- Tool tests for selected connection scope, source validation, pagination limits
|
||||
and temporary-only deck creation.
|
||||
- Browser test: start task, show generated temporary deck, retain manual save,
|
||||
display progress and completion or failure.
|
||||
- Opt-in real home Codex run: retrieve real posts and verify a readable report
|
||||
exists at the reported host location with original-source links.
|
||||
|
||||
## Deployment boundary
|
||||
|
||||
The currently used UM790-Pro runs the development server. The existing dotnix
|
||||
Twitter Lite service is configured on B450M-Pro4 with obsolete options and a
|
||||
different public route. Do not apply that configuration to this task implicitly.
|
||||
Service migration and backup scheduling need a concrete host configuration;
|
||||
they are separate from validating this first agent workflow.
|
||||
|
||||
## References
|
||||
|
||||
- [Codex App Server](https://learn.chatgpt.com/docs/app-server)
|
||||
- [Codex authentication](https://learn.chatgpt.com/docs/auth)
|
||||
- [Existing WebMCP contract](../webmcp-prototype.md)
|
||||
|
||||
## Web-to-resident-Codex examples inspected
|
||||
|
||||
Research on 2026-09-24; README and implementation inspection only, not runtime
|
||||
validation or adoption of these projects.
|
||||
|
||||
- [Redex](https://github.com/ladnir/redex/tree/a8032c49d1c9cc79db9679ad06b94a72defe3ec7):
|
||||
its Python HTTP/SSE bridge connects to a standalone Codex app-server through
|
||||
WebSocket. `src/redex/app_server.py` initializes the connection and sends
|
||||
`thread/resume` then `turn/start`; `src/redex/bridge.py` has a long-lived
|
||||
`LiveEventHub` separate from browser SSE connections. Desktop shared-runtime
|
||||
integration in this project's README requires its Codex fork; standalone
|
||||
WebSocket usage does not. The bridge suppresses the Origin header on its
|
||||
server-side connection because Codex rejects browser-style Origin headers.
|
||||
- [Pedregoneric/codex-webui](https://github.com/Pedregoneric/codex-webui/blob/5cbc718004c77657812f7151ae708e68cc6b398c/server.js):
|
||||
a small personal/Tailscale-oriented example. One `CodexBridge` owns a long-lived
|
||||
stdio child. Browser SSE close removes that subscriber, without killing Codex;
|
||||
gateway shutdown does kill the child. This demonstrates that browser lifetime
|
||||
independence is not itself a WebSocket feature. Its authentication is separate
|
||||
from the existing Tailscale identity boundary used by Twitter Lite.
|
||||
- [OpenAI's Codex Web architecture](https://openai.com/index/unlocking-the-codex-harness/):
|
||||
the February 2026 engineering article describes HTTP/SSE from browser to
|
||||
backend, and a worker maintaining a long-lived app-server connection. The
|
||||
backend owns task state so browser disconnection does not stop work. This is an
|
||||
architectural reference, not evidence of today's exact hosted implementation.
|
||||
- [nathan-chappell/codex_web_ui](https://github.com/nathan-chappell/codex_web_ui/tree/79be149c427040d7fbb94246d841f705ab53ce9e):
|
||||
the launcher starts a detached app-server on a private Unix socket. Its
|
||||
TypeScript bridge performs a WebSocket upgrade over that socket. Browser
|
||||
commands use HTTP RPC and events use SSE. Disconnecting an SSE subscriber does
|
||||
not stop the bridge or Codex. Relevant files: `bin/codex-web-ui.js`,
|
||||
`server/codexBridge.ts`, `server/appApi.ts`, `server/eventHub.ts`.
|
||||
- [seo-rii/codex-webui](https://github.com/seo-rii/codex-webui/tree/2619d78c55c6095aa8b9a7a1412fd0c7abf5e564):
|
||||
the Rust gateway connects directly over Unix-socket WebSocket in current code,
|
||||
despite older docs mentioning a proxy. It separates browser disconnect from
|
||||
gateway-restart handoff and normal shutdown. Request-ID deduplication avoids
|
||||
executing replayed mutations twice. Its dynamic-tool response handling is a
|
||||
useful reference, but we did not find custom SNS-style tool registration to
|
||||
reuse. Relevant files: `backend/src/codex_app_server.rs`,
|
||||
`backend/src/ws_transport_support.rs`, `backend/src/runtime_request_support.rs`.
|
||||
|
||||
The implementation should distinguish three events: browser disconnection,
|
||||
Twitter Lite backend restart, and Codex process restart. A WebSocket connection
|
||||
alone does not guarantee recovery across any of them. Agent tool requests should
|
||||
be handled by the backend so research can continue without the initiating tab.
|
||||
|
||||
## Prototype verification
|
||||
|
||||
- Implemented a loopback WebSocket client, background run controller, bounded
|
||||
account/deck/post tools, and a sidebar research dialog. No schema migrations.
|
||||
- 261 unit/component tests passed (one existing opt-in live test skipped).
|
||||
- Relevant Playwright coverage: 17 desktop cases, 6 mobile WebMCP cases, and
|
||||
11 mobile deck/research cases passed. The new sidebar action initially
|
||||
overlapped another mobile control; it now uses the existing compact pattern.
|
||||
- Typecheck, lint, knip, and production build passed.
|
||||
- A real Codex 0.156.1 / gpt-6-astra run started from the browser. The browser
|
||||
was closed, reopened, and observed the task continuing to completion.
|
||||
Local verification supplied the trusted Serve identity and origin headers;
|
||||
the tagged host cannot authenticate as the owner through Serve itself.
|
||||
- Codex generated two temporary columns, retrieved 20 Twitter posts and zero
|
||||
Mastodon results, and wrote a Japanese report with original post links and
|
||||
explicit limits on interpreting the empty Mastodon search. The report is
|
||||
under the ignored `.data/research/<run ID>/report.md` directory.
|
||||
- Reopening the run's generated deck produced two temporary columns. Saved
|
||||
user decks were not replaced, and the report was not inserted into SQLite.
|
||||
- After replacing polling with SSE, six desktop/mobile E2E cases passed for
|
||||
initial snapshots, reconnect snapshots, owner checks and the unconfigured UI.
|
||||
- A second real run pushed a one-column deck to the page. After closing and
|
||||
reopening the browser, the latest deck restored automatically and grew to two
|
||||
columns through SSE. Codex then completed `report.md`. Same-view updates,
|
||||
preserving saved decks, ordered asynchronous updates and late start responses
|
||||
are also covered by component/controller tests.
|
||||
- The actual Codex launcher was restarted successfully with configured MCP
|
||||
servers disabled; WebSocket `config/read` confirmed all seven disabled. Nix
|
||||
wrapper config overrides must precede the `app-server` subcommand.
|
||||
|
||||
## Conversation workspace verification
|
||||
|
||||
- 270 unit/component tests and 40 desktop/mobile Playwright cases passed.
|
||||
Typecheck, lint, knip and production build passed.
|
||||
- Browser coverage includes conversation/deck restoration from SSE snapshots,
|
||||
manual deck editing and saving, narrow viewports and existing WebMCP tools.
|
||||
Readiness checks wait for usable controls rather than network idleness because
|
||||
the event stream intentionally stays connected.
|
||||
- A real three-message Codex conversation called `list_decks`, reused a manually
|
||||
created column from the current-deck context, and edited that column after the
|
||||
browser was closed and reopened. The app-server thread ID and generated deck
|
||||
ID stayed unchanged; the deck advanced from version 1 to 2.
|
||||
- The live database had no saved decks, which Codex reported correctly. Saved
|
||||
deck discovery/reuse with populated records is covered by isolated tool tests.
|
||||
The live check did not add or change saved deck records or create a report.
|
||||
|
||||
## List discovery verification
|
||||
|
||||
- 279 unit/component tests passed (one opt-in live test skipped), with typecheck,
|
||||
lint and production build passing. Tool tests cover both platforms, selected
|
||||
account scope, empty lists, upstream failures, shared request limits and using
|
||||
discovered IDs to create and fetch list columns.
|
||||
- A real browser-initiated Codex run discovered 54 lists for Twitter account1
|
||||
and zero for Mastodon, then opened a temporary list column. Reopening the page
|
||||
restored that deck and the conversation. Twitter account2 initially returned
|
||||
an upstream `Dependency: Unspecified` error; a direct retry through the same
|
||||
Bird catalog API returned three lists. A follow-up message in the same Codex
|
||||
conversation then retried `list_lists` successfully and also reported three.
|
||||
|
||||
## New-chat verification
|
||||
|
||||
- 284 unit/component tests and eight desktop/mobile research E2E cases passed,
|
||||
along with typecheck, lint and production build.
|
||||
- A real Codex check on a separate development server reset a completed chat
|
||||
across two browser pages, cleared the draft and retained the current deck.
|
||||
The next message used a different thread ID, contained only the new user
|
||||
message and received the current deck context. The separate server avoided
|
||||
interrupting research already running in the public workspace.
|
||||
|
||||
## Markdown citation verification
|
||||
|
||||
- The full suite passed 306 unit/component tests, with one opt-in test skipped.
|
||||
Typecheck, lint, knip and production build passed. Follow-up card tests verify
|
||||
re-scrolling when a captured post is replaced by its loaded feed result.
|
||||
- 42 desktop/mobile E2E cases passed, including Markdown tables and emphasis,
|
||||
navigating to an offscreen column, captured-post display, focus/highlight,
|
||||
accessibility and existing manual deck/WebMCP operations.
|
||||
- A real Codex run on a separate development server fetched 20 Twitter posts,
|
||||
cited two in a Markdown response, and navigated from a citation to its card.
|
||||
Closing and reopening the page preserved working citation navigation.
|
||||
- react-markdown 10.1.0 and remark-gfm 4.0.1 were added with the user's approval.
|
||||
The pnpm dependency hash was updated and the x86_64-linux Nix package built.
|
||||
|
||||
## Conversation persistence verification
|
||||
|
||||
- SQLite repository tests reopen file-backed databases and verify conversation,
|
||||
deck, account, citation and active-selection restoration, plus transaction rollback.
|
||||
- Runner tests recreate the service against the same database, resume its thread,
|
||||
reject stale history switches, interrupt orphan turns and reject late orphan
|
||||
events. Failed writes prevent new execution or stop further tool calls.
|
||||
- 52 desktop/mobile E2E cases passed. The final focused research suite passed
|
||||
120 tests; typecheck, lint, knip and Nix packaging passed.
|
||||
- An actual Codex run captured 20 Twitter posts. A fresh Node process restored
|
||||
that conversation and used the same Codex thread and dynamic tools to add a
|
||||
second column, retaining all citations. Separate desktop/mobile browser
|
||||
contexts restored the saved chat, navigated citations, shared history selection
|
||||
through SSE and retained blank-new-chat selection across reload.
|
||||
@@ -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に投稿アーカイブを混ぜない。
|
||||
@@ -0,0 +1,136 @@
|
||||
# Research decks
|
||||
|
||||
## Workspace
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Definitions and connections
|
||||
|
||||
`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`.
|
||||
|
||||
| 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 |
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Implementation boundaries
|
||||
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
## Saved decks and temporary views
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Research chats separately retain the agent-generated deck and last supplied
|
||||
deck context. Reopening a saved chat reconstructs a temporary view, including
|
||||
on another device or after a server restart. Chat citations retain a limited
|
||||
post projection for source navigation; this does not persist the deck's feed
|
||||
cache, pagination cursors, scroll positions or unsent manual edits.
|
||||
|
||||
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.
|
||||
|
||||
## WebMCP
|
||||
|
||||
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": "Twitter",
|
||||
"connectionId": "twitter-connection-id",
|
||||
"source": { "platform": "twitter", "kind": "search", "query": "WebMCP lang:ja" }
|
||||
},
|
||||
{
|
||||
"title": "Mastodon",
|
||||
"connectionId": "mastodon-connection-id",
|
||||
"source": { "platform": "mastodon", "kind": "hashtag", "target": "WebMCP" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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 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.
|
||||
@@ -175,10 +175,14 @@ None of these functions exposes the Bird client, arbitrary method names, arbitra
|
||||
|
||||
### Search Flow
|
||||
|
||||
1. Trim and validate the raw query.
|
||||
2. Append `filter:follows` when the following toggle is enabled and the operator is not already present.
|
||||
3. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`.
|
||||
4. Return normalized tweets and the next cursor.
|
||||
1. Keep the raw query and structured author, period, language, content, and
|
||||
exclusion controls in typed URL search parameters.
|
||||
2. Compile selected controls into a deterministic advanced search expression
|
||||
using `from:`, `since:`, `until:`, `lang:`, and `filter:` operators.
|
||||
3. Trim and validate the compiled query, including its 512-character limit.
|
||||
4. Append `filter:follows` when the following toggle is enabled and the operator is not already present.
|
||||
5. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`.
|
||||
6. Return normalized tweets and the next cursor.
|
||||
|
||||
Advanced X search grammar remains available because the raw query is otherwise passed unchanged.
|
||||
|
||||
@@ -207,7 +211,11 @@ The selected layout is **Focused Tabs**:
|
||||
- no content on first load;
|
||||
- results directly below the submitted controls.
|
||||
|
||||
The User tab contains one target input and a Display button. The Search tab contains a query input, a Popular/Latest segmented control, a Following only checkbox, and a Search button.
|
||||
The User tab contains one target input and a Display button. The Search tab
|
||||
contains a query input; buttons that open inline author, period, language,
|
||||
content-type, and exclusion controls; removable active-condition chips; a
|
||||
collapsible compiled-query preview; a Popular/Latest segmented control; a
|
||||
Following only checkbox; and a Search button.
|
||||
|
||||
Submitting a user target normalizes it to a handle and navigates to `/:handle`.
|
||||
Submitting a search updates the route's typed search parameters. Changing tabs
|
||||
@@ -344,7 +352,9 @@ The work is complete when:
|
||||
target or query, following a post detail link, or manually opening a valid
|
||||
status URL.
|
||||
3. A valid handle or profile URL loads that user's posts.
|
||||
4. Search supports Top, Latest, and the follows filter.
|
||||
4. Search supports Top, Latest, the follows filter, and button-driven author,
|
||||
period, language, content-type, reply, and repost conditions while preserving
|
||||
raw advanced search syntax.
|
||||
5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
|
||||
6. Relay credentials and Bird remain server-only.
|
||||
7. No excluded discovery or mutation feature is reachable.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# WebMCP prototype
|
||||
|
||||
## Registration
|
||||
|
||||
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`.
|
||||
|
||||
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_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 |
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
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 view are available;
|
||||
select the view and allow it to render first.
|
||||
|
||||
`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, 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` for additional already loaded records; use
|
||||
`load_more_column` for another upstream page. These are cache snapshots, not
|
||||
archived evidence.
|
||||
|
||||
## Errors and annotations
|
||||
|
||||
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_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.
|
||||
|
||||
## Browser setup and verification
|
||||
|
||||
```sh
|
||||
nix develop -c pnpm test
|
||||
nix develop -c pnpm typecheck
|
||||
nix develop -c pnpm test:e2e e2e/integrations/webmcp.test.ts
|
||||
```
|
||||
|
||||
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. 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.
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'sqlite',
|
||||
schema: './src/features/storage/schema.ts',
|
||||
out: './drizzle',
|
||||
})
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `connection_credentials` ADD `app_id` text REFERENCES oauth_apps(id);
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `research_sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`snapshot` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `research_state` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`active_session_id` text,
|
||||
FOREIGN KEY (`active_session_id`) REFERENCES `research_sessions`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
CONSTRAINT "research_state_singleton" CHECK("research_state"."id" = 1)
|
||||
);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "d021bc5b-2422-467a-ab92-2493da6e642a",
|
||||
"prevId": "ebfb67e2-a014-4d40-89e3-30c9b55cf047",
|
||||
"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": {}
|
||||
},
|
||||
"research_sessions": {
|
||||
"name": "research_sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"snapshot": {
|
||||
"name": "snapshot",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"research_state": {
|
||||
"name": "research_state",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_session_id": {
|
||||
"name": "active_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"research_state_active_session_id_research_sessions_id_fk": {
|
||||
"name": "research_state_active_session_id_research_sessions_id_fk",
|
||||
"tableFrom": "research_state",
|
||||
"tableTo": "research_sessions",
|
||||
"columnsFrom": ["active_session_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"research_state_singleton": {
|
||||
"name": "research_state_singleton",
|
||||
"value": "\"research_state\".\"id\" = 1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1790242505709,
|
||||
"tag": "0003_romantic_scrambler",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
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 })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.disableRules(['meta-viewport']),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TweetLegacy } from '../generated/twitter-openapi/client.ts'
|
||||
|
||||
interface TweetLegacyFixtureOptions {
|
||||
conversationId?: string
|
||||
inReplyTo?: string
|
||||
replyCount?: number
|
||||
}
|
||||
|
||||
export const tweetLegacyFixture = (
|
||||
id: string,
|
||||
text: string,
|
||||
{
|
||||
conversationId = id,
|
||||
inReplyTo,
|
||||
replyCount = 1,
|
||||
}: TweetLegacyFixtureOptions = {},
|
||||
): TweetLegacy => ({
|
||||
bookmark_count: 0,
|
||||
bookmarked: false,
|
||||
conversation_id_str: conversationId,
|
||||
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
|
||||
display_text_range: [0, text.length],
|
||||
favorite_count: 3,
|
||||
favorited: false,
|
||||
full_text: text,
|
||||
id_str: id,
|
||||
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
|
||||
is_quote_status: false,
|
||||
lang: 'ja',
|
||||
quote_count: 0,
|
||||
reply_count: replyCount,
|
||||
retweet_count: 2,
|
||||
retweeted: false,
|
||||
user_id_str: '42',
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -0,0 +1,372 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
async function addColumn(
|
||||
page: Page,
|
||||
title: string,
|
||||
query: string,
|
||||
profile = 'e2e',
|
||||
kind = 'search',
|
||||
) {
|
||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||
const editor = page.getByRole('form', { name: 'カラムを追加', exact: true })
|
||||
await editor.getByLabel('カラム名', { exact: true }).fill(title)
|
||||
await editor
|
||||
.getByRole('combobox', { name: '接続プロファイル', exact: true })
|
||||
.selectOption({ label: profile })
|
||||
await editor
|
||||
.getByRole('combobox', { name: 'カラムの種類', exact: true })
|
||||
.selectOption(kind)
|
||||
await editor
|
||||
.getByLabel(
|
||||
kind === 'search'
|
||||
? 'Twitterの検索条件'
|
||||
: kind === 'user'
|
||||
? 'ユーザー'
|
||||
: 'リスト',
|
||||
{ exact: true },
|
||||
)
|
||||
.fill(query)
|
||||
await editor.getByRole('button', { name: '追加して検索' }).click()
|
||||
await expect(
|
||||
page
|
||||
.getByRole('region', { name: title, exact: true })
|
||||
.locator('article')
|
||||
.first(),
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'デッキとして保存', exact: true }),
|
||||
).toBeEnabled()
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
await addColumn(page, '通常の接続', 'same-query')
|
||||
await addColumn(page, '別の接続', 'same-query', 'alternate')
|
||||
const first = page.getByRole('region', { name: '通常の接続', exact: true })
|
||||
const second = page.getByRole('region', { name: '別の接続', exact: true })
|
||||
await expect(
|
||||
first.getByText('Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
second.getByText('alternate · Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
await first.getByRole('button', { name: '続きを読み込む' }).click()
|
||||
await expect(first.locator('article')).toHaveCount(2)
|
||||
await expect(second.locator('article')).toHaveCount(1)
|
||||
await second.getByRole('button', { name: '続きを読み込む' }).click()
|
||||
await expect(
|
||||
second.getByText('alternate · Latest · all page 2'),
|
||||
).toBeVisible()
|
||||
await second.locator('summary[aria-label="別の接続の操作"]').click()
|
||||
await second.getByRole('button', { name: '編集', exact: true }).click()
|
||||
await page
|
||||
.getByRole('combobox', { name: '接続プロファイル', exact: true })
|
||||
.selectOption({ label: 'e2e' })
|
||||
await page.getByRole('button', { name: '変更を保存' }).click()
|
||||
await expect(
|
||||
second.getByText('Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(second.getByText('alternate · Latest · all page 1')).toHaveCount(
|
||||
0,
|
||||
)
|
||||
await page.reload()
|
||||
await expect(
|
||||
second.getByText('Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(first.locator('article')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('creates, switches, renames and deletes persisted deck profiles', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: '名前を変更' }).click()
|
||||
await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査')
|
||||
await page.getByRole('button', { name: '名前を保存' }).click()
|
||||
await addColumn(page, '最初の観点', 'first-deck')
|
||||
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
|
||||
await page.getByLabel('新しいデッキ名').fill('別の調査')
|
||||
await page.getByRole('button', { name: '作成', exact: true }).click()
|
||||
await expect(page.locator('.deck-column')).toHaveCount(0)
|
||||
await addColumn(page, '別の観点', 'second-deck', 'alternate')
|
||||
await page
|
||||
.getByLabel('デッキプロファイル', { exact: true })
|
||||
.selectOption({ label: '最初の調査' })
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
|
||||
await page
|
||||
.getByLabel('デッキプロファイル', { exact: true })
|
||||
.selectOption({ label: '別の調査' })
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['別の観点'])
|
||||
await page.getByRole('button', { name: 'デッキを削除', exact: true }).click()
|
||||
await page.getByRole('button', { name: '削除する', exact: true }).click()
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
|
||||
await expect(
|
||||
page.getByLabel('デッキプロファイル', { exact: true }).locator('option'),
|
||||
).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('reads user and list timelines within columns and reorders, deletes and restores them', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addColumn(page, 'アカウント', 'yuta', 'alternate', 'user')
|
||||
await addColumn(page, 'リストの投稿', '1234567890', 'e2e', 'list')
|
||||
const user = page.getByRole('region', { name: 'アカウント', exact: true })
|
||||
const list = page.getByRole('region', { name: 'リストの投稿', exact: true })
|
||||
await expect(user.getByText('alternate profile page 1')).toBeVisible()
|
||||
await expect(list.getByText('list page 1')).toBeVisible()
|
||||
await list.getByRole('button', { name: '続きを読み込む' }).click()
|
||||
await expect(list.getByText('list page 2')).toBeVisible()
|
||||
await list.locator('summary[aria-label="リストの投稿の操作"]').click()
|
||||
await list.getByRole('button', { name: 'リストの投稿を左へ' }).click()
|
||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||
'リストの投稿',
|
||||
'アカウント',
|
||||
])
|
||||
await list.locator('summary[aria-label="リストの投稿の操作"]').click()
|
||||
await list.getByRole('button', { name: 'リストの投稿を削除' }).click()
|
||||
await page.getByRole('button', { name: '元に戻す' }).click()
|
||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||
'リストの投稿',
|
||||
'アカウント',
|
||||
])
|
||||
await page.reload()
|
||||
await expect(user.getByText('alternate profile page 1')).toBeVisible()
|
||||
await expect(list.getByText('list page 1')).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps other columns usable when pagination fails and retries that column', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`)
|
||||
await addColumn(page, '別の検索', 'independent-deck')
|
||||
const failed = page.getByRole('region', { name: '失敗する検索', exact: true })
|
||||
const other = page.getByRole('region', { name: '別の検索', exact: true })
|
||||
await failed.getByRole('button', { name: '続きを読み込む' }).click()
|
||||
await expect(failed.getByRole('alert')).toBeVisible()
|
||||
await expect(failed.locator('article')).toHaveCount(1)
|
||||
await other.getByRole('button', { name: '続きを読み込む' }).click()
|
||||
await expect(other.locator('article')).toHaveCount(2)
|
||||
await failed.getByRole('button', { name: '再試行' }).click()
|
||||
await expect(failed.locator('article')).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('keeps the deck and editor accessible within narrow viewports', async ({
|
||||
page,
|
||||
a11y,
|
||||
}, testInfo) => {
|
||||
await addColumn(page, '日本語の反応', 'WebMCP lang:ja')
|
||||
await addColumn(page, '海外の反応', 'WebMCP lang:en', 'alternate')
|
||||
await addColumn(page, '公式アカウント', 'OpenAI', 'e2e', 'user')
|
||||
for (const width of [320, 375, 414, 768]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > innerWidth,
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollHeight <= innerHeight,
|
||||
),
|
||||
).toBe(true)
|
||||
const board = await page.locator('.deck-board').boundingBox()
|
||||
expect(board).not.toBeNull()
|
||||
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901)
|
||||
}
|
||||
await page.setViewportSize({
|
||||
width: testInfo.project.name === 'desktop' ? 1440 : 393,
|
||||
height: 900,
|
||||
})
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'カラム一覧' }),
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '公式アカウント', exact: true }),
|
||||
).toBeVisible()
|
||||
expect((await a11y().analyze()).violations).toEqual([])
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('deck-populated.png'),
|
||||
fullPage: true,
|
||||
})
|
||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||
expect((await a11y().analyze()).violations).toEqual([])
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('deck.png'),
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('closes the column editor with Escape and restores focus without applying edits', async ({
|
||||
page,
|
||||
}) => {
|
||||
const opener = page.getByRole('button', { name: 'カラムを追加', exact: true })
|
||||
await opener.click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible()
|
||||
await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
await expect(opener).toBeFocused()
|
||||
await expect(page.locator('.deck-column')).toHaveCount(0)
|
||||
await opener.click()
|
||||
await expect(page.getByLabel('カラム名', { exact: true })).toHaveValue('')
|
||||
})
|
||||
|
||||
test('does not overwrite unreadable storage on reload', async ({ page }) => {
|
||||
await page.evaluate(() =>
|
||||
localStorage.setItem('twitter-lite-research-deck', '{broken'),
|
||||
)
|
||||
await page.reload()
|
||||
await expect(page.getByRole('alert')).toContainText('読み込めませんでした')
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
localStorage.getItem('twitter-lite-research-deck'),
|
||||
),
|
||||
).toBe('{broken')
|
||||
})
|
||||
|
||||
test('selects a list from the column profile catalog and refreshes it on profile change', async ({
|
||||
page,
|
||||
}) => {
|
||||
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')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'My E2E List', exact: true }),
|
||||
).toBeVisible()
|
||||
await page
|
||||
.getByRole('combobox', { name: '接続プロファイル', exact: true })
|
||||
.selectOption({ label: 'alternate' })
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'My E2E List', exact: true }),
|
||||
).toHaveCount(0)
|
||||
await page
|
||||
.getByRole('button', { name: 'Alternate List', exact: true })
|
||||
.click()
|
||||
await expect(page.getByLabel('リスト', { exact: true })).toHaveValue(
|
||||
'1234567890',
|
||||
)
|
||||
await page.getByRole('button', { name: '追加して検索' }).click()
|
||||
await expect(
|
||||
page
|
||||
.getByRole('region', { name: '購読リスト', exact: true })
|
||||
.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)
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { DeckColumn } from '../../src/features/decks/model'
|
||||
import type { ResearchRun } from '../../src/features/research/model'
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
test('pushes the current research snapshot on each SSE connection', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
const snapshots = await page.evaluate(async () => {
|
||||
const connect = () =>
|
||||
new Promise<unknown>((resolve, reject) => {
|
||||
const events = new EventSource('/api/research/events')
|
||||
const timer = setTimeout(() => {
|
||||
events.close()
|
||||
reject(new Error('SSE timed out'))
|
||||
}, 5000)
|
||||
events.onmessage = (event) => {
|
||||
clearTimeout(timer)
|
||||
events.close()
|
||||
resolve(JSON.parse(event.data))
|
||||
}
|
||||
events.onerror = () => {
|
||||
clearTimeout(timer)
|
||||
events.close()
|
||||
reject(new Error('SSE failed'))
|
||||
}
|
||||
})
|
||||
return [await connect(), await connect()]
|
||||
})
|
||||
expect(snapshots).toEqual([
|
||||
{ configured: false, run: null, history: [] },
|
||||
{ configured: false, run: null, history: [] },
|
||||
])
|
||||
})
|
||||
|
||||
test('restores the conversation and growing temporary deck from reconnect snapshots', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||
const accounts = page.getByRole('combobox', {
|
||||
name: '接続プロファイル',
|
||||
exact: true,
|
||||
})
|
||||
await accounts.selectOption({ label: 'e2e' })
|
||||
const connectionId = await accounts.inputValue()
|
||||
await page.keyboard.press('Escape')
|
||||
const run: ResearchRun = {
|
||||
id: '36ad8cc7-318c-46ae-94fa-28d30336f027',
|
||||
threadId: 'conversation-test',
|
||||
topic: '既存の観点を使って調査して',
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
deckVersion: 1,
|
||||
citations: [],
|
||||
message: '既存の検索条件を再利用しています。',
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', text: '既存の観点を使って調査して' },
|
||||
{ id: 'tool-1', role: 'tool', text: 'list_decks: 完了' },
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
text: '既存の検索条件を再利用しています。',
|
||||
},
|
||||
],
|
||||
deck: {
|
||||
id: 'generated-plan',
|
||||
title: '会話からの調査',
|
||||
columns: [
|
||||
{
|
||||
id: 'one',
|
||||
title: '最初の観点',
|
||||
connectionId,
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'WebMCP',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
await page.route('**/api/research/events', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'text/event-stream',
|
||||
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
|
||||
}),
|
||||
)
|
||||
await page.reload()
|
||||
const conversation = page.getByRole('log', { name: '調査の会話' })
|
||||
await expect(conversation).toContainText('既存の観点を使って調査して')
|
||||
await expect(conversation).toContainText('list_decks: 完了')
|
||||
await expect(conversation).toContainText('既存の検索条件を再利用しています。')
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
|
||||
run.deckVersion = 2
|
||||
run.status = 'complete'
|
||||
run.messages.push({
|
||||
id: 'assistant-2',
|
||||
role: 'assistant',
|
||||
text: '比較する観点を追加しました。',
|
||||
})
|
||||
run.deck?.columns.push({
|
||||
id: 'two',
|
||||
title: '比較の観点',
|
||||
connectionId,
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'WebMCP lang:ja',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
})
|
||||
await page.reload()
|
||||
await expect(conversation).toContainText('既存の観点を使って調査して')
|
||||
await expect(conversation).toContainText('比較する観点を追加しました。')
|
||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||
'最初の観点',
|
||||
'比較の観点',
|
||||
])
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'デッキとして保存', exact: true }),
|
||||
).toBeEnabled()
|
||||
await page.getByLabel('メッセージ', { exact: true }).fill('この観点を詳しく')
|
||||
await expect(
|
||||
page.getByRole('button', { name: '送信', exact: true }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
test('rejects a different tailnet owner at the SSE endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get('/api/research/events', {
|
||||
headers: { 'Tailscale-User-Login': '[email protected]' },
|
||||
})
|
||||
expect(response.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('renders Markdown and reveals an unloaded citation in an offscreen deck column', async ({
|
||||
page,
|
||||
a11y,
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||
const accounts = page.getByRole('combobox', {
|
||||
name: '接続プロファイル',
|
||||
exact: true,
|
||||
})
|
||||
await accounts.selectOption({ label: 'e2e' })
|
||||
const connectionId = await accounts.inputValue()
|
||||
await page.keyboard.press('Escape')
|
||||
const source: DeckColumn = {
|
||||
id: 'citation-column-2',
|
||||
title: '引用の観点',
|
||||
connectionId,
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'citation-query-2',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
}
|
||||
const columns: DeckColumn[] = ['概要', '比較'].map((title, index) => ({
|
||||
id: `citation-column-${index}`,
|
||||
title,
|
||||
connectionId,
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: `citation-query-${index}`,
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
}))
|
||||
columns.push(source)
|
||||
const run: ResearchRun = {
|
||||
id: '973ae084-18ed-4674-8899-c75851e51d1b',
|
||||
topic: '引用を確認',
|
||||
status: 'complete',
|
||||
startedAt: Date.now(),
|
||||
deckVersion: 1,
|
||||
message: '',
|
||||
deck: { id: 'citation-deck', title: '引用付きの調査', columns },
|
||||
messages: [
|
||||
{
|
||||
id: 'answer',
|
||||
role: 'assistant',
|
||||
text: '## 調査結果\n\n**重要な発見**です。\n\n- [根拠の投稿](https://x.com/author/status/123456789?s=20)\n\n| 観点 | 結果 |\n| --- | --- |\n| 投稿 | 確認済み |\n\n`コード`も表示します。',
|
||||
},
|
||||
],
|
||||
citations: [
|
||||
{
|
||||
column: source,
|
||||
post: {
|
||||
key: 'twitter:123456789',
|
||||
nativeId: '123456789',
|
||||
platform: 'twitter',
|
||||
url: 'https://twitter.com/author/status/123456789',
|
||||
text: 'AIが調査時に取得した引用本文',
|
||||
author: { name: '引用元の著者', handle: 'author' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
await page.route('**/api/research/events', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'text/event-stream',
|
||||
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
|
||||
}),
|
||||
)
|
||||
await page.reload()
|
||||
const chat = page.getByRole('log', { name: '調査の会話' })
|
||||
await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible()
|
||||
await expect(
|
||||
chat.locator('strong').filter({ hasText: '重要な発見' }),
|
||||
).toBeVisible()
|
||||
await expect(chat.getByRole('table')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('AIが調査時に取得した引用本文', { exact: true }),
|
||||
).toHaveCount(0)
|
||||
await chat.getByRole('link', { name: '根拠の投稿' }).click()
|
||||
const card = page.locator(
|
||||
'.research-post[data-post-url="https://twitter.com/author/status/123456789"]',
|
||||
)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toHaveClass(/citation-highlight/)
|
||||
await expect(card).toBeFocused()
|
||||
await expect(card).toContainText('調査時に取得した投稿')
|
||||
await expect(card).toContainText('AIが調査時に取得した引用本文')
|
||||
expect(
|
||||
await page.locator('.deck-board').evaluate((element) => element.scrollLeft),
|
||||
).toBeGreaterThan(0)
|
||||
expect(page.context().pages()).toHaveLength(1)
|
||||
expect((await a11y().analyze()).violations).toEqual([])
|
||||
})
|
||||
|
||||
test('explains unconfigured Codex and prevents starting research without affecting the deck', async ({
|
||||
page,
|
||||
a11y,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
const originalTitle = await heading.textContent()
|
||||
const panel = page.getByRole('complementary', { name: '調査チャット' })
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(
|
||||
panel.getByText('自宅のCodexへの接続が設定されていません。'),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
panel.getByRole('button', { name: '送信', exact: true }),
|
||||
).toBeDisabled()
|
||||
await expect(panel.getByLabel('メッセージ', { exact: true })).toBeDisabled()
|
||||
const accessibility = await a11y().analyze()
|
||||
expect(accessibility.violations).toEqual([])
|
||||
await expect(heading).toHaveText(originalTitle ?? '')
|
||||
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
|
||||
await page.getByLabel('新しいデッキ名').fill('手動の調査')
|
||||
await page.getByRole('button', { name: '作成', exact: true }).click()
|
||||
await expect(heading).toHaveText('手動の調査')
|
||||
await expect(panel).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import Database from 'better-sqlite3'
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
type NativeTesting = {
|
||||
listTools(): { name: string }[]
|
||||
executeTool(name: string, input: string): Promise<string | null>
|
||||
}
|
||||
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(() =>
|
||||
(
|
||||
navigator as Navigator & { modelContextTesting: NativeTesting }
|
||||
).modelContextTesting
|
||||
.listTools()
|
||||
.map((tool) => tool.name)
|
||||
.sort(),
|
||||
)
|
||||
}
|
||||
async function executeTool(
|
||||
page: Page,
|
||||
name: string,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<ToolResult> {
|
||||
const result = await page.evaluate(
|
||||
({ name, input }) =>
|
||||
(
|
||||
navigator as Navigator & { modelContextTesting: NativeTesting }
|
||||
).modelContextTesting.executeTool(name, JSON.stringify(input)),
|
||||
{ name, input },
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
return JSON.parse(result ?? '{}')
|
||||
}
|
||||
function decoded(result: ToolResult) {
|
||||
return JSON.parse(result.content[0]?.text ?? '{}')
|
||||
}
|
||||
const column = (title: string, connectionId: string) => ({
|
||||
title,
|
||||
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('/')
|
||||
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).toEqual([]))
|
||||
|
||||
test('creates temporary research, edits it, persists explicitly and reopens it on another device', async ({
|
||||
page,
|
||||
browser,
|
||||
baseURL,
|
||||
}) => {
|
||||
const created = await executeTool(page, 'set_deck', {
|
||||
title: 'WebMCPの反応',
|
||||
columns: [
|
||||
{ ...column('日本語', accounts.e2e ?? ''), id: 'new' },
|
||||
column('別の接続', accounts.alternate ?? ''),
|
||||
],
|
||||
})
|
||||
expect(created.isError).not.toBe(true)
|
||||
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')
|
||||
.click()
|
||||
await page
|
||||
.getByRole('region', { name: '日本語', exact: true })
|
||||
.getByRole('button', { name: '編集', exact: true })
|
||||
.click()
|
||||
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('dialog')).toHaveCount(0)
|
||||
expect(savedCount()).toEqual({ count: 0 })
|
||||
expect(
|
||||
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
|
||||
).not.toBe(true)
|
||||
expect(
|
||||
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
|
||||
).not.toBe(true)
|
||||
expect(savedCount()).toEqual({ count: 1 })
|
||||
const other = await browser.newContext({
|
||||
baseURL,
|
||||
extraHTTPHeaders: { 'Tailscale-User-Login': '[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(
|
||||
'更新した調査',
|
||||
)
|
||||
})
|
||||
|
||||
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('残す観点', accounts.e2e ?? '')],
|
||||
})
|
||||
const duplicate = { ...column('不正', accounts.e2e ?? ''), id: 'duplicate' }
|
||||
for (const columns of [
|
||||
[duplicate, duplicate],
|
||||
[column('不明', 'missing')],
|
||||
[
|
||||
{
|
||||
...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(savedCount()).toEqual({ count: 0 })
|
||||
})
|
||||
|
||||
test('keeps failed temporary saves visible and allows retry without duplicate decks', async ({
|
||||
page,
|
||||
}) => {
|
||||
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(
|
||||
'未保存の調査',
|
||||
)
|
||||
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('reads and pages bound accounts without mixing sibling columns', async ({
|
||||
page,
|
||||
}) => {
|
||||
const deck = decoded(
|
||||
await executeTool(page, 'set_deck', {
|
||||
title: '接続を比較',
|
||||
columns: [
|
||||
column('通常', accounts.e2e ?? ''),
|
||||
column('別の接続', accounts.alternate ?? ''),
|
||||
],
|
||||
}),
|
||||
).deck
|
||||
await expect(
|
||||
page.getByText('alternate · Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
const id = deck.columns[1].id
|
||||
const loaded = decoded(
|
||||
await executeTool(page, 'get_column_posts', { columnId: id }),
|
||||
)
|
||||
expect(loaded.column.connectionId).toBe(accounts.alternate)
|
||||
expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([
|
||||
'alternate · Latest · all page 1',
|
||||
])
|
||||
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',
|
||||
])
|
||||
await expect(
|
||||
page.getByRole('region', { name: '通常', exact: true }).locator('article'),
|
||||
).toHaveCount(1)
|
||||
await executeTool(page, 'set_deck', { title: '別の一時ビュー', columns: [] })
|
||||
expect(
|
||||
(await executeTool(page, 'get_column_posts', { columnId: id })).isError,
|
||||
).toBe(true)
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { tweetLegacyFixture } from './fixtures/twitter.ts'
|
||||
|
||||
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT
|
||||
const port = Number(portValue)
|
||||
@@ -11,10 +12,12 @@ const failedRetryQueries = new Set()
|
||||
const rootByTarget = new Map([
|
||||
['1001', '1000'],
|
||||
['2001', '2000'],
|
||||
['3001', '3000'],
|
||||
])
|
||||
const targetByRoot = new Map([
|
||||
['1000', '1001'],
|
||||
['2000', '2001'],
|
||||
['3000', '3001'],
|
||||
])
|
||||
const failedThreadRoots = new Set()
|
||||
|
||||
@@ -25,18 +28,14 @@ const tweetResult = (
|
||||
id,
|
||||
text,
|
||||
username = 'yuta',
|
||||
{ conversationId = id, inReplyTo } = {},
|
||||
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
|
||||
) => ({
|
||||
rest_id: id,
|
||||
legacy: {
|
||||
full_text: text,
|
||||
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
|
||||
reply_count: 1,
|
||||
retweet_count: 2,
|
||||
favorite_count: 3,
|
||||
conversation_id_str: conversationId,
|
||||
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
|
||||
},
|
||||
legacy: tweetLegacyFixture(id, text, {
|
||||
conversationId,
|
||||
inReplyTo,
|
||||
replyCount,
|
||||
}),
|
||||
core: {
|
||||
user_results: {
|
||||
result: {
|
||||
@@ -97,6 +96,11 @@ const timeline = (entries) => ({ instructions: [{ entries }] })
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`)
|
||||
if (request.method === 'GET' && url.pathname === '/profiles') {
|
||||
send(response, { profiles: ['e2e', 'alternate'] })
|
||||
return
|
||||
}
|
||||
|
||||
const operation = [
|
||||
'UserByScreenName',
|
||||
'UserTweets',
|
||||
@@ -118,7 +122,8 @@ const server = createServer(async (request, response) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (request.headers['x-profile-name'] !== 'e2e') {
|
||||
const profileName = request.headers['x-profile-name']
|
||||
if (profileName !== 'e2e' && profileName !== 'alternate') {
|
||||
fail(response, 403, 'invalid profile')
|
||||
return
|
||||
}
|
||||
@@ -164,10 +169,12 @@ const server = createServer(async (request, response) => {
|
||||
return
|
||||
}
|
||||
const secondPage = variables.cursor === 'user-next'
|
||||
const firstPageText =
|
||||
profileName === 'alternate' ? 'alternate profile page 1' : 'user page 1'
|
||||
const entries = secondPage
|
||||
? [tweet('1010', 'user page 2')]
|
||||
: [
|
||||
tweet('1001', 'user page 1', 'yuta', {
|
||||
tweet('1001', firstPageText, 'yuta', {
|
||||
conversationId: '1000',
|
||||
}),
|
||||
cursor('user-next'),
|
||||
@@ -211,7 +218,10 @@ const server = createServer(async (request, response) => {
|
||||
itemContent: {
|
||||
list: {
|
||||
id_str: '1234567890',
|
||||
name: 'My E2E List',
|
||||
name:
|
||||
profileName === 'alternate'
|
||||
? 'Alternate List'
|
||||
: 'My E2E List',
|
||||
description: 'Accounts selected for testing',
|
||||
member_count: 10,
|
||||
mode: 'Public',
|
||||
@@ -288,10 +298,16 @@ const server = createServer(async (request, response) => {
|
||||
|
||||
const targetRoot = rootByTarget.get(focalTweetId)
|
||||
if (targetRoot && variables.cursor === undefined) {
|
||||
const text = focalTweetId === '1001' ? 'user page 1' : 'retry focal'
|
||||
const noReplies = focalTweetId === '3001'
|
||||
const text = noReplies
|
||||
? 'no replies'
|
||||
: focalTweetId === '1001'
|
||||
? 'user page 1'
|
||||
: 'retry focal'
|
||||
const result = tweetResult(focalTweetId, text, 'focus', {
|
||||
conversationId: targetRoot,
|
||||
inReplyTo: targetRoot,
|
||||
replyCount: noReplies ? 0 : 1,
|
||||
})
|
||||
send(response, {
|
||||
data: {
|
||||
@@ -303,6 +319,7 @@ const server = createServer(async (request, response) => {
|
||||
tweet(focalTweetId, text, 'focus', {
|
||||
conversationId: targetRoot,
|
||||
inReplyTo: targetRoot,
|
||||
replyCount: noReplies ? 0 : 1,
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -323,6 +340,10 @@ const server = createServer(async (request, response) => {
|
||||
fail(response, 400, 'invalid thread cursor')
|
||||
return
|
||||
}
|
||||
if (variables.cursor && focalTweetId === '3000') {
|
||||
fail(response, 500, 'zero-reply thread must not request a continuation')
|
||||
return
|
||||
}
|
||||
if (
|
||||
variables.cursor &&
|
||||
focalTweetId === '2000' &&
|
||||
@@ -340,25 +361,37 @@ const server = createServer(async (request, response) => {
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
]
|
||||
: [
|
||||
tweet(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
tweet(
|
||||
targetId,
|
||||
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
||||
'focus',
|
||||
{
|
||||
: focalTweetId === '3000'
|
||||
? [
|
||||
tweet(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
tweet(targetId, 'no replies', 'focus', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: focalTweetId,
|
||||
},
|
||||
),
|
||||
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
cursor(expectedCursor),
|
||||
]
|
||||
replyCount: 0,
|
||||
}),
|
||||
cursor(expectedCursor),
|
||||
]
|
||||
: [
|
||||
tweet(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
tweet(
|
||||
targetId,
|
||||
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
||||
'focus',
|
||||
{
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: focalTweetId,
|
||||
},
|
||||
),
|
||||
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
cursor(expectedCursor),
|
||||
]
|
||||
send(response, {
|
||||
data: {
|
||||
tweetResult: {
|
||||
@@ -426,7 +459,7 @@ const server = createServer(async (request, response) => {
|
||||
}
|
||||
|
||||
const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all'
|
||||
const label = `${variables.product} · ${follows}`
|
||||
const label = `${profileName === 'alternate' ? 'alternate · ' : ''}${variables.product} · ${follows}`
|
||||
const id = encodeURIComponent(rawQuery)
|
||||
const entries = secondPage
|
||||
? [tweet(`s-${id}-2`, `${label} page 2`)]
|
||||
Generated
+75
-1
@@ -1,6 +1,78 @@
|
||||
{
|
||||
"nodes": {
|
||||
"agent-skills-nix": {
|
||||
"inputs": {
|
||||
"home-manager": "home-manager",
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783839654,
|
||||
"narHash": "sha256-rn5DkeQ9PBaJTZl9juBtPnVawyN4B83tyz1VpiinQFo=",
|
||||
"owner": "Kyure-A",
|
||||
"repo": "agent-skills-nix",
|
||||
"rev": "8326df98cf494dc515db99affa9ea9d2bd6666b6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Kyure-A",
|
||||
"repo": "agent-skills-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"hallmark": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1780605762,
|
||||
"narHash": "sha256-+yPIG1XdI6hhyOH48rd20+YlFrA9Gr416tPt1OoxfwQ=",
|
||||
"owner": "Nutlope",
|
||||
"repo": "hallmark",
|
||||
"rev": "aeb42fb354ff4efa36ab475773a082315a3af2ce",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Nutlope",
|
||||
"repo": "hallmark",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"agent-skills-nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783222121,
|
||||
"narHash": "sha256-E/ElL373TO8lQ2aMvYyzN+k4xkVaUGoRqoa8AtM71tI=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "a1645f40777620c4bd2b6d854b290c2fc354a266",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1783224372,
|
||||
"narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d407951447dcd00442e97087bf374aad70c04cea",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1783776592,
|
||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
||||
@@ -18,7 +90,9 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
"agent-skills-nix": "agent-skills-nix",
|
||||
"hallmark": "hallmark",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
description = "Intentional X reader";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
inputs.agent-skills-nix.url = "github:Kyure-A/agent-skills-nix";
|
||||
inputs.hallmark = {
|
||||
url = "github:Nutlope/hallmark";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, ... }:
|
||||
outputs = inputs@{ self, nixpkgs, ... }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
@@ -17,7 +22,7 @@
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pkgs.pnpm_11;
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE=";
|
||||
hash = "sha256-dV9yHImzYzkwE5ZK75zWLkpJ5+V0Rvqg8/zC0zRw76I=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
@@ -39,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
|
||||
'';
|
||||
|
||||
@@ -55,10 +62,34 @@
|
||||
});
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let pkgs = import nixpkgs { inherit system; };
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
agentLib = inputs.agent-skills-nix.lib.agent-skills;
|
||||
sources.hallmark = {
|
||||
path = inputs.hallmark;
|
||||
subdir = "skills";
|
||||
};
|
||||
catalog = agentLib.discoverCatalog sources;
|
||||
allowlist = agentLib.allowlistFor {
|
||||
inherit catalog sources;
|
||||
enableAll = true;
|
||||
};
|
||||
selection = agentLib.selectSkills {
|
||||
inherit catalog allowlist sources;
|
||||
skills = { };
|
||||
};
|
||||
bundle = agentLib.mkBundle { inherit pkgs selection; };
|
||||
localTargets = builtins.mapAttrs (_: target:
|
||||
target // {
|
||||
enable = true;
|
||||
}) agentLib.defaultLocalTargets;
|
||||
in {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [ nodejs_22 pnpm chromium ];
|
||||
shellHook = agentLib.mkShellHook {
|
||||
inherit pkgs bundle;
|
||||
targets = localTargets;
|
||||
};
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE =
|
||||
"${pkgs.chromium}/bin/chromium";
|
||||
@@ -84,17 +115,29 @@
|
||||
description = "Base URL of the Twitter relay.";
|
||||
};
|
||||
|
||||
profileName = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.nonEmptyStr;
|
||||
default = null;
|
||||
example = "default";
|
||||
description = "Relay profile to use when the relay has multiple profiles.";
|
||||
publicOrigin = lib.mkOption {
|
||||
type = lib.types.nonEmptyStr;
|
||||
example = "https://home.example-tailnet.ts.net";
|
||||
description = "Exact Tailscale Serve HTTPS origin, without a trailing slash.";
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
allowedLogin = lib.mkOption {
|
||||
type = lib.types.nonEmptyStr;
|
||||
default = "127.0.0.1";
|
||||
description = "Address on which Twitter Lite listens.";
|
||||
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 {
|
||||
@@ -110,14 +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;
|
||||
} // lib.optionalAttrs (cfg.profileName != null) {
|
||||
BIRD_PROFILE_NAME = cfg.profileName;
|
||||
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";
|
||||
};
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { KnipConfig } from 'knip'
|
||||
|
||||
export default {
|
||||
entry: [
|
||||
'playwright.config.ts',
|
||||
'tests/e2e/**/*.spec.ts',
|
||||
'tests/e2e/mock-relay.mjs',
|
||||
],
|
||||
entry: ['playwright.config.ts', 'e2e/**/*.test.ts', 'e2e/mock-relay.mjs'],
|
||||
playwright: false,
|
||||
} satisfies KnipConfig
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineConfig, defineTransformer } from 'orval'
|
||||
|
||||
const selectedTag = 'twitter-lite-e2e'
|
||||
const selectedOperationIds = new Set([
|
||||
'getUserByScreenName',
|
||||
'getUserTweets',
|
||||
'getTweetDetail',
|
||||
'getSearchTimeline',
|
||||
'getListLatestTweetsTimeline',
|
||||
])
|
||||
|
||||
const selectE2eOperations = defineTransformer((document) => {
|
||||
const foundOperationIds = new Set<string>()
|
||||
|
||||
for (const pathItem of Object.values(document.paths ?? {})) {
|
||||
if (!pathItem || typeof pathItem !== 'object') continue
|
||||
|
||||
for (const candidate of Object.values(pathItem)) {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'object' ||
|
||||
Array.isArray(candidate) ||
|
||||
!('operationId' in candidate)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const operation = candidate as {
|
||||
operationId?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
if (
|
||||
operation.operationId &&
|
||||
selectedOperationIds.has(operation.operationId)
|
||||
) {
|
||||
foundOperationIds.add(operation.operationId)
|
||||
operation.tags = [...(operation.tags ?? []), selectedTag]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missingOperationIds = [...selectedOperationIds].filter(
|
||||
(operationId) => !foundOperationIds.has(operationId),
|
||||
)
|
||||
if (missingOperationIds.length > 0) {
|
||||
throw new Error(
|
||||
`twitter-openapi is missing selected operations: ${missingOperationIds.join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
return document
|
||||
})
|
||||
|
||||
export default defineConfig({
|
||||
twitterOpenApi: {
|
||||
input: {
|
||||
target:
|
||||
'https://raw.githubusercontent.com/fa0311/twitter-openapi/590dae5c9f8575abc91d3774946bfe6f23960aba/dist/docs/openapi-3.0.yaml',
|
||||
filters: {
|
||||
mode: 'include',
|
||||
tags: [selectedTag],
|
||||
},
|
||||
override: {
|
||||
transformer: selectE2eOperations,
|
||||
},
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
client: 'fetch',
|
||||
mode: 'single',
|
||||
target: './e2e/generated/twitter-openapi/client.ts',
|
||||
},
|
||||
},
|
||||
})
|
||||
+26
-2
@@ -6,15 +6,27 @@
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"knip": {
|
||||
"ignoreBinaries": [
|
||||
"codex"
|
||||
],
|
||||
"ignore": [
|
||||
"e2e/generated/**"
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"codex:serve": "node scripts/serve-codex.mjs",
|
||||
"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 .",
|
||||
@@ -31,23 +43,35 @@
|
||||
"@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",
|
||||
"react-markdown": "10.1.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"sanitize-html": "2.17.7",
|
||||
"usewebmcp": "5.1.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.11.1",
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@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",
|
||||
"tsx": "4.23.12",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.1.4",
|
||||
"vitest": "4.1.10"
|
||||
|
||||
+12
-4
@@ -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(
|
||||
@@ -11,25 +18,26 @@ if (!chromiumExecutable) {
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
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',
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`,
|
||||
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} pnpm exec tsx e2e/mock-relay.mjs`,
|
||||
port: relayPort,
|
||||
reuseExistingServer: false,
|
||||
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_CODEX_URL= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= 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,
|
||||
},
|
||||
|
||||
Generated
+2864
-37
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,3 @@
|
||||
allowBuilds:
|
||||
better-sqlite3: false
|
||||
esbuild: true
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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'] },
|
||||
},
|
||||
})
|
||||
@@ -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`,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// This process is independent of Vite / the web backend. Keep it running while
|
||||
// restarting the app. Codex owns its own login and refreshes that login normally.
|
||||
const reportRoot = resolve(
|
||||
process.env.TWITTER_LITE_REPORT_ROOT || '.data/research',
|
||||
)
|
||||
const endpoint = new URL(
|
||||
process.env.TWITTER_LITE_CODEX_URL || 'ws://127.0.0.1:4500',
|
||||
)
|
||||
if (
|
||||
endpoint.protocol !== 'ws:' ||
|
||||
endpoint.hostname !== '127.0.0.1' ||
|
||||
endpoint.username ||
|
||||
endpoint.password
|
||||
) {
|
||||
throw new Error('The prototype Codex endpoint must be ws://127.0.0.1:<port>.')
|
||||
}
|
||||
await mkdir(reportRoot, { recursive: true, mode: 0o700 })
|
||||
const env = {
|
||||
HOME: homedir(),
|
||||
PATH: process.env.PATH,
|
||||
CODEX_HOME: process.env.CODEX_HOME || resolve(homedir(), '.codex'),
|
||||
}
|
||||
for (const name of [
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'SSL_CERT_FILE',
|
||||
'SSL_CERT_DIR',
|
||||
'NIX_SSL_CERT_FILE',
|
||||
'TMPDIR',
|
||||
]) {
|
||||
if (process.env[name]) env[name] = process.env[name]
|
||||
}
|
||||
// Enumerate the effective config, including CLI-wrapper and project settings.
|
||||
// Keep this output private: MCP transport settings can contain credentials.
|
||||
const configured = spawnSync('codex', ['mcp', 'list', '--json'], {
|
||||
cwd: reportRoot,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
})
|
||||
let mcpOverrides
|
||||
try {
|
||||
if (configured.error || configured.status !== 0) throw new Error()
|
||||
const servers = JSON.parse(configured.stdout)
|
||||
if (!Array.isArray(servers)) throw new Error()
|
||||
mcpOverrides = servers.flatMap((server) => {
|
||||
if (
|
||||
typeof server?.name !== 'string' ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(server.name)
|
||||
)
|
||||
throw new Error()
|
||||
return ['-c', `mcp_servers.${server.name}.enabled=false`]
|
||||
})
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Could not inspect Codex MCP configuration safely. Check codex mcp list locally.',
|
||||
)
|
||||
}
|
||||
const child = spawn(
|
||||
'codex',
|
||||
[
|
||||
// Keep overrides before the subcommand so they merge with wrapper settings.
|
||||
'-c',
|
||||
'shell_environment_policy.inherit="none"',
|
||||
'-c',
|
||||
'features.multi_agent=false',
|
||||
'-c',
|
||||
'features.hooks=false',
|
||||
'-c',
|
||||
'web_search="disabled"',
|
||||
...mcpOverrides,
|
||||
'app-server',
|
||||
'--listen',
|
||||
endpoint.origin,
|
||||
],
|
||||
{ cwd: reportRoot, env, stdio: 'inherit' },
|
||||
)
|
||||
child.on('error', () => {
|
||||
console.error(
|
||||
'Could not start Codex. Install codex and run codex login first.',
|
||||
)
|
||||
process.exitCode = 1
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
process.exitCode = code ?? 1
|
||||
})
|
||||
for (const signal of ['SIGINT', 'SIGTERM'])
|
||||
process.on(signal, () => child.kill(signal))
|
||||
@@ -1,36 +1,21 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function AppShell({
|
||||
active,
|
||||
sidebar,
|
||||
researchChat,
|
||||
children,
|
||||
}: {
|
||||
active?: 'user' | 'search' | 'list'
|
||||
sidebar: React.ReactNode
|
||||
researchChat: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<main className="app">
|
||||
<header className="app-header">
|
||||
<p className="brand">TWITTER LITE</p>
|
||||
<nav aria-label="閲覧方法" className="tabs">
|
||||
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
|
||||
ユーザー
|
||||
</Link>
|
||||
<Link
|
||||
aria-current={active === 'search' ? 'page' : undefined}
|
||||
search={{ q: '', product: 'Latest', following: false }}
|
||||
to="/search"
|
||||
>
|
||||
検索
|
||||
</Link>
|
||||
<Link
|
||||
aria-current={active === 'list' ? 'page' : undefined}
|
||||
to="/i/lists"
|
||||
>
|
||||
リスト
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<section className="reader">{children}</section>
|
||||
<main className="app app-deck">
|
||||
{researchChat}
|
||||
<section className="reader">
|
||||
<section className="deck-toolbar" aria-label="デッキ管理">
|
||||
{sidebar}
|
||||
</section>
|
||||
{children}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useId, useRef } from 'react'
|
||||
import { Icon } from './icon'
|
||||
|
||||
export function Dialog({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const headingId = useId()
|
||||
useEffect(() => {
|
||||
const element = dialog.current
|
||||
const opener =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
element?.showModal()
|
||||
return () => {
|
||||
element?.close()
|
||||
if (opener?.isConnected) opener.focus({ preventScroll: true })
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="deck-dialog"
|
||||
aria-labelledby={headingId}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<h2 id={headingId}>{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="閉じる"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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',
|
||||
plus: 'M12 5v14 M5 12h14',
|
||||
close: 'M6 6l12 12 M18 6 6 18',
|
||||
refresh: 'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6',
|
||||
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
|
||||
arrow: 'M7 17 17 7 M7 7h10v10',
|
||||
edit: 'm16 3 5 5-12 12H4v-5z M14 5l5 5',
|
||||
trash: 'M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7',
|
||||
} as const
|
||||
|
||||
export function Icon({ name }: { name: keyof typeof paths }) {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={paths[name]} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const URL = /(https?:\/\/[^\s]+)/g
|
||||
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
''': "'",
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
}
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
|
||||
if (!decimal && !hexadecimal) {
|
||||
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
|
||||
}
|
||||
|
||||
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
|
||||
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
|
||||
})
|
||||
}
|
||||
|
||||
function linkedText(text: string): ReactNode[] {
|
||||
const decoded = decodeHtmlEntities(text)
|
||||
const parts: ReactNode[] = []
|
||||
let cursor = 0
|
||||
for (const match of decoded.matchAll(URL)) {
|
||||
const href = match[0]
|
||||
const start = match.index
|
||||
if (start > cursor) parts.push(decoded.slice(cursor, start))
|
||||
parts.push(
|
||||
<a
|
||||
href={href}
|
||||
key={`link-${start}`}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{href}
|
||||
</a>,
|
||||
)
|
||||
cursor = start + href.length
|
||||
}
|
||||
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
|
||||
return parts
|
||||
}
|
||||
|
||||
export function PostText({ text }: { text: string }) {
|
||||
return <>{linkedText(text)}</>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.',
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useId, useState } from 'react'
|
||||
import type { Connection } from '#/features/connections/model'
|
||||
import {
|
||||
ColumnSourceEditor,
|
||||
defaultColumnSource,
|
||||
rebindColumnSource,
|
||||
} from './column-source-editor'
|
||||
import { columnSchema, type DeckColumn } from './model'
|
||||
|
||||
export function ColumnEditor({
|
||||
column,
|
||||
connections,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
column?: DeckColumn
|
||||
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 [connectionId, setConnectionId] = useState(
|
||||
column?.connectionId ?? available[0]?.id ?? '',
|
||||
)
|
||||
const [source, setSource] = useState(
|
||||
column?.source ?? defaultColumnSource(available[0]?.platform ?? 'twitter'),
|
||||
)
|
||||
const validConnection = available.some(
|
||||
(connection) => connection.id === connectionId,
|
||||
)
|
||||
|
||||
return (
|
||||
<form
|
||||
className="deck-editor"
|
||||
aria-label={column ? 'カラムを編集' : 'カラムを追加'}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
setError(undefined)
|
||||
const data = new FormData(event.currentTarget)
|
||||
const parsed = columnSchema.safeParse({
|
||||
id:
|
||||
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
|
||||
title: data.get('title'),
|
||||
connectionId,
|
||||
source,
|
||||
})
|
||||
if (!parsed.success || !validConnection) {
|
||||
setError(
|
||||
parsed.success
|
||||
? '接続プロファイルを選択してください。'
|
||||
: parsed.error.issues[0]?.message,
|
||||
)
|
||||
return
|
||||
}
|
||||
await onSave(parsed.data)
|
||||
}}
|
||||
>
|
||||
<div className="deck-editor-fields">
|
||||
<label htmlFor={`${formId}-title`}>
|
||||
カラム名
|
||||
<input
|
||||
id={`${formId}-title`}
|
||||
name="title"
|
||||
defaultValue={column?.title ?? ''}
|
||||
maxLength={80}
|
||||
placeholder="例:発表元の投稿"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-profile`}>
|
||||
接続プロファイル
|
||||
<select
|
||||
id={`${formId}-profile`}
|
||||
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>
|
||||
{connectionId && !validConnection && (
|
||||
<option value={connectionId} disabled>
|
||||
{connectionId}(利用不可)
|
||||
</option>
|
||||
)}
|
||||
{available.map((connection) => (
|
||||
<option key={connection.id} value={connection.id}>
|
||||
{connection.displayName}
|
||||
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<ColumnSourceEditor
|
||||
source={source}
|
||||
connectionId={validConnection ? connectionId : ''}
|
||||
onChange={setSource}
|
||||
/>
|
||||
<p className="deck-help">
|
||||
このカラムは選択した接続プロファイルで取得します。
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="deck-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="deck-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="deck-primary"
|
||||
disabled={!validConnection}
|
||||
>
|
||||
{column ? '変更を保存' : '追加して検索'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}>
|
||||
キャンセル
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useWebMCP } from 'usewebmcp'
|
||||
import { z } from 'zod'
|
||||
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 useResearchFeed>
|
||||
}
|
||||
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
|
||||
|
||||
export function useRegisterColumn(
|
||||
registry: ColumnRegistry,
|
||||
column: DeckColumn,
|
||||
query: ReturnType<typeof useResearchFeed>,
|
||||
) {
|
||||
const entry = useRef({ column, query })
|
||||
entry.current = { column, query }
|
||||
useEffect(() => {
|
||||
registry.set(column.id, entry)
|
||||
return () => {
|
||||
if (registry.get(column.id) === entry) registry.delete(column.id)
|
||||
}
|
||||
}, [registry, column.id])
|
||||
}
|
||||
const columnInput = z.object({ columnId: z.string().min(1) }).strict()
|
||||
const readInput = columnInput.extend({
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(50).default(20),
|
||||
})
|
||||
|
||||
function read(feed: ColumnFeed, offset = 0, limit = 20) {
|
||||
const posts = flattenResearchPages(feed.query.data?.pages ?? [])
|
||||
const selected = posts.slice(offset, offset + limit)
|
||||
return {
|
||||
column: feed.column,
|
||||
status: feed.query.isPending
|
||||
? 'loading'
|
||||
: feed.query.isError
|
||||
? 'error'
|
||||
: 'ready',
|
||||
loading: feed.query.isFetching,
|
||||
error:
|
||||
feed.query.error instanceof PostLoadError
|
||||
? feed.query.error.detail
|
||||
: null,
|
||||
posts: selected,
|
||||
loadedCount: posts.length,
|
||||
offset,
|
||||
nextOffset:
|
||||
offset + selected.length < posts.length ? offset + selected.length : null,
|
||||
hasMore: Boolean(feed.query.hasNextPage),
|
||||
}
|
||||
}
|
||||
|
||||
export function useColumnTools(registry: ColumnRegistry, ready: boolean) {
|
||||
const supported = useWebMCPSupported()
|
||||
const enabled = supported && ready
|
||||
function requireColumn(id: string) {
|
||||
const entry = registry.get(id)
|
||||
if (!entry)
|
||||
throw new Error(
|
||||
'Column is not loaded in the active deck. Select its deck and wait for it to render.',
|
||||
)
|
||||
return entry
|
||||
}
|
||||
useWebMCP({
|
||||
name: 'get_column_posts',
|
||||
description:
|
||||
'Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.',
|
||||
inputSchema: readInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { columnId, offset, limit } = readInput.parse(input)
|
||||
return read(requireColumn(columnId).current, offset, limit)
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'load_more_column',
|
||||
description:
|
||||
'Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.',
|
||||
inputSchema: columnInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: false, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(async () => {
|
||||
const { columnId } = columnInput.parse(input)
|
||||
const entry = requireColumn(columnId)
|
||||
const { column, query } = entry.current
|
||||
const identity = JSON.stringify(column)
|
||||
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 = flattenResearchPages(query.data?.pages ?? []).length
|
||||
if (!query.hasNextPage) return read(entry.current, offset)
|
||||
const result = await query.fetchNextPage({ cancelRefetch: false })
|
||||
if (
|
||||
registry.get(columnId) !== entry ||
|
||||
JSON.stringify(entry.current.column) !== identity
|
||||
)
|
||||
throw new Error(
|
||||
'The deck or column changed while loading. Read the current deck first.',
|
||||
)
|
||||
if (result.isError) throw result.error
|
||||
return read({ column, query: result }, offset)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { ResearchPost } from '../platforms/types'
|
||||
import type { CitationTarget } from '../research/use-citation-navigation'
|
||||
import { ResearchColumn } from './deck-column'
|
||||
|
||||
const feed = vi.hoisted(() => ({ use: vi.fn(), next: vi.fn() }))
|
||||
vi.mock('../platforms/use-research-feed', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../platforms/use-research-feed')>()),
|
||||
useResearchFeed: feed.use,
|
||||
}))
|
||||
vi.mock('./research-post-card', () => ({
|
||||
ResearchPostCard: ({
|
||||
post,
|
||||
citationSnapshot,
|
||||
}: {
|
||||
post: ResearchPost
|
||||
citationSnapshot?: boolean
|
||||
}) => (
|
||||
<article>
|
||||
{post.text}
|
||||
{citationSnapshot ? '調査時に取得した投稿' : ''}
|
||||
</article>
|
||||
),
|
||||
}))
|
||||
const citation: CitationTarget = {
|
||||
deckId: 'deck',
|
||||
request: 1,
|
||||
column: {
|
||||
id: 'column',
|
||||
title: 'Research',
|
||||
connectionId: 'account',
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'WebMCP',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
key: 'twitter:123',
|
||||
platform: 'twitter',
|
||||
nativeId: '123',
|
||||
url: 'https://x.com/alice/status/123',
|
||||
text: 'Snapshot',
|
||||
author: { name: 'Alice', handle: 'alice' },
|
||||
},
|
||||
}
|
||||
|
||||
it('temporarily renders missing evidence without inserting it into feed pages or changing pagination', () => {
|
||||
const page = Object.freeze({
|
||||
posts: Object.freeze([]),
|
||||
nextCursor: 'original-cursor',
|
||||
})
|
||||
feed.use.mockReturnValue({
|
||||
data: { pages: [page] },
|
||||
hasNextPage: true,
|
||||
fetchNextPage: feed.next,
|
||||
})
|
||||
render(
|
||||
<ResearchColumn
|
||||
column={citation.column}
|
||||
citation={citation}
|
||||
registry={new Map()}
|
||||
first
|
||||
last
|
||||
onEdit={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('article')).toHaveTextContent(
|
||||
'Snapshot調査時に取得した投稿',
|
||||
)
|
||||
expect(page.posts).toHaveLength(0)
|
||||
expect(page.nextCursor).toBe('original-cursor')
|
||||
fireEvent.click(screen.getByRole('button', { name: '続きを読み込む' }))
|
||||
expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false })
|
||||
})
|
||||
|
||||
it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias', () => {
|
||||
feed.use.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
posts: [
|
||||
{
|
||||
...citation.post,
|
||||
url: 'https://mobile.twitter.com/alice/status/123',
|
||||
text: 'Live',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
render(
|
||||
<ResearchColumn
|
||||
column={citation.column}
|
||||
citation={citation}
|
||||
registry={new Map()}
|
||||
first
|
||||
last
|
||||
onEdit={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByRole('article')).toHaveLength(1)
|
||||
expect(screen.getByRole('article')).toHaveTextContent('Live')
|
||||
expect(
|
||||
screen.queryByText(/Snapshot|調査時に取得した投稿/),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Icon } from '#/components/icon'
|
||||
import {
|
||||
flattenResearchPages,
|
||||
useResearchFeed,
|
||||
} from '#/features/platforms/use-research-feed'
|
||||
import { PostLoadError } from '#/features/posts/use-post-feed'
|
||||
import { citationUrlKey } from '#/features/research/citation-url'
|
||||
import type { CitationTarget } from '#/features/research/use-citation-navigation'
|
||||
import { PlatformIcon } from '../platforms/platform-icon'
|
||||
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,
|
||||
first,
|
||||
last,
|
||||
registry,
|
||||
citation,
|
||||
}: {
|
||||
column: DeckColumn
|
||||
connectionLabel?: string
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
onMove: (direction: -1 | 1) => void
|
||||
first: boolean
|
||||
last: boolean
|
||||
registry: ColumnRegistry
|
||||
citation?: CitationTarget
|
||||
}) {
|
||||
const query = useResearchFeed(column)
|
||||
useRegisterColumn(registry, column, query)
|
||||
const posts = flattenResearchPages(query.data?.pages ?? [])
|
||||
const citedKey = citation ? citationUrlKey(citation.post.url) : null
|
||||
const visiblePosts =
|
||||
citation && !posts.some((post) => citationUrlKey(post.url) === citedKey)
|
||||
? [citation.post, ...posts]
|
||||
: posts
|
||||
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">
|
||||
<div className="deck-column-title">
|
||||
<PlatformIcon platform={column.source.platform} />
|
||||
<h2 id={headingId}>{column.title}</h2>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="更新"
|
||||
title="更新"
|
||||
type="button"
|
||||
onClick={() => void query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
<details className="column-menu">
|
||||
<summary
|
||||
className="icon-button"
|
||||
aria-label={`${column.title}の操作`}
|
||||
title="カラムの操作"
|
||||
>
|
||||
<Icon name="more" />
|
||||
</summary>
|
||||
<div className="column-menu-panel">
|
||||
<button type="button" onClick={onEdit}>
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onMove(-1)
|
||||
event.currentTarget
|
||||
.closest('details')
|
||||
?.removeAttribute('open')
|
||||
}}
|
||||
disabled={first}
|
||||
aria-label={`${column.title}を左へ`}
|
||||
>
|
||||
← 左へ移動
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onMove(1)
|
||||
event.currentTarget
|
||||
.closest('details')
|
||||
?.removeAttribute('open')
|
||||
}}
|
||||
disabled={last}
|
||||
aria-label={`${column.title}を右へ`}
|
||||
>
|
||||
右へ移動 →
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={onRemove}
|
||||
aria-label={`${column.title}を削除`}
|
||||
>
|
||||
カラムを削除
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div className="deck-column-label">
|
||||
<span
|
||||
title={`接続プロファイル: ${connectionLabel ?? '接続が見つかりません'}`}
|
||||
>
|
||||
{connectionLabel ?? '接続が見つかりません'}
|
||||
</span>
|
||||
<span className="deck-source-label">
|
||||
<Icon name={column.source.kind} />
|
||||
{sourceDescription.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="deck-query">{sourceDescription.query}</p>
|
||||
<p className="deck-column-status" role="status">
|
||||
{query.isFetching
|
||||
? '投稿を取得しています…'
|
||||
: `${posts.length}件を読み込み済み`}
|
||||
</p>
|
||||
</header>
|
||||
<section
|
||||
className="deck-column-results"
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible.
|
||||
tabIndex={0}
|
||||
aria-label={`${column.title}の投稿`}
|
||||
>
|
||||
{visiblePosts.map((post) => (
|
||||
<ResearchPostCard
|
||||
key={post.key}
|
||||
post={post}
|
||||
citationRequest={
|
||||
citation && citationUrlKey(post.url) === citedKey
|
||||
? citation.request
|
||||
: undefined
|
||||
}
|
||||
citationSnapshot={citation?.post === post && !posts.includes(post)}
|
||||
/>
|
||||
))}
|
||||
{query.isError ? (
|
||||
<div className="deck-message" role="alert">
|
||||
<p>{error.message}</p>
|
||||
{error.retryable ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={query.isFetching}
|
||||
onClick={() =>
|
||||
void (query.isFetchNextPageError
|
||||
? query.fetchNextPage({ cancelRefetch: false })
|
||||
: query.refetch())
|
||||
}
|
||||
>
|
||||
再試行
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!query.isPending && !query.isError && visiblePosts.length === 0 ? (
|
||||
<p className="deck-message">条件に一致する投稿はありません。</p>
|
||||
) : null}
|
||||
{query.hasNextPage && !query.isFetchNextPageError ? (
|
||||
<div className="deck-message">
|
||||
<button
|
||||
type="button"
|
||||
disabled={query.isFetching}
|
||||
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
|
||||
>
|
||||
続きを読み込む
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{!query.isPending &&
|
||||
!query.isError &&
|
||||
posts.length > 0 &&
|
||||
!query.hasNextPage ? (
|
||||
<p className="deck-message">これ以上の投稿はありません。</p>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
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 { ConnectionManager } from '#/features/connections/connection-manager'
|
||||
import { loadConnections } from '#/features/connections/server-functions'
|
||||
import { syncResearchDeck } from '#/features/research/research-deck-sync'
|
||||
import { ResearchPanel } from '#/features/research/research-panel'
|
||||
import {
|
||||
sameCitationSource,
|
||||
useCitationNavigation,
|
||||
} from '#/features/research/use-citation-navigation'
|
||||
import { ColumnEditor } from './column-editor'
|
||||
import { type ColumnRegistry, useColumnTools } from './column-tools'
|
||||
import { ResearchColumn } from './deck-column'
|
||||
import { DeckSwitcher } from './deck-switcher'
|
||||
import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model'
|
||||
import { useDeck } from './use-deck'
|
||||
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 researchDeckBindings = useRef(new Map<string, string>()).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(editing !== null || renaming || switcherEditing)
|
||||
const citationNavigation = useCitationNavigation({
|
||||
registry,
|
||||
getWorkspace,
|
||||
select,
|
||||
createTemporary,
|
||||
})
|
||||
const fetchProfiles = useServerFn(loadConnections)
|
||||
const profiles = useQuery({
|
||||
queryKey: ['connections'],
|
||||
queryFn: () => fetchProfiles(),
|
||||
retry: false,
|
||||
})
|
||||
useColumnTools(registry, ready)
|
||||
const [removed, setRemoved] = useState<{
|
||||
column: DeckColumn
|
||||
index: number
|
||||
}>()
|
||||
|
||||
useDeckTools({
|
||||
ready,
|
||||
saving,
|
||||
storageError,
|
||||
save,
|
||||
select,
|
||||
remove,
|
||||
getWorkspace,
|
||||
createTemporary,
|
||||
persist,
|
||||
connections: profiles.data?.connections,
|
||||
onApplied: clearEditors,
|
||||
})
|
||||
|
||||
function clearEditors() {
|
||||
setEditing(null)
|
||||
setRenaming(false)
|
||||
setRemoved(undefined)
|
||||
}
|
||||
|
||||
async function saveColumn(column: DeckColumn) {
|
||||
const saved = await save({
|
||||
...deck,
|
||||
columns:
|
||||
editing === 'new'
|
||||
? [...deck.columns, column]
|
||||
: deck.columns.map((current) =>
|
||||
current.id === column.id ? column : current,
|
||||
),
|
||||
})
|
||||
if (saved) setEditing(null)
|
||||
return saved
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
researchChat={
|
||||
<ResearchPanel
|
||||
onNavigateCitation={citationNavigation.navigate}
|
||||
ready={ready}
|
||||
contextDeck={{
|
||||
id: deck.id,
|
||||
title: deck.title,
|
||||
columns: deck.columns,
|
||||
}}
|
||||
connections={profiles.data?.connections ?? []}
|
||||
onOpenDeck={async (plan, activate = false) => {
|
||||
await syncResearchDeck(plan, activate, researchDeckBindings, {
|
||||
getWorkspace,
|
||||
createTemporary,
|
||||
save,
|
||||
select,
|
||||
})
|
||||
if (activate) clearEditors()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sidebar={
|
||||
<>
|
||||
<DeckSwitcher
|
||||
key={deck.id}
|
||||
decks={workspace.decks}
|
||||
activeId={deck.id}
|
||||
ready={ready && !saving}
|
||||
onEditingChange={setSwitcherEditing}
|
||||
onSelect={(id) => {
|
||||
select(id)
|
||||
clearEditors()
|
||||
}}
|
||||
onCreate={async (title) => {
|
||||
const saved = await create(title)
|
||||
if (saved) clearEditors()
|
||||
return saved
|
||||
}}
|
||||
onDelete={async () => {
|
||||
const saved = await remove(deck.id)
|
||||
if (saved) clearEditors()
|
||||
return saved
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="deck-primary sidebar-add"
|
||||
aria-label="カラムを追加"
|
||||
title="カラムを追加"
|
||||
type="button"
|
||||
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>カラムを追加</span>
|
||||
</button>
|
||||
<p className="sidebar-note">
|
||||
{deck.persisted
|
||||
? 'サーバーに保存・端末間で共有'
|
||||
: '一時ビュー · このタブのみ'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => setManagingConnections(true)}
|
||||
>
|
||||
接続アカウントを管理
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="deck-heading">
|
||||
<div>
|
||||
<h1>{deck.title}</h1>
|
||||
<span className="deck-count">
|
||||
{deck.columns.length} / {MAX_COLUMNS} カラム
|
||||
</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 || saving}
|
||||
onClick={() => setRenaming(!renaming)}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{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">
|
||||
デッキを読み込んでいます…
|
||||
</p>
|
||||
) : null}
|
||||
{profiles.isError ? (
|
||||
<p role="alert" className="deck-error">
|
||||
接続プロファイルを取得できませんでした。
|
||||
<button type="button" onClick={() => void profiles.refetch()}>
|
||||
再試行
|
||||
</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={async (event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
if (await save({ ...deck, title })) setRenaming(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="deck-title">
|
||||
調査テーマ・デッキ名
|
||||
<input
|
||||
id="deck-title"
|
||||
name="title"
|
||||
defaultValue={deck.title}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">名前を保存</button>
|
||||
</form>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{editing !== null ? (
|
||||
<Dialog
|
||||
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
|
||||
onClose={() => setEditing(null)}
|
||||
>
|
||||
<ColumnEditor
|
||||
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
|
||||
connections={profiles.data?.connections ?? []}
|
||||
column={
|
||||
editing === 'new'
|
||||
? undefined
|
||||
: deck.columns.find((column) => column.id === editing.id)
|
||||
}
|
||||
onSave={saveColumn}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{removed ? (
|
||||
<div className="deck-undo" role="status">
|
||||
<span>「{removed.column.title}」を削除しました。</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
|
||||
onClick={async () => {
|
||||
const columns = [...deck.columns]
|
||||
columns.splice(removed.index, 0, removed.column)
|
||||
if (await save({ ...deck, columns })) setRemoved(undefined)
|
||||
}}
|
||||
>
|
||||
元に戻す
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{ready && deck.columns.length === 0 && editing === null ? (
|
||||
<div className="deck-empty">
|
||||
<Icon name="columns" />
|
||||
<h2>調べたいことから、デッキを作りましょう</h2>
|
||||
<p>
|
||||
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
|
||||
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
|
||||
</p>
|
||||
<button
|
||||
className="deck-primary"
|
||||
type="button"
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
手動でカラムを追加
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{ready && deck.columns.length > 0 ? (
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
|
||||
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
|
||||
{deck.columns.map((column, index) => (
|
||||
<ResearchColumn
|
||||
key={`${deck.id}:${column.id}`}
|
||||
registry={registry}
|
||||
column={column}
|
||||
citation={
|
||||
citationNavigation.target?.deckId === deck.id &&
|
||||
citationNavigation.target.column.id === column.id &&
|
||||
sameCitationSource(citationNavigation.target.column, column)
|
||||
? citationNavigation.target
|
||||
: undefined
|
||||
}
|
||||
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={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)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
import { Icon } from '#/components/icon'
|
||||
import type { DeckView } from './model'
|
||||
|
||||
export function DeckSwitcher({
|
||||
decks,
|
||||
activeId,
|
||||
ready,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onEditingChange,
|
||||
}: {
|
||||
decks: DeckView[]
|
||||
activeId: string
|
||||
ready: boolean
|
||||
onSelect: (id: string) => 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">
|
||||
<label htmlFor="deck-selection">デッキプロファイル</label>
|
||||
<select
|
||||
id="deck-selection"
|
||||
value={activeId}
|
||||
disabled={!ready}
|
||||
onChange={(event) => {
|
||||
onSelect(event.target.value)
|
||||
setCreating(false)
|
||||
setDeleting(false)
|
||||
}}
|
||||
>
|
||||
{decks.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.title}
|
||||
{deck.persisted ? '' : '(一時)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="workspace-create"
|
||||
aria-label="デッキを作成"
|
||||
title="デッキを作成"
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
setCreating(!creating)
|
||||
setDeleting(false)
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>デッキを作成</span>
|
||||
</button>
|
||||
<button
|
||||
className="workspace-delete"
|
||||
aria-label="デッキを削除"
|
||||
title="デッキを削除"
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
setDeleting(!deleting)
|
||||
setCreating(false)
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
<span>デッキを削除</span>
|
||||
</button>
|
||||
</div>
|
||||
{creating ? (
|
||||
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
if (await onCreate(title)) setCreating(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="new-deck-title">
|
||||
新しいデッキ名
|
||||
<input
|
||||
id="new-deck-title"
|
||||
name="title"
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">作成</button>
|
||||
<button type="button" onClick={() => setCreating(false)}>
|
||||
キャンセル
|
||||
</button>
|
||||
</form>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{deleting ? (
|
||||
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}>
|
||||
<div className="deck-undo">
|
||||
<p>このデッキとカラムの設定を削除します。</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await onDelete()) setDeleting(false)
|
||||
}}
|
||||
>
|
||||
削除する
|
||||
</button>
|
||||
<button type="button" onClick={() => setDeleting(false)}>
|
||||
キャンセル
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
|
||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
||||
.app-deck button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
min-height: 36px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app-deck button:hover:not(:disabled) {
|
||||
background: var(--color-paper-3);
|
||||
}
|
||||
.app-deck button:active:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.app-deck button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.app-deck .icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: var(--space-xs);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.app-deck .deck-primary {
|
||||
color: var(--color-accent-ink);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
.app-deck .deck-primary:hover:not(:disabled),
|
||||
.app-deck .deck-primary:active:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
color: var(--color-accent-ink);
|
||||
}
|
||||
.deck-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
gap: var(--space-sm);
|
||||
min-height: 58px;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
.deck-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-heading h1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-heading p {
|
||||
margin: var(--space-2xs) 0 0;
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.deck-help,
|
||||
.deck-save-status,
|
||||
.deck-column-status {
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.deck-save-status {
|
||||
margin: 0;
|
||||
padding: 0 var(--space-md) var(--space-xs);
|
||||
}
|
||||
.deck-board {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 328px;
|
||||
justify-content: start;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: var(--space-xs);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0 var(--space-xs) var(--space-xs);
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-color: var(--control-border) var(--canvas);
|
||||
}
|
||||
.deck-column {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.deck-column-header {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-bottom: var(--rule-hairline) solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.deck-column-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.deck-column h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-column-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-column-label span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-source-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
.deck-source-label svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.deck-query {
|
||||
margin: var(--space-2xs) 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-column-status {
|
||||
margin: var(--space-2xs) 0 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.deck-column-results {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
overscroll-behavior-y: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--control-border) var(--paper);
|
||||
}
|
||||
.column-menu {
|
||||
position: relative;
|
||||
}
|
||||
.column-menu summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.column-menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.column-menu summary:hover,
|
||||
.column-menu[open] summary {
|
||||
background: var(--color-paper-3);
|
||||
}
|
||||
.column-menu-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 158px;
|
||||
padding: var(--space-2xs);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 var(--space-xs) var(--space-lg) var(--color-shadow);
|
||||
}
|
||||
.column-menu-panel button {
|
||||
justify-content: flex-start;
|
||||
border: 0;
|
||||
}
|
||||
.deck-message {
|
||||
padding: var(--space-md);
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.research-post {
|
||||
padding: var(--space-sm);
|
||||
border-bottom: var(--rule-hairline) solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-post header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.research-post header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.research-post header img {
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
.research-post strong {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.research-post-text,
|
||||
.research-post blockquote p {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.55;
|
||||
font-size: var(--text-sm);
|
||||
margin: var(--space-xs) 0;
|
||||
}
|
||||
.research-post blockquote {
|
||||
margin: var(--space-sm) 0;
|
||||
padding: var(--space-sm);
|
||||
border: var(--rule-hairline) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.research-post footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.research-post a {
|
||||
color: var(--accent);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
.research-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 20rem;
|
||||
object-fit: contain;
|
||||
margin-block: var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.deck-dialog {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: var(--space-lg);
|
||||
overflow-y: auto;
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 var(--space-md) var(--space-xl) var(--color-shadow);
|
||||
}
|
||||
.deck-dialog::backdrop {
|
||||
background: var(--color-overlay);
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.dialog-heading h2,
|
||||
.deck-editor h2,
|
||||
.deck-empty h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-editor {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.deck-editor-fields,
|
||||
.deck-source-fields {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.deck-source-fields {
|
||||
margin-block: var(--space-md);
|
||||
}
|
||||
.deck-editor label,
|
||||
.deck-rename label {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-editor input:not([type="checkbox"]),
|
||||
.deck-editor select,
|
||||
.deck-rename input,
|
||||
.deck-workspaces select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: var(--space-xs);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
}
|
||||
.deck-editor .deck-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.deck-checkbox input {
|
||||
accent-color: var(--accent);
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
.deck-rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.deck-rename label {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.deck-error {
|
||||
color: var(--error);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.reader > .deck-error {
|
||||
margin: 0;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
.deck-undo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.deck-undo p {
|
||||
margin: 0;
|
||||
}
|
||||
.deck-empty {
|
||||
margin: auto;
|
||||
max-width: 32rem;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.deck-empty p {
|
||||
color: var(--secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.deck-workspaces {
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-workspaces > .deck-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
.deck-workspaces > .deck-actions > label {
|
||||
width: 100%;
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-workspaces > .deck-actions > select {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.deck-workspaces > .deck-actions > button {
|
||||
flex: 1;
|
||||
}
|
||||
.deck-list-choices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.app-deck .sidebar-add {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.deck-workspaces {
|
||||
flex: 1;
|
||||
}
|
||||
.deck-workspaces > .deck-actions {
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
.deck-workspaces > .deck-actions > label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
.deck-workspaces > .deck-actions > select {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
}
|
||||
.deck-workspaces > .deck-actions > button {
|
||||
flex: none;
|
||||
}
|
||||
.app-deck .sidebar-add {
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
.sidebar-add span {
|
||||
display: none;
|
||||
}
|
||||
.deck-heading {
|
||||
min-height: 52px;
|
||||
padding-inline: var(--space-sm);
|
||||
}
|
||||
.deck-heading .deck-actions {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.deck-board {
|
||||
grid-auto-columns: min(328px, 100%);
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.app-deck .icon-button,
|
||||
.column-menu summary {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
.deck-count {
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.research-post header time {
|
||||
display: block;
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-column-status {
|
||||
text-align: right;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.workspace-create span,
|
||||
.workspace-delete span {
|
||||
display: none;
|
||||
}
|
||||
.app-deck .workspace-create,
|
||||
.app-deck .workspace-delete {
|
||||
width: 36px;
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
}
|
||||
.research-post header > div {
|
||||
flex: 1;
|
||||
}
|
||||
.research-post header time {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.research-post footer a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
.research-post footer svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.column-menu-panel .danger {
|
||||
color: var(--error);
|
||||
}
|
||||
@@ -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' },
|
||||
)
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type Deck, deckSchema, moveColumn } from './model'
|
||||
|
||||
const deck: Deck = {
|
||||
id: 'research',
|
||||
title: '調査',
|
||||
columns: ['a', 'b'].map((id) => ({
|
||||
id,
|
||||
title: id,
|
||||
connectionId: 'main',
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'AI lang:ja',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
})),
|
||||
}
|
||||
describe('deck definitions', () => {
|
||||
it('preserves native search and the bound relay profile when serialized', () => {
|
||||
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
|
||||
})
|
||||
it('normalizes user and list targets and rejects invalid targets', () => {
|
||||
const column = deck.columns[0]
|
||||
expect(
|
||||
deckSchema.parse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: { platform: 'twitter', kind: 'user', target: '@alice' },
|
||||
},
|
||||
],
|
||||
}).columns[0]?.source,
|
||||
).toEqual({ platform: 'twitter', kind: 'user', target: 'alice' })
|
||||
expect(
|
||||
deckSchema.parse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'list',
|
||||
target: 'https://x.com/i/lists/123',
|
||||
},
|
||||
},
|
||||
],
|
||||
}).columns[0]?.source,
|
||||
).toEqual({ platform: 'twitter', kind: 'list', target: '123' })
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: { platform: 'twitter', kind: 'list', target: 'invalid' },
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('rejects duplicate identities and missing profiles', () => {
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [deck.columns[0], deck.columns[0]],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [{ ...deck.columns[0], connectionId: '' }],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('rejects queries exceeding the upstream limit after adding follows', () => {
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...deck.columns[0],
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'a'.repeat(512),
|
||||
product: 'Latest',
|
||||
following: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('moves a column without changing its conditions or the previous deck', () => {
|
||||
expect(moveColumn(deck, 'b', -1).columns).toEqual([
|
||||
deck.columns[1],
|
||||
deck.columns[0],
|
||||
])
|
||||
expect(deck.columns.map((column) => column.id)).toEqual(['a', 'b'])
|
||||
expect(moveColumn(deck, 'a', -1)).toBe(deck)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from 'zod'
|
||||
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
|
||||
import {
|
||||
buildSearchQuery,
|
||||
normalizeListTarget,
|
||||
normalizeUserTarget,
|
||||
} from '#/features/posts/inputs'
|
||||
|
||||
export const MAX_COLUMNS = 6
|
||||
|
||||
const searchSourceSchema = z
|
||||
.object({
|
||||
platform: z.literal('twitter'),
|
||||
kind: z.literal('search'),
|
||||
query: z.string().trim().min(1).max(512),
|
||||
product: z.enum(['Latest', 'Top']),
|
||||
following: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((source, ctx) => {
|
||||
try {
|
||||
buildSearchQuery(source.query, source.following)
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '検索条件を確認してください。',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function targetSchema(normalize: (target: string) => string) {
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.transform((target, ctx) => {
|
||||
try {
|
||||
return normalize(target)
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message:
|
||||
error instanceof Error ? error.message : '対象を確認してください。',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const userSourceSchema = z
|
||||
.object({
|
||||
platform: z.literal('twitter'),
|
||||
kind: z.literal('user'),
|
||||
target: targetSchema(normalizeUserTarget),
|
||||
})
|
||||
.strict()
|
||||
const listSourceSchema = z
|
||||
.object({
|
||||
platform: z.literal('twitter'),
|
||||
kind: z.literal('list'),
|
||||
target: targetSchema(normalizeListTarget),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const columnSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(128),
|
||||
title: z.string().trim().min(1).max(80),
|
||||
connectionId: z.string().min(1),
|
||||
source: z.union([
|
||||
z.discriminatedUnion('kind', [
|
||||
searchSourceSchema,
|
||||
userSourceSchema,
|
||||
listSourceSchema,
|
||||
]),
|
||||
mastodonSourceSchema,
|
||||
]),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const deckSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(128),
|
||||
title: z.string().trim().min(1).max(120),
|
||||
columns: z.array(columnSchema).max(MAX_COLUMNS),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(deck) =>
|
||||
new Set(deck.columns.map((column) => column.id)).size ===
|
||||
deck.columns.length,
|
||||
'カラム ID が重複しています。',
|
||||
)
|
||||
|
||||
export type Deck = z.infer<typeof deckSchema>
|
||||
export type DeckColumn = z.infer<typeof columnSchema>
|
||||
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 = {
|
||||
activeDeckId: EMPTY_DECK.id,
|
||||
decks: [{ ...EMPTY_DECK, persisted: false }],
|
||||
}
|
||||
export function newId() {
|
||||
return crypto.getRandomValues(new Uint32Array(4)).join('-')
|
||||
}
|
||||
|
||||
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
|
||||
const index = deck.columns.findIndex((column) => column.id === id)
|
||||
const target = index + direction
|
||||
if (index < 0 || target < 0 || target >= deck.columns.length) return deck
|
||||
const columns = [...deck.columns]
|
||||
const [column] = columns.splice(index, 1)
|
||||
if (column) columns.splice(target, 0, column)
|
||||
return { ...deck, columns }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
)
|
||||
}
|
||||
@@ -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,87 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { ResearchPost } from '../platforms/types'
|
||||
import { ResearchPostCard } from './research-post-card'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('focuses and briefly highlights a cited card without revealing sensitive content', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: true }))
|
||||
const post: ResearchPost = {
|
||||
key: 'mastodon:1',
|
||||
platform: 'mastodon',
|
||||
nativeId: '1',
|
||||
url: 'https://social.invalid/@a/1',
|
||||
text: 'Hidden content',
|
||||
author: { name: 'Alice', handle: '[email protected]' },
|
||||
contentWarning: 'Spoiler',
|
||||
sensitive: true,
|
||||
}
|
||||
const { rerender } = render(<ResearchPostCard post={post} />)
|
||||
const article = screen.getByRole('article')
|
||||
const scroll = vi.fn()
|
||||
article.scrollIntoView = scroll
|
||||
rerender(
|
||||
<ResearchPostCard post={post} citationRequest={1} citationSnapshot />,
|
||||
)
|
||||
expect(article).toHaveFocus()
|
||||
expect(article).toHaveClass('citation-highlight')
|
||||
expect(scroll).toHaveBeenCalledWith({
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
behavior: 'instant',
|
||||
})
|
||||
expect(screen.getByText('調査時に取得した投稿')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '内容を表示' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
)
|
||||
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
|
||||
rerender(<ResearchPostCard post={post} citationRequest={1} />)
|
||||
expect(scroll).toHaveBeenCalledTimes(2)
|
||||
expect(article).toHaveFocus()
|
||||
expect(screen.queryByText('調査時に取得した投稿')).not.toBeInTheDocument()
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2500)
|
||||
})
|
||||
expect(article).not.toHaveClass('citation-highlight')
|
||||
rerender(<ResearchPostCard post={post} citationRequest={2} />)
|
||||
expect(article).toHaveClass('citation-highlight')
|
||||
rerender(<ResearchPostCard post={post} />)
|
||||
expect(article).not.toHaveClass('citation-highlight')
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from '#/components/icon'
|
||||
import { PostText } from '#/components/post-text'
|
||||
import type { ResearchPost } from '#/features/platforms/types'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Tokyo',
|
||||
})
|
||||
|
||||
export function ResearchPostCard({
|
||||
post,
|
||||
citationRequest,
|
||||
citationSnapshot,
|
||||
}: {
|
||||
post: ResearchPost
|
||||
citationRequest?: number
|
||||
citationSnapshot?: boolean
|
||||
}) {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
const article = useRef<HTMLElement>(null)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Re-scroll when a snapshot moves to its live feed position.
|
||||
useEffect(() => {
|
||||
if (citationRequest === undefined) {
|
||||
setHighlighted(false)
|
||||
return
|
||||
}
|
||||
setHighlighted(true)
|
||||
article.current?.focus({ preventScroll: true })
|
||||
article.current?.scrollIntoView({
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
? 'instant'
|
||||
: 'smooth',
|
||||
})
|
||||
const timer = setTimeout(() => setHighlighted(false), 2500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [citationRequest, citationSnapshot])
|
||||
const concealed = Boolean(post.contentWarning || post.sensitive)
|
||||
return (
|
||||
<article
|
||||
ref={article}
|
||||
tabIndex={-1}
|
||||
className={`research-post${highlighted ? ' citation-highlight' : ''}`}
|
||||
data-post-url={post.url}
|
||||
>
|
||||
{citationSnapshot ? <p className="handle">調査時に取得した投稿</p> : null}
|
||||
{post.boostedBy ? (
|
||||
<p className="handle">{post.boostedBy.name} がブースト</p>
|
||||
) : null}
|
||||
<header>
|
||||
{post.author.avatarUrl ? (
|
||||
<img
|
||||
src={post.author.avatarUrl}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<strong>{post.author.name}</strong>
|
||||
<span className="handle">
|
||||
@{post.author.handle.replace(/^@/, '')}
|
||||
</span>
|
||||
</div>
|
||||
{post.createdAt ? (
|
||||
<time dateTime={post.createdAt} title={post.createdAt}>
|
||||
{dateFormatter.format(new Date(post.createdAt))}
|
||||
</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
|
||||
className="research-media"
|
||||
key={media.url}
|
||||
src={media.url}
|
||||
alt={media.alt ?? ''}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
key={media.url}
|
||||
href={media.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
動画を開く
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
{post.quotedPost ? (
|
||||
<blockquote>
|
||||
<strong>{post.quotedPost.author.name}</strong>
|
||||
<p>
|
||||
<PostText text={post.quotedPost.text} />
|
||||
</p>
|
||||
<a
|
||||
href={post.quotedPost.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
引用元を開く
|
||||
</a>
|
||||
</blockquote>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<footer>
|
||||
<a href={post.url} target="_blank" rel="noreferrer noopener">
|
||||
元の投稿を開く <Icon name="arrow" />
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
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.clearAllMocks()
|
||||
server.load.mockResolvedValue({ ok: true, data: [] })
|
||||
server.create.mockImplementation(async ({ data }) => ({
|
||||
ok: true,
|
||||
data: { ...data, revision: 1, createdAt: 1, updatedAt: 1 },
|
||||
}))
|
||||
})
|
||||
|
||||
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,
|
||||
)
|
||||
})
|
||||
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 })
|
||||
})
|
||||
@@ -0,0 +1,335 @@
|
||||
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,
|
||||
} from './model'
|
||||
import type { SavedDeck } from './persistence-model'
|
||||
import {
|
||||
createSavedDeck,
|
||||
deleteSavedDeck,
|
||||
importBrowserDecks,
|
||||
loadSavedDecks,
|
||||
replaceSavedDeck,
|
||||
} from './server-functions'
|
||||
|
||||
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(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)
|
||||
|
||||
const apply = useCallback((next: Workspace) => {
|
||||
current.current = next
|
||||
setWorkspace(next)
|
||||
try {
|
||||
localStorage.setItem(ACTIVE_DECK_KEY, next.activeDeckId)
|
||||
} catch {
|
||||
/* Selection is a device-local preference, not saved deck data. */
|
||||
}
|
||||
}, [])
|
||||
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 {
|
||||
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
|
||||
}
|
||||
},
|
||||
[reconcile],
|
||||
)
|
||||
|
||||
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],
|
||||
})
|
||||
}
|
||||
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('デッキが見つかりません。')
|
||||
apply({ ...current.current, activeDeckId: id })
|
||||
return true
|
||||
}
|
||||
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
|
||||
const decks = previous.decks.filter((deck) => deck.id !== id)
|
||||
if (!decks.length)
|
||||
decks.push({ ...EMPTY_DECK, id: newId(), persisted: false })
|
||||
apply({
|
||||
activeDeckId:
|
||||
previous.activeDeckId === id
|
||||
? (decks[0]?.id ?? '')
|
||||
: previous.activeDeckId,
|
||||
decks,
|
||||
})
|
||||
}
|
||||
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: 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
export function useWebMCPSupported() {
|
||||
const [supported, setSupported] = useState(false)
|
||||
useEffect(() => {
|
||||
setSupported(Boolean(document.modelContext))
|
||||
}, [])
|
||||
return supported
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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: '観点',
|
||||
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] }, [
|
||||
account('main'),
|
||||
])
|
||||
expect(deck.columns[0]?.source).toEqual({
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'AI',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
})
|
||||
expect(
|
||||
new Set([deck.id, ...deck.columns.map((column) => column.id)]).size,
|
||||
).toBe(3)
|
||||
})
|
||||
it('keeps supplied identities and rejects duplicates', () => {
|
||||
const identified = { ...column, id: 'existing' }
|
||||
expect(
|
||||
prepareDeck(
|
||||
{ deckId: 'research', title: '調査', columns: [identified] },
|
||||
[account('main')],
|
||||
),
|
||||
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
|
||||
expect(() =>
|
||||
prepareDeck({ title: '調査', columns: [identified, identified] }, [
|
||||
account('main'),
|
||||
]),
|
||||
).toThrow()
|
||||
})
|
||||
it('rejects unavailable profiles before applying a definition', () => {
|
||||
expect(() =>
|
||||
prepareDeck({ title: '調査', columns: [column] }, [account('work')]),
|
||||
).toThrow('Unavailable connection')
|
||||
})
|
||||
it('accepts user and list columns bound to separate profiles', () => {
|
||||
const deck = prepareDeck(
|
||||
{
|
||||
title: '調査',
|
||||
columns: [
|
||||
{ ...column, source: { kind: 'user', target: '@alice' } },
|
||||
{
|
||||
...column,
|
||||
connectionId: 'work',
|
||||
source: { kind: 'list', target: '123' },
|
||||
},
|
||||
],
|
||||
},
|
||||
[account('main'), account('work')],
|
||||
)
|
||||
expect(deck.columns.map((column) => column.connectionId)).toEqual([
|
||||
'main',
|
||||
'work',
|
||||
])
|
||||
expect(deck.columns.map((column) => column.source.kind)).toEqual([
|
||||
'user',
|
||||
'list',
|
||||
])
|
||||
})
|
||||
it('rejects unsupported platforms, overlong queries and more than six columns', () => {
|
||||
expect(() =>
|
||||
prepareDeck(
|
||||
{
|
||||
title: '調査',
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: { kind: 'search', platform: 'nostr', query: 'AI' },
|
||||
},
|
||||
],
|
||||
},
|
||||
[account('main')],
|
||||
),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
prepareDeck(
|
||||
{
|
||||
title: '調査',
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: {
|
||||
kind: 'search',
|
||||
query: 'a'.repeat(512),
|
||||
following: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
[account('main')],
|
||||
),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
prepareDeck(
|
||||
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
|
||||
[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'],
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
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'
|
||||
|
||||
const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
|
||||
id: columnSchema.shape.id
|
||||
.optional()
|
||||
.describe(
|
||||
'Keep an existing column ID when editing; omit for a new column.',
|
||||
),
|
||||
source: z.union([
|
||||
mastodonSourceSchema,
|
||||
z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('twitter').default('twitter'),
|
||||
kind: z.literal('search'),
|
||||
query: z.string().trim().min(1).max(512),
|
||||
product: z.enum(['Latest', 'Top']).default('Latest'),
|
||||
following: z.boolean().default(false),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('twitter').default('twitter'),
|
||||
kind: z.literal('user'),
|
||||
target: z.string().min(1).max(256),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('twitter').default('twitter'),
|
||||
kind: z.literal('list'),
|
||||
target: z.string().min(1).max(256),
|
||||
})
|
||||
.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()
|
||||
export const setDeckInput = z
|
||||
.object({
|
||||
deckId: deckSchema.shape.id
|
||||
.optional()
|
||||
.describe(
|
||||
'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 connection ID from list_connections. Omitted existing columns are removed.',
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export function prepareDeck(
|
||||
input: unknown,
|
||||
connections: readonly Connection[],
|
||||
) {
|
||||
const parsed = setDeckInput.parse(input)
|
||||
const deck = deckSchema.parse({
|
||||
id: parsed.deckId ?? newId(),
|
||||
title: parsed.title,
|
||||
columns: parsed.columns.map((column) => ({
|
||||
...column,
|
||||
id: column.id ?? newId(),
|
||||
})),
|
||||
})
|
||||
for (const column of deck.columns) {
|
||||
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
|
||||
}
|
||||
|
||||
export async function toolResult(run: () => unknown | Promise<unknown>) {
|
||||
try {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: JSON.stringify(await run()) },
|
||||
] as const,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
code:
|
||||
error instanceof z.ZodError || error instanceof InputError
|
||||
? 'invalid-input'
|
||||
: 'tool-error',
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Tool execution failed.',
|
||||
retryable: false,
|
||||
}),
|
||||
},
|
||||
] as const,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useWebMCP } from 'usewebmcp'
|
||||
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,
|
||||
setDeckInput,
|
||||
toolResult,
|
||||
} from './webmcp-contracts'
|
||||
|
||||
export function useDeckTools({
|
||||
ready,
|
||||
saving,
|
||||
storageError,
|
||||
save,
|
||||
select,
|
||||
remove,
|
||||
getWorkspace,
|
||||
connections,
|
||||
onApplied,
|
||||
createTemporary,
|
||||
persist,
|
||||
}: {
|
||||
ready: boolean
|
||||
saving: boolean
|
||||
storageError?: string
|
||||
save: (deck: Deck, expectedRevision?: number) => Promise<boolean>
|
||||
select: (id: string) => boolean
|
||||
remove: (id: string, expectedRevision?: number) => Promise<boolean>
|
||||
getWorkspace: () => Workspace
|
||||
connections: Connection[] | undefined
|
||||
onApplied: () => void
|
||||
createTemporary: (title: string, columns?: Deck['columns']) => DeckView
|
||||
persist: (id: string) => Promise<boolean>
|
||||
}) {
|
||||
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_connections',
|
||||
description:
|
||||
'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)
|
||||
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 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(() => ({
|
||||
deck: requireDeck(getDeckInput.parse(input).deckId),
|
||||
storageError: storageError ?? null,
|
||||
})),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'set_deck',
|
||||
description:
|
||||
'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(async () => {
|
||||
requireIdle()
|
||||
const parsed = setDeckInput.parse(input)
|
||||
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.',
|
||||
)
|
||||
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:
|
||||
'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))
|
||||
return getWorkspace()
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'delete_deck',
|
||||
description:
|
||||
'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(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()
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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' },
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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) }
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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> & 😀</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><script> &lt;</p><p>next<br>end</p>')
|
||||
.text,
|
||||
).toBe('<script> <\nnext\nend')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DeckColumn } from '../decks/model'
|
||||
|
||||
// Brand paths from Simple Icons (CC0): twitter v9.0.0 and mastodon.
|
||||
// https://github.com/simple-icons/simple-icons
|
||||
const icons = {
|
||||
twitter: {
|
||||
label: 'Twitter',
|
||||
color: '#1d9bf0',
|
||||
path: 'M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z',
|
||||
},
|
||||
mastodon: {
|
||||
label: 'Mastodon',
|
||||
color: '#858afa',
|
||||
path: 'M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z',
|
||||
},
|
||||
}
|
||||
|
||||
export function PlatformIcon({
|
||||
platform,
|
||||
}: {
|
||||
platform: DeckColumn['source']['platform']
|
||||
}) {
|
||||
const icon = icons[platform]
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill={icon.color}
|
||||
role="img"
|
||||
aria-label={icon.label}
|
||||
>
|
||||
<title>{icon.label}</title>
|
||||
<path d={icon.path} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user