feat: add shared decks and multi-account Mastodon OAuth
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
type AccessConfig = { origin: string; allowedLogin: string }
|
||||
|
||||
export function readAccessConfig(): AccessConfig | null {
|
||||
const origin = process.env.TWITTER_LITE_ORIGIN
|
||||
const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN
|
||||
if (!origin || !allowedLogin?.trim()) return null
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
const secure = url.protocol === 'https:'
|
||||
const local = url.protocol === 'http:' && url.hostname === '127.0.0.1'
|
||||
if ((!secure && !local) || url.origin !== origin) return null
|
||||
return { origin, allowedLogin }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The backend must bind to loopback; only Serve may supply identity headers. */
|
||||
export function checkAccess(
|
||||
request: Request,
|
||||
config: AccessConfig | null,
|
||||
): Response | null {
|
||||
if (!config) {
|
||||
return new Response('Access configuration is required.', { status: 503 })
|
||||
}
|
||||
if (request.headers.get('Tailscale-User-Login') !== config.allowedLogin) {
|
||||
return new Response('Forbidden', { status: 403 })
|
||||
}
|
||||
if (
|
||||
!['GET', 'HEAD', 'OPTIONS'].includes(request.method) &&
|
||||
request.headers.get('Origin') !== config.origin
|
||||
) {
|
||||
return new Response('Forbidden', { status: 403 })
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { checkAccess, readAccessConfig } from './policy.server'
|
||||
|
||||
const config = {
|
||||
origin: 'https://deck.invalid',
|
||||
allowedLogin: '[email protected]',
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('Serve access boundary', () => {
|
||||
it('fails closed when the deployment is not configured', () => {
|
||||
vi.stubEnv('TWITTER_LITE_ORIGIN', '')
|
||||
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
|
||||
expect(readAccessConfig()).toBeNull()
|
||||
expect(checkAccess(new Request(config.origin), null)?.status).toBe(503)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'https://deck.invalid/path',
|
||||
'http://deck.invalid',
|
||||
'not a URL',
|
||||
])('rejects an invalid configured origin: %s', (origin) => {
|
||||
vi.stubEnv('TWITTER_LITE_ORIGIN', origin)
|
||||
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
|
||||
expect(readAccessConfig()).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://deck.invalid',
|
||||
'http://127.0.0.1:4173',
|
||||
])('accepts an explicit deployment origin: %s', (origin) => {
|
||||
vi.stubEnv('TWITTER_LITE_ORIGIN', origin)
|
||||
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
|
||||
expect(readAccessConfig()).toEqual({ ...config, origin })
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'[email protected]',
|
||||
'[email protected], [email protected]',
|
||||
])('rejects absent, foreign, or ambiguous identities: %s', (login) => {
|
||||
const headers = new Headers()
|
||||
if (login) headers.set('Tailscale-User-Login', login)
|
||||
expect(
|
||||
checkAccess(new Request(config.origin, { headers }), config)?.status,
|
||||
).toBe(403)
|
||||
})
|
||||
|
||||
it('permits owner navigation back from an OAuth provider without Origin', () => {
|
||||
const request = new Request(
|
||||
`${config.origin}/oauth/mastodon/callback?code=code`,
|
||||
{
|
||||
headers: {
|
||||
'Tailscale-User-Login': config.allowedLogin,
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(checkAccess(request, config)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'POST',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'DELETE',
|
||||
])('requires exact Origin for %s even with same-origin Fetch Metadata', (method) => {
|
||||
const headers = {
|
||||
'Tailscale-User-Login': config.allowedLogin,
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
}
|
||||
expect(
|
||||
checkAccess(new Request(config.origin, { method, headers }), config)
|
||||
?.status,
|
||||
).toBe(403)
|
||||
expect(
|
||||
checkAccess(
|
||||
new Request(config.origin, {
|
||||
method,
|
||||
headers: { ...headers, Origin: 'https://other.invalid' },
|
||||
}),
|
||||
config,
|
||||
)?.status,
|
||||
).toBe(403)
|
||||
expect(
|
||||
checkAccess(
|
||||
new Request(config.origin, {
|
||||
method,
|
||||
headers: { ...headers, Origin: config.origin },
|
||||
}),
|
||||
config,
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
.connection-manager {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.connection-manager-description,
|
||||
.connection-manager-note {
|
||||
margin: 0;
|
||||
color: #aab8c2;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.connection-manager-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.connection-manager-list > li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding-block: 0.75rem;
|
||||
border-bottom: 1px solid #38444d;
|
||||
}
|
||||
.connection-manager-account {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.connection-manager-account > span {
|
||||
font-size: 0.8rem;
|
||||
color: #aab8c2;
|
||||
}
|
||||
.connection-manager-account > .connection-manager-status-connected {
|
||||
color: #71d6ad;
|
||||
}
|
||||
.connection-manager-account > .connection-manager-status-expired {
|
||||
color: #f2c66d;
|
||||
}
|
||||
.connection-manager-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.connection-manager-add {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.connection-manager-add h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.connection-manager select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: #15202b;
|
||||
color: #e7e9ea;
|
||||
border: 1px solid #536471;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
.connection-manager button {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.connection-manager-list > li {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ConnectionManager } from './connection-manager'
|
||||
import type { Connection } from './model'
|
||||
|
||||
const actions = vi.hoisted(() => ({
|
||||
load: vi.fn(),
|
||||
authorize: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
|
||||
vi.mock('../mastodon/server-functions', () => ({
|
||||
loadMastodonInstances: actions.load,
|
||||
startMastodonOAuth: actions.authorize,
|
||||
disconnectMastodonAccount: actions.disconnect,
|
||||
}))
|
||||
|
||||
const account: Connection = {
|
||||
id: 'mastodon-first',
|
||||
platform: 'mastodon',
|
||||
origin: 'https://mastodon.invalid',
|
||||
accountId: '1',
|
||||
displayName: '@first',
|
||||
status: 'connected',
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
actions.load.mockResolvedValue({
|
||||
origins: ['https://mastodon.invalid', 'https://second.invalid'],
|
||||
})
|
||||
})
|
||||
|
||||
function show(
|
||||
connections: Connection[] = [account],
|
||||
onChanged = vi.fn(),
|
||||
hasTemporaryDecks = false,
|
||||
) {
|
||||
return render(
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
}
|
||||
>
|
||||
<ConnectionManager
|
||||
connections={connections}
|
||||
onChanged={onChanged}
|
||||
hasTemporaryDecks={hasTemporaryDecks}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('shows per-account status and warns about temporary decks without blocking actions', async () => {
|
||||
show(
|
||||
[
|
||||
account,
|
||||
{ ...account, id: 'expired', displayName: '@expired', status: 'expired' },
|
||||
],
|
||||
vi.fn(),
|
||||
true,
|
||||
)
|
||||
expect(screen.getByText('接続済み')).toBeVisible()
|
||||
expect(screen.getByText('再接続が必要')).toBeVisible()
|
||||
expect(screen.getByText(/一時デッキは消えます/)).toBeVisible()
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Mastodonで認可する' }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
it('uses the selected instance and reports a safe error when authorization fails', async () => {
|
||||
actions.authorize.mockRejectedValue(
|
||||
new Error('private server credential detail'),
|
||||
)
|
||||
show()
|
||||
fireEvent.change(await screen.findByLabelText('Mastodonサーバー'), {
|
||||
target: { value: 'https://second.invalid' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Mastodonで認可する' }))
|
||||
await waitFor(() =>
|
||||
expect(actions.authorize).toHaveBeenCalledWith({
|
||||
data: { origin: 'https://second.invalid' },
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'認可を開始できませんでした',
|
||||
)
|
||||
expect(screen.queryByText(/private server/)).toBeNull()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Mastodonで認可する' }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
it('binds reconnect to the selected account rather than whichever instance is selected for addition', async () => {
|
||||
actions.authorize.mockRejectedValue(new Error('offline'))
|
||||
show()
|
||||
await screen.findByLabelText('Mastodonサーバー')
|
||||
fireEvent.click(screen.getByRole('button', { name: '@firstを再接続' }))
|
||||
await waitFor(() =>
|
||||
expect(actions.authorize).toHaveBeenCalledWith({
|
||||
data: { origin: account.origin, connectionId: account.id },
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByRole('alert')).toBeVisible()
|
||||
})
|
||||
|
||||
it('refreshes the account list after successful disconnect', async () => {
|
||||
const onChanged = vi.fn().mockResolvedValue(undefined)
|
||||
actions.disconnect.mockResolvedValue({ disconnected: true })
|
||||
show([account], onChanged)
|
||||
fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' }))
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce())
|
||||
expect(actions.disconnect).toHaveBeenCalledWith({
|
||||
data: { connectionId: account.id },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps account controls available after a failed disconnect', async () => {
|
||||
actions.disconnect.mockRejectedValue(new Error('private token'))
|
||||
const onChanged = vi.fn()
|
||||
show([account], onChanged)
|
||||
fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'接続を解除できませんでした',
|
||||
)
|
||||
expect(onChanged).not.toHaveBeenCalled()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '@firstの接続を解除' }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
it('does not offer Mastodon reconnect or revoke for Twitter relay accounts', async () => {
|
||||
show([{ ...account, platform: 'twitter', id: 'twitter-first' }])
|
||||
await screen.findByLabelText('Mastodonサーバー')
|
||||
expect(screen.queryByRole('button', { name: '@firstを再接続' })).toBeNull()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '@firstの接続を解除' }),
|
||||
).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import { useId, useState } from 'react'
|
||||
import {
|
||||
disconnectMastodonAccount,
|
||||
loadMastodonInstances,
|
||||
startMastodonOAuth,
|
||||
} from '../mastodon/server-functions'
|
||||
import type { Connection } from './model'
|
||||
import './connection-manager.css'
|
||||
|
||||
const statusNames = {
|
||||
connected: '接続済み',
|
||||
disconnected: '未接続',
|
||||
expired: '再接続が必要',
|
||||
}
|
||||
|
||||
export function ConnectionManager({
|
||||
connections,
|
||||
onChanged,
|
||||
hasTemporaryDecks = false,
|
||||
}: {
|
||||
connections: Connection[]
|
||||
onChanged: () => unknown
|
||||
hasTemporaryDecks?: boolean
|
||||
}) {
|
||||
const fetchInstances = useServerFn(loadMastodonInstances)
|
||||
const authorize = useServerFn(startMastodonOAuth)
|
||||
const disconnect = useServerFn(disconnectMastodonAccount)
|
||||
const instances = useQuery({
|
||||
queryKey: ['mastodon-instances'],
|
||||
queryFn: () => fetchInstances(),
|
||||
retry: false,
|
||||
})
|
||||
const [selected, setSelected] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const selectId = useId()
|
||||
const origin = selected || instances.data?.origins[0] || ''
|
||||
|
||||
async function connect(origin: string, connectionId?: string) {
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const result = await authorize({
|
||||
data: { origin, ...(connectionId ? { connectionId } : {}) },
|
||||
})
|
||||
window.location.assign(result.authorizationUrl)
|
||||
} catch {
|
||||
setError(
|
||||
'Mastodonの認可を開始できませんでした。接続先を確認して再試行してください。',
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(connectionId: string) {
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await disconnect({ data: { connectionId } })
|
||||
try {
|
||||
await onChanged()
|
||||
} catch {
|
||||
setError(
|
||||
'接続は解除しましたが、一覧を更新できませんでした。画面を再読み込みしてください。',
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setError('接続を解除できませんでした。時間をおいて再試行してください。')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="connection-manager" aria-label="接続アカウント管理">
|
||||
<p className="connection-manager-description">
|
||||
追加したアカウントは、どの端末のカラムからも選べます。
|
||||
</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{connections.length === 0 ? (
|
||||
<p>接続アカウントはまだありません。</p>
|
||||
) : (
|
||||
<ul className="connection-manager-list">
|
||||
{connections.map((connection) => (
|
||||
<li key={connection.id}>
|
||||
<div className="connection-manager-account">
|
||||
<strong>{connection.displayName}</strong>
|
||||
<span>
|
||||
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
|
||||
{new URL(connection.origin).hostname}
|
||||
</span>
|
||||
<span
|
||||
className={`connection-manager-status connection-manager-status-${connection.status}`}
|
||||
>
|
||||
{statusNames[connection.status]}
|
||||
</span>
|
||||
</div>
|
||||
{connection.platform === 'mastodon' && (
|
||||
<div className="connection-manager-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-label={`${connection.displayName}を再接続`}
|
||||
onClick={() =>
|
||||
void connect(connection.origin, connection.id)
|
||||
}
|
||||
>
|
||||
再接続
|
||||
</button>
|
||||
{connection.status !== 'disconnected' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-label={`${connection.displayName}の接続を解除`}
|
||||
onClick={() => void remove(connection.id)}
|
||||
>
|
||||
接続解除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
className="connection-manager-add"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (origin) void connect(origin)
|
||||
}}
|
||||
>
|
||||
<h3>Mastodonアカウントを追加</h3>
|
||||
{instances.isPending ? (
|
||||
<p role="status">接続先を確認しています…</p>
|
||||
) : instances.isError ? (
|
||||
<p role="alert">
|
||||
接続先を取得できませんでした。
|
||||
<button type="button" onClick={() => void instances.refetch()}>
|
||||
再試行
|
||||
</button>
|
||||
</p>
|
||||
) : instances.data.origins.length === 0 ? (
|
||||
<p>利用するMastodonサーバーがまだ設定されていません。</p>
|
||||
) : (
|
||||
<>
|
||||
<label htmlFor={selectId}>Mastodonサーバー</label>
|
||||
<select
|
||||
id={selectId}
|
||||
value={origin}
|
||||
onChange={(event) => setSelected(event.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
{instances.data.origins.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{new URL(value).hostname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" disabled={busy || !origin}>
|
||||
Mastodonで認可する
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{hasTemporaryDecks && (
|
||||
<p className="connection-manager-note">
|
||||
認可画面へ移動すると、一時デッキは消えます。残したいデッキは先に保存してください。
|
||||
</p>
|
||||
)}
|
||||
{busy && <p role="status">処理しています…</p>}
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { z } from 'zod'
|
||||
|
||||
const envelopeSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
keyId: z.string(),
|
||||
iv: z.string(),
|
||||
tag: z.string(),
|
||||
ciphertext: z.string(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
function decodeBase64(value: string): Buffer {
|
||||
const result = Buffer.from(value, 'base64')
|
||||
if (result.toString('base64') !== value) throw new Error('Invalid encoding')
|
||||
return result
|
||||
}
|
||||
|
||||
function loadKey() {
|
||||
const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE
|
||||
if (!path) throw new Error('TWITTER_LITE_CREDENTIAL_KEY_FILE is required.')
|
||||
let encoded: string
|
||||
try {
|
||||
encoded = readFileSync(path, 'utf8').trim()
|
||||
} catch {
|
||||
throw new Error('Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.')
|
||||
}
|
||||
let key: Buffer
|
||||
try {
|
||||
key = decodeBase64(encoded)
|
||||
if (key.length !== 32) throw new Error('Invalid key length')
|
||||
} catch {
|
||||
throw new Error(
|
||||
'TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.',
|
||||
)
|
||||
}
|
||||
return {
|
||||
key,
|
||||
keyId: createHash('sha256').update(key).digest('hex').slice(0, 32),
|
||||
}
|
||||
}
|
||||
|
||||
function associatedData(context: string, keyId: string) {
|
||||
if (!context) throw new Error('A credential record and purpose are required.')
|
||||
return Buffer.from(
|
||||
JSON.stringify(['twitter-lite-credential', 1, keyId, context]),
|
||||
)
|
||||
}
|
||||
|
||||
/** Context must identify both record and purpose; persist the returned opaque JSON. */
|
||||
export function encryptCredential(plaintext: string, context: string): string {
|
||||
const { key, keyId } = loadKey()
|
||||
const iv = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
cipher.setAAD(associatedData(context, keyId))
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
])
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
keyId,
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
ciphertext: ciphertext.toString('base64'),
|
||||
})
|
||||
}
|
||||
|
||||
export function decryptCredential(serialized: string, context: string): string {
|
||||
const { key, keyId } = loadKey()
|
||||
try {
|
||||
const envelope = envelopeSchema.parse(JSON.parse(serialized))
|
||||
if (envelope.keyId !== keyId) throw new Error('Different key')
|
||||
const iv = decodeBase64(envelope.iv)
|
||||
const tag = decodeBase64(envelope.tag)
|
||||
if (iv.length !== 12 || tag.length !== 16)
|
||||
throw new Error('Invalid envelope')
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAAD(associatedData(context, keyId))
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([
|
||||
decipher.update(decodeBase64(envelope.ciphertext)),
|
||||
decipher.final(),
|
||||
]).toString('utf8')
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Credential could not be decrypted. Check the stored credential and encryption key.',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment node
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { decryptCredential, encryptCredential } from './credentials.server'
|
||||
|
||||
let directory: string
|
||||
let keyPath: string
|
||||
const context = 'connection:first:access-token'
|
||||
const secret = 'sensitive-token-秘密'
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-credentials-'))
|
||||
keyPath = join(directory, 'key')
|
||||
writeFileSync(keyPath, `${randomBytes(32).toString('base64')}\n`, {
|
||||
mode: 0o600,
|
||||
})
|
||||
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', keyPath)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('round-trips credentials with distinct randomized ciphertext and no plaintext', () => {
|
||||
const first = encryptCredential(secret, context)
|
||||
const second = encryptCredential(secret, context)
|
||||
expect(first).not.toBe(second)
|
||||
expect(first).not.toContain(secret)
|
||||
expect(first).not.toContain(Buffer.from(secret).toString('base64'))
|
||||
expect(JSON.parse(first)).toMatchObject({
|
||||
version: 1,
|
||||
keyId: expect.stringMatching(/^[a-f0-9]{32}$/),
|
||||
})
|
||||
expect(decryptCredential(first, context)).toBe(secret)
|
||||
expect(decryptCredential(second, context)).toBe(secret)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'connection:second:access-token',
|
||||
'oauth-app:first:client-secret',
|
||||
])('rejects ciphertext moved to another record or purpose: %s', (otherContext) => {
|
||||
const stored = encryptCredential(secret, context)
|
||||
expect(() => decryptCredential(stored, otherContext)).toThrow(
|
||||
'Credential could not be decrypted.',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'iv',
|
||||
'tag',
|
||||
'ciphertext',
|
||||
'keyId',
|
||||
'version',
|
||||
])('rejects tampering with %s without disclosing the secret', (field) => {
|
||||
const envelope = JSON.parse(encryptCredential(secret, context))
|
||||
envelope[field] = 'tampered'
|
||||
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
|
||||
'Credential could not be decrypted. Check the stored credential and encryption key.',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a validly encoded altered authentication tag', () => {
|
||||
const envelope = JSON.parse(encryptCredential(secret, context))
|
||||
const tag = Buffer.from(envelope.tag, 'base64')
|
||||
tag[0] = (tag[0] ?? 0) ^ 1
|
||||
envelope.tag = tag.toString('base64')
|
||||
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
|
||||
'Credential could not be decrypted.',
|
||||
)
|
||||
})
|
||||
|
||||
it('requires the original key after restart or restore', () => {
|
||||
const originalKey = readFileSync(keyPath)
|
||||
const stored = encryptCredential(secret, context)
|
||||
writeFileSync(keyPath, randomBytes(32).toString('base64'))
|
||||
expect(() => decryptCredential(stored, context)).toThrow(
|
||||
'Credential could not be decrypted.',
|
||||
)
|
||||
writeFileSync(keyPath, originalKey)
|
||||
expect(decryptCredential(stored, context)).toBe(secret)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'not-json',
|
||||
'{}',
|
||||
'{"version":2}',
|
||||
])('rejects malformed or unsupported stored credentials: %s', (serialized) => {
|
||||
expect(() => decryptCredential(serialized, context)).toThrow(
|
||||
'Credential could not be decrypted.',
|
||||
)
|
||||
})
|
||||
|
||||
it('requires an explicitly configured credential key', () => {
|
||||
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', '')
|
||||
expect(() => encryptCredential(secret, context)).toThrow(
|
||||
'TWITTER_LITE_CREDENTIAL_KEY_FILE is required.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unreadable key without including the path or plaintext', () => {
|
||||
rmSync(keyPath)
|
||||
expect(() => encryptCredential(secret, context)).toThrow(
|
||||
'Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'this is not base64',
|
||||
Buffer.alloc(16).toString('base64'),
|
||||
])('rejects invalid key material', (encoded) => {
|
||||
writeFileSync(keyPath, encoded)
|
||||
expect(() => encryptCredential(secret, context)).toThrow(
|
||||
'must contain 32 random bytes encoded as base64.',
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses encryption without record binding', () => {
|
||||
expect(() => encryptCredential(secret, '')).toThrow(
|
||||
'A credential record and purpose are required.',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const connectionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
platform: z.enum(['twitter', 'mastodon']),
|
||||
origin: z.string().url(),
|
||||
accountId: z.string().nullable(),
|
||||
displayName: z.string(),
|
||||
status: z.enum(['connected', 'disconnected', 'expired']),
|
||||
})
|
||||
|
||||
export type Connection = z.infer<typeof connectionSchema>
|
||||
@@ -0,0 +1,125 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { asc, eq } from 'drizzle-orm'
|
||||
import { ProfileUnavailableError } from '../profiles/errors'
|
||||
import { fetchProfileNames } from '../profiles/profile-service.server'
|
||||
import { type AppDatabase, getDatabase } from '../storage/database.server'
|
||||
import { connections } from '../storage/schema'
|
||||
import type { Connection } from './model'
|
||||
|
||||
function relayOrigin(): string {
|
||||
const configured = process.env.TWITTER_RELAY_BASE_URL
|
||||
if (!configured)
|
||||
throw new ProfileUnavailableError(
|
||||
'TWITTER_RELAY_BASE_URL を設定してください。',
|
||||
)
|
||||
return new URL(configured).origin
|
||||
}
|
||||
|
||||
async function syncTwitterConnections(
|
||||
database: AppDatabase,
|
||||
fetchProfiles: typeof fetchProfileNames,
|
||||
) {
|
||||
const origin = relayOrigin()
|
||||
const profiles = new Set(
|
||||
await fetchProfiles(origin).catch(() => {
|
||||
throw new ProfileUnavailableError(
|
||||
'接続プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。',
|
||||
)
|
||||
}),
|
||||
)
|
||||
database.transaction((tx) => {
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(connections)
|
||||
.where(eq(connections.platform, 'twitter'))
|
||||
.all()
|
||||
const now = Date.now()
|
||||
for (const connection of existing) {
|
||||
const status =
|
||||
connection.origin === origin &&
|
||||
connection.relayProfile !== null &&
|
||||
profiles.has(connection.relayProfile)
|
||||
? 'connected'
|
||||
: 'disconnected'
|
||||
if (connection.status !== status) {
|
||||
tx.update(connections)
|
||||
.set({ status, updatedAt: now })
|
||||
.where(eq(connections.id, connection.id))
|
||||
.run()
|
||||
}
|
||||
}
|
||||
const known = new Set(
|
||||
existing
|
||||
.filter((connection) => connection.origin === origin)
|
||||
.map((connection) => connection.relayProfile),
|
||||
)
|
||||
for (const profile of profiles) {
|
||||
if (known.has(profile)) continue
|
||||
tx.insert(connections)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
platform: 'twitter',
|
||||
origin,
|
||||
relayProfile: profile,
|
||||
displayName: profile,
|
||||
status: 'connected',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
})
|
||||
return origin
|
||||
}
|
||||
|
||||
const publicFields = {
|
||||
id: connections.id,
|
||||
platform: connections.platform,
|
||||
origin: connections.origin,
|
||||
accountId: connections.accountId,
|
||||
displayName: connections.displayName,
|
||||
status: connections.status,
|
||||
}
|
||||
|
||||
export async function listConnections(
|
||||
database = getDatabase(),
|
||||
fetchProfiles = fetchProfileNames,
|
||||
): Promise<{ connections: Connection[]; relayError?: string }> {
|
||||
let relayError: string | undefined
|
||||
try {
|
||||
await syncTwitterConnections(database, fetchProfiles)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProfileUnavailableError)) throw error
|
||||
relayError = error.message
|
||||
}
|
||||
const saved = database
|
||||
.select(publicFields)
|
||||
.from(connections)
|
||||
.orderBy(asc(connections.createdAt), asc(connections.id))
|
||||
.all()
|
||||
return { connections: saved, ...(relayError ? { relayError } : {}) }
|
||||
}
|
||||
|
||||
export async function requireTwitterConnection(
|
||||
id: string,
|
||||
database = getDatabase(),
|
||||
fetchProfiles = fetchProfileNames,
|
||||
): Promise<string> {
|
||||
const origin = await syncTwitterConnections(database, fetchProfiles)
|
||||
const connection = database
|
||||
.select()
|
||||
.from(connections)
|
||||
.where(eq(connections.id, id))
|
||||
.get()
|
||||
if (
|
||||
connection?.platform !== 'twitter' ||
|
||||
connection.origin !== origin ||
|
||||
connection.status !== 'connected' ||
|
||||
!connection.relayProfile
|
||||
) {
|
||||
throw new ProfileUnavailableError(
|
||||
'この接続は利用できません。カラムの接続アカウントを確認してください。',
|
||||
)
|
||||
}
|
||||
return connection.relayProfile
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { type AppDatabase, openDatabase } from '../storage/database.server'
|
||||
import { connectionCredentials, connections } from '../storage/schema'
|
||||
import { listConnections, requireTwitterConnection } from './repository.server'
|
||||
|
||||
let database: AppDatabase
|
||||
beforeEach(() => {
|
||||
database = openDatabase(':memory:')
|
||||
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid/')
|
||||
})
|
||||
afterEach(() => {
|
||||
database.$client.close()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('retains stable distinct IDs for multiple relay profiles across repeated discovery', async () => {
|
||||
const fetchProfiles = vi.fn().mockResolvedValue(['first', 'second', 'first'])
|
||||
const { connections: first } = await listConnections(database, fetchProfiles)
|
||||
const { connections: second } = await listConnections(database, fetchProfiles)
|
||||
expect(first).toHaveLength(2)
|
||||
expect(new Set(first.map((connection) => connection.id)).size).toBe(2)
|
||||
expect(second).toEqual(first)
|
||||
expect(
|
||||
await requireTwitterConnection(first[0]?.id ?? '', database, fetchProfiles),
|
||||
).toBe(first[0]?.displayName)
|
||||
expect(fetchProfiles).toHaveBeenCalledWith('https://relay.invalid')
|
||||
})
|
||||
|
||||
it('preserves unavailable bindings and reconnects the original ID when a profile returns', async () => {
|
||||
const { connections: original } = await listConnections(
|
||||
database,
|
||||
async () => ['first'],
|
||||
)
|
||||
const id = original[0]?.id ?? ''
|
||||
expect((await listConnections(database, async () => [])).connections).toEqual(
|
||||
[expect.objectContaining({ id, status: 'disconnected' })],
|
||||
)
|
||||
await expect(
|
||||
requireTwitterConnection(id, database, async () => []),
|
||||
).rejects.toThrow('この接続は利用できません')
|
||||
expect(
|
||||
(await listConnections(database, async () => ['first'])).connections,
|
||||
).toEqual(original)
|
||||
})
|
||||
|
||||
it('does not reuse an old relay binding for the same profile at another origin', async () => {
|
||||
const { connections: original } = await listConnections(
|
||||
database,
|
||||
async () => ['first'],
|
||||
)
|
||||
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://another-relay.invalid')
|
||||
const { connections: current } = await listConnections(database, async () => [
|
||||
'first',
|
||||
])
|
||||
expect(current).toHaveLength(2)
|
||||
expect(current).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: original[0]?.id, status: 'disconnected' }),
|
||||
expect.objectContaining({
|
||||
origin: 'https://another-relay.invalid',
|
||||
status: 'connected',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
await expect(
|
||||
requireTwitterConnection(original[0]?.id ?? '', database, async () => [
|
||||
'first',
|
||||
]),
|
||||
).rejects.toThrow('この接続は利用できません')
|
||||
})
|
||||
|
||||
it('does not convert a relay outage into persisted account removal', async () => {
|
||||
const original = await listConnections(database, async () => ['first'])
|
||||
const unavailable = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('private network detail'))
|
||||
const failed = await listConnections(database, unavailable)
|
||||
expect(failed.connections).toEqual(original.connections)
|
||||
expect(failed.relayError).toContain(
|
||||
'Relay への接続を確認して再試行してください。',
|
||||
)
|
||||
expect(database.select().from(connections).get()?.status).toBe('connected')
|
||||
expect(await listConnections(database, async () => ['first'])).toEqual(
|
||||
original,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns public metadata for all platforms without joining or exposing credentials', async () => {
|
||||
database
|
||||
.insert(connections)
|
||||
.values({
|
||||
id: 'mastodon-account',
|
||||
platform: 'mastodon',
|
||||
origin: 'https://mastodon.invalid',
|
||||
accountId: '42',
|
||||
displayName: '@owner',
|
||||
status: 'connected',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})
|
||||
.run()
|
||||
database
|
||||
.insert(connectionCredentials)
|
||||
.values({
|
||||
connectionId: 'mastodon-account',
|
||||
encryptedToken: 'stored-secret-envelope',
|
||||
updatedAt: 1,
|
||||
})
|
||||
.run()
|
||||
const { connections: discovered } = await listConnections(
|
||||
database,
|
||||
async () => ['first'],
|
||||
)
|
||||
expect(discovered[0]).toEqual({
|
||||
id: 'mastodon-account',
|
||||
platform: 'mastodon',
|
||||
origin: 'https://mastodon.invalid',
|
||||
accountId: '42',
|
||||
displayName: '@owner',
|
||||
status: 'connected',
|
||||
})
|
||||
expect(Object.keys(discovered[1] ?? {}).sort()).toEqual([
|
||||
'accountId',
|
||||
'displayName',
|
||||
'id',
|
||||
'origin',
|
||||
'platform',
|
||||
'status',
|
||||
])
|
||||
expect(JSON.stringify(discovered)).not.toContain('stored-secret-envelope')
|
||||
const outage = await listConnections(database, async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
expect(outage.connections).toEqual(discovered)
|
||||
expect(outage.relayError).toBeDefined()
|
||||
await expect(
|
||||
requireTwitterConnection('mastodon-account', database, async () => [
|
||||
'first',
|
||||
]),
|
||||
).rejects.toThrow('この接続は利用できません')
|
||||
expect(
|
||||
database
|
||||
.select()
|
||||
.from(connections)
|
||||
.where(eq(connections.id, 'mastodon-account'))
|
||||
.get()?.status,
|
||||
).toBe('connected')
|
||||
})
|
||||
|
||||
it('rejects an unknown connection without treating its ID as a relay profile', async () => {
|
||||
await expect(
|
||||
requireTwitterConnection('first', database, async () => ['first']),
|
||||
).rejects.toThrow('この接続は利用できません')
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
|
||||
export const loadConnections = createServerFn({ method: 'GET' }).handler(
|
||||
async () => {
|
||||
const { listConnections } = await import('./repository.server')
|
||||
return listConnections()
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { Connection } from '../connections/model'
|
||||
import { ColumnEditor } from './column-editor'
|
||||
|
||||
const lists = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
|
||||
vi.mock('#/features/platforms/mastodon-server-functions', () => ({
|
||||
loadMastodonLists: lists,
|
||||
}))
|
||||
vi.mock('#/features/posts/use-list-choices', () => ({
|
||||
useListChoices: () => ({
|
||||
isPending: false,
|
||||
isError: false,
|
||||
data: [],
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
const connections: Connection[] = [
|
||||
{
|
||||
id: 'twitter',
|
||||
platform: 'twitter',
|
||||
origin: 'https://relay.invalid',
|
||||
accountId: null,
|
||||
displayName: 'Twitter account',
|
||||
status: 'connected',
|
||||
},
|
||||
{
|
||||
id: 'mastodon-first',
|
||||
platform: 'mastodon',
|
||||
origin: 'https://mastodon.invalid',
|
||||
accountId: '1',
|
||||
displayName: '@first',
|
||||
status: 'connected',
|
||||
},
|
||||
{
|
||||
id: 'mastodon-second',
|
||||
platform: 'mastodon',
|
||||
origin: 'https://mastodon.invalid',
|
||||
accountId: '2',
|
||||
displayName: '@second',
|
||||
status: 'connected',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
lists
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
async ({ data }: { data: { connectionId: string } }) => ({
|
||||
lists: [
|
||||
{
|
||||
id: data.connectionId === 'mastodon-first' ? '10' : '20',
|
||||
name: data.connectionId,
|
||||
isPrivate: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function show() {
|
||||
const onSave = vi.fn().mockResolvedValue(true)
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
}
|
||||
>
|
||||
<ColumnEditor
|
||||
connections={connections}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('カラム名'), {
|
||||
target: { value: '調査' },
|
||||
})
|
||||
return onSave
|
||||
}
|
||||
|
||||
it('creates a Mastodon search without Twitter ordering or following fields', async () => {
|
||||
const save = show()
|
||||
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
|
||||
target: { value: 'mastodon-first' },
|
||||
})
|
||||
expect(screen.queryByLabelText('表示順')).toBeNull()
|
||||
expect(screen.queryByLabelText('フォロー中のみ')).toBeNull()
|
||||
expect(screen.getByText(/結果が0件でも/)).toBeVisible()
|
||||
fireEvent.change(screen.getByLabelText('Mastodonの検索条件'), {
|
||||
target: { value: 'WebMCP' },
|
||||
})
|
||||
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
|
||||
await waitFor(() =>
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionId: 'mastodon-first',
|
||||
source: { platform: 'mastodon', kind: 'search', query: 'WebMCP' },
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('creates hashtags and resets unsupported source kind when changing to Twitter', async () => {
|
||||
const save = show()
|
||||
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
|
||||
target: { value: 'mastodon-first' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('カラムの種類'), {
|
||||
target: { value: 'hashtag' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('ハッシュタグ'), {
|
||||
target: { value: 'WebMCP' },
|
||||
})
|
||||
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
|
||||
await waitFor(() =>
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' },
|
||||
}),
|
||||
),
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
|
||||
target: { value: 'twitter' },
|
||||
})
|
||||
expect(screen.getByLabelText('カラムの種類')).toHaveValue('search')
|
||||
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue('')
|
||||
expect(screen.queryByRole('option', { name: 'ハッシュタグ' })).toBeNull()
|
||||
})
|
||||
|
||||
it('loads lists for each account and clears the selected list when the binding changes', async () => {
|
||||
show()
|
||||
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
|
||||
target: { value: 'mastodon-first' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('カラムの種類'), {
|
||||
target: { value: 'list' },
|
||||
})
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' }))
|
||||
expect(screen.getByLabelText('リスト')).toHaveValue('10')
|
||||
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
|
||||
target: { value: 'mastodon-second' },
|
||||
})
|
||||
expect(screen.getByLabelText('リスト')).toHaveValue('')
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: 'mastodon-second' }),
|
||||
)
|
||||
expect(screen.getByLabelText('リスト')).toHaveValue('20')
|
||||
expect(lists).toHaveBeenCalledWith({
|
||||
data: { connectionId: 'mastodon-second' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves title and Twitter search options after a rejected save', async () => {
|
||||
const save = show()
|
||||
save.mockResolvedValue(false)
|
||||
fireEvent.change(screen.getByLabelText('Twitterの検索条件'), {
|
||||
target: { value: 'from:owner WebMCP' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('表示順'), {
|
||||
target: { value: 'Top' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
|
||||
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledOnce())
|
||||
expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
|
||||
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue(
|
||||
'from:owner WebMCP',
|
||||
)
|
||||
expect(screen.getByLabelText('表示順')).toHaveValue('Top')
|
||||
expect(screen.getByLabelText('フォロー中のみ')).toBeChecked()
|
||||
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
|
||||
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
? '投稿を取得しています…'
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
>
|
||||
削除する
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getDatabase } from '../storage/database.server'
|
||||
import { connections, legacyImports } from '../storage/schema'
|
||||
import { legacyWorkspaceSchema } from './legacy-import'
|
||||
import {
|
||||
createDeck,
|
||||
DeckPersistenceError,
|
||||
listDecks,
|
||||
} from './repository.server'
|
||||
|
||||
export function importLegacyWorkspace(
|
||||
input: unknown,
|
||||
database = getDatabase(),
|
||||
relayOrigin = new URL(process.env.TWITTER_RELAY_BASE_URL ?? '').origin,
|
||||
) {
|
||||
const legacy = legacyWorkspaceSchema.parse(input)
|
||||
const payloadHash = createHash('sha256')
|
||||
.update(JSON.stringify(legacy.decks))
|
||||
.digest('hex')
|
||||
return database.transaction(
|
||||
() => {
|
||||
const marker = database
|
||||
.select()
|
||||
.from(legacyImports)
|
||||
.where(eq(legacyImports.id, 'local-storage-v2'))
|
||||
.get()
|
||||
if (marker) {
|
||||
if (marker.payloadHash !== payloadHash)
|
||||
throw new DeckPersistenceError(
|
||||
'conflict',
|
||||
'旧デッキはすでに取り込み済みです。別の内容で上書きできません。',
|
||||
)
|
||||
return listDecks(database)
|
||||
}
|
||||
const available = database
|
||||
.select()
|
||||
.from(connections)
|
||||
.where(
|
||||
and(
|
||||
eq(connections.platform, 'twitter'),
|
||||
eq(connections.origin, relayOrigin),
|
||||
eq(connections.status, 'connected'),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
const imported = legacy.decks.map((deck) =>
|
||||
createDeck(
|
||||
{
|
||||
...deck,
|
||||
id: randomUUID(),
|
||||
columns: deck.columns.map(({ profileName, ...column }) => {
|
||||
const connection = available.find(
|
||||
(connection) => connection.relayProfile === profileName,
|
||||
)
|
||||
if (!connection)
|
||||
throw new DeckPersistenceError(
|
||||
'connection-unavailable',
|
||||
`旧プロファイル「${profileName}」に対応する接続がありません。接続一覧を更新してください。`,
|
||||
)
|
||||
return { ...column, connectionId: connection.id }
|
||||
}),
|
||||
},
|
||||
database,
|
||||
),
|
||||
)
|
||||
database
|
||||
.insert(legacyImports)
|
||||
.values({
|
||||
id: 'local-storage-v2',
|
||||
payloadHash,
|
||||
deckIds: imported.map((deck) => deck.id),
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run()
|
||||
return listDecks(database)
|
||||
},
|
||||
{ behavior: 'immediate' },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { type AppDatabase, openDatabase } from '../storage/database.server'
|
||||
import { connections, legacyImports } from '../storage/schema'
|
||||
import { importLegacyWorkspace } from './import.server'
|
||||
import { createDeck, listDecks } from './repository.server'
|
||||
|
||||
let database: AppDatabase
|
||||
const legacy = {
|
||||
version: 2,
|
||||
activeDeckId: 'same-id',
|
||||
decks: [
|
||||
{
|
||||
id: 'same-id',
|
||||
title: 'Legacy',
|
||||
columns: [
|
||||
{
|
||||
id: 'a',
|
||||
title: 'Account',
|
||||
profileName: 'old-profile',
|
||||
source: { platform: 'twitter', kind: 'user', target: 'alice' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
beforeEach(() => {
|
||||
database = openDatabase(':memory:')
|
||||
database
|
||||
.insert(connections)
|
||||
.values({
|
||||
id: 'new-connection-id',
|
||||
platform: 'twitter',
|
||||
origin: 'https://relay.invalid',
|
||||
relayProfile: 'old-profile',
|
||||
displayName: 'Old account',
|
||||
status: 'connected',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
afterEach(() => database.$client.close())
|
||||
it('imports legacy profiles as connection bindings without overwriting existing same-ID decks', () => {
|
||||
const existing = createDeck(
|
||||
{ id: 'same-id', title: 'Existing server deck', columns: [] },
|
||||
database,
|
||||
)
|
||||
const result = importLegacyWorkspace(
|
||||
legacy,
|
||||
database,
|
||||
'https://relay.invalid',
|
||||
)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.find((deck) => deck.id === existing.id)).toEqual(existing)
|
||||
const imported = result.find((deck) => deck.title === 'Legacy')
|
||||
expect(imported?.id).not.toBe('same-id')
|
||||
expect(imported?.columns[0]).toEqual({
|
||||
id: 'a',
|
||||
title: 'Account',
|
||||
connectionId: 'new-connection-id',
|
||||
source: { platform: 'twitter', kind: 'user', target: 'alice' },
|
||||
})
|
||||
expect(
|
||||
importLegacyWorkspace(legacy, database, 'https://relay.invalid'),
|
||||
).toEqual(result)
|
||||
})
|
||||
it('blocks a different browser snapshot after the one-time import', () => {
|
||||
const result = importLegacyWorkspace(
|
||||
legacy,
|
||||
database,
|
||||
'https://relay.invalid',
|
||||
)
|
||||
expect(() =>
|
||||
importLegacyWorkspace(
|
||||
{ ...legacy, decks: [{ ...legacy.decks[0], title: 'Different' }] },
|
||||
database,
|
||||
'https://relay.invalid',
|
||||
),
|
||||
).toThrow('旧デッキはすでに取り込み済みです')
|
||||
expect(listDecks(database)).toEqual(result)
|
||||
})
|
||||
it('rolls back all imported decks and marker when any profile is unavailable', () => {
|
||||
const second = {
|
||||
...legacy.decks[0],
|
||||
id: 'second',
|
||||
columns: [{ ...legacy.decks[0]?.columns[0], profileName: 'missing' }],
|
||||
}
|
||||
expect(() =>
|
||||
importLegacyWorkspace(
|
||||
{ ...legacy, decks: [...legacy.decks, second] },
|
||||
database,
|
||||
'https://relay.invalid',
|
||||
),
|
||||
).toThrow('対応する接続がありません')
|
||||
expect(listDecks(database)).toEqual([])
|
||||
expect(database.select().from(legacyImports).all()).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod'
|
||||
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
|
||||
|
||||
export const LEGACY_STORAGE_KEY = 'twitter-lite-research-deck'
|
||||
const legacyColumn = columnSchema
|
||||
.omit({ connectionId: true })
|
||||
.extend({ profileName: z.string().min(1) })
|
||||
const legacyDeck = z
|
||||
.object({
|
||||
id: deckSchema.shape.id,
|
||||
title: deckSchema.shape.title,
|
||||
columns: z.array(legacyColumn).max(MAX_COLUMNS),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(deck) =>
|
||||
new Set(deck.columns.map((column) => column.id)).size ===
|
||||
deck.columns.length,
|
||||
'カラム ID が重複しています。',
|
||||
)
|
||||
export const legacyWorkspaceSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
activeDeckId: z.string(),
|
||||
decks: z.array(legacyDeck).min(1).max(100),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) =>
|
||||
new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
|
||||
'デッキ ID が重複しています。',
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import { type ReactElement, useId } from 'react'
|
||||
import { loadMastodonLists } from '../platforms/mastodon-server-functions'
|
||||
import type { MastodonSource } from '../platforms/mastodon-source'
|
||||
|
||||
type EditorProps<T extends MastodonSource = MastodonSource> = {
|
||||
source: T
|
||||
onChange: (source: MastodonSource) => void
|
||||
}
|
||||
|
||||
export function defaultMastodonSource(
|
||||
kind: MastodonSource['kind'] = 'search',
|
||||
): MastodonSource {
|
||||
switch (kind) {
|
||||
case 'search':
|
||||
return { platform: 'mastodon', kind, query: '' }
|
||||
case 'user':
|
||||
return { platform: 'mastodon', kind, target: '' }
|
||||
case 'list':
|
||||
return { platform: 'mastodon', kind, target: '' }
|
||||
case 'hashtag':
|
||||
return { platform: 'mastodon', kind, target: '' }
|
||||
}
|
||||
}
|
||||
|
||||
export function MastodonSourceEditor({
|
||||
source,
|
||||
connectionId,
|
||||
onChange,
|
||||
}: EditorProps & { connectionId: string }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={id}>
|
||||
カラムの種類
|
||||
<select
|
||||
id={id}
|
||||
value={source.kind}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
defaultMastodonSource(
|
||||
event.target.value as MastodonSource['kind'],
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="search">検索</option>
|
||||
<option value="user">ユーザー投稿</option>
|
||||
<option value="list">リスト</option>
|
||||
<option value="hashtag">ハッシュタグ</option>
|
||||
</select>
|
||||
</label>
|
||||
<MastodonSourceFields
|
||||
source={source}
|
||||
connectionId={connectionId}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MastodonSourceFields({
|
||||
source,
|
||||
connectionId,
|
||||
onChange,
|
||||
}: EditorProps & { connectionId: string }): ReactElement {
|
||||
switch (source.kind) {
|
||||
case 'search':
|
||||
return <MastodonSearchEditor source={source} onChange={onChange} />
|
||||
case 'user':
|
||||
return <MastodonUserEditor source={source} onChange={onChange} />
|
||||
case 'list':
|
||||
return (
|
||||
<MastodonListEditor
|
||||
source={source}
|
||||
connectionId={connectionId}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
case 'hashtag':
|
||||
return <MastodonHashtagEditor source={source} onChange={onChange} />
|
||||
}
|
||||
}
|
||||
|
||||
function MastodonSearchEditor({
|
||||
source,
|
||||
onChange,
|
||||
}: EditorProps<Extract<MastodonSource, { kind: 'search' }>>) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
Mastodonの検索条件
|
||||
<input
|
||||
id={id}
|
||||
value={source.query}
|
||||
onChange={(event) =>
|
||||
onChange({ ...source, query: event.target.value })
|
||||
}
|
||||
maxLength={512}
|
||||
placeholder="例:WebMCP"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="deck-help">
|
||||
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MastodonUserEditor({
|
||||
source,
|
||||
onChange,
|
||||
}: EditorProps<Extract<MastodonSource, { kind: 'user' }>>) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
ユーザー
|
||||
<input
|
||||
id={id}
|
||||
value={source.target}
|
||||
onChange={(event) =>
|
||||
onChange({ ...source, target: event.target.value })
|
||||
}
|
||||
placeholder="user@server またはアカウントID"
|
||||
maxLength={256}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MastodonHashtagEditor({
|
||||
source,
|
||||
onChange,
|
||||
}: EditorProps<Extract<MastodonSource, { kind: 'hashtag' }>>) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
ハッシュタグ
|
||||
<input
|
||||
id={id}
|
||||
value={source.target}
|
||||
onChange={(event) =>
|
||||
onChange({ ...source, target: event.target.value })
|
||||
}
|
||||
placeholder="# を除いたタグ名"
|
||||
maxLength={128}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MastodonListEditor({
|
||||
source,
|
||||
connectionId,
|
||||
onChange,
|
||||
}: EditorProps<Extract<MastodonSource, { kind: 'list' }>> & {
|
||||
connectionId: string
|
||||
}) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
リスト
|
||||
<input
|
||||
id={id}
|
||||
value={source.target}
|
||||
onChange={(event) =>
|
||||
onChange({ ...source, target: event.target.value })
|
||||
}
|
||||
placeholder="このアカウントのリストID"
|
||||
maxLength={32}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{connectionId && (
|
||||
<MastodonListChoices
|
||||
connectionId={connectionId}
|
||||
onSelect={(target) => onChange({ ...source, target })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MastodonListChoices({
|
||||
connectionId,
|
||||
onSelect,
|
||||
}: {
|
||||
connectionId: string
|
||||
onSelect: (target: string) => void
|
||||
}) {
|
||||
const fetchLists = useServerFn(loadMastodonLists)
|
||||
const lists = useQuery({
|
||||
queryKey: ['mastodon-lists', connectionId],
|
||||
queryFn: () => fetchLists({ data: { connectionId } }),
|
||||
retry: false,
|
||||
})
|
||||
if (lists.isPending) return <p role="status">リストを取得しています…</p>
|
||||
if (lists.isError)
|
||||
return (
|
||||
<p role="alert">
|
||||
リストを取得できませんでした。
|
||||
<button type="button" onClick={() => void lists.refetch()}>
|
||||
再試行
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
return (
|
||||
<section className="deck-list-choices" aria-label="このアカウントのリスト">
|
||||
{lists.data.lists.length === 0 ? (
|
||||
<p>このアカウントのリストはありません。</p>
|
||||
) : (
|
||||
lists.data.lists.map((list) => (
|
||||
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
|
||||
{list.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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('-')
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
import { deckSchema } from './model'
|
||||
|
||||
export const savedDeckDefinitionSchema = deckSchema
|
||||
|
||||
export const savedDeckSchema = savedDeckDefinitionSchema.safeExtend({
|
||||
revision: z.number().int().positive(),
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
updatedAt: z.number().int().nonnegative(),
|
||||
})
|
||||
export const replaceDeckInputSchema = z
|
||||
.object({
|
||||
deck: savedDeckDefinitionSchema,
|
||||
expectedRevision: z.number().int().positive(),
|
||||
})
|
||||
.strict()
|
||||
export const deleteDeckInputSchema = z
|
||||
.object({
|
||||
id: deckSchema.shape.id,
|
||||
expectedRevision: z.number().int().positive(),
|
||||
})
|
||||
.strict()
|
||||
export type SavedDeckDefinition = z.infer<typeof savedDeckDefinitionSchema>
|
||||
export type SavedDeck = z.infer<typeof savedDeckSchema>
|
||||
export type DeckPersistenceFailure = {
|
||||
ok: false
|
||||
error: {
|
||||
code:
|
||||
| 'conflict'
|
||||
| 'not-found'
|
||||
| 'connection-unavailable'
|
||||
| 'invalid-input'
|
||||
| 'storage-unavailable'
|
||||
message: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { and, asc, eq } from 'drizzle-orm'
|
||||
import { type AppDatabase, getDatabase } from '../storage/database.server'
|
||||
import { connections, deckColumns, decks } from '../storage/schema'
|
||||
import {
|
||||
type DeckPersistenceFailure,
|
||||
deleteDeckInputSchema,
|
||||
replaceDeckInputSchema,
|
||||
type SavedDeck,
|
||||
type SavedDeckDefinition,
|
||||
savedDeckDefinitionSchema,
|
||||
savedDeckSchema,
|
||||
} from './persistence-model'
|
||||
|
||||
export class DeckPersistenceError extends Error {
|
||||
constructor(
|
||||
readonly code: DeckPersistenceFailure['error']['code'],
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
const conflict = () =>
|
||||
new DeckPersistenceError(
|
||||
'conflict',
|
||||
'別の端末で変更されました。最新のデッキを読み直してください。',
|
||||
)
|
||||
const missing = () =>
|
||||
new DeckPersistenceError(
|
||||
'not-found',
|
||||
'このデッキは削除されたか、見つかりません。',
|
||||
)
|
||||
|
||||
export function loadDeck(
|
||||
id: string,
|
||||
database = getDatabase(),
|
||||
): SavedDeck | null {
|
||||
const record = database.select().from(decks).where(eq(decks.id, id)).get()
|
||||
if (!record) return null
|
||||
const columns = database
|
||||
.select({
|
||||
id: deckColumns.id,
|
||||
title: deckColumns.title,
|
||||
connectionId: deckColumns.connectionId,
|
||||
source: deckColumns.source,
|
||||
})
|
||||
.from(deckColumns)
|
||||
.where(eq(deckColumns.deckId, id))
|
||||
.orderBy(asc(deckColumns.position))
|
||||
.all()
|
||||
return savedDeckSchema.parse({ ...record, columns })
|
||||
}
|
||||
|
||||
export function listDecks(database = getDatabase()): SavedDeck[] {
|
||||
return database.transaction(() =>
|
||||
database
|
||||
.select({ id: decks.id })
|
||||
.from(decks)
|
||||
.orderBy(asc(decks.createdAt), asc(decks.id))
|
||||
.all()
|
||||
.map(({ id }) => {
|
||||
const deck = loadDeck(id, database)
|
||||
if (!deck) throw missing()
|
||||
return deck
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
|
||||
for (const column of deck.columns) {
|
||||
const connection = database
|
||||
.select({ platform: connections.platform, status: connections.status })
|
||||
.from(connections)
|
||||
.where(eq(connections.id, column.connectionId))
|
||||
.get()
|
||||
if (
|
||||
!connection ||
|
||||
connection.platform !== column.source.platform ||
|
||||
connection.status !== 'connected'
|
||||
) {
|
||||
throw new DeckPersistenceError(
|
||||
'connection-unavailable',
|
||||
'カラムの接続アカウントが利用できません。接続を確認してください。',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
|
||||
database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run()
|
||||
if (deck.columns.length)
|
||||
database
|
||||
.insert(deckColumns)
|
||||
.values(
|
||||
deck.columns.map((column, position) => ({
|
||||
...column,
|
||||
deckId: deck.id,
|
||||
position,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
export function createDeck(
|
||||
input: unknown,
|
||||
database = getDatabase(),
|
||||
): SavedDeck {
|
||||
const deck = savedDeckDefinitionSchema.parse(input)
|
||||
return database.transaction(
|
||||
() => {
|
||||
const existing = loadDeck(deck.id, database)
|
||||
if (existing) {
|
||||
const original = savedDeckDefinitionSchema.parse({
|
||||
id: existing.id,
|
||||
title: existing.title,
|
||||
columns: existing.columns,
|
||||
})
|
||||
if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict()
|
||||
return existing
|
||||
}
|
||||
validateConnections(deck, database)
|
||||
const now = Date.now()
|
||||
const result = { ...deck, revision: 1, createdAt: now, updatedAt: now }
|
||||
database
|
||||
.insert(decks)
|
||||
.values({
|
||||
id: deck.id,
|
||||
title: deck.title,
|
||||
revision: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
writeColumns(deck, database)
|
||||
return result
|
||||
},
|
||||
{ behavior: 'immediate' },
|
||||
)
|
||||
}
|
||||
|
||||
export function replaceDeck(
|
||||
input: unknown,
|
||||
database = getDatabase(),
|
||||
): SavedDeck {
|
||||
const { deck, expectedRevision } = replaceDeckInputSchema.parse(input)
|
||||
return database.transaction(
|
||||
() => {
|
||||
const existing = loadDeck(deck.id, database)
|
||||
if (!existing) throw missing()
|
||||
if (existing.revision !== expectedRevision) throw conflict()
|
||||
validateConnections(deck, database)
|
||||
const updatedAt = Date.now()
|
||||
const result = database
|
||||
.update(decks)
|
||||
.set({ title: deck.title, revision: expectedRevision + 1, updatedAt })
|
||||
.where(and(eq(decks.id, deck.id), eq(decks.revision, expectedRevision)))
|
||||
.run()
|
||||
if (result.changes !== 1) throw conflict()
|
||||
writeColumns(deck, database)
|
||||
return {
|
||||
...deck,
|
||||
revision: expectedRevision + 1,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt,
|
||||
}
|
||||
},
|
||||
{ behavior: 'immediate' },
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteDeck(input: unknown, database = getDatabase()): string {
|
||||
const { id, expectedRevision } = deleteDeckInputSchema.parse(input)
|
||||
return database.transaction(
|
||||
() => {
|
||||
if (!loadDeck(id, database)) throw missing()
|
||||
const result = database
|
||||
.delete(decks)
|
||||
.where(and(eq(decks.id, id), eq(decks.revision, expectedRevision)))
|
||||
.run()
|
||||
if (result.changes !== 1) throw conflict()
|
||||
return id
|
||||
},
|
||||
{ behavior: 'immediate' },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { type AppDatabase, openDatabase } from '../storage/database.server'
|
||||
import {
|
||||
connectionCredentials,
|
||||
connections,
|
||||
deckColumns,
|
||||
} from '../storage/schema'
|
||||
import type { SavedDeckDefinition } from './persistence-model'
|
||||
import {
|
||||
createDeck,
|
||||
deleteDeck,
|
||||
listDecks,
|
||||
loadDeck,
|
||||
replaceDeck,
|
||||
} from './repository.server'
|
||||
|
||||
let directory: string
|
||||
let database: AppDatabase
|
||||
let second: AppDatabase
|
||||
const deck: SavedDeckDefinition = {
|
||||
id: 'request-123',
|
||||
title: 'Research',
|
||||
columns: [
|
||||
{
|
||||
id: 'first',
|
||||
title: 'First',
|
||||
connectionId: 'twitter-one',
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'WebMCP',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
title: 'Second',
|
||||
connectionId: 'twitter-two',
|
||||
source: { platform: 'twitter', kind: 'user', target: 'alice' },
|
||||
},
|
||||
],
|
||||
}
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-deck-repository-'))
|
||||
database = openDatabase(join(directory, 'app.db'))
|
||||
second = openDatabase(join(directory, 'app.db'))
|
||||
database
|
||||
.insert(connections)
|
||||
.values(
|
||||
['twitter-one', 'twitter-two'].map((id) => ({
|
||||
id,
|
||||
platform: 'twitter' as const,
|
||||
origin: 'https://relay.invalid',
|
||||
relayProfile: id,
|
||||
displayName: id,
|
||||
status: 'connected' as const,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
})
|
||||
afterEach(() => {
|
||||
second.$client.close()
|
||||
database.$client.close()
|
||||
rmSync(directory, { recursive: true })
|
||||
})
|
||||
|
||||
it('shares deck order and per-column account bindings with a separate database client', () => {
|
||||
const saved = createDeck(deck, database)
|
||||
expect(saved).toMatchObject({ ...deck, revision: 1 })
|
||||
expect(loadDeck(deck.id, second)).toEqual(saved)
|
||||
expect(listDecks(second)).toEqual([saved])
|
||||
})
|
||||
|
||||
it('makes retried creation idempotent but rejects different content using the same ID', () => {
|
||||
const saved = createDeck(deck, database)
|
||||
expect(createDeck(deck, second)).toEqual(saved)
|
||||
expect(() =>
|
||||
createDeck({ ...deck, title: 'Different request' }, second),
|
||||
).toThrow('別の端末で変更されました')
|
||||
expect(listDecks(database)).toEqual([saved])
|
||||
})
|
||||
|
||||
it('replaces title, ordered columns and bindings atomically, then rejects stale edits', () => {
|
||||
const initial = createDeck(deck, database)
|
||||
const reversed = {
|
||||
...deck,
|
||||
title: 'Changed',
|
||||
columns: [...deck.columns].reverse(),
|
||||
}
|
||||
const saved = replaceDeck(
|
||||
{ deck: reversed, expectedRevision: initial.revision },
|
||||
database,
|
||||
)
|
||||
expect(saved).toMatchObject({
|
||||
...reversed,
|
||||
revision: 2,
|
||||
createdAt: initial.createdAt,
|
||||
})
|
||||
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
|
||||
'別の端末で変更されました',
|
||||
)
|
||||
expect(loadDeck(deck.id, second)).toEqual(saved)
|
||||
})
|
||||
|
||||
it('clears all columns and prevents stale deletion from another device', () => {
|
||||
createDeck(deck, database)
|
||||
const saved = replaceDeck(
|
||||
{ deck: { ...deck, columns: [] }, expectedRevision: 1 },
|
||||
database,
|
||||
)
|
||||
expect(loadDeck(deck.id, second)?.columns).toEqual([])
|
||||
expect(() =>
|
||||
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
|
||||
).toThrow('別の端末で変更されました')
|
||||
expect(loadDeck(deck.id, second)).toEqual(saved)
|
||||
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id)
|
||||
expect(loadDeck(deck.id, database)).toBeNull()
|
||||
})
|
||||
|
||||
it('cascades deleted deck columns and reports already-deleted decks', () => {
|
||||
createDeck(deck, database)
|
||||
deleteDeck({ id: deck.id, expectedRevision: 1 }, database)
|
||||
expect(database.select().from(deckColumns).all()).toEqual([])
|
||||
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
|
||||
'このデッキは削除されたか',
|
||||
)
|
||||
expect(() =>
|
||||
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
|
||||
).toThrow('このデッキは削除されたか')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'disconnected',
|
||||
'expired',
|
||||
] as const)('rejects %s connections without partially saving a deck', (status) => {
|
||||
const initial = createDeck(deck, database)
|
||||
database
|
||||
.update(connections)
|
||||
.set({ status })
|
||||
.where(eq(connections.id, 'twitter-two'))
|
||||
.run()
|
||||
expect(() =>
|
||||
replaceDeck(
|
||||
{ deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 },
|
||||
database,
|
||||
),
|
||||
).toThrow('カラムの接続アカウントが利用できません')
|
||||
expect(loadDeck(deck.id, second)).toEqual(initial)
|
||||
})
|
||||
|
||||
it('rejects a connection from another platform', () => {
|
||||
database
|
||||
.update(connections)
|
||||
.set({ platform: 'mastodon' })
|
||||
.where(eq(connections.id, 'twitter-one'))
|
||||
.run()
|
||||
expect(() => createDeck(deck, database)).toThrow(
|
||||
'カラムの接続アカウントが利用できません',
|
||||
)
|
||||
expect(listDecks(database)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects unknown connections without creating rows', () => {
|
||||
database.delete(connections).where(eq(connections.id, 'twitter-two')).run()
|
||||
expect(() => createDeck(deck, database)).toThrow(
|
||||
'カラムの接続アカウントが利用できません',
|
||||
)
|
||||
expect(listDecks(database)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate column IDs without changing the saved version', () => {
|
||||
const saved = createDeck(deck, database)
|
||||
expect(() =>
|
||||
replaceDeck(
|
||||
{
|
||||
deck: { ...deck, columns: [deck.columns[0], deck.columns[0]] },
|
||||
expectedRevision: 1,
|
||||
},
|
||||
database,
|
||||
),
|
||||
).toThrow('カラム ID が重複しています')
|
||||
expect(loadDeck(deck.id, database)).toEqual(saved)
|
||||
})
|
||||
|
||||
it('returns only deck definitions and revision metadata, never credential data', () => {
|
||||
database
|
||||
.insert(connectionCredentials)
|
||||
.values({
|
||||
connectionId: 'twitter-one',
|
||||
encryptedToken: 'secret-envelope',
|
||||
updatedAt: 1,
|
||||
})
|
||||
.run()
|
||||
createDeck(deck, database)
|
||||
const loaded = listDecks(database)
|
||||
expect(Object.keys(loaded[0] ?? {}).sort()).toEqual([
|
||||
'columns',
|
||||
'createdAt',
|
||||
'id',
|
||||
'revision',
|
||||
'title',
|
||||
'updatedAt',
|
||||
])
|
||||
expect(JSON.stringify(loaded)).not.toContain('secret-envelope')
|
||||
expect(JSON.stringify(loaded)).not.toContain('relayProfile')
|
||||
})
|
||||
@@ -0,0 +1,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()
|
||||
})
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
import { legacyWorkspaceSchema } from './legacy-import'
|
||||
import {
|
||||
type DeckPersistenceFailure,
|
||||
deleteDeckInputSchema,
|
||||
replaceDeckInputSchema,
|
||||
savedDeckDefinitionSchema,
|
||||
} from './persistence-model'
|
||||
|
||||
async function run<T>(
|
||||
operation: (
|
||||
repository: typeof import('./repository.server'),
|
||||
) => T | Promise<T>,
|
||||
): Promise<{ ok: true; data: T } | DeckPersistenceFailure> {
|
||||
const repository = await import('./repository.server')
|
||||
try {
|
||||
return { ok: true, data: await operation(repository) }
|
||||
} catch (error) {
|
||||
if (error instanceof repository.DeckPersistenceError)
|
||||
return { ok: false, error: { code: error.code, message: error.message } }
|
||||
if (error instanceof z.ZodError)
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid-input',
|
||||
message: 'デッキの入力内容を確認してください。',
|
||||
},
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'storage-unavailable',
|
||||
message: 'デッキを保存・取得できませんでした。再試行してください。',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const loadSavedDecks = createServerFn({ method: 'GET' }).handler(() =>
|
||||
run((repository) => repository.listDecks()),
|
||||
)
|
||||
export const createSavedDeck = createServerFn({ method: 'POST' })
|
||||
.validator(savedDeckDefinitionSchema)
|
||||
.handler(({ data }) => run((repository) => repository.createDeck(data)))
|
||||
export const replaceSavedDeck = createServerFn({ method: 'POST' })
|
||||
.validator(replaceDeckInputSchema)
|
||||
.handler(({ data }) => run((repository) => repository.replaceDeck(data)))
|
||||
export const deleteSavedDeck = createServerFn({ method: 'POST' })
|
||||
.validator(deleteDeckInputSchema)
|
||||
.handler(({ data }) => run((repository) => repository.deleteDeck(data)))
|
||||
|
||||
export const importBrowserDecks = createServerFn({ method: 'POST' })
|
||||
.validator(legacyWorkspaceSchema)
|
||||
.handler(({ data }) =>
|
||||
run(async () => {
|
||||
const { importLegacyWorkspace } = await import('./import.server')
|
||||
return importLegacyWorkspace(data)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DeckColumn } from './model'
|
||||
|
||||
/** Describe the bound source once, keeping platform/kind decisions out of the column layout. */
|
||||
export function describeSource(source: DeckColumn['source']): {
|
||||
label: string
|
||||
query: string
|
||||
} {
|
||||
switch (source.platform) {
|
||||
case 'twitter':
|
||||
switch (source.kind) {
|
||||
case 'search':
|
||||
return {
|
||||
label: `${source.product === 'Latest' ? '最新' : '話題'}${source.following ? ' · フォロー中' : ''}`,
|
||||
query: source.query,
|
||||
}
|
||||
case 'user':
|
||||
return { label: 'ユーザー投稿', query: source.target }
|
||||
case 'list':
|
||||
return { label: 'リスト', query: source.target }
|
||||
}
|
||||
break
|
||||
case 'mastodon':
|
||||
switch (source.kind) {
|
||||
case 'search':
|
||||
return { label: '全文検索', query: source.query }
|
||||
case 'user':
|
||||
return { label: 'ユーザー投稿', query: source.target }
|
||||
case 'list':
|
||||
return { label: 'リスト', query: source.target }
|
||||
case 'hashtag':
|
||||
return { label: 'ハッシュタグ', query: `#${source.target}` }
|
||||
}
|
||||
break
|
||||
}
|
||||
const exhaustive: never = source
|
||||
return exhaustive
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { type ReactElement, useId } from 'react'
|
||||
import { useListChoices } from '../posts/use-list-choices'
|
||||
import type { DeckColumn } from './model'
|
||||
|
||||
type Source = Extract<DeckColumn['source'], { platform: 'twitter' }>
|
||||
type EditorProps<T extends Source = Source> = {
|
||||
source: T
|
||||
onChange: (source: Source) => void
|
||||
}
|
||||
|
||||
export function defaultTwitterSource(kind: Source['kind'] = 'search'): Source {
|
||||
switch (kind) {
|
||||
case 'search':
|
||||
return {
|
||||
platform: 'twitter',
|
||||
kind,
|
||||
query: '',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
}
|
||||
case 'user':
|
||||
return { platform: 'twitter', kind, target: '' }
|
||||
case 'list':
|
||||
return { platform: 'twitter', kind, target: '' }
|
||||
}
|
||||
}
|
||||
|
||||
export function TwitterSourceEditor({
|
||||
source,
|
||||
connectionId,
|
||||
onChange,
|
||||
}: EditorProps & { connectionId: string }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={id}>
|
||||
カラムの種類
|
||||
<select
|
||||
id={id}
|
||||
value={source.kind}
|
||||
onChange={(event) =>
|
||||
onChange(defaultTwitterSource(event.target.value as Source['kind']))
|
||||
}
|
||||
>
|
||||
<option value="search">検索</option>
|
||||
<option value="user">ユーザー投稿</option>
|
||||
<option value="list">リスト</option>
|
||||
</select>
|
||||
</label>
|
||||
<TwitterSourceFields
|
||||
source={source}
|
||||
connectionId={connectionId}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TwitterSourceFields({
|
||||
source,
|
||||
connectionId,
|
||||
onChange,
|
||||
}: EditorProps & { connectionId: string }): ReactElement {
|
||||
switch (source.kind) {
|
||||
case 'search':
|
||||
return <TwitterSearchEditor source={source} onChange={onChange} />
|
||||
case 'user':
|
||||
return <TwitterUserEditor source={source} onChange={onChange} />
|
||||
case 'list':
|
||||
return (
|
||||
<TwitterListEditor
|
||||
source={source}
|
||||
connectionId={connectionId}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function TwitterSearchEditor({
|
||||
source,
|
||||
onChange,
|
||||
}: EditorProps<Extract<Source, { kind: 'search' }>>) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={`${id}-query`}>
|
||||
Twitterの検索条件
|
||||
<input
|
||||
id={`${id}-query`}
|
||||
value={source.query}
|
||||
onChange={(event) =>
|
||||
onChange({ ...source, query: event.target.value })
|
||||
}
|
||||
maxLength={512}
|
||||
placeholder="例:WebMCP lang:ja"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${id}-product`}>
|
||||
表示順
|
||||
<select
|
||||
id={`${id}-product`}
|
||||
value={source.product}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...source,
|
||||
product: event.target.value as 'Latest' | 'Top',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="Latest">最新</option>
|
||||
<option value="Top">話題</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="deck-help">
|
||||
from:、lang:、since: など、Twitterの検索構文を使えます。
|
||||
</p>
|
||||
<label className="deck-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={source.following}
|
||||
onChange={(event) =>
|
||||
onChange({ ...source, following: event.target.checked })
|
||||
}
|
||||
/>
|
||||
フォロー中のみ
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TwitterUserEditor({
|
||||
source,
|
||||
onChange,
|
||||
}: EditorProps<Extract<Source, { kind: 'user' }>>) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
ユーザー
|
||||
<input
|
||||
id={id}
|
||||
value={source.target}
|
||||
onChange={(event) =>
|
||||
onChange({ ...source, target: event.target.value })
|
||||
}
|
||||
placeholder="@handle またはプロフィールURL"
|
||||
maxLength={256}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TwitterListEditor({
|
||||
source,
|
||||
connectionId,
|
||||
onChange,
|
||||
}: EditorProps<Extract<Source, { kind: 'list' }>> & { connectionId: string }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<label htmlFor={id}>
|
||||
リスト
|
||||
<input
|
||||
id={id}
|
||||
value={source.target}
|
||||
onChange={(event) =>
|
||||
onChange({ ...source, target: event.target.value })
|
||||
}
|
||||
placeholder="リストのURLまたはID"
|
||||
maxLength={256}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{connectionId && (
|
||||
<TwitterListChoices
|
||||
connectionId={connectionId}
|
||||
onSelect={(target) => onChange({ ...source, target })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TwitterListChoices({
|
||||
connectionId,
|
||||
onSelect,
|
||||
}: {
|
||||
connectionId: string
|
||||
onSelect: (target: string) => void
|
||||
}) {
|
||||
const lists = useListChoices(connectionId)
|
||||
if (lists.isPending) return <p role="status">リストを取得しています…</p>
|
||||
if (lists.isError)
|
||||
return (
|
||||
<p role="alert">
|
||||
リストを取得できませんでした。
|
||||
<button type="button" onClick={() => void lists.refetch()}>
|
||||
再試行
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
return (
|
||||
<section
|
||||
className="deck-list-choices"
|
||||
aria-label="このプロファイルのリスト"
|
||||
>
|
||||
{lists.data.length === 0 ? (
|
||||
<p>リストはありません。URLまたはIDでも指定できます。</p>
|
||||
) : (
|
||||
lists.data.map((list) => (
|
||||
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
|
||||
{list.name}
|
||||
{list.isPrivate ? ' · 非公開' : ''}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { decryptCredential } from '../connections/credentials.server'
|
||||
import { getDatabase } from '../storage/database.server'
|
||||
import { connectionCredentials, connections } from '../storage/schema'
|
||||
import { requireMastodonOrigin, safeMastodonRequest } from './transport.server'
|
||||
|
||||
export async function requestMastodon(
|
||||
connectionId: string,
|
||||
path: string,
|
||||
query?: URLSearchParams,
|
||||
): Promise<Response> {
|
||||
if (
|
||||
!path.startsWith('/api/') ||
|
||||
path.startsWith('//') ||
|
||||
path.includes('\\') ||
|
||||
path.includes('?') ||
|
||||
path.includes('#')
|
||||
) {
|
||||
throw new Error('Invalid Mastodon API path.')
|
||||
}
|
||||
const database = getDatabase()
|
||||
const connection = database
|
||||
.select()
|
||||
.from(connections)
|
||||
.where(eq(connections.id, connectionId))
|
||||
.get()
|
||||
if (connection?.platform !== 'mastodon' || connection.status !== 'connected')
|
||||
throw new Error(
|
||||
'Mastodon connection is unavailable. Reconnect this account.',
|
||||
)
|
||||
const credential = database
|
||||
.select()
|
||||
.from(connectionCredentials)
|
||||
.where(eq(connectionCredentials.connectionId, connectionId))
|
||||
.get()
|
||||
if (!credential)
|
||||
throw new Error(
|
||||
'Mastodon credentials are unavailable. Reconnect this account.',
|
||||
)
|
||||
const url = new URL(path, requireMastodonOrigin(connection.origin))
|
||||
if (query) url.search = query.toString()
|
||||
const response = await safeMastodonRequest(url, {
|
||||
headers: {
|
||||
authorization: `Bearer ${decryptCredential(credential.encryptedToken, `connection:${connectionId}:access-token`)}`,
|
||||
},
|
||||
})
|
||||
if (response.status === 401) {
|
||||
database.transaction(
|
||||
(tx) => {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(connectionCredentials)
|
||||
.where(eq(connectionCredentials.connectionId, connectionId))
|
||||
.get()
|
||||
if (current?.encryptedToken !== credential.encryptedToken) return
|
||||
tx.update(connections)
|
||||
.set({ status: 'expired', updatedAt: Date.now() })
|
||||
.where(eq(connections.id, connectionId))
|
||||
.run()
|
||||
},
|
||||
{ behavior: 'immediate' },
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment node
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { type AppDatabase, openDatabase } from '../storage/database.server'
|
||||
import { connectionCredentials, connections } from '../storage/schema'
|
||||
import { requestMastodon } from './client.server'
|
||||
|
||||
let database: AppDatabase
|
||||
const send = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../storage/database.server', async (original) => ({
|
||||
...(await original<typeof import('../storage/database.server')>()),
|
||||
getDatabase: () => database,
|
||||
}))
|
||||
vi.mock('../connections/credentials.server', () => ({
|
||||
decryptCredential: (value: string) => value,
|
||||
}))
|
||||
vi.mock('./transport.server', () => ({
|
||||
requireMastodonOrigin: (origin: string) => origin,
|
||||
safeMastodonRequest: send,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
send.mockReset()
|
||||
database = openDatabase(':memory:')
|
||||
database
|
||||
.insert(connections)
|
||||
.values({
|
||||
id: 'account',
|
||||
platform: 'mastodon',
|
||||
origin: 'https://mastodon.invalid',
|
||||
accountId: '1',
|
||||
displayName: 'Account',
|
||||
status: 'connected',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})
|
||||
.run()
|
||||
database
|
||||
.insert(connectionCredentials)
|
||||
.values({
|
||||
connectionId: 'account',
|
||||
encryptedToken: 'old-token',
|
||||
updatedAt: 1,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
afterEach(() => database.$client.close())
|
||||
|
||||
it('marks the current rejected credential expired', async () => {
|
||||
send.mockResolvedValue(new Response(null, { status: 401 }))
|
||||
expect((await requestMastodon('account', '/api/v1/lists')).status).toBe(401)
|
||||
expect(database.select().from(connections).get()?.status).toBe('expired')
|
||||
})
|
||||
|
||||
it('does not expire a reconnected account when an old request returns 401 later', async () => {
|
||||
let resolveResponse!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
send.mockReturnValueOnce(response)
|
||||
const request = requestMastodon('account', '/api/v1/lists')
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
new URL('https://mastodon.invalid/api/v1/lists'),
|
||||
{ headers: { authorization: 'Bearer old-token' } },
|
||||
)
|
||||
database
|
||||
.update(connectionCredentials)
|
||||
.set({
|
||||
encryptedToken: 'new-token',
|
||||
updatedAt: 2,
|
||||
})
|
||||
.where(eq(connectionCredentials.connectionId, 'account'))
|
||||
.run()
|
||||
resolveResponse(new Response(null, { status: 401 }))
|
||||
expect((await request).status).toBe(401)
|
||||
expect(database.select().from(connections).get()?.status).toBe('connected')
|
||||
expect(
|
||||
database.select().from(connectionCredentials).get()?.encryptedToken,
|
||||
).toBe('new-token')
|
||||
send.mockResolvedValueOnce(Response.json([]))
|
||||
expect((await requestMastodon('account', '/api/v1/lists')).ok).toBe(true)
|
||||
expect(send).toHaveBeenLastCalledWith(
|
||||
new URL('https://mastodon.invalid/api/v1/lists'),
|
||||
{ headers: { authorization: 'Bearer new-token' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('does not change a disconnected account when an old request returns 401 later', async () => {
|
||||
let resolveResponse!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
send.mockReturnValueOnce(response)
|
||||
const request = requestMastodon('account', '/api/v1/lists')
|
||||
database.delete(connectionCredentials).run()
|
||||
database.update(connections).set({ status: 'disconnected' }).run()
|
||||
resolveResponse(new Response(null, { status: 401 }))
|
||||
await request
|
||||
expect(database.select().from(connections).get()?.status).toBe('disconnected')
|
||||
})
|
||||
@@ -0,0 +1,364 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
||||
import { and, eq, gt, isNull, lt } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { readAccessConfig } from '../access/policy.server'
|
||||
import {
|
||||
decryptCredential,
|
||||
encryptCredential,
|
||||
} from '../connections/credentials.server'
|
||||
import { type AppDatabase, getDatabase } from '../storage/database.server'
|
||||
import {
|
||||
connectionCredentials,
|
||||
connections,
|
||||
oauthApps,
|
||||
oauthAttempts,
|
||||
} from '../storage/schema'
|
||||
import { requireMastodonOrigin, safeMastodonRequest } from './transport.server'
|
||||
|
||||
export const oauthLifetimeSeconds = 600
|
||||
const scopes = 'read:accounts read:statuses read:lists read:search'
|
||||
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
||||
const random = () => randomBytes(32).toString('base64url')
|
||||
const appSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
client_secret: z.string().min(1),
|
||||
})
|
||||
const tokenSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
token_type: z.string().regex(/^bearer$/i),
|
||||
scope: z.string(),
|
||||
})
|
||||
const accountSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
acct: z.string().min(1),
|
||||
})
|
||||
type Send = typeof safeMastodonRequest
|
||||
|
||||
export function oauthCookieName(state: string) {
|
||||
return `mastodon_oauth_${hash(state).slice(0, 24)}`
|
||||
}
|
||||
|
||||
function callbackUri() {
|
||||
const config = readAccessConfig()
|
||||
if (!config) throw new Error('Access configuration is required.')
|
||||
return `${config.origin}/oauth/mastodon/callback`
|
||||
}
|
||||
|
||||
async function formRequest(
|
||||
send: Send,
|
||||
origin: string,
|
||||
path: string,
|
||||
fields: Record<string, string>,
|
||||
) {
|
||||
const response = await send(new URL(path, origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
if (!response.ok)
|
||||
throw new Error(`Mastodon request failed with HTTP ${response.status}.`)
|
||||
return response
|
||||
}
|
||||
|
||||
async function getApp(origin: string, database: AppDatabase, send: Send) {
|
||||
const redirectUri = callbackUri()
|
||||
const existing = database
|
||||
.select()
|
||||
.from(oauthApps)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthApps.origin, origin),
|
||||
eq(oauthApps.redirectUri, redirectUri),
|
||||
eq(oauthApps.scopes, scopes),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
if (existing) return existing
|
||||
const metadataResponse = await send(
|
||||
new URL('/.well-known/oauth-authorization-server', origin),
|
||||
)
|
||||
if (!metadataResponse.ok)
|
||||
throw new Error('Mastodon OAuth metadata is unavailable.')
|
||||
const metadata = z
|
||||
.object({ code_challenge_methods_supported: z.array(z.string()) })
|
||||
.parse(await metadataResponse.json())
|
||||
if (!metadata.code_challenge_methods_supported.includes('S256'))
|
||||
throw new Error('This instance does not support PKCE S256.')
|
||||
const registered = appSchema.parse(
|
||||
await (
|
||||
await formRequest(send, origin, '/api/v1/apps', {
|
||||
client_name: 'Twitter Lite',
|
||||
redirect_uris: redirectUri,
|
||||
scopes,
|
||||
})
|
||||
).json(),
|
||||
)
|
||||
const id = randomUUID()
|
||||
const app = {
|
||||
id,
|
||||
origin,
|
||||
redirectUri,
|
||||
scopes,
|
||||
clientId: registered.client_id,
|
||||
encryptedClientSecret: encryptCredential(
|
||||
registered.client_secret,
|
||||
`oauth-app:${id}:client-secret`,
|
||||
),
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
database.insert(oauthApps).values(app).onConflictDoNothing().run()
|
||||
return (
|
||||
database
|
||||
.select()
|
||||
.from(oauthApps)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthApps.origin, origin),
|
||||
eq(oauthApps.redirectUri, redirectUri),
|
||||
eq(oauthApps.scopes, scopes),
|
||||
),
|
||||
)
|
||||
.get() ?? app
|
||||
)
|
||||
}
|
||||
|
||||
export async function beginMastodonOAuth(
|
||||
input: { origin: string; connectionId?: string },
|
||||
database = getDatabase(),
|
||||
send: Send = safeMastodonRequest,
|
||||
) {
|
||||
database
|
||||
.delete(oauthAttempts)
|
||||
.where(lt(oauthAttempts.expiresAt, Date.now()))
|
||||
.run()
|
||||
const origin = requireMastodonOrigin(input.origin)
|
||||
if (input.connectionId) {
|
||||
const connection = database
|
||||
.select()
|
||||
.from(connections)
|
||||
.where(eq(connections.id, input.connectionId))
|
||||
.get()
|
||||
if (connection?.platform !== 'mastodon' || connection.origin !== origin)
|
||||
throw new Error('The reconnect account does not match this instance.')
|
||||
}
|
||||
const app = await getApp(origin, database, send)
|
||||
const state = random()
|
||||
const browserCookie = random()
|
||||
const verifier = random()
|
||||
const stateHash = hash(state)
|
||||
database
|
||||
.insert(oauthAttempts)
|
||||
.values({
|
||||
stateHash,
|
||||
browserHash: hash(browserCookie),
|
||||
appId: app.id,
|
||||
encryptedVerifier: encryptCredential(
|
||||
verifier,
|
||||
`oauth-attempt:${stateHash}:verifier`,
|
||||
),
|
||||
connectionId: input.connectionId ?? null,
|
||||
expiresAt: Date.now() + oauthLifetimeSeconds * 1000,
|
||||
})
|
||||
.run()
|
||||
const authorize = new URL('/oauth/authorize', origin)
|
||||
authorize.search = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: app.clientId,
|
||||
redirect_uri: app.redirectUri,
|
||||
scope: app.scopes,
|
||||
state,
|
||||
force_login: 'true',
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
||||
}).toString()
|
||||
return { authorizationUrl: authorize.toString(), state, browserCookie }
|
||||
}
|
||||
|
||||
export async function completeMastodonOAuth(
|
||||
input: {
|
||||
state: string
|
||||
browserCookie?: string
|
||||
code?: string
|
||||
error?: string
|
||||
},
|
||||
database = getDatabase(),
|
||||
send: Send = safeMastodonRequest,
|
||||
): Promise<string> {
|
||||
if (!input.state || !input.browserCookie)
|
||||
throw new Error('OAuth state or browser binding is missing.')
|
||||
const stateHash = hash(input.state)
|
||||
const now = Date.now()
|
||||
const attempt = database
|
||||
.update(oauthAttempts)
|
||||
.set({ consumedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(oauthAttempts.stateHash, stateHash),
|
||||
eq(oauthAttempts.browserHash, hash(input.browserCookie)),
|
||||
isNull(oauthAttempts.consumedAt),
|
||||
gt(oauthAttempts.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.get()
|
||||
if (!attempt)
|
||||
throw new Error('OAuth attempt is invalid, expired, or already used.')
|
||||
if (input.error || !input.code)
|
||||
throw new Error('Mastodon authorization was not completed.')
|
||||
const app = database
|
||||
.select()
|
||||
.from(oauthApps)
|
||||
.where(eq(oauthApps.id, attempt.appId))
|
||||
.get()
|
||||
if (!app) throw new Error('OAuth app is unavailable.')
|
||||
requireMastodonOrigin(app.origin)
|
||||
const token = tokenSchema.parse(
|
||||
await (
|
||||
await formRequest(send, app.origin, '/oauth/token', {
|
||||
grant_type: 'authorization_code',
|
||||
code: input.code,
|
||||
client_id: app.clientId,
|
||||
client_secret: decryptCredential(
|
||||
app.encryptedClientSecret,
|
||||
`oauth-app:${app.id}:client-secret`,
|
||||
),
|
||||
redirect_uri: app.redirectUri,
|
||||
code_verifier: decryptCredential(
|
||||
attempt.encryptedVerifier,
|
||||
`oauth-attempt:${stateHash}:verifier`,
|
||||
),
|
||||
})
|
||||
).json(),
|
||||
)
|
||||
const granted = new Set(token.scope.split(' '))
|
||||
if (!scopes.split(' ').every((scope) => granted.has(scope)))
|
||||
throw new Error('Required Mastodon read permissions were not granted.')
|
||||
const verified = await send(
|
||||
new URL('/api/v1/accounts/verify_credentials', app.origin),
|
||||
{
|
||||
headers: { authorization: `Bearer ${token.access_token}` },
|
||||
},
|
||||
)
|
||||
if (!verified.ok) throw new Error('Mastodon account verification failed.')
|
||||
const account = accountSchema.parse(await verified.json())
|
||||
return database.transaction((tx) => {
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(connections)
|
||||
.where(
|
||||
and(
|
||||
eq(connections.platform, 'mastodon'),
|
||||
eq(connections.origin, app.origin),
|
||||
eq(connections.accountId, account.id),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
if (attempt.connectionId && existing?.id !== attempt.connectionId)
|
||||
throw new Error(
|
||||
'The authorized account does not match the account being reconnected.',
|
||||
)
|
||||
const id = existing?.id ?? randomUUID()
|
||||
tx.insert(connections)
|
||||
.values({
|
||||
id,
|
||||
platform: 'mastodon',
|
||||
origin: app.origin,
|
||||
accountId: account.id,
|
||||
displayName: `@${account.acct}`,
|
||||
status: 'connected',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: connections.id,
|
||||
set: {
|
||||
displayName: `@${account.acct}`,
|
||||
status: 'connected',
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
const credential = {
|
||||
connectionId: id,
|
||||
appId: app.id,
|
||||
encryptedToken: encryptCredential(
|
||||
token.access_token,
|
||||
`connection:${id}:access-token`,
|
||||
),
|
||||
updatedAt: now,
|
||||
}
|
||||
tx.insert(connectionCredentials)
|
||||
.values(credential)
|
||||
.onConflictDoUpdate({
|
||||
target: connectionCredentials.connectionId,
|
||||
set: credential,
|
||||
})
|
||||
.run()
|
||||
return id
|
||||
})
|
||||
}
|
||||
|
||||
export async function disconnectMastodon(
|
||||
connectionId: string,
|
||||
database = getDatabase(),
|
||||
send: Send = safeMastodonRequest,
|
||||
) {
|
||||
const connection = database
|
||||
.select()
|
||||
.from(connections)
|
||||
.where(eq(connections.id, connectionId))
|
||||
.get()
|
||||
if (connection?.platform !== 'mastodon')
|
||||
throw new Error('Mastodon connection was not found.')
|
||||
const credential = database
|
||||
.select()
|
||||
.from(connectionCredentials)
|
||||
.where(eq(connectionCredentials.connectionId, connectionId))
|
||||
.get()
|
||||
if (credential) {
|
||||
const app = credential.appId
|
||||
? database
|
||||
.select()
|
||||
.from(oauthApps)
|
||||
.where(eq(oauthApps.id, credential.appId))
|
||||
.get()
|
||||
: undefined
|
||||
if (!app)
|
||||
throw new Error(
|
||||
'The OAuth app required to revoke this connection is missing.',
|
||||
)
|
||||
requireMastodonOrigin(connection.origin)
|
||||
await formRequest(send, connection.origin, '/oauth/revoke', {
|
||||
client_id: app.clientId,
|
||||
client_secret: decryptCredential(
|
||||
app.encryptedClientSecret,
|
||||
`oauth-app:${app.id}:client-secret`,
|
||||
),
|
||||
token: decryptCredential(
|
||||
credential.encryptedToken,
|
||||
`connection:${connectionId}:access-token`,
|
||||
),
|
||||
})
|
||||
}
|
||||
database.transaction(
|
||||
(tx) => {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(connectionCredentials)
|
||||
.where(eq(connectionCredentials.connectionId, connectionId))
|
||||
.get()
|
||||
if (current?.encryptedToken !== credential?.encryptedToken)
|
||||
throw new Error(
|
||||
'This account was reconnected during revocation. Its new connection has been kept. Refresh the account list before trying again.',
|
||||
)
|
||||
tx.delete(connectionCredentials)
|
||||
.where(eq(connectionCredentials.connectionId, connectionId))
|
||||
.run()
|
||||
tx.update(connections)
|
||||
.set({ status: 'disconnected', updatedAt: Date.now() })
|
||||
.where(eq(connections.id, connectionId))
|
||||
.run()
|
||||
},
|
||||
{ behavior: 'immediate' },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// @vitest-environment node
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { decryptCredential } from '../connections/credentials.server'
|
||||
import { type AppDatabase, openDatabase } from '../storage/database.server'
|
||||
import {
|
||||
connectionCredentials,
|
||||
connections,
|
||||
oauthApps,
|
||||
oauthAttempts,
|
||||
} from '../storage/schema'
|
||||
import {
|
||||
beginMastodonOAuth,
|
||||
completeMastodonOAuth,
|
||||
disconnectMastodon,
|
||||
} from './oauth.server'
|
||||
|
||||
let database: AppDatabase
|
||||
let directory: string
|
||||
let accountId: string
|
||||
const origin = 'https://mastodon.invalid'
|
||||
const token = 'private-access-token'
|
||||
const clientSecret = 'private-client-secret'
|
||||
const send = vi.fn(async (url: URL, _init?: RequestInit): Promise<Response> => {
|
||||
if (url.pathname === '/.well-known/oauth-authorization-server')
|
||||
return Response.json({ code_challenge_methods_supported: ['S256'] })
|
||||
if (url.pathname === '/api/v1/apps')
|
||||
return Response.json({ client_id: 'client', client_secret: clientSecret })
|
||||
if (url.pathname === '/oauth/token')
|
||||
return Response.json({
|
||||
access_token: token,
|
||||
token_type: 'Bearer',
|
||||
scope: 'read:accounts read:statuses read:lists read:search',
|
||||
})
|
||||
if (url.pathname === '/api/v1/accounts/verify_credentials')
|
||||
return Response.json({ id: accountId, acct: `user${accountId}` })
|
||||
if (url.pathname === '/oauth/revoke') return Response.json({})
|
||||
throw new Error('Unexpected mock endpoint')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
database = openDatabase(':memory:')
|
||||
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-oauth-'))
|
||||
const key = join(directory, 'key')
|
||||
writeFileSync(key, randomBytes(32).toString('base64'), { mode: 0o600 })
|
||||
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', key)
|
||||
vi.stubEnv('TWITTER_LITE_ORIGIN', 'https://deck.invalid')
|
||||
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '[email protected]')
|
||||
vi.stubEnv(
|
||||
'TWITTER_LITE_MASTODON_ORIGINS',
|
||||
`${origin},https://second.invalid`,
|
||||
)
|
||||
accountId = '1'
|
||||
send.mockClear()
|
||||
})
|
||||
afterEach(() => {
|
||||
database.$client.close()
|
||||
rmSync(directory, { force: true, recursive: true })
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('registers once and uses force-login, state and PKCE while storing only encrypted secrets', async () => {
|
||||
const attempt = await beginMastodonOAuth({ origin }, database, send)
|
||||
const url = new URL(attempt.authorizationUrl)
|
||||
expect(url.origin).toBe(origin)
|
||||
expect(url.searchParams.get('redirect_uri')).toBe(
|
||||
'https://deck.invalid/oauth/mastodon/callback',
|
||||
)
|
||||
expect(url.searchParams.get('force_login')).toBe('true')
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
|
||||
expect(url.searchParams.get('state')).toBe(attempt.state)
|
||||
const id = await completeMastodonOAuth(
|
||||
{ ...attempt, code: 'authorization-code' },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
const credentials = database.select().from(connectionCredentials).get()
|
||||
expect(credentials?.connectionId).toBe(id)
|
||||
expect(
|
||||
decryptCredential(
|
||||
credentials?.encryptedToken ?? '',
|
||||
`connection:${id}:access-token`,
|
||||
),
|
||||
).toBe(token)
|
||||
const saved = JSON.stringify({
|
||||
apps: database.select().from(oauthApps).all(),
|
||||
attempts: database.select().from(oauthAttempts).all(),
|
||||
credentials,
|
||||
})
|
||||
expect(saved).not.toContain(token)
|
||||
expect(saved).not.toContain(clientSecret)
|
||||
expect(saved).not.toContain(attempt.state)
|
||||
expect(saved).not.toContain(attempt.browserCookie)
|
||||
const exchange = send.mock.calls.find(
|
||||
([request]) => request.pathname === '/oauth/token',
|
||||
)
|
||||
const fields = exchange?.[1]?.body as URLSearchParams
|
||||
expect(
|
||||
createHash('sha256')
|
||||
.update(fields.get('code_verifier') ?? '')
|
||||
.digest('base64url'),
|
||||
).toBe(url.searchParams.get('code_challenge'))
|
||||
await beginMastodonOAuth({ origin }, database, send)
|
||||
expect(
|
||||
send.mock.calls.filter(([request]) => request.pathname === '/api/v1/apps'),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('supports two accounts per instance and accounts on another instance without changing reconnect IDs', async () => {
|
||||
const first = await beginMastodonOAuth({ origin }, database, send)
|
||||
const firstId = await completeMastodonOAuth(
|
||||
{ ...first, code: 'first' },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
accountId = '2'
|
||||
const second = await beginMastodonOAuth({ origin }, database, send)
|
||||
const secondId = await completeMastodonOAuth(
|
||||
{ ...second, code: 'second' },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
expect(secondId).not.toBe(firstId)
|
||||
const another = await beginMastodonOAuth(
|
||||
{ origin: 'https://second.invalid' },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
const anotherId = await completeMastodonOAuth(
|
||||
{ ...another, code: 'another' },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
expect(anotherId).not.toBe(secondId)
|
||||
const reconnect = await beginMastodonOAuth(
|
||||
{ origin, connectionId: secondId },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
expect(
|
||||
await completeMastodonOAuth(
|
||||
{ ...reconnect, code: 'reconnect' },
|
||||
database,
|
||||
send,
|
||||
),
|
||||
).toBe(secondId)
|
||||
expect(database.select().from(connections).all()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('rejects mismatched reconnect accounts without replacing the existing credential', async () => {
|
||||
const first = await beginMastodonOAuth({ origin }, database, send)
|
||||
const id = await completeMastodonOAuth(
|
||||
{ ...first, code: 'first' },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
const before = database.select().from(connectionCredentials).get()
|
||||
const reconnect = await beginMastodonOAuth(
|
||||
{ origin, connectionId: id },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
accountId = 'different'
|
||||
await expect(
|
||||
completeMastodonOAuth({ ...reconnect, code: 'other' }, database, send),
|
||||
).rejects.toThrow('does not match')
|
||||
expect(database.select().from(connectionCredentials).get()).toEqual(before)
|
||||
})
|
||||
|
||||
it('rejects browser mismatch and unknown state before exchanging a code', async () => {
|
||||
const attempt = await beginMastodonOAuth({ origin }, database, send)
|
||||
const calls = send.mock.calls.length
|
||||
await expect(
|
||||
completeMastodonOAuth(
|
||||
{ ...attempt, browserCookie: 'another-browser', code: 'code' },
|
||||
database,
|
||||
send,
|
||||
),
|
||||
).rejects.toThrow('invalid, expired, or already used')
|
||||
await expect(
|
||||
completeMastodonOAuth(
|
||||
{ ...attempt, state: 'unknown-state', code: 'code' },
|
||||
database,
|
||||
send,
|
||||
),
|
||||
).rejects.toThrow('invalid, expired, or already used')
|
||||
expect(send).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('consumes rejected authorization attempts so they cannot be retried', async () => {
|
||||
const attempt = await beginMastodonOAuth({ origin }, database, send)
|
||||
await expect(
|
||||
completeMastodonOAuth(
|
||||
{ ...attempt, error: 'access_denied' },
|
||||
database,
|
||||
send,
|
||||
),
|
||||
).rejects.toThrow('not completed')
|
||||
await expect(
|
||||
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
|
||||
).rejects.toThrow('already used')
|
||||
expect(database.select().from(connections).all()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects expired attempts without exchanging the code', async () => {
|
||||
const attempt = await beginMastodonOAuth({ origin }, database, send)
|
||||
database
|
||||
.update(oauthAttempts)
|
||||
.set({ expiresAt: Date.now() - 1 })
|
||||
.run()
|
||||
await expect(
|
||||
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
|
||||
).rejects.toThrow('expired')
|
||||
expect(
|
||||
send.mock.calls.filter(([request]) => request.pathname === '/oauth/token'),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('allows exactly one exchange when callbacks race', async () => {
|
||||
const attempt = await beginMastodonOAuth({ origin }, database, send)
|
||||
const results = await Promise.allSettled([
|
||||
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
|
||||
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
|
||||
])
|
||||
expect(results.map((result) => result.status).sort()).toEqual([
|
||||
'fulfilled',
|
||||
'rejected',
|
||||
])
|
||||
expect(
|
||||
send.mock.calls.filter(([request]) => request.pathname === '/oauth/token'),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects instances outside the configured allowlist without network requests', async () => {
|
||||
await expect(
|
||||
beginMastodonOAuth(
|
||||
{ origin: 'https://unconfigured.invalid' },
|
||||
database,
|
||||
send,
|
||||
),
|
||||
).rejects.toThrow('not configured')
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revokes with the original app, erases credentials, and retains the column binding ID', async () => {
|
||||
const attempt = await beginMastodonOAuth({ origin }, database, send)
|
||||
const id = await completeMastodonOAuth(
|
||||
{ ...attempt, code: 'code' },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
vi.stubEnv('TWITTER_LITE_ORIGIN', 'https://new-deck.invalid')
|
||||
await disconnectMastodon(id, database, send)
|
||||
expect(database.select().from(connectionCredentials).all()).toHaveLength(0)
|
||||
expect(
|
||||
database.select().from(connections).where(eq(connections.id, id)).get()
|
||||
?.status,
|
||||
).toBe('disconnected')
|
||||
const revoke = send.mock.calls.find(
|
||||
([request]) => request.pathname === '/oauth/revoke',
|
||||
)
|
||||
const fields = revoke?.[1]?.body as URLSearchParams
|
||||
expect(fields.get('client_secret')).toBe(clientSecret)
|
||||
expect(fields.get('token')).toBe(token)
|
||||
})
|
||||
|
||||
it('keeps credentials available for another revoke attempt when revocation fails', async () => {
|
||||
const attempt = await beginMastodonOAuth({ origin }, database, send)
|
||||
const id = await completeMastodonOAuth(
|
||||
{ ...attempt, code: 'code' },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
const failedRevoke = vi.fn(async () => new Response(null, { status: 503 }))
|
||||
await expect(disconnectMastodon(id, database, failedRevoke)).rejects.toThrow(
|
||||
'HTTP 503',
|
||||
)
|
||||
expect(database.select().from(connectionCredentials).all()).toHaveLength(1)
|
||||
expect(database.select().from(connections).get()?.status).toBe('connected')
|
||||
})
|
||||
|
||||
it('keeps a new credential when an older disconnect completes after reconnection', async () => {
|
||||
const attempt = await beginMastodonOAuth({ origin }, database, send)
|
||||
const id = await completeMastodonOAuth(
|
||||
{ ...attempt, code: 'code' },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
let resolveResponse!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
const pendingRevoke = vi.fn(() => response)
|
||||
const disconnect = disconnectMastodon(id, database, pendingRevoke)
|
||||
expect(pendingRevoke).toHaveBeenCalledTimes(1)
|
||||
const reconnect = await beginMastodonOAuth(
|
||||
{ origin, connectionId: id },
|
||||
database,
|
||||
send,
|
||||
)
|
||||
await completeMastodonOAuth(
|
||||
{ ...reconnect, code: 'reconnect' },
|
||||
database,
|
||||
async (url, init) =>
|
||||
url.pathname === '/oauth/token'
|
||||
? Response.json({
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
scope: 'read:accounts read:statuses read:lists read:search',
|
||||
})
|
||||
: send(url, init),
|
||||
)
|
||||
resolveResponse(Response.json({}))
|
||||
await expect(disconnect).rejects.toThrow('reconnected during revocation')
|
||||
const credential = database.select().from(connectionCredentials).get()
|
||||
expect(
|
||||
decryptCredential(
|
||||
credential?.encryptedToken ?? '',
|
||||
`connection:${id}:access-token`,
|
||||
),
|
||||
).toBe('new-access-token')
|
||||
expect(database.select().from(connections).get()?.status).toBe('connected')
|
||||
})
|
||||
|
||||
it('rejects incomplete scopes without saving the token', async () => {
|
||||
const attempt = await beginMastodonOAuth({ origin }, database, send)
|
||||
const limitedGrant = vi.fn(async () =>
|
||||
Response.json({
|
||||
access_token: token,
|
||||
token_type: 'Bearer',
|
||||
scope: 'read:accounts',
|
||||
}),
|
||||
)
|
||||
await expect(
|
||||
completeMastodonOAuth({ ...attempt, code: 'code' }, database, limitedGrant),
|
||||
).rejects.toThrow('permissions were not granted')
|
||||
expect(database.select().from(connectionCredentials).all()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('consumes an attempt before a failing token exchange', async () => {
|
||||
const attempt = await beginMastodonOAuth({ origin }, database, send)
|
||||
const failedExchange = vi.fn(async () => new Response(null, { status: 400 }))
|
||||
await expect(
|
||||
completeMastodonOAuth(
|
||||
{ ...attempt, code: 'code' },
|
||||
database,
|
||||
failedExchange,
|
||||
),
|
||||
).rejects.toThrow('HTTP 400')
|
||||
await expect(
|
||||
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
|
||||
).rejects.toThrow('already used')
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const loadMastodonInstances = createServerFn({ method: 'GET' }).handler(
|
||||
async () => {
|
||||
const { mastodonOrigins } = await import('./transport.server')
|
||||
return { origins: mastodonOrigins() }
|
||||
},
|
||||
)
|
||||
|
||||
export const startMastodonOAuth = createServerFn({ method: 'POST' })
|
||||
.validator(
|
||||
z
|
||||
.object({
|
||||
origin: z.string(),
|
||||
connectionId: z.string().min(1).optional(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.handler(async ({ data }) => {
|
||||
const { setCookie } = await import('@tanstack/react-start/server')
|
||||
const { beginMastodonOAuth, oauthCookieName, oauthLifetimeSeconds } =
|
||||
await import('./oauth.server')
|
||||
const { readAccessConfig } = await import('../access/policy.server')
|
||||
const attempt = await beginMastodonOAuth(data)
|
||||
setCookie(oauthCookieName(attempt.state), attempt.browserCookie, {
|
||||
httpOnly: true,
|
||||
secure: readAccessConfig()?.origin.startsWith('https:') === true,
|
||||
sameSite: 'lax',
|
||||
path: '/oauth/mastodon/callback',
|
||||
maxAge: oauthLifetimeSeconds,
|
||||
})
|
||||
return { authorizationUrl: attempt.authorizationUrl }
|
||||
})
|
||||
|
||||
export const disconnectMastodonAccount = createServerFn({ method: 'POST' })
|
||||
.validator(z.object({ connectionId: z.string().min(1) }).strict())
|
||||
.handler(async ({ data }) => {
|
||||
const { disconnectMastodon } = await import('./oauth.server')
|
||||
await disconnectMastodon(data.connectionId)
|
||||
return { disconnected: true }
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { lookup } from 'node:dns/promises'
|
||||
import { request } from 'node:https'
|
||||
import { isIP } from 'node:net'
|
||||
|
||||
export function mastodonOrigins(): string[] {
|
||||
return (process.env.TWITTER_LITE_MASTODON_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map((value) => {
|
||||
const url = new URL(value)
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.origin !== value ||
|
||||
url.port ||
|
||||
isIP(url.hostname)
|
||||
) {
|
||||
throw new Error(
|
||||
'Mastodon origins must be HTTPS host origins without paths or custom ports.',
|
||||
)
|
||||
}
|
||||
return url.origin
|
||||
})
|
||||
}
|
||||
|
||||
export function requireMastodonOrigin(origin: string): string {
|
||||
if (!mastodonOrigins().includes(origin))
|
||||
throw new Error('This Mastodon instance is not configured.')
|
||||
return origin
|
||||
}
|
||||
|
||||
export function isPublicAddress(address: string): boolean {
|
||||
if (isIP(address) === 4) {
|
||||
const [a = 0, b = 0, c = 0] = address.split('.').map(Number)
|
||||
return !(
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
a >= 224 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 192 && b === 0) ||
|
||||
(a === 192 && b === 88 && c === 99) ||
|
||||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
||||
(a === 203 && b === 0 && c === 113)
|
||||
)
|
||||
}
|
||||
if (isIP(address) === 6) {
|
||||
const normalized = address.toLowerCase()
|
||||
return (
|
||||
/^[23]/.test(normalized) &&
|
||||
!/^2001:(0*:|db8:)/.test(normalized) &&
|
||||
!normalized.startsWith('2002:')
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Fixed approved HTTPS origins, pinned public DNS results, no redirects. */
|
||||
export async function safeMastodonRequest(
|
||||
url: URL,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
requireMastodonOrigin(url.origin)
|
||||
if (url.username || url.password) throw new Error('Invalid Mastodon URL.')
|
||||
const addresses = await lookup(url.hostname, { all: true, verbatim: true })
|
||||
const address = addresses[0]
|
||||
if (!address || addresses.some((value) => !isPublicAddress(value.address))) {
|
||||
throw new Error('Mastodon must resolve to public network addresses.')
|
||||
}
|
||||
const headers = Object.fromEntries(new Headers(init.headers))
|
||||
const body =
|
||||
init.body instanceof URLSearchParams ? init.body.toString() : init.body
|
||||
if (body !== undefined && body !== null && typeof body !== 'string')
|
||||
throw new Error('Unsupported Mastodon request body.')
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = request(
|
||||
url,
|
||||
{
|
||||
method: init.method ?? 'GET',
|
||||
headers,
|
||||
family: address.family,
|
||||
lookup: (_hostname, _options, callback) =>
|
||||
callback(null, address.address, address.family),
|
||||
},
|
||||
(incoming) => {
|
||||
const chunks: Buffer[] = []
|
||||
let bytes = 0
|
||||
incoming.on('data', (chunk: Buffer) => {
|
||||
bytes += chunk.length
|
||||
if (bytes > 5 * 1024 * 1024)
|
||||
incoming.destroy(new Error('Mastodon response is too large.'))
|
||||
else chunks.push(chunk)
|
||||
})
|
||||
incoming.on('error', () =>
|
||||
reject(new Error('Mastodon response failed.')),
|
||||
)
|
||||
incoming.on('end', () => {
|
||||
const status = incoming.statusCode ?? 502
|
||||
if (status >= 300 && status < 400)
|
||||
return reject(new Error('Mastodon redirects are not allowed.'))
|
||||
const responseHeaders = new Headers()
|
||||
for (const [name, value] of Object.entries(incoming.headers)) {
|
||||
if (value !== undefined)
|
||||
responseHeaders.set(
|
||||
name,
|
||||
Array.isArray(value) ? value.join(', ') : value,
|
||||
)
|
||||
}
|
||||
resolve(
|
||||
new Response(status === 204 ? null : Buffer.concat(chunks), {
|
||||
status,
|
||||
headers: responseHeaders,
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
const timeout = setTimeout(() => {
|
||||
outgoing.destroy(new Error('Mastodon request timed out.'))
|
||||
}, 20_000)
|
||||
outgoing.on('close', () => clearTimeout(timeout))
|
||||
outgoing.on('error', () => reject(new Error('Mastodon request failed.')))
|
||||
outgoing.end(body)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isPublicAddress,
|
||||
mastodonOrigins,
|
||||
requireMastodonOrigin,
|
||||
} from './transport.server'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
it.each([
|
||||
'127.0.0.1',
|
||||
'10.0.0.1',
|
||||
'172.16.0.1',
|
||||
'192.168.1.1',
|
||||
'100.91.91.87',
|
||||
'169.254.169.254',
|
||||
'0.0.0.0',
|
||||
'224.0.0.1',
|
||||
'198.18.0.1',
|
||||
'192.0.2.1',
|
||||
'::1',
|
||||
'::ffff:127.0.0.1',
|
||||
'fe80::1',
|
||||
'fd00::1',
|
||||
'2001:db8::1',
|
||||
'2002:7f00:1::1',
|
||||
])('rejects non-public destination %s', (address) =>
|
||||
expect(isPublicAddress(address)).toBe(false))
|
||||
it.each([
|
||||
'1.1.1.1',
|
||||
'8.8.8.8',
|
||||
'2606:4700:4700::1111',
|
||||
])('accepts public destination %s', (address) =>
|
||||
expect(isPublicAddress(address)).toBe(true))
|
||||
it.each([
|
||||
'http://mastodon.invalid',
|
||||
'https://mastodon.invalid/path',
|
||||
'https://mastodon.invalid:8443',
|
||||
'https://127.0.0.1',
|
||||
'https://user:[email protected]',
|
||||
])('rejects unsafe configured origin %s', (origin) => {
|
||||
vi.stubEnv('TWITTER_LITE_MASTODON_ORIGINS', origin)
|
||||
expect(() => mastodonOrigins()).toThrow()
|
||||
})
|
||||
it('requires exact configured origin equality', () => {
|
||||
vi.stubEnv('TWITTER_LITE_MASTODON_ORIGINS', 'https://mastodon.invalid')
|
||||
expect(requireMastodonOrigin('https://mastodon.invalid')).toBe(
|
||||
'https://mastodon.invalid',
|
||||
)
|
||||
expect(() =>
|
||||
requireMastodonOrigin('https://mastodon.invalid.attacker.invalid'),
|
||||
).toThrow('not configured')
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { requestMastodon } from '../mastodon/client.server'
|
||||
import { getDatabase } from '../storage/database.server'
|
||||
import { connections } from '../storage/schema'
|
||||
import { mapMastodonPost } from './mastodon.server'
|
||||
import { mastodonFeedInput } from './mastodon-source'
|
||||
import type { ResearchPage } from './types'
|
||||
|
||||
export class MastodonFeedError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'account-unavailable'
|
||||
| 'rate-limit'
|
||||
| 'search-unavailable'
|
||||
| 'request-failed',
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
async function requireOk(response: Response, search = false) {
|
||||
if (response.ok) return response
|
||||
if (response.status === 401)
|
||||
throw new MastodonFeedError(
|
||||
'account-unavailable',
|
||||
'アカウントを再接続してください。',
|
||||
)
|
||||
if (response.status === 429)
|
||||
throw new MastodonFeedError(
|
||||
'rate-limit',
|
||||
'取得回数の制限に達しました。しばらく待ってから再試行してください。',
|
||||
)
|
||||
if (search && [400, 422, 501, 503].includes(response.status))
|
||||
throw new MastodonFeedError(
|
||||
'search-unavailable',
|
||||
'検索を実行できません。接続先の対応状況と検索条件を確認してください。',
|
||||
)
|
||||
throw new MastodonFeedError(
|
||||
'request-failed',
|
||||
'Mastodonから取得できませんでした。接続と対象を確認してください。',
|
||||
)
|
||||
}
|
||||
|
||||
/** Extract only a numeric max_id; never follow an upstream-provided URL. */
|
||||
export function nextMastodonCursor(
|
||||
link: string | null,
|
||||
origin: string,
|
||||
path: string,
|
||||
) {
|
||||
for (const part of link?.split(',') ?? []) {
|
||||
const match = part.match(/<([^>]+)>;\s*rel="next"/)
|
||||
if (!match?.[1]) continue
|
||||
try {
|
||||
const url = new URL(match[1])
|
||||
const cursor = url.searchParams.get('max_id')
|
||||
if (
|
||||
url.origin === origin &&
|
||||
url.pathname === path &&
|
||||
cursor &&
|
||||
/^\d{1,32}$/.test(cursor)
|
||||
)
|
||||
return cursor
|
||||
} catch {
|
||||
/* A malformed continuation must not become a request target. */
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function fetchMastodonPage(
|
||||
raw: unknown,
|
||||
request = requestMastodon,
|
||||
): Promise<ResearchPage> {
|
||||
const { connectionId, source, cursor } = mastodonFeedInput.parse(raw)
|
||||
const connection = getDatabase()
|
||||
.select()
|
||||
.from(connections)
|
||||
.where(eq(connections.id, connectionId))
|
||||
.get()
|
||||
if (connection?.platform !== 'mastodon' || connection.status !== 'connected')
|
||||
throw new MastodonFeedError(
|
||||
'account-unavailable',
|
||||
'Mastodonアカウントを接続してください。',
|
||||
)
|
||||
const params = new URLSearchParams({ limit: String(PAGE_SIZE) })
|
||||
let path: string
|
||||
if (source.kind === 'search') {
|
||||
if (cursor && !/^\d{1,8}$/.test(cursor))
|
||||
throw new Error('Invalid search cursor.')
|
||||
path = '/api/v2/search'
|
||||
params.set('q', source.query)
|
||||
params.set('type', 'statuses')
|
||||
params.set('resolve', 'false')
|
||||
params.set('offset', cursor ?? '0')
|
||||
} else {
|
||||
if (cursor && !/^\d{1,32}$/.test(cursor))
|
||||
throw new Error('Invalid timeline cursor.')
|
||||
if (cursor) params.set('max_id', cursor)
|
||||
if (source.kind === 'user') {
|
||||
let id = source.target
|
||||
if (!/^\d{1,32}$/.test(id)) {
|
||||
const lookup = await requireOk(
|
||||
await request(
|
||||
connectionId,
|
||||
'/api/v1/accounts/lookup',
|
||||
new URLSearchParams({ acct: id.replace(/^@/, '') }),
|
||||
),
|
||||
)
|
||||
id = z
|
||||
.object({ id: z.string().regex(/^\d{1,32}$/) })
|
||||
.parse(await lookup.json()).id
|
||||
}
|
||||
path = `/api/v1/accounts/${id}/statuses`
|
||||
} else if (source.kind === 'list')
|
||||
path = `/api/v1/timelines/list/${source.target}`
|
||||
else path = `/api/v1/timelines/tag/${encodeURIComponent(source.target)}`
|
||||
}
|
||||
const response = await requireOk(
|
||||
await request(connectionId, path, params),
|
||||
source.kind === 'search',
|
||||
)
|
||||
const data: unknown = await response.json()
|
||||
const statuses =
|
||||
source.kind === 'search'
|
||||
? z.object({ statuses: z.array(z.unknown()) }).parse(data).statuses
|
||||
: z.array(z.unknown()).parse(data)
|
||||
const nextCursor =
|
||||
source.kind === 'search'
|
||||
? statuses.length === PAGE_SIZE
|
||||
? String(Number(cursor ?? '0') + PAGE_SIZE)
|
||||
: undefined
|
||||
: nextMastodonCursor(
|
||||
response.headers.get('link'),
|
||||
connection.origin,
|
||||
path,
|
||||
)
|
||||
return {
|
||||
posts: statuses.map((post) => mapMastodonPost(post, connection.origin)),
|
||||
...(nextCursor ? { nextCursor } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMastodonLists(connectionId: string) {
|
||||
const response = await requireOk(
|
||||
await requestMastodon(connectionId, '/api/v1/lists'),
|
||||
)
|
||||
return z
|
||||
.array(z.object({ id: z.string(), title: z.string() }))
|
||||
.parse(await response.json())
|
||||
.map((list) => ({ id: list.id, name: list.title, isPrivate: true }))
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { type AppDatabase, openDatabase } from '../storage/database.server'
|
||||
import { connections } from '../storage/schema'
|
||||
import { fetchMastodonPage, nextMastodonCursor } from './mastodon-feed.server'
|
||||
|
||||
let database: AppDatabase
|
||||
vi.mock('../storage/database.server', async (original) => ({
|
||||
...(await original<typeof import('../storage/database.server')>()),
|
||||
getDatabase: () => database,
|
||||
}))
|
||||
beforeEach(() => {
|
||||
database = openDatabase(':memory:')
|
||||
database
|
||||
.insert(connections)
|
||||
.values({
|
||||
id: 'account-a',
|
||||
platform: 'mastodon',
|
||||
origin: 'https://social.invalid',
|
||||
accountId: '1',
|
||||
displayName: 'A',
|
||||
status: 'connected',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
afterEach(() => database.$client.close())
|
||||
const source = { platform: 'mastodon', kind: 'hashtag', target: '猫' }
|
||||
|
||||
describe('Mastodon feed requests', () => {
|
||||
it('uses the column connection and numeric continuation without following links', async () => {
|
||||
const request = vi.fn().mockResolvedValue(
|
||||
new Response('[]', {
|
||||
headers: {
|
||||
link: '<https://social.invalid/api/v1/timelines/tag/%E7%8C%AB?max_id=100>; rel="next"',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const result = await fetchMastodonPage(
|
||||
{ connectionId: 'account-a', source, cursor: '200' },
|
||||
request,
|
||||
)
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'account-a',
|
||||
'/api/v1/timelines/tag/%E7%8C%AB',
|
||||
new URLSearchParams({ limit: '20', max_id: '200' }),
|
||||
)
|
||||
expect(result.nextCursor).toBe('100')
|
||||
expect(
|
||||
nextMastodonCursor(
|
||||
'<https://evil.invalid/api/v1/timelines/tag/cat?max_id=10>; rel="next"',
|
||||
'https://social.invalid',
|
||||
'/api/v1/timelines/tag/cat',
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
it('resolves remote account handles in the selected instance before reading statuses', async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(Response.json({ id: '42' }))
|
||||
.mockResolvedValueOnce(Response.json([]))
|
||||
await fetchMastodonPage(
|
||||
{
|
||||
connectionId: 'account-a',
|
||||
source: {
|
||||
platform: 'mastodon',
|
||||
kind: 'user',
|
||||
target: '@[email protected]',
|
||||
},
|
||||
},
|
||||
request,
|
||||
)
|
||||
expect(request.mock.calls[0]).toEqual([
|
||||
'account-a',
|
||||
'/api/v1/accounts/lookup',
|
||||
new URLSearchParams({ acct: '[email protected]' }),
|
||||
])
|
||||
expect(request.mock.calls[1]?.[1]).toBe('/api/v1/accounts/42/statuses')
|
||||
})
|
||||
it('distinguishes rate limits, expired accounts, and unavailable search', async () => {
|
||||
for (const [status, code] of [
|
||||
[429, 'rate-limit'],
|
||||
[401, 'account-unavailable'],
|
||||
[422, 'search-unavailable'],
|
||||
] as const) {
|
||||
const request = vi.fn().mockResolvedValue(new Response('{}', { status }))
|
||||
await expect(
|
||||
fetchMastodonPage(
|
||||
{
|
||||
connectionId: 'account-a',
|
||||
source: { platform: 'mastodon', kind: 'search', query: 'test' },
|
||||
},
|
||||
request,
|
||||
),
|
||||
).rejects.toMatchObject({ code })
|
||||
}
|
||||
})
|
||||
it('rejects invalid cursors and missing accounts before sending a request', async () => {
|
||||
const request = vi.fn()
|
||||
await expect(
|
||||
fetchMastodonPage(
|
||||
{ connectionId: 'account-a', source, cursor: 'https://evil.invalid' },
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow()
|
||||
await expect(
|
||||
fetchMastodonPage({ connectionId: 'missing', source }, request),
|
||||
).rejects.toMatchObject({ code: 'account-unavailable' })
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
import { mastodonFeedInput } from './mastodon-source'
|
||||
|
||||
export const loadMastodonPosts = createServerFn({ method: 'GET' })
|
||||
.validator(mastodonFeedInput)
|
||||
.handler(async ({ data }) => {
|
||||
const { fetchMastodonPage, MastodonFeedError } = await import(
|
||||
'./mastodon-feed.server'
|
||||
)
|
||||
try {
|
||||
return { ok: true as const, page: await fetchMastodonPage(data) }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code:
|
||||
error instanceof MastodonFeedError ? error.code : 'request-failed',
|
||||
message:
|
||||
error instanceof MastodonFeedError
|
||||
? error.message
|
||||
: 'Mastodonから取得できませんでした。',
|
||||
retryable:
|
||||
!(error instanceof MastodonFeedError) ||
|
||||
error.code !== 'account-unavailable',
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
export const loadMastodonLists = createServerFn({ method: 'GET' })
|
||||
.validator(z.object({ connectionId: z.string().min(1) }).strict())
|
||||
.handler(async ({ data }) => {
|
||||
const { fetchMastodonLists } = await import('./mastodon-feed.server')
|
||||
return { lists: await fetchMastodonLists(data.connectionId) }
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const mastodonSourceSchema = z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('mastodon'),
|
||||
kind: z.literal('user'),
|
||||
target: z.string().trim().min(1).max(256),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('mastodon'),
|
||||
kind: z.literal('list'),
|
||||
target: z.string().regex(/^\d{1,32}$/),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('mastodon'),
|
||||
kind: z.literal('hashtag'),
|
||||
target: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[\p{L}\p{N}_]+$/u)
|
||||
.max(128),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('mastodon'),
|
||||
kind: z.literal('search'),
|
||||
query: z.string().trim().min(1).max(512),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
export type MastodonSource = z.infer<typeof mastodonSourceSchema>
|
||||
export const mastodonFeedInput = z
|
||||
.object({
|
||||
connectionId: z.string().min(1),
|
||||
source: mastodonSourceSchema,
|
||||
cursor: z.string().max(128).optional(),
|
||||
})
|
||||
.strict()
|
||||
@@ -0,0 +1,134 @@
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import { z } from 'zod'
|
||||
import type { ResearchPost } from './types'
|
||||
|
||||
const webUrl = z
|
||||
.string()
|
||||
.url()
|
||||
.refine((value) => {
|
||||
const url = new URL(value)
|
||||
return (
|
||||
['https:', 'http:'].includes(url.protocol) &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
)
|
||||
})
|
||||
const accountSchema = z.object({
|
||||
id: z.string(),
|
||||
acct: z.string(),
|
||||
display_name: z.string(),
|
||||
username: z.string(),
|
||||
avatar: z.string().optional(),
|
||||
})
|
||||
const baseStatusSchema = z.object({
|
||||
id: z.string(),
|
||||
uri: webUrl,
|
||||
url: webUrl.nullable(),
|
||||
content: z.string(),
|
||||
created_at: z.string(),
|
||||
spoiler_text: z.string(),
|
||||
sensitive: z.boolean(),
|
||||
account: accountSchema,
|
||||
media_attachments: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
url: z.string().nullable(),
|
||||
preview_url: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const statusSchema = baseStatusSchema.extend({
|
||||
reblog: baseStatusSchema.nullable().optional(),
|
||||
})
|
||||
|
||||
export function cleanMastodonContent(content: string) {
|
||||
const html = sanitizeHtml(content, {
|
||||
allowedTags: [
|
||||
'p',
|
||||
'br',
|
||||
'a',
|
||||
'span',
|
||||
'strong',
|
||||
'em',
|
||||
'b',
|
||||
'i',
|
||||
'code',
|
||||
'pre',
|
||||
'blockquote',
|
||||
],
|
||||
allowedAttributes: { a: ['href', 'rel', 'target'] },
|
||||
allowedSchemes: ['https', 'http'],
|
||||
allowProtocolRelative: false,
|
||||
transformTags: {
|
||||
a: sanitizeHtml.simpleTransform('a', {
|
||||
target: '_blank',
|
||||
rel: 'noreferrer noopener',
|
||||
}),
|
||||
},
|
||||
})
|
||||
const escapedText = sanitizeHtml(html.replace(/<\/p>|<br\s*\/?>/g, '\n'), {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
})
|
||||
const entities: Record<string, string> = { amp: '&', lt: '<', gt: '>' }
|
||||
const text = escapedText
|
||||
.replace(/&(amp|lt|gt);/g, (_, name: string) => entities[name] ?? '')
|
||||
.trim()
|
||||
return { html, text }
|
||||
}
|
||||
|
||||
export function mapMastodonPost(raw: unknown, origin: string): ResearchPost {
|
||||
const wrapper = statusSchema.parse(raw)
|
||||
const post = wrapper.reblog ?? wrapper
|
||||
const author = (account: z.infer<typeof accountSchema>) => ({
|
||||
name: account.display_name || account.username,
|
||||
handle: account.acct.includes('@')
|
||||
? account.acct
|
||||
: `${account.acct}@${new URL(origin).host}`,
|
||||
})
|
||||
const timestamp = Date.parse(post.created_at)
|
||||
const media: NonNullable<ResearchPost['media']> =
|
||||
post.media_attachments.flatMap((item) => {
|
||||
if (
|
||||
!['image', 'video', 'gifv'].includes(item.type) ||
|
||||
!webUrl.safeParse(item.url).success
|
||||
)
|
||||
return []
|
||||
return [
|
||||
{
|
||||
type:
|
||||
item.type === 'image'
|
||||
? ('photo' as const)
|
||||
: item.type === 'gifv'
|
||||
? ('gif' as const)
|
||||
: ('video' as const),
|
||||
url: item.url as string,
|
||||
...(webUrl.safeParse(item.preview_url).success
|
||||
? { previewUrl: item.preview_url as string }
|
||||
: {}),
|
||||
...(item.description ? { alt: item.description } : {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
return {
|
||||
key: `mastodon:${wrapper.uri}`,
|
||||
platform: 'mastodon',
|
||||
nativeId: post.id,
|
||||
url: post.url ?? post.uri,
|
||||
...cleanMastodonContent(post.content),
|
||||
author: {
|
||||
...author(post.account),
|
||||
...(webUrl.safeParse(post.account.avatar).success
|
||||
? { avatarUrl: post.account.avatar }
|
||||
: {}),
|
||||
},
|
||||
...(Number.isFinite(timestamp)
|
||||
? { createdAt: new Date(timestamp).toISOString() }
|
||||
: {}),
|
||||
...(post.spoiler_text ? { contentWarning: post.spoiler_text } : {}),
|
||||
sensitive: post.sensitive,
|
||||
...(wrapper.reblog ? { boostedBy: author(wrapper.account) } : {}),
|
||||
media,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cleanMastodonContent, mapMastodonPost } from './mastodon.server'
|
||||
|
||||
const status = {
|
||||
id: 'local-123',
|
||||
uri: 'https://remote.invalid/users/a/statuses/1',
|
||||
url: 'https://remote.invalid/@a/1',
|
||||
content: '<p>Hello <strong>world</strong> & 😀</p>',
|
||||
created_at: '2026-09-24T00:00:00Z',
|
||||
spoiler_text: 'CW',
|
||||
sensitive: true,
|
||||
account: {
|
||||
id: '7',
|
||||
acct: '[email protected]',
|
||||
username: 'a',
|
||||
display_name: 'Alice',
|
||||
avatar: 'javascript:alert(1)',
|
||||
},
|
||||
media_attachments: [
|
||||
{
|
||||
type: 'image',
|
||||
url: 'https://remote.invalid/image.png',
|
||||
preview_url: null,
|
||||
description: 'An image',
|
||||
},
|
||||
],
|
||||
}
|
||||
describe('Mastodon normalization', () => {
|
||||
it('preserves canonical identity, local API id, formatting, CW, and media descriptions', () => {
|
||||
const post = mapMastodonPost(status, 'https://local.invalid')
|
||||
expect(post).toMatchObject({
|
||||
key: `mastodon:${status.uri}`,
|
||||
nativeId: 'local-123',
|
||||
text: 'Hello world & 😀',
|
||||
contentWarning: 'CW',
|
||||
sensitive: true,
|
||||
author: { name: 'Alice', handle: '[email protected]' },
|
||||
media: [{ type: 'photo', alt: 'An image' }],
|
||||
})
|
||||
expect(post.html).toContain('<strong>world</strong>')
|
||||
expect(post.author.avatarUrl).toBeUndefined()
|
||||
})
|
||||
it('distinguishes a boost from the original post', () => {
|
||||
const boost = mapMastodonPost(
|
||||
{
|
||||
...status,
|
||||
id: 'boost-99',
|
||||
uri: 'https://local.invalid/boost/99',
|
||||
account: {
|
||||
...status.account,
|
||||
acct: 'booster',
|
||||
display_name: 'Booster',
|
||||
},
|
||||
reblog: status,
|
||||
},
|
||||
'https://local.invalid',
|
||||
)
|
||||
expect(boost.key).toBe('mastodon:https://local.invalid/boost/99')
|
||||
expect(boost.nativeId).toBe(status.id)
|
||||
expect(boost.author.name).toBe('Alice')
|
||||
expect(boost.boostedBy).toEqual({
|
||||
name: 'Booster',
|
||||
handle: '[email protected]',
|
||||
})
|
||||
})
|
||||
it('strips active markup, dangerous links, styling and remote inline images', () => {
|
||||
const { html, text } = cleanMastodonContent(
|
||||
'<p onclick="alert(1)">Hi<script>alert(1)</script><img src=x onerror=alert(1)><a href="javascript:alert(1)">bad</a><a href="https://safe.invalid/" style="color:red">safe</a></p>',
|
||||
)
|
||||
expect(html).not.toMatch(/script|onclick|onerror|<img|javascript|style=/)
|
||||
expect(html).toContain('href="https://safe.invalid/"')
|
||||
expect(html).toContain('rel="noreferrer noopener"')
|
||||
expect(text).toBe('Hibadsafe')
|
||||
})
|
||||
it('returns decoded plain text without interpreting escaped markup', () => {
|
||||
expect(
|
||||
cleanMastodonContent('<p><script> &lt;</p><p>next<br>end</p>')
|
||||
.text,
|
||||
).toBe('<script> <\nnext\nend')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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() }
|
||||
},
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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(),
|
||||
})
|
||||
Reference in New Issue
Block a user