mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
egregoros compat
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user