egregoros compat

This commit is contained in:
Moon.eth
2026-07-29 20:59:52 +09:00
parent 081460c7f6
commit 5b7b4360d2
16 changed files with 713 additions and 137 deletions
+40
View File
@@ -0,0 +1,40 @@
import type { InstanceInfo } from './types'
export interface PublicProfileCapabilities {
avatar: boolean
removeAvatar: boolean
header: boolean
fields: boolean
identity: boolean
}
export function isEgregoros(instance: InstanceInfo | null | undefined): boolean {
return /^egregoros\//i.test(instance?.version?.trim() ?? '')
}
/**
* Egregoros deliberately implements a smaller update_credentials surface than
* Mastodon/Pleroma. Keep unsupported controls out of the UI instead of sending
* fields the server silently ignores and claiming that they were saved.
*/
export function publicProfileCapabilities(
instance: InstanceInfo | null | undefined,
): PublicProfileCapabilities {
if (isEgregoros(instance)) {
return {
avatar: true,
removeAvatar: false,
header: false,
fields: false,
identity: false,
}
}
return {
avatar: true,
removeAvatar: true,
header: true,
fields: true,
identity: true,
}
}
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiClient } from './client'
function response(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...headers },
})
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('ApiClient Egregoros compatibility', () => {
it('refreshes an expired bearer token once and retries the request', async () => {
const refreshToken = vi.fn().mockResolvedValue('fresh-token')
const fetchMock = vi
.fn()
.mockResolvedValueOnce(response({ error: 'Invalid or expired token' }, 401))
.mockResolvedValueOnce(response({ id: 'alice' }))
vi.stubGlobal('fetch', fetchMock)
const client = new ApiClient('egregoros.example', 'expired-token', refreshToken)
await expect(client.get('/api/v1/accounts/verify_credentials')).resolves.toEqual({
id: 'alice',
})
expect(refreshToken).toHaveBeenCalledOnce()
expect((fetchMock.mock.calls[0][1].headers as Headers).get('Authorization')).toBe(
'Bearer expired-token',
)
expect((fetchMock.mock.calls[1][1].headers as Headers).get('Authorization')).toBe(
'Bearer fresh-token',
)
})
it('does not invent a next page when the server supplied only a prev link', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
response(
[{ id: 'newest' }, { id: 'oldest' }],
200,
{
Link: '<https://egregoros.example/api/v1/timelines/public?since_id=newest>; rel="prev"',
},
),
),
)
const page = await new ApiClient('egregoros.example').page<{ id: string }>(
'/api/v1/timelines/public',
{ limit: 20 },
)
expect(page.links.sinceId).toBe('newest')
expect(page.links.maxId).toBeUndefined()
})
it('derives a next cursor only for a full page when Link was stripped entirely', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(response([{ id: 'newest' }, { id: 'oldest' }])),
)
const page = await new ApiClient('egregoros.example').page<{ id: string }>(
'/api/v1/timelines/public',
{ limit: 2 },
)
expect(page.links.maxId).toBe('oldest')
})
})
+47 -5
View File
@@ -45,6 +45,7 @@ export interface Page<T> {
export type QueryValue = string | number | boolean | undefined | null | string[]
export type Query = Record<string, QueryValue>
export type AccessTokenRefresher = () => Promise<string | null>
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
@@ -124,10 +125,16 @@ function buildQuery(query: Query | undefined): string {
export class ApiClient {
readonly host: string
private token: string | null
private readonly refreshAccessToken?: AccessTokenRefresher
constructor(host: string, token: string | null = null) {
constructor(
host: string,
token: string | null = null,
refreshAccessToken?: AccessTokenRefresher,
) {
this.host = normalizeHost(host)
this.token = token
this.refreshAccessToken = refreshAccessToken
}
get origin(): string {
@@ -143,7 +150,7 @@ export class ApiClient {
}
withToken(token: string | null): ApiClient {
return new ApiClient(this.host, token)
return new ApiClient(this.host, token, this.refreshAccessToken)
}
private url(path: string, query?: Query): string {
@@ -164,6 +171,14 @@ export class ApiClient {
/** Perform a request and return the parsed body plus the raw response. */
async raw<T>(path: string, options: RequestOptions = {}): Promise<{ data: T; response: Response }> {
return this.perform<T>(path, options, true)
}
private async perform<T>(
path: string,
options: RequestOptions,
mayRefresh: boolean,
): Promise<{ data: T; response: Response }> {
const url = this.url(path, options.query)
let response: Response
try {
@@ -187,6 +202,26 @@ export class ApiClient {
)
}
if (
response.status === 401 &&
mayRefresh &&
options.token === undefined &&
this.token &&
this.refreshAccessToken
) {
let refreshed: string | null = null
try {
refreshed = await this.refreshAccessToken()
} catch {
// Preserve the original API response when renewal itself fails. The
// session owns clearing invalid refresh credentials.
}
if (refreshed) {
this.token = refreshed
return this.perform<T>(path, options, false)
}
}
const text = await response.text()
let data: unknown = null
if (text) {
@@ -251,11 +286,18 @@ export class ApiClient {
async page<T>(path: string, query?: Query, options: RequestOptions = {}): Promise<Page<T>> {
const { data, response } = await this.raw<T[]>(path, { ...options, method: 'GET', query })
const items = Array.isArray(data) ? data : []
const links = parseLinkHeader(response.headers.get('Link'))
const linkHeader = response.headers.get('Link')
const links = parseLinkHeader(linkHeader)
// Fallback for servers that drop the Link header: derive `max_id` from the
// last item so "see more" keeps working.
if (!links.maxId && items.length > 0) {
// last item of a full page so "see more" keeps working. Do not fabricate a
// next cursor when the server explicitly supplied only `rel="prev"`:
// Egregoros does that on its final page.
const requestedLimit =
typeof query?.limit === 'number' && Number.isFinite(query.limit)
? Math.max(1, query.limit)
: 20
if (!linkHeader && !links.maxId && items.length >= requestedLimit) {
const last = items[items.length - 1] as { id?: string }
if (last && typeof last.id === 'string') links.maxId = last.id
}
+32 -1
View File
@@ -1,6 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiClient } from './client'
import { postStatus, updateProfileFields, updatePublicProfile, votePoll } from './endpoints'
import {
fetchNotifications,
postStatus,
updateProfileFields,
updatePublicProfile,
votePoll,
} from './endpoints'
afterEach(() => {
vi.unstubAllGlobals()
@@ -145,3 +151,28 @@ describe('poll endpoints', () => {
})
})
})
describe('fetchNotifications', () => {
it('defensively applies requested types when a server ignores the filter', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify([
{ id: 'favourite-1', type: 'favourite' },
{ id: 'mention-1', type: 'mention' },
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
)
const page = await fetchNotifications(
new ApiClient('egregoros.example', 'token'),
{ limit: 20 },
['favourite'],
)
expect(page.items).toEqual([{ id: 'favourite-1', type: 'favourite' }])
})
})
+9 -2
View File
@@ -330,14 +330,21 @@ export async function uploadMedia(api: ApiClient, file: File, description?: stri
/* ------------------------------------------------------------- mail centre */
export function fetchNotifications(
export async function fetchNotifications(
api: ApiClient,
cursor: Cursor = {},
types?: string[],
): Promise<Page<Notification>> {
const query: Query = { ...cursor }
if (types?.length) query.types = types
return api.page<Notification>('/api/v1/notifications', query)
const page = await api.page<Notification>('/api/v1/notifications', query)
if (!types?.length) return page
// Egregoros currently accepts but ignores `types[]`. Apply the same filter
// locally so Mail folders remain correct; this is harmless when the server
// already filtered the page.
const allowed = new Set(types)
return { ...page, items: page.items.filter((notification) => allowed.has(notification.type)) }
}
export function fetchFollowRequests(api: ApiClient, cursor: Cursor = {}): Promise<Page<Account>> {
+54
View File
@@ -5,6 +5,7 @@ import {
completeLogin,
ensureApp,
pkceChallenge,
refreshAccessToken,
redirectUri,
revoke,
SCOPES,
@@ -176,6 +177,59 @@ describe('OAuth request compatibility', () => {
expect(fetchMock).toHaveBeenCalledOnce()
})
it('retains Egregoros refresh credentials and rotates them with form encoding', async () => {
saveApp()
savePending()
window.history.replaceState({}, '', '/?code=authorization-code&state=expected-state')
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
response({
access_token: 'first-access-token',
refresh_token: 'first-refresh-token',
token_type: 'Bearer',
scope: SCOPES,
created_at: 123,
expires_in: 3600,
authorization_expires_in: 31_536_000,
}),
)
.mockResolvedValueOnce(
response({
access_token: 'rotated-access-token',
refresh_token: 'rotated-refresh-token',
token_type: 'Bearer',
scope: SCOPES,
created_at: 456,
expires_in: 3600,
}),
)
vi.stubGlobal('fetch', fetchMock)
await expect(completeLogin()).resolves.toEqual({
host: 'social.example',
token: 'first-access-token',
refreshToken: 'first-refresh-token',
returnTo: '#/timeline/home',
})
await expect(
refreshAccessToken('social.example', 'first-refresh-token'),
).resolves.toEqual({
token: 'rotated-access-token',
refreshToken: 'rotated-refresh-token',
})
const [url, options] = fetchMock.mock.calls[1] as [string, RequestInit]
expect(url).toBe('https://social.example/oauth/token')
expect(options.body).toBeInstanceOf(URLSearchParams)
expect(Object.fromEntries(new URLSearchParams(String(options.body)))).toEqual({
grant_type: 'refresh_token',
client_id: 'client-id',
client_secret: 'client-secret',
refresh_token: 'first-refresh-token',
})
})
it('rejects expired attempts before exchanging the code', async () => {
saveApp()
savePending({ createdAt: Date.now() - 11 * 60 * 1000 })
+47 -1
View File
@@ -157,9 +157,15 @@ export async function beginLogin(
export interface CompletedLogin {
host: string
token: string
refreshToken?: string
returnTo: string
}
export interface RefreshedAccessToken {
token: string
refreshToken?: string
}
/**
* Complete the flow if the current URL carries an authorization code.
* Returns null when this is an ordinary page load.
@@ -211,7 +217,47 @@ export async function completeLogin(): Promise<CompletedLogin | null> {
throw new Error('The server returned an invalid OAuth token response.')
}
return { host: pending.host, token: token.access_token, returnTo: pending.returnTo || '#/' }
return {
host: pending.host,
token: token.access_token,
...(validRefreshToken(token.refresh_token) ? { refreshToken: token.refresh_token } : {}),
returnTo: pending.returnTo || '#/',
}
}
/**
* Renew a short-lived access token. Egregoros rotates its refresh token on
* every use, while older Mastodon/Pleroma servers simply omit refresh tokens;
* keeping this optional preserves both behaviours.
*/
export async function refreshAccessToken(
host: string,
refreshToken: string,
): Promise<RefreshedAccessToken> {
const key = normalizeHost(host)
const app = readApps()[key]
if (!app?.client_id || !app.client_secret) {
throw new Error('Lost the app registration for this server. Please sign in again.')
}
const token = await new ApiClient(key).postForm<OAuthToken>('/oauth/token', {
grant_type: 'refresh_token',
client_id: app.client_id,
client_secret: app.client_secret,
refresh_token: refreshToken,
})
if (!token || typeof token.access_token !== 'string' || !token.access_token) {
throw new Error('The server returned an invalid OAuth token response.')
}
return {
token: token.access_token,
...(validRefreshToken(token.refresh_token) ? { refreshToken: token.refresh_token } : {}),
}
}
function validRefreshToken(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}
function parsePending(raw: string): PendingAuth {
+3
View File
@@ -329,7 +329,10 @@ export interface OAuthApp {
export interface OAuthToken {
access_token: string
refresh_token?: string
token_type: string
scope: string
created_at: number
expires_in?: number
authorization_expires_in?: number
}
+69 -6
View File
@@ -17,31 +17,40 @@ const STORAGE_KEY = 'plspace:session'
interface PersistedSession {
host: string
token: string | null
refreshToken: string | null
}
function load(): PersistedSession {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { host: '', token: null }
if (!raw) return { host: '', token: null, refreshToken: null }
const parsed = JSON.parse(raw) as PersistedSession
return { host: normalizeHost(parsed.host ?? ''), token: parsed.token ?? null }
return {
host: normalizeHost(parsed.host ?? ''),
token: typeof parsed.token === 'string' ? parsed.token : null,
refreshToken: typeof parsed.refreshToken === 'string' ? parsed.refreshToken : null,
}
} catch {
return { host: '', token: null }
return { host: '', token: null, refreshToken: null }
}
}
class Session {
export class Session {
host = $state('')
token = $state<string | null>(null)
refreshToken = $state<string | null>(null)
me = $state<CredentialAccount | null>(null)
instance = $state<InstanceInfo | null>(null)
private refreshInFlight: Promise<string | null> | null = null
/** True until the first `restore()` settles, so routes can hold off. */
loading = $state(true)
error = $state<string | null>(null)
/** A client bound to the current host and token. Recomputed on change. */
readonly api = $derived(new ApiClient(this.host, this.token))
readonly api = $derived(
new ApiClient(this.host, this.token, () => this.renewAccessToken()),
)
readonly signedIn = $derived(Boolean(this.token && this.me))
readonly connected = $derived(Boolean(this.host))
@@ -60,12 +69,14 @@ class Session {
if (completed) {
this.host = completed.host
this.token = completed.token
this.refreshToken = completed.refreshToken ?? null
this.persist()
landing = completed.returnTo
} else {
const stored = load()
this.host = stored.host
this.token = stored.token
this.refreshToken = stored.refreshToken
}
if (!this.host) return landing
@@ -80,6 +91,7 @@ class Session {
if (cause instanceof ApiError && cause.isAuthFailure) {
// Token revoked server-side, or the instance was reinstalled.
this.token = null
this.refreshToken = null
this.me = null
this.persist()
this.error = 'Your sign-in expired. Please log in again.'
@@ -116,6 +128,7 @@ class Session {
this.host = normalized
this.token = null
this.refreshToken = null
this.me = null
this.instance = instance
this.error = null
@@ -131,6 +144,7 @@ class Session {
async logout(): Promise<void> {
const { host, token } = this
this.token = null
this.refreshToken = null
this.me = null
this.persist()
if (host && token) await oauth.revoke(host, token)
@@ -145,9 +159,58 @@ class Session {
}
private persist(): void {
const payload: PersistedSession = { host: this.host, token: this.token }
const payload: PersistedSession = {
host: this.host,
token: this.token,
refreshToken: this.refreshToken,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
}
/**
* Egregoros access tokens are intentionally short-lived. Serialize renewal
* because several timeline/sidebar requests can discover expiry together,
* and Egregoros rotates each refresh token exactly once.
*/
private renewAccessToken(): Promise<string | null> {
if (this.refreshInFlight) return this.refreshInFlight
if (!this.host || !this.refreshToken) return Promise.resolve(null)
const attempt = this.performTokenRenewal()
this.refreshInFlight = attempt
void attempt.finally(() => {
if (this.refreshInFlight === attempt) this.refreshInFlight = null
})
return attempt
}
private async performTokenRenewal(): Promise<string | null> {
const host = this.host
const refreshToken = this.refreshToken
if (!host || !refreshToken) return null
try {
const refreshed = await oauth.refreshAccessToken(host, refreshToken)
if (this.host !== host || this.refreshToken !== refreshToken) return null
this.token = refreshed.token
this.refreshToken = refreshed.refreshToken ?? refreshToken
this.persist()
return refreshed.token
} catch (cause) {
// A transient CORS/offline failure should not erase a renewable session.
// A server response means the rotating token is no longer usable.
if (!(cause instanceof ApiError) || cause.status !== 0) {
if (this.host === host && this.refreshToken === refreshToken) {
this.token = null
this.refreshToken = null
this.me = null
this.persist()
}
}
return null
}
}
}
export const session = new Session()
+108
View File
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Session } from './session.svelte'
const SESSION_KEY = 'plspace:session'
const APP_KEY = 'plspace:oauth:apps'
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Session Egregoros compatibility', () => {
it('persists and automatically rotates a refresh token after an expired API request', async () => {
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
host: 'egregoros.example',
token: 'expired-access-token',
refreshToken: 'first-refresh-token',
}),
)
localStorage.setItem(
APP_KEY,
JSON.stringify({
'egregoros.example': {
id: 'app-1',
name: 'plspace',
client_id: 'client-id',
client_secret: 'client-secret',
redirect_uri: `${window.location.origin}${window.location.pathname}`,
plspace_redirect_uri: `${window.location.origin}${window.location.pathname}`,
},
}),
)
const verifyCalls: string[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith('/api/v2/instance')) {
return json({ title: 'Egregoros', version: 'egregoros/0.1.0' })
}
if (url.endsWith('/oauth/token')) {
expect(Object.fromEntries(new URLSearchParams(String(init?.body)))).toMatchObject({
grant_type: 'refresh_token',
refresh_token: 'first-refresh-token',
})
return json({
access_token: 'fresh-access-token',
refresh_token: 'rotated-refresh-token',
token_type: 'Bearer',
scope: 'read write follow',
created_at: 123,
expires_in: 3600,
})
}
if (url.endsWith('/api/v1/accounts/verify_credentials')) {
const authorization = (init?.headers as Headers).get('Authorization') ?? ''
verifyCalls.push(authorization)
if (authorization === 'Bearer expired-access-token') {
return json({ error: 'Invalid or expired token' }, 401)
}
return json({
id: 'account-1',
username: 'alice',
acct: 'alice',
display_name: 'Alice',
note: '',
url: 'https://egregoros.example/@alice',
avatar: '',
avatar_static: '',
header: '',
header_static: '',
locked: false,
created_at: '2026-01-01T00:00:00Z',
statuses_count: 0,
followers_count: 0,
following_count: 0,
fields: [],
emojis: [],
})
}
throw new Error(`Unexpected request: ${url}`)
}),
)
const session = new Session()
await session.restore()
expect(verifyCalls).toEqual([
'Bearer expired-access-token',
'Bearer fresh-access-token',
])
expect(session.token).toBe('fresh-access-token')
expect(session.signedIn).toBe(true)
expect(JSON.parse(String(localStorage.getItem(SESSION_KEY)))).toEqual({
host: 'egregoros.example',
token: 'fresh-access-token',
refreshToken: 'rotated-refresh-token',
})
})
})