feat: add shared decks and multi-account Mastodon OAuth

This commit is contained in:
2026-09-24 16:52:55 +09:00
parent d2cbf4dbd3
commit c47f58f065
100 changed files with 9215 additions and 1027 deletions
@@ -0,0 +1,73 @@
.connection-manager {
display: grid;
gap: 1rem;
}
.connection-manager-description,
.connection-manager-note {
margin: 0;
color: #aab8c2;
font-size: 0.875rem;
}
.connection-manager-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.75rem;
}
.connection-manager-list > li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-block: 0.75rem;
border-bottom: 1px solid #38444d;
}
.connection-manager-account {
display: grid;
gap: 0.25rem;
min-width: 0;
overflow-wrap: anywhere;
}
.connection-manager-account > span {
font-size: 0.8rem;
color: #aab8c2;
}
.connection-manager-account > .connection-manager-status-connected {
color: #71d6ad;
}
.connection-manager-account > .connection-manager-status-expired {
color: #f2c66d;
}
.connection-manager-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.connection-manager-add {
display: grid;
gap: 0.75rem;
}
.connection-manager-add h3 {
margin: 0;
font-size: 1rem;
}
.connection-manager select {
width: 100%;
min-width: 0;
background: #15202b;
color: #e7e9ea;
border: 1px solid #536471;
border-radius: 0.25rem;
padding: 0.6rem;
}
.connection-manager button {
min-height: 2.5rem;
}
@media (max-width: 480px) {
.connection-manager-list > li {
align-items: flex-start;
flex-direction: column;
}
}
@@ -0,0 +1,139 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { ConnectionManager } from './connection-manager'
import type { Connection } from './model'
const actions = vi.hoisted(() => ({
load: vi.fn(),
authorize: vi.fn(),
disconnect: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('../mastodon/server-functions', () => ({
loadMastodonInstances: actions.load,
startMastodonOAuth: actions.authorize,
disconnectMastodonAccount: actions.disconnect,
}))
const account: Connection = {
id: 'mastodon-first',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: '@first',
status: 'connected',
}
beforeEach(() => {
vi.resetAllMocks()
actions.load.mockResolvedValue({
origins: ['https://mastodon.invalid', 'https://second.invalid'],
})
})
function show(
connections: Connection[] = [account],
onChanged = vi.fn(),
hasTemporaryDecks = false,
) {
return render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<ConnectionManager
connections={connections}
onChanged={onChanged}
hasTemporaryDecks={hasTemporaryDecks}
/>
</QueryClientProvider>,
)
}
it('shows per-account status and warns about temporary decks without blocking actions', async () => {
show(
[
account,
{ ...account, id: 'expired', displayName: '@expired', status: 'expired' },
],
vi.fn(),
true,
)
expect(screen.getByText('接続済み')).toBeVisible()
expect(screen.getByText('再接続が必要')).toBeVisible()
expect(screen.getByText(/一時デッキは消えます/)).toBeVisible()
expect(
await screen.findByRole('button', { name: 'Mastodonで認可する' }),
).toBeEnabled()
})
it('uses the selected instance and reports a safe error when authorization fails', async () => {
actions.authorize.mockRejectedValue(
new Error('private server credential detail'),
)
show()
fireEvent.change(await screen.findByLabelText('Mastodonサーバー'), {
target: { value: 'https://second.invalid' },
})
fireEvent.click(screen.getByRole('button', { name: 'Mastodonで認可する' }))
await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: 'https://second.invalid' },
}),
)
expect(await screen.findByRole('alert')).toHaveTextContent(
'認可を開始できませんでした',
)
expect(screen.queryByText(/private server/)).toBeNull()
expect(
screen.getByRole('button', { name: 'Mastodonで認可する' }),
).toBeEnabled()
})
it('binds reconnect to the selected account rather than whichever instance is selected for addition', async () => {
actions.authorize.mockRejectedValue(new Error('offline'))
show()
await screen.findByLabelText('Mastodonサーバー')
fireEvent.click(screen.getByRole('button', { name: '@firstを再接続' }))
await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: account.origin, connectionId: account.id },
}),
)
expect(await screen.findByRole('alert')).toBeVisible()
})
it('refreshes the account list after successful disconnect', async () => {
const onChanged = vi.fn().mockResolvedValue(undefined)
actions.disconnect.mockResolvedValue({ disconnected: true })
show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' }))
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce())
expect(actions.disconnect).toHaveBeenCalledWith({
data: { connectionId: account.id },
})
})
it('keeps account controls available after a failed disconnect', async () => {
actions.disconnect.mockRejectedValue(new Error('private token'))
const onChanged = vi.fn()
show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'接続を解除できませんでした',
)
expect(onChanged).not.toHaveBeenCalled()
expect(
screen.getByRole('button', { name: '@firstの接続を解除' }),
).toBeEnabled()
})
it('does not offer Mastodon reconnect or revoke for Twitter relay accounts', async () => {
show([{ ...account, platform: 'twitter', id: 'twitter-first' }])
await screen.findByLabelText('Mastodonサーバー')
expect(screen.queryByRole('button', { name: '@firstを再接続' })).toBeNull()
expect(
screen.queryByRole('button', { name: '@firstの接続を解除' }),
).toBeNull()
})
@@ -0,0 +1,177 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { useId, useState } from 'react'
import {
disconnectMastodonAccount,
loadMastodonInstances,
startMastodonOAuth,
} from '../mastodon/server-functions'
import type { Connection } from './model'
import './connection-manager.css'
const statusNames = {
connected: '接続済み',
disconnected: '未接続',
expired: '再接続が必要',
}
export function ConnectionManager({
connections,
onChanged,
hasTemporaryDecks = false,
}: {
connections: Connection[]
onChanged: () => unknown
hasTemporaryDecks?: boolean
}) {
const fetchInstances = useServerFn(loadMastodonInstances)
const authorize = useServerFn(startMastodonOAuth)
const disconnect = useServerFn(disconnectMastodonAccount)
const instances = useQuery({
queryKey: ['mastodon-instances'],
queryFn: () => fetchInstances(),
retry: false,
})
const [selected, setSelected] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string>()
const selectId = useId()
const origin = selected || instances.data?.origins[0] || ''
async function connect(origin: string, connectionId?: string) {
setBusy(true)
setError(undefined)
try {
const result = await authorize({
data: { origin, ...(connectionId ? { connectionId } : {}) },
})
window.location.assign(result.authorizationUrl)
} catch {
setError(
'Mastodonの認可を開始できませんでした。接続先を確認して再試行してください。',
)
} finally {
setBusy(false)
}
}
async function remove(connectionId: string) {
setBusy(true)
setError(undefined)
try {
await disconnect({ data: { connectionId } })
try {
await onChanged()
} catch {
setError(
'接続は解除しましたが、一覧を更新できませんでした。画面を再読み込みしてください。',
)
}
} catch {
setError('接続を解除できませんでした。時間をおいて再試行してください。')
} finally {
setBusy(false)
}
}
return (
<section className="connection-manager" aria-label="接続アカウント管理">
<p className="connection-manager-description">
追加したアカウントは、どの端末のカラムからも選べます。
</p>
{error && <p role="alert">{error}</p>}
{connections.length === 0 ? (
<p>接続アカウントはまだありません。</p>
) : (
<ul className="connection-manager-list">
{connections.map((connection) => (
<li key={connection.id}>
<div className="connection-manager-account">
<strong>{connection.displayName}</strong>
<span>
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
{new URL(connection.origin).hostname}
</span>
<span
className={`connection-manager-status connection-manager-status-${connection.status}`}
>
{statusNames[connection.status]}
</span>
</div>
{connection.platform === 'mastodon' && (
<div className="connection-manager-actions">
<button
type="button"
disabled={busy}
aria-label={`${connection.displayName}を再接続`}
onClick={() =>
void connect(connection.origin, connection.id)
}
>
再接続
</button>
{connection.status !== 'disconnected' && (
<button
type="button"
disabled={busy}
aria-label={`${connection.displayName}の接続を解除`}
onClick={() => void remove(connection.id)}
>
接続解除
</button>
)}
</div>
)}
</li>
))}
</ul>
)}
<form
className="connection-manager-add"
onSubmit={(event) => {
event.preventDefault()
if (origin) void connect(origin)
}}
>
<h3>Mastodonアカウントを追加</h3>
{instances.isPending ? (
<p role="status">接続先を確認しています…</p>
) : instances.isError ? (
<p role="alert">
接続先を取得できませんでした。
<button type="button" onClick={() => void instances.refetch()}>
再試行
</button>
</p>
) : instances.data.origins.length === 0 ? (
<p>利用するMastodonサーバーがまだ設定されていません。</p>
) : (
<>
<label htmlFor={selectId}>Mastodonサーバー</label>
<select
id={selectId}
value={origin}
onChange={(event) => setSelected(event.target.value)}
disabled={busy}
>
{instances.data.origins.map((value) => (
<option key={value} value={value}>
{new URL(value).hostname}
</option>
))}
</select>
<button type="submit" disabled={busy || !origin}>
Mastodonで認可する
</button>
</>
)}
{hasTemporaryDecks && (
<p className="connection-manager-note">
認可画面へ移動すると、一時デッキは消えます。残したいデッキは先に保存してください。
</p>
)}
{busy && <p role="status">処理しています…</p>}
</form>
</section>
)
}
@@ -0,0 +1,97 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto'
import { readFileSync } from 'node:fs'
import { z } from 'zod'
const envelopeSchema = z
.object({
version: z.literal(1),
keyId: z.string(),
iv: z.string(),
tag: z.string(),
ciphertext: z.string(),
})
.strict()
function decodeBase64(value: string): Buffer {
const result = Buffer.from(value, 'base64')
if (result.toString('base64') !== value) throw new Error('Invalid encoding')
return result
}
function loadKey() {
const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE
if (!path) throw new Error('TWITTER_LITE_CREDENTIAL_KEY_FILE is required.')
let encoded: string
try {
encoded = readFileSync(path, 'utf8').trim()
} catch {
throw new Error('Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.')
}
let key: Buffer
try {
key = decodeBase64(encoded)
if (key.length !== 32) throw new Error('Invalid key length')
} catch {
throw new Error(
'TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.',
)
}
return {
key,
keyId: createHash('sha256').update(key).digest('hex').slice(0, 32),
}
}
function associatedData(context: string, keyId: string) {
if (!context) throw new Error('A credential record and purpose are required.')
return Buffer.from(
JSON.stringify(['twitter-lite-credential', 1, keyId, context]),
)
}
/** Context must identify both record and purpose; persist the returned opaque JSON. */
export function encryptCredential(plaintext: string, context: string): string {
const { key, keyId } = loadKey()
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
cipher.setAAD(associatedData(context, keyId))
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
])
return JSON.stringify({
version: 1,
keyId,
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
})
}
export function decryptCredential(serialized: string, context: string): string {
const { key, keyId } = loadKey()
try {
const envelope = envelopeSchema.parse(JSON.parse(serialized))
if (envelope.keyId !== keyId) throw new Error('Different key')
const iv = decodeBase64(envelope.iv)
const tag = decodeBase64(envelope.tag)
if (iv.length !== 12 || tag.length !== 16)
throw new Error('Invalid envelope')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAAD(associatedData(context, keyId))
decipher.setAuthTag(tag)
return Buffer.concat([
decipher.update(decodeBase64(envelope.ciphertext)),
decipher.final(),
]).toString('utf8')
} catch {
throw new Error(
'Credential could not be decrypted. Check the stored credential and encryption key.',
)
}
}
@@ -0,0 +1,126 @@
// @vitest-environment node
import { randomBytes } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { decryptCredential, encryptCredential } from './credentials.server'
let directory: string
let keyPath: string
const context = 'connection:first:access-token'
const secret = 'sensitive-token-秘密'
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-credentials-'))
keyPath = join(directory, 'key')
writeFileSync(keyPath, `${randomBytes(32).toString('base64')}\n`, {
mode: 0o600,
})
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', keyPath)
})
afterEach(() => {
vi.unstubAllEnvs()
rmSync(directory, { recursive: true, force: true })
})
it('round-trips credentials with distinct randomized ciphertext and no plaintext', () => {
const first = encryptCredential(secret, context)
const second = encryptCredential(secret, context)
expect(first).not.toBe(second)
expect(first).not.toContain(secret)
expect(first).not.toContain(Buffer.from(secret).toString('base64'))
expect(JSON.parse(first)).toMatchObject({
version: 1,
keyId: expect.stringMatching(/^[a-f0-9]{32}$/),
})
expect(decryptCredential(first, context)).toBe(secret)
expect(decryptCredential(second, context)).toBe(secret)
})
it.each([
'connection:second:access-token',
'oauth-app:first:client-secret',
])('rejects ciphertext moved to another record or purpose: %s', (otherContext) => {
const stored = encryptCredential(secret, context)
expect(() => decryptCredential(stored, otherContext)).toThrow(
'Credential could not be decrypted.',
)
})
it.each([
'iv',
'tag',
'ciphertext',
'keyId',
'version',
])('rejects tampering with %s without disclosing the secret', (field) => {
const envelope = JSON.parse(encryptCredential(secret, context))
envelope[field] = 'tampered'
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
'Credential could not be decrypted. Check the stored credential and encryption key.',
)
})
it('rejects a validly encoded altered authentication tag', () => {
const envelope = JSON.parse(encryptCredential(secret, context))
const tag = Buffer.from(envelope.tag, 'base64')
tag[0] = (tag[0] ?? 0) ^ 1
envelope.tag = tag.toString('base64')
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
'Credential could not be decrypted.',
)
})
it('requires the original key after restart or restore', () => {
const originalKey = readFileSync(keyPath)
const stored = encryptCredential(secret, context)
writeFileSync(keyPath, randomBytes(32).toString('base64'))
expect(() => decryptCredential(stored, context)).toThrow(
'Credential could not be decrypted.',
)
writeFileSync(keyPath, originalKey)
expect(decryptCredential(stored, context)).toBe(secret)
})
it.each([
'not-json',
'{}',
'{"version":2}',
])('rejects malformed or unsupported stored credentials: %s', (serialized) => {
expect(() => decryptCredential(serialized, context)).toThrow(
'Credential could not be decrypted.',
)
})
it('requires an explicitly configured credential key', () => {
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', '')
expect(() => encryptCredential(secret, context)).toThrow(
'TWITTER_LITE_CREDENTIAL_KEY_FILE is required.',
)
})
it('reports an unreadable key without including the path or plaintext', () => {
rmSync(keyPath)
expect(() => encryptCredential(secret, context)).toThrow(
'Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.',
)
})
it.each([
'',
'this is not base64',
Buffer.alloc(16).toString('base64'),
])('rejects invalid key material', (encoded) => {
writeFileSync(keyPath, encoded)
expect(() => encryptCredential(secret, context)).toThrow(
'must contain 32 random bytes encoded as base64.',
)
})
it('refuses encryption without record binding', () => {
expect(() => encryptCredential(secret, '')).toThrow(
'A credential record and purpose are required.',
)
})
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod'
const connectionSchema = z.object({
id: z.string().min(1),
platform: z.enum(['twitter', 'mastodon']),
origin: z.string().url(),
accountId: z.string().nullable(),
displayName: z.string(),
status: z.enum(['connected', 'disconnected', 'expired']),
})
export type Connection = z.infer<typeof connectionSchema>
@@ -0,0 +1,125 @@
import { randomUUID } from 'node:crypto'
import { asc, eq } from 'drizzle-orm'
import { ProfileUnavailableError } from '../profiles/errors'
import { fetchProfileNames } from '../profiles/profile-service.server'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { connections } from '../storage/schema'
import type { Connection } from './model'
function relayOrigin(): string {
const configured = process.env.TWITTER_RELAY_BASE_URL
if (!configured)
throw new ProfileUnavailableError(
'TWITTER_RELAY_BASE_URL を設定してください。',
)
return new URL(configured).origin
}
async function syncTwitterConnections(
database: AppDatabase,
fetchProfiles: typeof fetchProfileNames,
) {
const origin = relayOrigin()
const profiles = new Set(
await fetchProfiles(origin).catch(() => {
throw new ProfileUnavailableError(
'接続プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。',
)
}),
)
database.transaction((tx) => {
const existing = tx
.select()
.from(connections)
.where(eq(connections.platform, 'twitter'))
.all()
const now = Date.now()
for (const connection of existing) {
const status =
connection.origin === origin &&
connection.relayProfile !== null &&
profiles.has(connection.relayProfile)
? 'connected'
: 'disconnected'
if (connection.status !== status) {
tx.update(connections)
.set({ status, updatedAt: now })
.where(eq(connections.id, connection.id))
.run()
}
}
const known = new Set(
existing
.filter((connection) => connection.origin === origin)
.map((connection) => connection.relayProfile),
)
for (const profile of profiles) {
if (known.has(profile)) continue
tx.insert(connections)
.values({
id: randomUUID(),
platform: 'twitter',
origin,
relayProfile: profile,
displayName: profile,
status: 'connected',
createdAt: now,
updatedAt: now,
})
.run()
}
})
return origin
}
const publicFields = {
id: connections.id,
platform: connections.platform,
origin: connections.origin,
accountId: connections.accountId,
displayName: connections.displayName,
status: connections.status,
}
export async function listConnections(
database = getDatabase(),
fetchProfiles = fetchProfileNames,
): Promise<{ connections: Connection[]; relayError?: string }> {
let relayError: string | undefined
try {
await syncTwitterConnections(database, fetchProfiles)
} catch (error) {
if (!(error instanceof ProfileUnavailableError)) throw error
relayError = error.message
}
const saved = database
.select(publicFields)
.from(connections)
.orderBy(asc(connections.createdAt), asc(connections.id))
.all()
return { connections: saved, ...(relayError ? { relayError } : {}) }
}
export async function requireTwitterConnection(
id: string,
database = getDatabase(),
fetchProfiles = fetchProfileNames,
): Promise<string> {
const origin = await syncTwitterConnections(database, fetchProfiles)
const connection = database
.select()
.from(connections)
.where(eq(connections.id, id))
.get()
if (
connection?.platform !== 'twitter' ||
connection.origin !== origin ||
connection.status !== 'connected' ||
!connection.relayProfile
) {
throw new ProfileUnavailableError(
'この接続は利用できません。カラムの接続アカウントを確認してください。',
)
}
return connection.relayProfile
}
+157
View File
@@ -0,0 +1,157 @@
// @vitest-environment node
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connectionCredentials, connections } from '../storage/schema'
import { listConnections, requireTwitterConnection } from './repository.server'
let database: AppDatabase
beforeEach(() => {
database = openDatabase(':memory:')
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid/')
})
afterEach(() => {
database.$client.close()
vi.unstubAllEnvs()
})
it('retains stable distinct IDs for multiple relay profiles across repeated discovery', async () => {
const fetchProfiles = vi.fn().mockResolvedValue(['first', 'second', 'first'])
const { connections: first } = await listConnections(database, fetchProfiles)
const { connections: second } = await listConnections(database, fetchProfiles)
expect(first).toHaveLength(2)
expect(new Set(first.map((connection) => connection.id)).size).toBe(2)
expect(second).toEqual(first)
expect(
await requireTwitterConnection(first[0]?.id ?? '', database, fetchProfiles),
).toBe(first[0]?.displayName)
expect(fetchProfiles).toHaveBeenCalledWith('https://relay.invalid')
})
it('preserves unavailable bindings and reconnects the original ID when a profile returns', async () => {
const { connections: original } = await listConnections(
database,
async () => ['first'],
)
const id = original[0]?.id ?? ''
expect((await listConnections(database, async () => [])).connections).toEqual(
[expect.objectContaining({ id, status: 'disconnected' })],
)
await expect(
requireTwitterConnection(id, database, async () => []),
).rejects.toThrow('この接続は利用できません')
expect(
(await listConnections(database, async () => ['first'])).connections,
).toEqual(original)
})
it('does not reuse an old relay binding for the same profile at another origin', async () => {
const { connections: original } = await listConnections(
database,
async () => ['first'],
)
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://another-relay.invalid')
const { connections: current } = await listConnections(database, async () => [
'first',
])
expect(current).toHaveLength(2)
expect(current).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: original[0]?.id, status: 'disconnected' }),
expect.objectContaining({
origin: 'https://another-relay.invalid',
status: 'connected',
}),
]),
)
await expect(
requireTwitterConnection(original[0]?.id ?? '', database, async () => [
'first',
]),
).rejects.toThrow('この接続は利用できません')
})
it('does not convert a relay outage into persisted account removal', async () => {
const original = await listConnections(database, async () => ['first'])
const unavailable = vi
.fn()
.mockRejectedValue(new Error('private network detail'))
const failed = await listConnections(database, unavailable)
expect(failed.connections).toEqual(original.connections)
expect(failed.relayError).toContain(
'Relay への接続を確認して再試行してください。',
)
expect(database.select().from(connections).get()?.status).toBe('connected')
expect(await listConnections(database, async () => ['first'])).toEqual(
original,
)
})
it('returns public metadata for all platforms without joining or exposing credentials', async () => {
database
.insert(connections)
.values({
id: 'mastodon-account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '42',
displayName: '@owner',
status: 'connected',
createdAt: 1,
updatedAt: 1,
})
.run()
database
.insert(connectionCredentials)
.values({
connectionId: 'mastodon-account',
encryptedToken: 'stored-secret-envelope',
updatedAt: 1,
})
.run()
const { connections: discovered } = await listConnections(
database,
async () => ['first'],
)
expect(discovered[0]).toEqual({
id: 'mastodon-account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '42',
displayName: '@owner',
status: 'connected',
})
expect(Object.keys(discovered[1] ?? {}).sort()).toEqual([
'accountId',
'displayName',
'id',
'origin',
'platform',
'status',
])
expect(JSON.stringify(discovered)).not.toContain('stored-secret-envelope')
const outage = await listConnections(database, async () => {
throw new Error('offline')
})
expect(outage.connections).toEqual(discovered)
expect(outage.relayError).toBeDefined()
await expect(
requireTwitterConnection('mastodon-account', database, async () => [
'first',
]),
).rejects.toThrow('この接続は利用できません')
expect(
database
.select()
.from(connections)
.where(eq(connections.id, 'mastodon-account'))
.get()?.status,
).toBe('connected')
})
it('rejects an unknown connection without treating its ID as a relay profile', async () => {
await expect(
requireTwitterConnection('first', database, async () => ['first']),
).rejects.toThrow('この接続は利用できません')
})
@@ -0,0 +1,8 @@
import { createServerFn } from '@tanstack/react-start'
export const loadConnections = createServerFn({ method: 'GET' }).handler(
async () => {
const { listConnections } = await import('./repository.server')
return listConnections()
},
)