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