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:
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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>> {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 — you enter it on your own server, and
|
it. Your password is never typed into plspace — 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user