feat: add shared decks and multi-account Mastodon OAuth

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