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
+11 -5
View File
@@ -50,8 +50,9 @@ GitHub Pages — with no server rewrite rules.
plspace registers itself as an OAuth app on your server the first time you sign plspace registers itself as an OAuth app on your server the first time you sign
in there, then redirects you to that server's own consent screen. Your password in there, then redirects you to that server's own consent screen. Your password
is never entered into plspace; it only ever receives an access token, which is is never entered into plspace; it only ever receives OAuth tokens, which are
stored in `localStorage` and used directly from your browser. stored in `localStorage` and used directly from your browser. Short-lived access
tokens are renewed with a server-issued refresh token when available.
PKCE is used where the server supports it, with an automatic fallback for PKCE is used where the server supports it, with an automatic fallback for
servers that don't. servers that don't.
@@ -113,9 +114,14 @@ rewritten to in-app routes; every other link gets `target="_blank"` with
## Compatibility ## Compatibility
Written against the standard `/api/v1` REST API and tested against live servers. Written against the standard `/api/v1` REST API and the local Egregoros
implementation, as well as live servers.
Anything implementations differ on degrades rather than fails: `/api/v2/instance` Anything implementations differ on degrades rather than fails: `/api/v2/instance`
falls back to v1, `/api/v1/accounts/lookup` falls back to search, pagination falls back to v1, `/api/v1/accounts/lookup` falls back to search, pagination
falls back to the last item's id when a server drops the `Link` header, and falls back to the last item's id when a server drops the `Link` header, and
per-account privacy flags are read in both their spellings. Other software per-account privacy flags are read in both their spellings. Egregoros'
speaking the same API therefore works, but Pleroma is what this targets. hour-long access tokens and rotating refresh tokens are supported; notification
folders are filtered defensively because its current API ignores `types[]`.
Profile controls are capability-aware, so unsupported Egregoros fields are not
shown as though they could be saved. Other software speaking the same API
therefore works, but Pleroma is what this targets.
@@ -5,6 +5,7 @@
*/ */
import { untrack } from 'svelte' import { untrack } from 'svelte'
import type { CredentialAccount } from '$lib/api/types' import type { CredentialAccount } from '$lib/api/types'
import { isEgregoros, publicProfileCapabilities } from '$lib/api/capabilities'
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import { profileFieldLimits } from '$lib/stores/theme.svelte' import { profileFieldLimits } from '$lib/stores/theme.svelte'
import { toPlainText } from '$lib/util/html' import { toPlainText } from '$lib/util/html'
@@ -65,16 +66,21 @@
let error = $state<string | null>(null) let error = $state<string | null>(null)
const isPleroma = $derived(Boolean(session.instance?.pleroma)) const isPleroma = $derived(Boolean(session.instance?.pleroma))
const isEgregorosServer = $derived(isEgregoros(session.instance))
const capabilities = $derived(publicProfileCapabilities(session.instance))
const limits = $derived(profileFieldLimits(session.instance)) const limits = $derived(profileFieldLimits(session.instance))
const reservedFields = $derived(internalFields(session.me).length) const reservedFields = $derived(internalFields(session.me).length)
const availablePublicFields = $derived(Math.max(0, limits.maxFields - reservedFields)) const availablePublicFields = $derived(
capabilities.fields ? Math.max(0, limits.maxFields - reservedFields) : 0,
)
const canAddField = $derived(fields.length < availablePublicFields) const canAddField = $derived(fields.length < availablePublicFields)
const fieldsValid = $derived( const fieldsValid = $derived(
fields.filter((field) => field.name.trim()).length <= availablePublicFields && !capabilities.fields ||
(fields.filter((field) => field.name.trim()).length <= availablePublicFields &&
fields.every( fields.every(
(field) => (field) =>
field.name.length <= limits.nameLength && field.value.length <= limits.valueLength, field.name.length <= limits.nameLength && field.value.length <= limits.valueLength,
), )),
) )
const canSave = $derived(Boolean(displayName.trim()) && fieldsValid && !busy) const canSave = $derived(Boolean(displayName.trim()) && fieldsValid && !busy)
@@ -140,23 +146,25 @@
try { try {
// Read these at save time so a CSS edit made while this form is open is // Read these at save time so a CSS edit made while this form is open is
// never overwritten by an older copy of the hidden storage fields. // never overwritten by an older copy of the hidden storage fields.
const hidden = internalFields(session.me) const hidden = capabilities.fields ? internalFields(session.me) : []
const visible = fields const visible = capabilities.fields
? fields
.filter((field) => field.name.trim()) .filter((field) => field.name.trim())
.map(({ name, value }) => ({ name: name.trim(), value })) .map(({ name, value }) => ({ name: name.trim(), value }))
: []
const updated = await endpoints.updatePublicProfile(session.api, { const updated = await endpoints.updatePublicProfile(session.api, {
displayName: displayName.trim(), displayName: displayName.trim(),
note, note,
fields: [...visible, ...hidden], fields: capabilities.fields ? [...visible, ...hidden] : undefined,
avatar: imageValue(avatarMode, avatarFile), avatar: capabilities.avatar ? imageValue(avatarMode, avatarFile) : undefined,
header: imageValue(headerMode, headerFile), header: capabilities.header ? imageValue(headerMode, headerFile) : undefined,
background: isPleroma ? imageValue(backgroundMode, backgroundFile) : undefined, background: isPleroma ? imageValue(backgroundMode, backgroundFile) : undefined,
avatarDescription: isPleroma ? avatarDescription : undefined, avatarDescription: isPleroma ? avatarDescription : undefined,
headerDescription: isPleroma ? headerDescription : undefined, headerDescription: isPleroma ? headerDescription : undefined,
bot: isPleroma ? undefined : actorType === 'Service', bot: capabilities.identity && !isPleroma ? actorType === 'Service' : undefined,
actorType: isPleroma ? actorType : undefined, actorType: capabilities.identity && isPleroma ? actorType : undefined,
birthday: isPleroma ? birthday : undefined, birthday: capabilities.identity && isPleroma ? birthday : undefined,
showBirthday: isPleroma ? showBirthday : undefined, showBirthday: capabilities.identity && isPleroma ? showBirthday : undefined,
}) })
session.me = updated session.me = updated
resetFrom(updated) resetFrom(updated)
@@ -176,6 +184,12 @@
<p class="notice"> <p class="notice">
Everything in this form is public-facing and may be federated to other servers. Everything in this form is public-facing and may be federated to other servers.
</p> </p>
{#if isEgregorosServer}
<p class="notice">
Egregoros currently exposes display name, bio, and profile photo through its Mastodon API.
plspace hides the other profile controls because that server would silently ignore them.
</p>
{/if}
{#if error}<p class="error-note" role="alert">{error}</p>{/if} {#if error}<p class="error-note" role="alert">{error}</p>{/if}
{#if saved}<p class="notice" role="status">Your public profile was updated.</p>{/if} {#if saved}<p class="notice" role="status">Your public profile was updated.</p>{/if}
@@ -210,6 +224,7 @@
aria-label="Choose a new profile photo" aria-label="Choose a new profile photo"
onchange={(event) => chooseImage('avatar', event)} onchange={(event) => chooseImage('avatar', event)}
/> />
{#if capabilities.removeAvatar}
<button <button
type="button" type="button"
class="button button--small" class="button button--small"
@@ -218,11 +233,13 @@
avatarFile = null avatarFile = null
}} }}
>Remove photo</button> >Remove photo</button>
{/if}
{#if avatarMode === 'replace' && avatarFile}<span class="field-hint">{avatarFile.name}</span>{/if} {#if avatarMode === 'replace' && avatarFile}<span class="field-hint">{avatarFile.name}</span>{/if}
{#if avatarMode === 'remove'}<span class="field-hint">Will be removed when saved.</span>{/if} {#if avatarMode === 'remove'}<span class="field-hint">Will be removed when saved.</span>{/if}
</div> </div>
</div> </div>
{#if capabilities.header}
<div class="profile-editor-image profile-editor-image--wide"> <div class="profile-editor-image profile-editor-image--wide">
<img src={session.me.header} alt="Current banner" /> <img src={session.me.header} alt="Current banner" />
<div> <div>
@@ -245,6 +262,7 @@
{#if headerMode === 'remove'}<span class="field-hint">Will be removed when saved.</span>{/if} {#if headerMode === 'remove'}<span class="field-hint">Will be removed when saved.</span>{/if}
</div> </div>
</div> </div>
{/if}
{#if isPleroma} {#if isPleroma}
<div class="profile-editor-image profile-editor-image--wide"> <div class="profile-editor-image profile-editor-image--wide">
@@ -287,6 +305,7 @@
{/if} {/if}
</fieldset> </fieldset>
{#if capabilities.fields}
<fieldset class="profile-editor-fields"> <fieldset class="profile-editor-fields">
<legend>Profile details</legend> <legend>Profile details</legend>
<p class="field-hint"> <p class="field-hint">
@@ -326,7 +345,9 @@
</button> </button>
<span class="field-hint">{fields.length} of {availablePublicFields} public fields used.</span> <span class="field-hint">{fields.length} of {availablePublicFields} public fields used.</span>
</fieldset> </fieldset>
{/if}
{#if capabilities.identity}
<fieldset class="profile-editor-identity"> <fieldset class="profile-editor-identity">
<legend>Public identity</legend> <legend>Public identity</legend>
{#if isPleroma} {#if isPleroma}
@@ -359,6 +380,7 @@
</label> </label>
{/if} {/if}
</fieldset> </fieldset>
{/if}
<div class="field-row"> <div class="field-row">
<button class="button button--primary" type="submit" disabled={!canSave}> <button class="button button--primary" type="submit" disabled={!canSave}>
@@ -135,4 +135,53 @@ describe('PublicProfileEditor', () => {
}) })
expect(await view.findByText('Your public profile was updated.')).toBeInTheDocument() expect(await view.findByText('Your public profile was updated.')).toBeInTheDocument()
}) })
it('shows and submits only the profile fields Egregoros exposes through its API', async () => {
const me = credential()
const updatePublicProfile = vi.fn().mockResolvedValue(me)
const services = testServices({
session: session({
token: 'token',
me,
signedIn: true,
instance: {
title: 'Egregoros',
version: 'egregoros/0.1.0',
},
}),
endpoints: { updatePublicProfile },
})
const view = render(PublicProfileEditor, {
context: new Map([[APP_SERVICES, services]]),
})
expect(view.getByText(/currently exposes display name, bio, and profile photo/i)).toBeInTheDocument()
expect(view.queryByText('Profile banner')).not.toBeInTheDocument()
expect(view.queryByRole('button', { name: 'Remove photo' })).not.toBeInTheDocument()
expect(view.queryByRole('button', { name: 'Add profile detail' })).not.toBeInTheDocument()
expect(view.queryByRole('checkbox', { name: 'This is a bot account' })).not.toBeInTheDocument()
await fireEvent.input(view.getByLabelText('Display name'), {
target: { value: 'Alice on Egregoros' },
})
await fireEvent.input(view.getByLabelText('About me / bio'), {
target: { value: 'An Egregoros bio' },
})
const avatar = new File(['avatar'], 'avatar.png', { type: 'image/png' })
await fireEvent.change(view.getByLabelText('Choose a new profile photo'), {
target: { files: [avatar] },
})
await fireEvent.click(view.getByRole('button', { name: 'Save public profile' }))
await waitFor(() => expect(updatePublicProfile).toHaveBeenCalledOnce())
const sent = updatePublicProfile.mock.calls[0][1]
expect(sent).toMatchObject({
displayName: 'Alice on Egregoros',
note: 'An Egregoros bio',
avatar,
})
expect(sent.fields).toBeUndefined()
expect(sent.header).toBeUndefined()
expect(sent.bot).toBeUndefined()
})
}) })
@@ -6,6 +6,7 @@
* server mutation and changes what every plspace visitor sees on this profile. * server mutation and changes what every plspace visitor sees on this profile.
*/ */
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import { publicProfileCapabilities } from '$lib/api/capabilities'
import { import {
profileFieldLimits, profileFieldLimits,
publishedCssFieldValues, publishedCssFieldValues,
@@ -23,6 +24,7 @@
let locallyPublishedCss = $state<string | null>(null) let locallyPublishedCss = $state<string | null>(null)
const limits = $derived(profileFieldLimits(session.instance)) const limits = $derived(profileFieldLimits(session.instance))
const canPublish = $derived(publicProfileCapabilities(session.instance).fields)
const currentCss = $derived( const currentCss = $derived(
locallyPublishedCss ?? publishedCssFromFields(session.me?.fields), locallyPublishedCss ?? publishedCssFromFields(session.me?.fields),
) )
@@ -89,7 +91,13 @@
browser. Other clients may display the CSS as ordinary profile fields. browser. Other clients may display the CSS as ordinary profile fields.
</p> </p>
{#if editing} {#if !canPublish}
<p class="notice">
This server does not expose profile fields through its Mastodon API, so plspace cannot
publish profile CSS here. Your private browser-only CSS above still works.
</p>
<a class="button" href={`#/@${session.me.acct}`}>View my profile</a>
{:else if editing}
<label class="field-label" for="published-css">CSS shown on your profile</label> <label class="field-label" for="published-css">CSS shown on your profile</label>
<textarea <textarea
id="published-css" id="published-css"
@@ -99,4 +99,27 @@ describe('PublishedCssEditor', () => {
expect(sent.slice(1).map((field) => field.name)).toEqual(['plspace:css1']) expect(sent.slice(1).map((field) => field.name)).toEqual(['plspace:css1'])
expect(publishedCssFromFields(sent.slice(1))).toBe('.new { color: pink; }') expect(publishedCssFromFields(sent.slice(1))).toBe('.new { color: pink; }')
}) })
it('does not offer publishing when the server has no profile-fields API', () => {
const updateProfileFields = vi.fn()
const services = testServices({
session: session({
token: 'token',
me: credential([]),
signedIn: true,
instance: {
title: 'Egregoros',
version: 'egregoros/0.1.0',
},
}),
endpoints: { updateProfileFields },
})
const view = render(PublishedCssEditor, {
context: new Map([[APP_SERVICES, services]]),
})
expect(view.getByText(/does not expose profile fields through its Mastodon API/i)).toBeInTheDocument()
expect(view.queryByRole('button', { name: 'Add CSS to my profile' })).not.toBeInTheDocument()
expect(updateProfileFields).not.toHaveBeenCalled()
})
}) })
+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 QueryValue = string | number | boolean | undefined | null | string[]
export type Query = Record<string, QueryValue> export type Query = Record<string, QueryValue>
export type AccessTokenRefresher = () => Promise<string | null>
export interface RequestOptions { export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
@@ -124,10 +125,16 @@ function buildQuery(query: Query | undefined): string {
export class ApiClient { export class ApiClient {
readonly host: string readonly host: string
private token: string | null 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.host = normalizeHost(host)
this.token = token this.token = token
this.refreshAccessToken = refreshAccessToken
} }
get origin(): string { get origin(): string {
@@ -143,7 +150,7 @@ export class ApiClient {
} }
withToken(token: string | null): 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 { 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. */ /** Perform a request and return the parsed body plus the raw response. */
async raw<T>(path: string, options: RequestOptions = {}): Promise<{ data: T; response: 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) const url = this.url(path, options.query)
let response: Response let response: Response
try { 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() const text = await response.text()
let data: unknown = null let data: unknown = null
if (text) { if (text) {
@@ -251,11 +286,18 @@ export class ApiClient {
async page<T>(path: string, query?: Query, options: RequestOptions = {}): Promise<Page<T>> { 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 { data, response } = await this.raw<T[]>(path, { ...options, method: 'GET', query })
const items = Array.isArray(data) ? data : [] 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 // Fallback for servers that drop the Link header: derive `max_id` from the
// last item so "see more" keeps working. // last item of a full page so "see more" keeps working. Do not fabricate a
if (!links.maxId && items.length > 0) { // 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 } const last = items[items.length - 1] as { id?: string }
if (last && typeof last.id === 'string') links.maxId = last.id 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 { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiClient } from './client' import { ApiClient } from './client'
import { postStatus, updateProfileFields, updatePublicProfile, votePoll } from './endpoints' import {
fetchNotifications,
postStatus,
updateProfileFields,
updatePublicProfile,
votePoll,
} from './endpoints'
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals() 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 */ /* ------------------------------------------------------------- mail centre */
export function fetchNotifications( export async function fetchNotifications(
api: ApiClient, api: ApiClient,
cursor: Cursor = {}, cursor: Cursor = {},
types?: string[], types?: string[],
): Promise<Page<Notification>> { ): Promise<Page<Notification>> {
const query: Query = { ...cursor } const query: Query = { ...cursor }
if (types?.length) query.types = types 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>> { export function fetchFollowRequests(api: ApiClient, cursor: Cursor = {}): Promise<Page<Account>> {
+54
View File
@@ -5,6 +5,7 @@ import {
completeLogin, completeLogin,
ensureApp, ensureApp,
pkceChallenge, pkceChallenge,
refreshAccessToken,
redirectUri, redirectUri,
revoke, revoke,
SCOPES, SCOPES,
@@ -176,6 +177,59 @@ describe('OAuth request compatibility', () => {
expect(fetchMock).toHaveBeenCalledOnce() 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 () => { it('rejects expired attempts before exchanging the code', async () => {
saveApp() saveApp()
savePending({ createdAt: Date.now() - 11 * 60 * 1000 }) savePending({ createdAt: Date.now() - 11 * 60 * 1000 })
+47 -1
View File
@@ -157,9 +157,15 @@ export async function beginLogin(
export interface CompletedLogin { export interface CompletedLogin {
host: string host: string
token: string token: string
refreshToken?: string
returnTo: string returnTo: string
} }
export interface RefreshedAccessToken {
token: string
refreshToken?: string
}
/** /**
* Complete the flow if the current URL carries an authorization code. * Complete the flow if the current URL carries an authorization code.
* Returns null when this is an ordinary page load. * 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.') 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 { function parsePending(raw: string): PendingAuth {
+3
View File
@@ -329,7 +329,10 @@ export interface OAuthApp {
export interface OAuthToken { export interface OAuthToken {
access_token: string access_token: string
refresh_token?: string
token_type: string token_type: string
scope: string scope: string
created_at: number 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 { interface PersistedSession {
host: string host: string
token: string | null token: string | null
refreshToken: string | null
} }
function load(): PersistedSession { function load(): PersistedSession {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) 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 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 { } catch {
return { host: '', token: null } return { host: '', token: null, refreshToken: null }
} }
} }
class Session { export class Session {
host = $state('') host = $state('')
token = $state<string | null>(null) token = $state<string | null>(null)
refreshToken = $state<string | null>(null)
me = $state<CredentialAccount | null>(null) me = $state<CredentialAccount | null>(null)
instance = $state<InstanceInfo | 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. */ /** True until the first `restore()` settles, so routes can hold off. */
loading = $state(true) loading = $state(true)
error = $state<string | null>(null) error = $state<string | null>(null)
/** A client bound to the current host and token. Recomputed on change. */ /** 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 signedIn = $derived(Boolean(this.token && this.me))
readonly connected = $derived(Boolean(this.host)) readonly connected = $derived(Boolean(this.host))
@@ -60,12 +69,14 @@ class Session {
if (completed) { if (completed) {
this.host = completed.host this.host = completed.host
this.token = completed.token this.token = completed.token
this.refreshToken = completed.refreshToken ?? null
this.persist() this.persist()
landing = completed.returnTo landing = completed.returnTo
} else { } else {
const stored = load() const stored = load()
this.host = stored.host this.host = stored.host
this.token = stored.token this.token = stored.token
this.refreshToken = stored.refreshToken
} }
if (!this.host) return landing if (!this.host) return landing
@@ -80,6 +91,7 @@ class Session {
if (cause instanceof ApiError && cause.isAuthFailure) { if (cause instanceof ApiError && cause.isAuthFailure) {
// Token revoked server-side, or the instance was reinstalled. // Token revoked server-side, or the instance was reinstalled.
this.token = null this.token = null
this.refreshToken = null
this.me = null this.me = null
this.persist() this.persist()
this.error = 'Your sign-in expired. Please log in again.' this.error = 'Your sign-in expired. Please log in again.'
@@ -116,6 +128,7 @@ class Session {
this.host = normalized this.host = normalized
this.token = null this.token = null
this.refreshToken = null
this.me = null this.me = null
this.instance = instance this.instance = instance
this.error = null this.error = null
@@ -131,6 +144,7 @@ class Session {
async logout(): Promise<void> { async logout(): Promise<void> {
const { host, token } = this const { host, token } = this
this.token = null this.token = null
this.refreshToken = null
this.me = null this.me = null
this.persist() this.persist()
if (host && token) await oauth.revoke(host, token) if (host && token) await oauth.revoke(host, token)
@@ -145,9 +159,58 @@ class Session {
} }
private persist(): void { 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)) 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() 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',
})
})
})
+2 -2
View File
@@ -122,10 +122,10 @@
<p> <p>
plspace registers itself as an application on your server, then sends you there to approve plspace registers itself as an application on your server, then sends you there to approve
it. Your password is never typed into plspace &mdash; you enter it on your own server, and it. Your password is never typed into plspace &mdash; you enter it on your own server, and
plspace only ever receives an access token. plspace only ever receives OAuth tokens.
</p> </p>
<p> <p>
That token is stored in this browser's local storage and used directly from your browser. Those tokens are stored in this browser's local storage and used directly from your browser.
There is no plspace backend; nothing you read or post passes through anyone else's server. There is no plspace backend; nothing you read or post passes through anyone else's server.
</p> </p>
{#if session.signedIn} {#if session.signedIn}