chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Account page tests - sign-in form, cookie session, account screen
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createAccountJwt } from '../packages/core/src/auth.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';
import { cborEncodeDagCbor } from '../packages/core/src/repo.js';
const DID = 'did:plc:test123';
const HANDLE = 'test.handle';
const PASSWORD = 'test-password';
const AVATAR_CID = 'bafkreiabc123def456ghi789jkl012mno345pqr678stu901vwx234yz';
// The storage key for an OAuth session is the refresh token itself
const REFRESH_TOKEN = 'refresh-token-that-must-never-be-rendered';
/**
* @param {Object} [options]
* @param {Record<string, unknown>|null} [options.profile] - The account's
* app.bsky.actor.profile record, or null when it has none
* @param {string[]} [options.records] - Record keys, as `collection/rkey`
* @param {string[]} [options.blobs] - Stored blob CIDs
* @param {string} [options.status] - Account status
*/
function createMockStorage({
profile = null,
records = [],
blobs = [],
status = 'active',
} = {}) {
let currentStatus = status;
/** @type {import('../packages/core/src/ports.js').EmailState|null} */
let email = null;
/** @type {import('../packages/core/src/ports.js').PlcToken|null} */
let plcToken = null;
/** @type {import('../packages/core/src/ports.js').AccountUiState|null} */
let accountUiState = null;
/** @type {import('../packages/core/src/ports.js').AccountPasswordState|null} */
let accountPassword = null;
return {
async getDid() {
return DID;
},
async getAccountPassword() {
return accountPassword;
},
async setAccountPassword(/** @type {any} */ state) {
accountPassword = state;
},
async getEmail() {
return email;
},
async setEmail(/** @type {any} */ state) {
email = state;
},
async getPlcToken() {
return plcToken;
},
async setPlcToken(/** @type {any} */ token) {
plcToken = token;
},
async getHandle() {
return HANDLE;
},
async getAccountStatus() {
return currentStatus;
},
async setAccountStatus(/** @type {string} */ next) {
currentStatus = next;
},
async getPrivateKey() {
return null;
},
async getRecord(/** @type {string} */ uri) {
if (!profile || uri !== `at://${DID}/app.bsky.actor.profile/self`) {
return null;
}
return { cid: 'bafytest', value: cborEncodeDagCbor(profile) };
},
async listAllRecords() {
return records.map((key) => ({ key, cid: 'bafytest' }));
},
async listRecords(
/** @type {string} */ collection,
/** @type {string|null} */ _cursor,
/** @type {number} */ limit,
) {
const matching = records
.filter((key) => key.startsWith(`${collection}/`))
.slice(0, limit)
.map((key) => ({
uri: `at://${DID}/${key}`,
cid: 'bafyrecord',
value: cborEncodeDagCbor({
$type: collection,
text: `record ${key.split('/')[1]}`,
}),
}));
return { records: matching, cursor: null };
},
async listBlobs() {
return { cids: blobs, cursor: null };
},
async getLatestCommit() {
return { seq: 7, cid: 'bafycommit', rev: '3lrev' };
},
async getEvents() {
return { events: [], cursor: 0 };
},
async getLatestSeq() {
return 7;
},
async putEvent() {},
async getAccountUiState() {
return accountUiState;
},
async setAccountUiState(
/** @type {import('../packages/core/src/ports.js').AccountUiState} */ state,
) {
accountUiState = state;
},
};
}
/**
* @param {Object} [options]
* @param {Array<{name: string, privileged: boolean, createdAt: string}>} [options.appPasswords]
* @param {Array<Record<string, unknown>>} [options.oauthTokens]
*/
function createMockSharedStorage({ appPasswords = [], oauthTokens = [] } = {}) {
/** @type {any[]} */
const passwords = [...appPasswords];
/** @type {any[]} */
const passkeys = [];
/** @type {any[]} */
const sessions = [];
/** @type {Map<string, any>} */
const oauthRequests = new Map();
let tokens = [...oauthTokens];
return {
get tokens() {
return tokens;
},
async deleteOAuthToken(/** @type {string} */ tokenId) {
tokens = tokens.filter((token) => token.tokenId !== tokenId);
},
async listAppPasswords() {
// Hashes stay in storage, as the real adapters keep them
return passwords.map(({ hash: _hash, ...entry }) => entry);
},
async getAppPasswordByHash(
/** @type {string} */ _did,
/** @type {string} */ hash,
) {
const found = passwords.find((entry) => entry.hash === hash);
if (!found) return null;
const { hash: _stored, ...record } = found;
return record;
},
async putAppPassword(
/** @type {string} */ _did,
/** @type {string} */ name,
/** @type {string} */ hash,
/** @type {boolean} */ privileged,
/** @type {string} */ createdAt,
) {
if (passwords.some((entry) => entry.name === name)) return false;
passwords.push({ name, privileged, createdAt, hash });
return true;
},
async deleteAppPassword(
/** @type {string} */ _did,
/** @type {string} */ name,
) {
const index = passwords.findIndex((entry) => entry.name === name);
if (index === -1) return false;
passwords.splice(index, 1);
return true;
},
async listOAuthTokensByDid() {
return tokens;
},
async getOAuthRequest(/** @type {string} */ uri) {
return oauthRequests.get(uri) || null;
},
async putOAuthRequest(/** @type {string} */ uri, /** @type {any} */ data) {
oauthRequests.set(uri, data);
},
async deleteOAuthRequest(/** @type {string} */ uri) {
oauthRequests.delete(uri);
},
async putSession(/** @type {any} */ session) {
const index = sessions.findIndex((s) => s.jti === session.jti);
if (index === -1) sessions.push(session);
else sessions[index] = session;
},
async getSession(/** @type {string} */ jti) {
return sessions.find((s) => s.jti === jti) || null;
},
async listSessions() {
return sessions;
},
async deleteSession(/** @type {string} */ jti) {
const index = sessions.findIndex((s) => s.jti === jti);
if (index === -1) return false;
sessions.splice(index, 1);
return true;
},
async putPasskey(/** @type {any} */ record) {
if (passkeys.some((p) => p.credentialId === record.credentialId)) {
return false;
}
passkeys.push(record);
return true;
},
async getPasskey(/** @type {string} */ credentialId) {
return passkeys.find((p) => p.credentialId === credentialId) || null;
},
async listPasskeys() {
return passkeys;
},
async deletePasskey(
/** @type {string} */ _did,
/** @type {string} */ credentialId,
) {
const index = passkeys.findIndex((p) => p.credentialId === credentialId);
if (index === -1) return false;
passkeys.splice(index, 1);
return true;
},
async touchPasskey(
/** @type {string} */ credentialId,
/** @type {number} */ signCount,
/** @type {string} */ lastUsedAt,
) {
const passkey = passkeys.find((p) => p.credentialId === credentialId);
if (passkey) Object.assign(passkey, { signCount, lastUsedAt });
},
};
}
/**
* A stand-in authenticator: a P-256 key pair plus the byte layouts a real one
* produces, so the ceremonies can run end to end without a browser.
* @returns {Promise<{credentialId: string, publicKey: string, privateKey: CryptoKey}>}
*/
async function createTestCredential() {
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
);
const spki = new Uint8Array(
await crypto.subtle.exportKey('spki', keyPair.publicKey),
);
return {
credentialId: b64url(crypto.getRandomValues(new Uint8Array(16))),
publicKey: b64url(spki),
privateKey: keyPair.privateKey,
};
}
/**
* @param {{credentialId: string, privateKey: CryptoKey}} credential
* @param {string} challenge
*/
async function signTestAssertion(credential, challenge) {
const rpIdHash = new Uint8Array(
await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode('pds.example.com'),
),
);
const authData = new Uint8Array(37);
authData.set(rpIdHash, 0);
authData[32] = 0x01; // user present
const clientDataJSON = encodeClientData({
type: 'webauthn.get',
challenge,
origin: 'https://pds.example.com',
});
const clientDataHash = new Uint8Array(
await crypto.subtle.digest('SHA-256', fromB64url(clientDataJSON)),
);
const signed = new Uint8Array(authData.length + clientDataHash.length);
signed.set(authData, 0);
signed.set(clientDataHash, authData.length);
const raw = new Uint8Array(
await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
credential.privateKey,
signed,
),
);
return {
credentialId: credential.credentialId,
authenticatorData: b64url(authData),
clientDataJSON,
signature: b64url(toDer(raw)),
};
}
/** WebCrypto signs raw r||s; a browser sends DER. */
function toDer(/** @type {Uint8Array} */ raw) {
const encodeInt = (/** @type {Uint8Array} */ bytes) => {
let start = 0;
while (start < bytes.length - 1 && bytes[start] === 0) start++;
let value = bytes.slice(start);
if (value[0] & 0x80) value = Uint8Array.from([0, ...value]);
return Uint8Array.from([0x02, value.length, ...value]);
};
const r = encodeInt(raw.slice(0, 32));
const s = encodeInt(raw.slice(32));
return Uint8Array.from([0x30, r.length + s.length, ...r, ...s]);
}
/** @param {object} data */
function encodeClientData(data) {
return b64url(new TextEncoder().encode(JSON.stringify(data)));
}
/** @param {Uint8Array} bytes */
function b64url(bytes) {
return Buffer.from(bytes).toString('base64url');
}
/** @param {string} value */
function fromB64url(value) {
return new Uint8Array(Buffer.from(value, 'base64url'));
}
/**
* Sign in and return the cookie header value for subsequent requests.
* @param {PersonalDataServer} pds
* @returns {Promise<string>}
*/
async function signedInCookie(pds) {
const cookie = sessionCookie(
await signIn(pds, { username: HANDLE, password: PASSWORD }),
);
return `pdsjs_account=${encodeURIComponent(cookie)}`;
}
/**
* @param {PersonalDataServer} pds
* @param {string} path
* @param {string} cookie
* @returns {Promise<{status: number, html: string}>}
*/
async function getPage(pds, path, cookie) {
const response = await pds.fetch(
new Request(`https://pds.example.com${path}`, {
headers: { Cookie: cookie },
}),
);
return { status: response.status, html: await response.text() };
}
/**
* @param {PersonalDataServer} pds
* @param {string} path
* @param {Record<string, string>} fields
* @param {string} cookie
* @returns {Promise<Response>}
*/
function postForm(pds, path, fields, cookie) {
return pds.fetch(
new Request(`https://pds.example.com${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: cookie,
},
body: new URLSearchParams(fields).toString(),
}),
);
}
/**
* @param {Response} response
* @returns {string} The session cookie value, or '' when the cookie is cleared
*/
function sessionCookie(response) {
const header = response.headers.get('set-cookie') || '';
return decodeURIComponent(header.match(/pdsjs_account=([^;]*)/)?.[1] ?? '');
}
/**
* @param {PersonalDataServer} pds
* @param {Record<string, string>} fields
* @returns {Promise<Response>}
*/
function signIn(pds, fields) {
return pds.fetch(
new Request('https://pds.example.com/account/sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
}),
);
}
describe('Account pages', () => {
/** @type {PersonalDataServer} */
let pds;
beforeEach(() => {
pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ ({}),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
});
it('serves the sign-in form when signed out', async () => {
const response = await pds.fetch(
new Request('https://pds.example.com/account'),
);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/html');
expect(response.headers.get('cache-control')).toBe('no-store');
const html = await response.text();
// The server knows its one account: the form names the handle in a
// hidden field and asks for the password alone.
expect(html).toContain(`value="${HANDLE}"`);
expect(html).toContain('type="hidden"');
expect(html).not.toContain('placeholder="handle or DID"');
expect(html).toContain('name="password"');
expect(html).not.toContain(DID);
});
it('signs in with the handle and redirects to the account screen', async () => {
const response = await signIn(pds, {
username: HANDLE,
password: PASSWORD,
});
expect(response.status).toBe(303);
expect(response.headers.get('location')).toBe('/account');
const cookie = response.headers.get('set-cookie') || '';
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('SameSite=Lax');
expect(cookie).toContain('Secure');
expect(sessionCookie(response)).not.toBe('');
});
it('accepts the DID as identifier', async () => {
const response = await signIn(pds, { username: DID, password: PASSWORD });
expect(response.status).toBe(303);
});
it('rejects an unknown identifier', async () => {
const response = await signIn(pds, {
username: 'someone.else',
password: PASSWORD,
});
expect(response.status).toBe(401);
expect(await response.text()).toContain('Invalid identifier');
expect(sessionCookie(response)).toBe('');
});
it('rejects a wrong password and keeps the account named', async () => {
const response = await signIn(pds, { username: HANDLE, password: 'nope' });
expect(response.status).toBe(401);
const html = await response.text();
expect(html).toContain('Invalid password');
expect(html).toContain(`value="${HANDLE}"`);
expect(sessionCookie(response)).toBe('');
});
it('refuses to sign in when no password is configured', async () => {
const noPassword = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ ({}),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
});
const response = await signIn(noPassword, {
username: HANDLE,
password: '',
});
expect(response.status).toBe(401);
expect(await response.text()).toContain('not configured');
});
it('shows the account screen to a session cookie holder', async () => {
const cookie = sessionCookie(
await signIn(pds, { username: HANDLE, password: PASSWORD }),
);
const response = await pds.fetch(
new Request('https://pds.example.com/account', {
headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` },
}),
);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain(DID);
expect(html).toContain(`@${HANDLE}`);
expect(html).toContain('/account/sign-out');
expect(html).not.toContain('name="password"');
});
it('shows the display name and avatar from the local profile record', async () => {
const withProfile = new PersonalDataServer({
actorStorage: /** @type {any} */ (
createMockStorage({
profile: {
$type: 'app.bsky.actor.profile',
displayName: 'Demo Account',
avatar: {
$type: 'blob',
ref: { $link: AVATAR_CID },
mimeType: 'image/jpeg',
size: 1234,
},
},
})
),
sharedStorage: /** @type {any} */ ({}),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
const cookie = sessionCookie(
await signIn(withProfile, { username: HANDLE, password: PASSWORD }),
);
const html = await (
await withProfile.fetch(
new Request('https://pds.example.com/account', {
headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` },
}),
)
).text();
expect(html).toContain('Demo Account');
// Served from this PDS's own blob store, not from an AppView. The query
// separator is `&` because the URL sits in an HTML attribute.
expect(html).toContain(
`/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(DID)}&cid=${AVATAR_CID}`,
);
});
it('renders without a name or avatar when there is no profile record', async () => {
const cookie = sessionCookie(
await signIn(pds, { username: HANDLE, password: PASSWORD }),
);
const html = await (
await pds.fetch(
new Request('https://pds.example.com/account', {
headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` },
}),
)
).text();
expect(html).not.toContain('<img');
expect(html).toContain(`@${HANDLE}`);
});
it('never calls out to a third-party AppView', async () => {
const cookie = sessionCookie(
await signIn(pds, { username: HANDLE, password: PASSWORD }),
);
const html = await (
await pds.fetch(
new Request('https://pds.example.com/account', {
headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` },
}),
)
).text();
expect(html).not.toContain('bsky.app');
expect(html).not.toContain('<script');
});
it('ignores a cookie signed with another secret', async () => {
const cookie = sessionCookie(
await signIn(pds, { username: HANDLE, password: PASSWORD }),
);
const other = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ ({}),
blobs: /** @type {any} */ ({}),
jwtSecret: 'other-secret',
password: PASSWORD,
});
const response = await other.fetch(
new Request('https://pds.example.com/account', {
headers: { Cookie: `pdsjs_account=${encodeURIComponent(cookie)}` },
}),
);
expect(await response.text()).toContain('name="password"');
});
it('does not accept an API access token as a session cookie', async () => {
const session = await pds.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.createSession',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: HANDLE, password: PASSWORD }),
},
),
);
const { accessJwt } = await session.json();
const response = await pds.fetch(
new Request('https://pds.example.com/account', {
headers: { Cookie: `pdsjs_account=${accessJwt}` },
}),
);
expect(await response.text()).toContain('name="password"');
});
it('signs out by expiring the cookie', async () => {
const response = await pds.fetch(
new Request('https://pds.example.com/account/sign-out', {
method: 'POST',
}),
);
expect(response.status).toBe(303);
expect(response.headers.get('location')).toBe('/account');
expect(response.headers.get('set-cookie')).toContain('Max-Age=0');
});
it('leaves a fresh session cookie alone', async () => {
const cookie = await signedInCookie(pds);
const response = await pds.fetch(
new Request('https://pds.example.com/account', {
headers: { Cookie: cookie },
}),
);
expect(response.headers.get('set-cookie')).toBe(null);
});
it('reissues a session cookie that has spent a day of its life', async () => {
const spent = await createAccountJwt(DID, 'test-secret', 60);
const response = await pds.fetch(
new Request('https://pds.example.com/account', {
headers: { Cookie: `pdsjs_account=${encodeURIComponent(spent)}` },
}),
);
expect(response.status).toBe(200);
const reissued = sessionCookie(response);
expect(reissued).not.toBe('');
expect(reissued).not.toBe(spent);
expect(response.headers.get('set-cookie')).toContain('Max-Age=604800');
// The replacement opens the account screen on its own
const { html } = await getPage(
pds,
'/account',
`pdsjs_account=${encodeURIComponent(reissued)}`,
);
expect(html).toContain(DID);
});
it('reissues nothing for a session cookie it did not sign', async () => {
const forged = await createAccountJwt(DID, 'other-secret', 60);
const response = await pds.fetch(
new Request('https://pds.example.com/account', {
headers: { Cookie: `pdsjs_account=${encodeURIComponent(forged)}` },
}),
);
expect(response.headers.get('set-cookie')).toBe(null);
expect(await response.text()).toContain('name="password"');
});
it('signs out a part-spent session rather than reissuing it', async () => {
const spent = await createAccountJwt(DID, 'test-secret', 60);
const response = await pds.fetch(
new Request('https://pds.example.com/account/sign-out', {
method: 'POST',
headers: { Cookie: `pdsjs_account=${encodeURIComponent(spent)}` },
}),
);
expect(response.headers.getSetCookie()).toHaveLength(1);
expect(response.headers.get('set-cookie')).toContain('Max-Age=0');
});
it('rejects cross-origin form posts', async () => {
const response = await pds.fetch(
new Request('https://pds.example.com/account/sign-in', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Origin: 'https://evil.example',
},
body: new URLSearchParams({
username: HANDLE,
password: PASSWORD,
}).toString(),
}),
);
expect(response.status).toBe(403);
});
it('rejects a GET to the sign-in endpoint', async () => {
const response = await pds.fetch(
new Request('https://pds.example.com/account/sign-in'),
);
expect(response.status).toBe(405);
});
it('turns away every section when signed out', async () => {
for (const path of [
'/account/apps',
'/account/app-passwords',
'/account/repo',
'/account/identity',
]) {
const { status, html } = await getPage(pds, path, '');
expect(status, path).toBe(401);
expect(html, path).toContain('name="password"');
}
});
});
describe('Account sections', () => {
/** @type {PersonalDataServer} */
let pds;
/** @type {string} */
let cookie;
beforeEach(async () => {
pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (
createMockStorage({
records: [
'app.bsky.feed.post/aaa',
'app.bsky.feed.post/bbb',
'app.bsky.actor.profile/self',
],
blobs: ['bafblob1', 'bafblob2'],
})
),
sharedStorage: /** @type {any} */ (
createMockSharedStorage({
appPasswords: [
{ name: 'phone', privileged: false, createdAt: '2026-01-02' },
],
oauthTokens: [
{
did: DID,
clientId: 'https://app.example/client-metadata.json',
scope: 'atproto repo:app.bsky.feed.post',
dpopJkt: 'thumbprint-abcdefghijklmnop',
createdAt: Date.parse('2026-02-03T00:00:00Z'),
tokenId: REFRESH_TOKEN,
},
],
})
),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
plcUrl: 'http://localhost:2582',
});
cookie = await signedInCookie(pds);
});
it('counts records, blobs, apps and app passwords on the home page', async () => {
const { html } = await getPage(pds, '/account', cookie);
expect(html).toContain('>3</div><div class="l">Records');
expect(html).toContain('>2</div><div class="l">Blobs');
// Apps counts distinct app domains, not sessions: bsky.app (from the
// app.bsky.* records, no session) plus app.example (the one session) = 2.
expect(html).toContain('>2</div><div class="l">Apps');
expect(html).toContain('>1</div><div class="l">App passwords');
});
it('lists connected apps with scope and authorization date', async () => {
const { html } = await getPage(pds, '/account/apps', cookie);
expect(html).toContain('https://app.example/client-metadata.json');
expect(html).toContain('repo:app.bsky.feed.post');
expect(html).toContain('2026-02-03');
// Only a prefix of the DPoP thumbprint, enough to tell sessions apart
expect(html).toContain('thumbprint-a');
expect(html).not.toContain('thumbprint-abcdefghijklmnop');
});
it('never renders the refresh token behind a session', async () => {
const { html } = await getPage(pds, '/account/apps', cookie);
expect(html).not.toContain(REFRESH_TOKEN);
expect(html).toContain('name="session"');
});
it('revokes an app session', async () => {
const listing = await getPage(pds, '/account/apps', cookie);
const sessionId = listing.html.match(/name="session" value="([^"]+)"/)?.[1];
expect(sessionId).toBeTruthy();
const response = await postForm(
pds,
'/account/apps/revoke',
{ session: /** @type {string} */ (sessionId) },
cookie,
);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('Revoked the session');
expect(html).toContain('No app has been authorized yet.');
// Gone from storage, not just from the page
const after = await getPage(pds, '/account/apps', cookie);
expect(after.html).not.toContain(
'https://app.example/client-metadata.json',
);
});
it('refuses a session handle it did not issue', async () => {
const response = await postForm(
pds,
'/account/apps/revoke',
{ session: 'not-a-real-session-handle' },
cookie,
);
expect(response.status).toBe(400);
expect(await response.text()).toContain('no longer exists');
// The real session survives a failed guess
const { html } = await getPage(pds, '/account/apps', cookie);
expect(html).toContain('https://app.example/client-metadata.json');
});
it('offers no revoke button when storage does not return its keys', async () => {
const legacy = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (
createMockSharedStorage({
// An adapter predating the tokenId field
oauthTokens: [{ did: DID, clientId: 'https://old.example' }],
})
),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
const { html } = await getPage(
legacy,
'/account/apps',
await signedInCookie(legacy),
);
expect(html).toContain('https://old.example');
expect(html).not.toContain('name="session"');
});
it('creates an app password and shows it exactly once', async () => {
const response = await postForm(
pds,
'/account/app-passwords/create',
{ name: 'laptop' },
cookie,
);
expect(response.status).toBe(200);
const html = await response.text();
const shown = html.match(/class="pw">([^<]+)</)?.[1];
expect(shown).toMatch(/^[a-z2-7]{4}-[a-z2-7]{4}-[a-z2-7]{4}-[a-z2-7]{4}$/);
// Gone from the listing on the next load — only the hash was kept
const next = await getPage(pds, '/account/app-passwords', cookie);
expect(next.html).toContain('laptop');
expect(next.html).not.toContain(shown);
});
it('creates a privileged app password when asked', async () => {
await postForm(
pds,
'/account/app-passwords/create',
{ name: 'trusted', privileged: 'true' },
cookie,
);
const { html } = await getPage(pds, '/account/app-passwords', cookie);
expect(html).toContain('privileged');
});
it('refuses a duplicate app password name', async () => {
const response = await postForm(
pds,
'/account/app-passwords/create',
{ name: 'phone' },
cookie,
);
expect(response.status).toBe(400);
expect(await response.text()).toContain('already exists');
});
it('refuses an app password with no name', async () => {
const response = await postForm(
pds,
'/account/app-passwords/create',
{ name: ' ' },
cookie,
);
expect(response.status).toBe(400);
expect(await response.text()).toContain('name is required');
});
it('revokes an app password', async () => {
const response = await postForm(
pds,
'/account/app-passwords/revoke',
{ name: 'phone' },
cookie,
);
expect(response.status).toBe(200);
const html = await response.text();
// Escaped, because the name is interpolated into the page
expect(html).toContain('Revoked "phone"');
expect(html).toContain('No app passwords yet.');
});
it('reports an unknown app password on revoke', async () => {
const response = await postForm(
pds,
'/account/app-passwords/revoke',
{ name: 'nothing' },
cookie,
);
expect(response.status).toBe(400);
expect(await response.text()).toContain('No app password named');
});
it('groups repository records by collection', async () => {
const { html } = await getPage(pds, '/account/repo', cookie);
expect(html).toContain('app.bsky.feed.post');
expect(html).toContain('2 records');
expect(html).toContain('app.bsky.actor.profile');
expect(html).toContain('1 record<');
expect(html).toContain('3lrev');
});
it('falls back to the request origin when no hostname is configured', async () => {
const { html } = await getPage(pds, '/account/identity', cookie);
// Before the hostname fallback this rendered "https://undefined", the
// same string signPlcOperation would have published to the directory.
expect(html).not.toContain('undefined');
expect(html).toContain('https://pds.example.com');
});
it('reads the records in a repo collection', async () => {
const { status, html } = await getPage(
pds,
'/account/repo/records?collection=app.bsky.feed.post',
cookie,
);
expect(status).toBe(200);
// The card is the getRecord response shape, labelled in its own terms
expect(html).toContain('<span class="k">uri:</span>');
expect(html).toContain(`at://${DID}/app.bsky.feed.post/aaa`);
expect(html).toContain('<span class="k">cid:</span>');
// Record fields as a key/value tree, not JSON text
expect(html).toContain('<span class="k">text:</span>');
expect(html).toContain('record aaa');
expect(html).not.toContain('"text"');
expect(html).toContain('Public records');
// Only the named collection
expect(html).not.toContain('app.bsky.actor.profile');
});
it('links each repo collection to its records', async () => {
const { html } = await getPage(pds, '/account/repo', cookie);
expect(html).toContain(
'/account/repo/records?collection=app.bsky.feed.post',
);
});
it('refuses a repo record view with no collection', async () => {
const { status, html } = await getPage(
pds,
'/account/repo/records',
cookie,
);
expect(status).toBe(400);
expect(html).toContain('Name a collection');
});
it('turns away the repo record view when signed out', async () => {
const { status } = await getPage(
pds,
'/account/repo/records?collection=app.bsky.feed.post',
'',
);
expect(status).toBe(401);
});
it('shows identity and the configured directory', async () => {
const { html } = await getPage(pds, '/account/identity', cookie);
expect(html).toContain(DID);
expect(html).toContain('http://localhost:2582');
expect(html).toContain('Active');
expect(html).toContain('value="deactivate"');
});
it('deactivates and reactivates the account', async () => {
const off = await postForm(
pds,
'/account/status',
{ action: 'deactivate' },
cookie,
);
expect(off.status).toBe(303);
expect(off.headers.get('location')).toBe('/account/identity');
const deactivated = await getPage(pds, '/account/identity', cookie);
expect(deactivated.html).toContain('Deactivated');
expect(deactivated.html).toContain('value="activate"');
await postForm(pds, '/account/status', { action: 'activate' }, cookie);
const reactivated = await getPage(pds, '/account/identity', cookie);
expect(reactivated.html).toContain('Active');
});
it('rejects an unknown status action', async () => {
const response = await postForm(
pds,
'/account/status',
{ action: 'destroy' },
cookie,
);
expect(response.status).toBe(400);
expect(await response.text()).toContain('Unknown status action');
});
it('refuses account writes on a read-only server', async () => {
const readOnly = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
readOnly: true,
});
const readOnlyCookie = await signedInCookie(readOnly);
const created = await postForm(
readOnly,
'/account/app-passwords/create',
{ name: 'nope' },
readOnlyCookie,
);
expect(created.status).toBe(403);
expect(await created.text()).toContain('read-only');
// The status control is not offered at all
const { html } = await getPage(
readOnly,
'/account/identity',
readOnlyCookie,
);
expect(html).not.toContain('value="deactivate"');
});
});
describe('Account email', () => {
/** @type {PersonalDataServer} */
let pds;
/** @type {string} */
let cookie;
/** @type {Array<{to: string, subject: string, text: string}>} */
let sent;
beforeEach(async () => {
sent = [];
pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
emailer: {
async send(message) {
sent.push(message);
},
},
});
cookie = await signedInCookie(pds);
});
/** @returns {string} The code from the message just sent */
const codeFromEmail = () =>
/** @type {string} */ (sent.at(-1)?.text.match(/(\d{6})/)?.[1]);
it('sends a code and holds the address pending', async () => {
const response = await postForm(
pds,
'/account/email',
{ address: 'someone@example.com' },
cookie,
);
expect(response.status).toBe(200);
expect(sent).toHaveLength(1);
expect(sent[0].to).toBe('someone@example.com');
expect(codeFromEmail()).toMatch(/^\d{6}$/);
const html = await response.text();
expect(html).toContain('Sent a code');
// Not the account's address until the code comes back
expect(html).toContain('No email address on this account.');
});
it('confirms the address with the code', async () => {
await postForm(
pds,
'/account/email',
{ address: 'someone@example.com' },
cookie,
);
const response = await postForm(
pds,
'/account/email/verify',
{ code: codeFromEmail() },
cookie,
);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('someone@example.com is confirmed');
expect(html).toContain('confirmed');
});
it('rejects a wrong code and keeps the address pending', async () => {
await postForm(
pds,
'/account/email',
{ address: 'someone@example.com' },
cookie,
);
const response = await postForm(
pds,
'/account/email/verify',
{ code: '000000' },
cookie,
);
expect(response.status).toBe(400);
const html = await response.text();
expect(html).toContain('That code is wrong');
expect(html).toContain('Confirm someone@example.com');
});
it('refuses a code minted for a different address', async () => {
await postForm(pds, '/account/email', { address: 'a@example.com' }, cookie);
const firstCode = codeFromEmail();
// Superseded by a second request, so the first code must not confirm it
await postForm(pds, '/account/email', { address: 'b@example.com' }, cookie);
const response = await postForm(
pds,
'/account/email/verify',
{ code: firstCode },
cookie,
);
expect(response.status).toBe(400);
});
it('rejects a malformed address before sending anything', async () => {
const response = await postForm(
pds,
'/account/email',
{ address: 'not-an-address' },
cookie,
);
expect(response.status).toBe(400);
expect(sent).toHaveLength(0);
});
it('drops the pending address when sending fails', async () => {
const broken = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
emailer: {
async send() {
throw new Error('smtp is down');
},
},
});
const brokenCookie = await signedInCookie(broken);
const response = await postForm(
broken,
'/account/email',
{ address: 'someone@example.com' },
brokenCookie,
);
expect(response.status).toBe(502);
// No confirmation form for a code that never went out
const { html } = await getPage(broken, '/account/email', brokenCookie);
expect(html).not.toContain('Confirm someone@example.com');
});
it('cancels a pending change', async () => {
await postForm(
pds,
'/account/email',
{ address: 'someone@example.com' },
cookie,
);
const response = await postForm(pds, '/account/email/cancel', {}, cookie);
expect(await response.text()).toContain('Change cancelled');
});
it('removes a confirmed address', async () => {
await postForm(
pds,
'/account/email',
{ address: 'someone@example.com' },
cookie,
);
await postForm(
pds,
'/account/email/verify',
{ code: codeFromEmail() },
cookie,
);
const response = await postForm(pds, '/account/email/remove', {}, cookie);
expect(await response.text()).toContain(
'No email address on this account.',
);
});
it('stores an unconfirmed address when no sender is configured', async () => {
const noMail = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
const noMailCookie = await signedInCookie(noMail);
const response = await postForm(
noMail,
'/account/email',
{ address: 'someone@example.com' },
noMailCookie,
);
const html = await response.text();
expect(html).toContain('stays unconfirmed');
expect(html).toContain('unconfirmed');
});
});
describe('Account passkeys', () => {
/** @type {PersonalDataServer} */
let pds;
/** @type {string} */
let cookie;
beforeEach(async () => {
pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
cookie = await signedInCookie(pds);
});
/**
* Drive the browser half of a registration: read the challenge the page
* embedded, then answer it with a synthetic authenticator.
*/
async function register(name = 'laptop') {
const page = await pds.fetch(
new Request('https://pds.example.com/account/passkeys', {
headers: { Cookie: cookie },
}),
);
const html = await page.text();
const challenge = html.match(/unb64\("([^"]+)"\)/)?.[1];
const ceremonyCookie = (page.headers.get('set-cookie') || '').split(';')[0];
const credential = await createTestCredential();
const response = await pds.fetch(
new Request('https://pds.example.com/account/passkeys/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: `${cookie}; ${ceremonyCookie}`,
},
body: JSON.stringify({
name,
credentialId: credential.credentialId,
publicKey: credential.publicKey,
algorithm: -7,
clientDataJSON: encodeClientData({
type: 'webauthn.create',
challenge,
origin: 'https://pds.example.com',
}),
}),
}),
);
return { response, credential, challenge };
}
it('registers a passkey and lists it', async () => {
const { response } = await register('laptop');
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ ok: true, name: 'laptop' });
const { html } = await getPage(pds, '/account/passkeys', cookie);
expect(html).toContain('laptop');
expect(html).toContain('never used');
});
it('refuses a registration with no challenge cookie', async () => {
const credential = await createTestCredential();
const response = await pds.fetch(
new Request('https://pds.example.com/account/passkeys/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({
credentialId: credential.credentialId,
publicKey: credential.publicKey,
algorithm: -7,
clientDataJSON: encodeClientData({
type: 'webauthn.create',
challenge: 'made-up',
origin: 'https://pds.example.com',
}),
}),
}),
);
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/expired/);
});
it('refuses a registration when not signed in', async () => {
const response = await pds.fetch(
new Request('https://pds.example.com/account/passkeys/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
}),
);
expect(response.status).toBe(401);
});
it('signs in with a registered passkey', async () => {
const { credential } = await register();
const start = await pds.fetch(
new Request('https://pds.example.com/account/passkey/challenge'),
);
const { challenge, rpId } = await start.json();
expect(rpId).toBe('pds.example.com');
const ceremonyCookie = (start.headers.get('set-cookie') || '').split(
';',
)[0];
const assertion = await signTestAssertion(credential, challenge);
const response = await pds.fetch(
new Request('https://pds.example.com/account/passkey/sign-in', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: ceremonyCookie,
},
body: JSON.stringify(assertion),
}),
);
expect(response.status).toBe(200);
// The response opens a real account session
const issued = sessionCookie(response);
expect(issued).not.toBe('');
const { html } = await getPage(
pds,
'/account',
`pdsjs_account=${encodeURIComponent(issued)}`,
);
expect(html).toContain(DID);
});
it('refuses an assertion signed by an unregistered key', async () => {
await register();
const start = await pds.fetch(
new Request('https://pds.example.com/account/passkey/challenge'),
);
const { challenge } = await start.json();
const ceremonyCookie = (start.headers.get('set-cookie') || '').split(
';',
)[0];
const impostor = await createTestCredential();
const assertion = await signTestAssertion(impostor, challenge);
const response = await pds.fetch(
new Request('https://pds.example.com/account/passkey/sign-in', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: ceremonyCookie,
},
body: JSON.stringify(assertion),
}),
);
expect(response.status).toBe(401);
expect(sessionCookie(response)).toBe('');
});
it('refuses a sign-in challenge spent as a registration', async () => {
const start = await pds.fetch(
new Request('https://pds.example.com/account/passkey/challenge'),
);
const { challenge } = await start.json();
const ceremonyCookie = (start.headers.get('set-cookie') || '').split(
';',
)[0];
const credential = await createTestCredential();
const response = await pds.fetch(
new Request('https://pds.example.com/account/passkeys/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: `${cookie}; ${ceremonyCookie}`,
},
body: JSON.stringify({
credentialId: credential.credentialId,
publicKey: credential.publicKey,
algorithm: -7,
clientDataJSON: encodeClientData({
type: 'webauthn.create',
challenge,
origin: 'https://pds.example.com',
}),
}),
}),
);
expect(response.status).toBe(400);
});
it('removes a passkey', async () => {
const { credential } = await register();
const response = await postForm(
pds,
'/account/passkeys/revoke',
{ credentialId: credential.credentialId },
cookie,
);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('Passkey removed.');
expect(html).toContain('No passkeys yet.');
});
it('offers a passkey button on the sign-in page only when supported', async () => {
const { html } = await getPage(pds, '/account', '');
expect(html).toContain('Use a passkey');
const noPasskeys = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ ({}),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
const plain = await getPage(noPasskeys, '/account', '');
expect(plain.html).not.toContain('Use a passkey');
});
});
describe('PLC operation confirmation', () => {
/** @type {Array<{to: string, subject: string, text: string}>} */
let sent;
/**
* @param {boolean} withEmail - Whether a confirmed address exists
* @returns {Promise<PersonalDataServer>}
*/
async function createServer(withEmail) {
sent = [];
const pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
emailer: withEmail
? {
async send(message) {
sent.push(message);
},
}
: undefined,
});
if (withEmail) {
const cookie = await signedInCookie(pds);
await postForm(
pds,
'/account/email',
{ address: 'a@example.com' },
cookie,
);
const code = /** @type {string} */ (sent[0].text.match(/(\d{6})/)?.[1]);
await postForm(pds, '/account/email/verify', { code }, cookie);
sent.length = 0;
}
return pds;
}
/** @param {PersonalDataServer} pds */
async function accessToken(pds) {
const session = await pds.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.createSession',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: HANDLE, password: PASSWORD }),
},
),
);
return (await session.json()).accessJwt;
}
/**
* @param {PersonalDataServer} pds
* @param {string} path
* @param {object} body
* @param {string} token
*/
function xrpc(pds, path, body, token) {
return pds.fetch(
new Request(`https://pds.example.com/xrpc/${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
}),
);
}
it('mails a code when the account has a confirmed address', async () => {
const pds = await createServer(true);
const token = await accessToken(pds);
const response = await xrpc(
pds,
'com.atproto.identity.requestPlcOperationSignature',
{},
token,
);
expect(response.status).toBe(200);
expect(sent).toHaveLength(1);
expect(sent[0].subject).toMatch(/identity change/);
expect(sent[0].text).toMatch(/\d{6}/);
});
it('refuses to sign without the code', async () => {
const pds = await createServer(true);
const token = await accessToken(pds);
await xrpc(
pds,
'com.atproto.identity.requestPlcOperationSignature',
{},
token,
);
const response = await xrpc(
pds,
'com.atproto.identity.signPlcOperation',
{ token: '000000' },
token,
);
expect(response.status).toBe(400);
expect((await response.json()).error).toBe('InvalidToken');
});
it('refuses to sign when no code was ever requested', async () => {
const pds = await createServer(true);
const token = await accessToken(pds);
const response = await xrpc(
pds,
'com.atproto.identity.signPlcOperation',
{},
token,
);
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/Ask for a confirmation/);
});
it('spends the code, so it signs only one operation', async () => {
const pds = await createServer(true);
const token = await accessToken(pds);
await xrpc(
pds,
'com.atproto.identity.requestPlcOperationSignature',
{},
token,
);
const code = /** @type {string} */ (sent[0].text.match(/(\d{6})/)?.[1]);
// Signing needs a key this mock account does not have, but the code is
// checked first: a wrong-token rejection is what proves it was accepted
const first = await xrpc(
pds,
'com.atproto.identity.signPlcOperation',
{ token: code },
token,
);
expect((await first.json()).error).not.toBe('InvalidToken');
const second = await xrpc(
pds,
'com.atproto.identity.signPlcOperation',
{ token: code },
token,
);
expect((await second.json()).error).toBe('InvalidToken');
});
it('keeps session-only authorization when no address is confirmed', async () => {
const pds = await createServer(false);
const token = await accessToken(pds);
const requested = await xrpc(
pds,
'com.atproto.identity.requestPlcOperationSignature',
{},
token,
);
expect(requested.status).toBe(200);
// No code was issued, so signing must not start demanding one
const response = await xrpc(
pds,
'com.atproto.identity.signPlcOperation',
{},
token,
);
expect((await response.json()).error).not.toBe('InvalidToken');
});
});
describe('Password sessions', () => {
/** @type {PersonalDataServer} */
let pds;
/** @type {string} */
let cookie;
beforeEach(async () => {
pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
cookie = await signedInCookie(pds);
});
/**
* @param {string} [password]
* @param {string} [userAgent]
*/
async function createSession(
password = PASSWORD,
userAgent = 'TestBrowser/1.0',
) {
const response = await pds.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.createSession',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: JSON.stringify({ identifier: HANDLE, password }),
},
),
);
return response.json();
}
/** @param {string} refreshJwt */
function refresh(refreshJwt) {
return pds.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.refreshSession',
{
method: 'POST',
headers: { Authorization: `Bearer ${refreshJwt}` },
},
),
);
}
it('lists a password login with its user agent', async () => {
await createSession();
const { html } = await getPage(pds, '/account/sessions', cookie);
expect(html).toContain('Account password');
expect(html).toContain('TestBrowser/1.0');
});
it('labels an app-password login by its name', async () => {
await postForm(
pds,
'/account/app-passwords/create',
{ name: 'phone' },
cookie,
);
const created = await getPage(pds, '/account/app-passwords', cookie);
expect(created.html).toContain('phone');
// Reuse the password the create page revealed
const revealed = await postForm(
pds,
'/account/app-passwords/create',
{ name: 'tablet' },
cookie,
);
const appPassword = /** @type {string} */ (
(await revealed.text()).match(/class="pw">([^<]+)</)?.[1]
);
await createSession(appPassword);
const { html } = await getPage(pds, '/account/sessions', cookie);
expect(html).toContain('App password: tablet');
expect(html).toContain('limited');
});
it('rotates the session on refresh rather than adding one', async () => {
const session = await createSession();
const refreshed = await refresh(session.refreshJwt);
expect(refreshed.status).toBe(200);
const { html } = await getPage(pds, '/account/sessions', cookie);
expect(html.match(/Account password/g)).toHaveLength(1);
expect(html).toContain('renewed');
});
it('redeems a superseded refresh token onto the same replacement', async () => {
const session = await createSession();
const refreshed = await refresh(session.refreshJwt);
expect(refreshed.status).toBe(200);
// The superseded token stays redeemable for its grace window, so a client
// that refreshed twice converges on one token instead of losing the
// session. It buys no second session either.
const replay = await refresh(session.refreshJwt);
expect(replay.status).toBe(200);
// Compared by id: the two tokens are minted a moment apart, so they differ
// as strings while naming the same successor.
const jti = (/** @type {string} */ jwt) =>
JSON.parse(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
.jti;
expect(jti((await replay.json()).refreshJwt)).toBe(
jti((await refreshed.json()).refreshJwt),
);
const { html } = await getPage(pds, '/account/sessions', cookie);
expect(html.match(/Account password/g)).toHaveLength(1);
});
it('revoking from the page stops the session refreshing', async () => {
const session = await createSession();
const { html } = await getPage(pds, '/account/sessions', cookie);
const jti = /** @type {string} */ (
html.match(/name="jti" value="([^"]+)"/)?.[1]
);
const response = await postForm(
pds,
'/account/sessions/revoke',
{ jti },
cookie,
);
expect(response.status).toBe(200);
expect(await response.text()).toContain('Session revoked');
const after = await refresh(session.refreshJwt);
expect(after.status).toBe(401);
});
it('deleteSession ends the session it names', async () => {
const session = await createSession();
const response = await pds.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.deleteSession',
{
method: 'POST',
headers: { Authorization: `Bearer ${session.refreshJwt}` },
},
),
);
expect(response.status).toBe(200);
expect((await refresh(session.refreshJwt)).status).toBe(401);
const { html } = await getPage(pds, '/account/sessions', cookie);
expect(html).toContain('No password logins are open.');
});
it('deleteSession refuses an access token in place of a refresh token', async () => {
const session = await createSession();
const response = await pds.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.deleteSession',
{
method: 'POST',
headers: { Authorization: `Bearer ${session.accessJwt}` },
},
),
);
expect(response.status).toBe(401);
// The session survives the bad attempt
expect((await refresh(session.refreshJwt)).status).toBe(200);
});
it('keeps stateless refresh on storage with no registry', async () => {
const stateless = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ ({}),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
const session = await (
await stateless.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.createSession',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: HANDLE, password: PASSWORD }),
},
),
)
).json();
const refreshed = await stateless.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.refreshSession',
{
method: 'POST',
headers: { Authorization: `Bearer ${session.refreshJwt}` },
},
),
);
expect(refreshed.status).toBe(200);
const { html } = await getPage(
stateless,
'/account/sessions',
await signedInCookie(stateless),
);
expect(html).toContain('does not keep a session registry');
});
});
describe('Spaces section', () => {
/** A space storage stand-in, shaped like @pdsjs/spaces' own. */
const OWN_SPACE = 'at://did:plc:test123/club.run.space/abc';
const JOINED_SPACE =
'at://did:plc:otheraccountotheraccount/club.run.space/xyz';
function createSpaceStorage() {
return {
async listSpaces() {
return {
spaces: [
{
uri: OWN_SPACE,
spaceDid: DID,
spaceType: 'club.run.space',
isOwner: true,
policy: { $type: 'com.atproto.memberspace.policy' },
createdAt: '2026-03-04T00:00:00.000Z',
deletedAt: null,
},
{
// Joined, not owned: the row a member's own PDS materialises
// on its first write into someone else's space
uri: JOINED_SPACE,
spaceDid: 'did:plc:otheraccountotheraccount',
spaceType: 'club.run.space',
isOwner: false,
policy: null,
createdAt: '2026-03-05T00:00:00.000Z',
deletedAt: null,
},
],
cursor: null,
};
},
async listMembers(/** @type {string} */ space) {
// A member's PDS is never told the authority's member list
return space === OWN_SPACE
? {
members: [
{ did: DID, read: true, write: true },
{ did: 'did:plc:friend', read: true, write: true },
],
cursor: null,
}
: { members: [], cursor: null };
},
async listAllSpaceRecords(/** @type {string} */ space) {
if (space !== OWN_SPACE) {
return [{ collection: 'club.run.activity', rkey: 'z', cid: 'bafy9' }];
}
return [
{ collection: 'club.run.activity', rkey: 'a', cid: 'bafy1' },
{ collection: 'club.run.activity', rkey: 'b', cid: 'bafy2' },
{ collection: 'club.run.note', rkey: 'c', cid: 'bafy3' },
];
},
async listSpaceRecords(
/** @type {string} */ _space,
/** @type {string} */ collection,
/** @type {string|null} */ _cursor,
/** @type {number} */ limit,
) {
const values = [
{ $type: collection, note: 'first', distanceMeters: 8000 },
{ $type: collection, note: 'second', distanceMeters: 5200 },
];
return {
records: values.slice(0, limit).map((value, index) => ({
rkey: `rkey-${index}`,
cid: `bafyrecord${index}`,
indexedAt: '2026-03-06T00:00:00.000Z',
value: cborEncodeDagCbor(value),
})),
cursor: null,
};
},
};
}
/** @param {boolean} enabled */
async function createServer(enabled) {
const pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
spaceStorage: enabled
? /** @type {any} */ (createSpaceStorage())
: undefined,
});
return { pds, cookie: await signedInCookie(pds) };
}
it('lists spaces with member and record counts', async () => {
const { pds, cookie } = await createServer(true);
const { html } = await getPage(pds, '/account/spaces', cookie);
expect(html).toContain('club.run.space');
expect(html).toContain('at://did:plc:test123/club.run.space/abc');
expect(html).toContain('yours');
expect(html).toContain('2 members');
expect(html).toContain(' · memberspace · ');
expect(html).toContain('club.run.activity (2)');
expect(html).toContain('club.run.note (1)');
});
it('marks a space the account joined but does not own', async () => {
const { pds, cookie } = await createServer(true);
const { html } = await getPage(pds, '/account/spaces', cookie);
// The owned one is tagged; the joined one names its authority instead
expect(html.match(/>yours</g)).toHaveLength(1);
expect(html).toContain('did:plc:otheraccountotheraccount');
expect(html).toContain('0 members');
});
it('shows the records in a collection', async () => {
const { pds, cookie } = await createServer(true);
const { status, html } = await getPage(
pds,
`/account/spaces/records?space=${encodeURIComponent(OWN_SPACE)}&collection=club.run.activity`,
cookie,
);
expect(status).toBe(200);
// Space record uris carry the writer: space/did/collection/rkey
expect(html).toContain(`${OWN_SPACE}/${DID}/club.run.activity/rkey-0`);
expect(html).toContain('<span class="k">note:</span>');
expect(html).toContain('first');
expect(html).toContain('<span class="k">distanceMeters:</span>');
expect(html).toContain('<span class="n">8000</span>');
expect(html).toContain('bafyrecord0');
});
it('refuses to read a space this account does not hold', async () => {
const { pds, cookie } = await createServer(true);
const { status, html } = await getPage(
pds,
'/account/spaces/records?space=at%3A%2F%2Fdid%3Aplc%3Astranger%2Fclub.run.space%2Fnope&collection=club.run.activity',
cookie,
);
expect(status).toBe(404);
expect(html).toContain('No such space');
});
it('turns away the record view when signed out', async () => {
const { pds } = await createServer(true);
const { status } = await getPage(
pds,
`/account/spaces/records?space=${encodeURIComponent(OWN_SPACE)}&collection=club.run.activity`,
'',
);
expect(status).toBe(401);
});
it('says permissioned data is off when no browser is injected', async () => {
const { pds, cookie } = await createServer(false);
const { html } = await getPage(pds, '/account/spaces', cookie);
expect(html).toContain('Permissioned data is off');
expect(html).not.toContain('club.run.space');
});
});
describe('Passkey consent', () => {
/** @type {PersonalDataServer} */
let pds;
/** @type {string} */
let cookie;
const REQUEST_URI = 'urn:ietf:params:oauth:request_uri:req-1';
const CLIENT_ID = 'http://localhost:3000';
beforeEach(async () => {
pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
cookie = await signedInCookie(pds);
await pds.sharedStorage.putOAuthRequest(
REQUEST_URI,
{
clientId: CLIENT_ID,
clientMetadata: { client_name: 'Test App' },
redirectUri: 'http://localhost:3000/callback',
scope: 'atproto',
state: 'state-1',
responseMode: null,
codeChallenge: 'challenge',
codeChallengeMethod: 'S256',
},
Date.now() + 600000,
);
});
/** Register a passkey through the account page, as a browser would. */
async function registerPasskey() {
const page = await pds.fetch(
new Request('https://pds.example.com/account/passkeys', {
headers: { Cookie: cookie },
}),
);
const challenge = (await page.text()).match(/unb64\("([^"]+)"\)/)?.[1];
const ceremonyCookie = (page.headers.get('set-cookie') || '').split(';')[0];
const credential = await createTestCredential();
await pds.fetch(
new Request('https://pds.example.com/account/passkeys/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: `${cookie}; ${ceremonyCookie}`,
},
body: JSON.stringify({
credentialId: credential.credentialId,
publicKey: credential.publicKey,
algorithm: -7,
clientDataJSON: encodeClientData({
type: 'webauthn.create',
challenge,
origin: 'https://pds.example.com',
}),
}),
}),
);
return credential;
}
/** Load the consent page and return its passkey challenge and cookie. */
async function loadConsent() {
const response = await pds.fetch(
new Request(
`https://pds.example.com/oauth/authorize?request_uri=${encodeURIComponent(REQUEST_URI)}&client_id=${encodeURIComponent(CLIENT_ID)}`,
),
);
const html = await response.text();
return {
response,
html,
challenge: html.match(/unb64\("([^"]+)"\)/)?.[1],
ceremonyCookie: (response.headers.get('set-cookie') || '').split(';')[0],
};
}
it('offers a passkey on the consent page and binds a challenge', async () => {
const { response, html, challenge } = await loadConsent();
expect(response.status).toBe(200);
expect(html).toContain('Authorize with a passkey');
expect(challenge).toBeTruthy();
expect(response.headers.get('set-cookie')).toContain('Path=/oauth');
// The password path is still there
expect(html).toContain('name="password"');
});
it('issues the code for a valid assertion', async () => {
const credential = await registerPasskey();
const { challenge, ceremonyCookie } = await loadConsent();
const assertion = await signTestAssertion(
credential,
/** @type {string} */ (challenge),
);
const response = await pds.fetch(
new Request('https://pds.example.com/oauth/authorize/passkey', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: ceremonyCookie,
},
body: JSON.stringify({
request_uri: REQUEST_URI,
client_id: CLIENT_ID,
...assertion,
}),
}),
);
expect(response.status).toBe(200);
const { redirect } = await response.json();
const location = new URL(redirect);
expect(location.origin).toBe('http://localhost:3000');
expect(location.searchParams.get('code')).toBeTruthy();
expect(location.searchParams.get('state')).toBe('state-1');
expect(location.searchParams.get('iss')).toBe('https://pds.example.com');
// The challenge cookie is spent alongside the approval
expect(response.headers.get('set-cookie')).toContain('Max-Age=0');
});
it('refuses a sign-in challenge spent as consent', async () => {
const credential = await registerPasskey();
// A challenge minted for the account sign-in ceremony, not for consent
const start = await pds.fetch(
new Request('https://pds.example.com/account/passkey/challenge'),
);
const { challenge } = await start.json();
const wrongCookie = (start.headers.get('set-cookie') || '').split(';')[0];
const assertion = await signTestAssertion(credential, challenge);
const response = await pds.fetch(
new Request('https://pds.example.com/oauth/authorize/passkey', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: wrongCookie,
},
body: JSON.stringify({
request_uri: REQUEST_URI,
client_id: CLIENT_ID,
...assertion,
}),
}),
);
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/expired/);
});
it('refuses an assertion from an unregistered key', async () => {
await registerPasskey();
const { challenge, ceremonyCookie } = await loadConsent();
const impostor = await createTestCredential();
const assertion = await signTestAssertion(
impostor,
/** @type {string} */ (challenge),
);
const response = await pds.fetch(
new Request('https://pds.example.com/oauth/authorize/passkey', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: ceremonyCookie,
},
body: JSON.stringify({
request_uri: REQUEST_URI,
client_id: CLIENT_ID,
...assertion,
}),
}),
);
expect(response.status).toBe(401);
});
it('offers no passkey button when storage cannot check one', async () => {
const plain = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ ({
async getOAuthRequest() {
return {
clientId: CLIENT_ID,
clientMetadata: { client_name: 'Test App' },
redirectUri: 'http://localhost:3000/callback',
scope: 'atproto',
};
},
}),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
const response = await plain.fetch(
new Request(
`https://pds.example.com/oauth/authorize?request_uri=${encodeURIComponent(REQUEST_URI)}&client_id=${encodeURIComponent(CLIENT_ID)}`,
),
);
const html = await response.text();
expect(html).not.toContain('passkey');
expect(html).toContain('name="password"');
});
});
describe('Account API', () => {
/** @type {PersonalDataServer} */
let pds;
/** @type {string} */
let cookie;
beforeEach(async () => {
pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (
createMockStorage({ records: ['app.bsky.feed.post/aaa'] })
),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
// What a platform package injects when the application is installed
// A realistic shell: the sign-in bootstrap is injected before </head>.
accountApp:
'<!doctype html><html><head></head><body><div id="root"></div></body></html>',
});
cookie = await signedInCookie(pds);
});
/** @param {string} path @param {string} [withCookie] */
async function api(path, withCookie = cookie) {
const response = await pds.fetch(
new Request(`https://pds.example.com/account/api${path}`, {
headers: { Cookie: withCookie },
}),
);
return { status: response.status, body: await response.json() };
}
it('serves the application for a signed-in section path', async () => {
for (const path of ['/account', '/account/apps', '/account/identity']) {
const { html } = await getPage(pds, path, cookie);
expect(html, path).toContain('<div id="root">');
}
});
it('boots the mounted app into sign-in mode when signed out', async () => {
const { html } = await getPage(pds, '/account', '');
// The shell is served either way; signed out it carries a sign-in-mode
// bootstrap rather than the authenticated application.
expect(html).toContain('<div id="root">');
expect(html).toContain('window.__PDS__');
expect(html).toContain('signin');
});
it('leaves the API to answer its own paths', async () => {
const { status, body } = await api('/overview');
expect(status).toBe(200);
expect(body.did).toBe(DID);
expect(body.stats.records).toBe(1);
expect(body.features).toMatchObject({ passkeys: true, sessions: true });
});
it('turns away every API path when signed out', async () => {
for (const path of [
'/overview',
'/apps',
'/sessions',
'/identity',
'/version',
]) {
const { status, body } = await api(path, '');
expect(status, path).toBe(401);
expect(body.error).toBe('Not signed in');
}
});
it('rejects a cross-origin API write', async () => {
const response = await pds.fetch(
new Request('https://pds.example.com/account/api/status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: cookie,
Origin: 'https://evil.example',
},
body: JSON.stringify({ action: 'deactivate' }),
}),
);
expect(response.status).toBe(403);
});
it('creates and revokes an app password through the API', async () => {
const created = await pds.fetch(
new Request('https://pds.example.com/account/api/app-passwords/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ name: 'laptop' }),
}),
);
const body = await created.json();
expect(body.password).toMatch(
/^[a-z2-7]{4}-[a-z2-7]{4}-[a-z2-7]{4}-[a-z2-7]{4}$/,
);
const listed = await api('/app-passwords');
expect(listed.body.passwords[0].name).toBe('laptop');
// Storage keeps a hash, so a listing cannot leak the credential
expect(JSON.stringify(listed.body)).not.toContain(body.password);
});
it('reads a repo collection through the API', async () => {
const { status, body } = await api(
'/records?collection=app.bsky.feed.post',
);
expect(status).toBe(200);
expect(body.records[0].uri).toBe(`at://${DID}/app.bsky.feed.post/aaa`);
expect(body.records[0].value.text).toBe('record aaa');
expect(body.blobBase).toContain('com.atproto.sync.getBlob');
});
it('reports an unknown collection as an empty read, not an error', async () => {
const { status, body } = await api(
'/records?collection=app.bsky.feed.like',
);
expect(status).toBe(200);
expect(body.records).toEqual([]);
});
/** @param {Record<string, unknown>} payload */
async function postAlias(payload) {
const response = await pds.fetch(
new Request('https://pds.example.com/account/api/apps/alias', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify(payload),
}),
);
return { status: response.status, body: await response.json() };
}
it('combines two app rows and serves the alias back with the apps list', async () => {
const combined = await postAlias({
action: 'combine',
from: 'tangled.sh',
to: 'tangled.org',
});
expect(combined.status).toBe(200);
expect(combined.body.aliases).toEqual({ 'tangled.sh': 'tangled.org' });
const { body } = await api('/apps');
expect(body.aliases).toEqual({ 'tangled.sh': 'tangled.org' });
expect(body.aliasesAvailable).toBe(true);
});
it('separates a combined row again', async () => {
await postAlias({
action: 'combine',
from: 'tangled.sh',
to: 'tangled.org',
});
const { status, body } = await postAlias({
action: 'separate',
from: 'tangled.sh',
});
expect(status).toBe(200);
expect(body.aliases).toEqual({});
});
it('remembers a dismissed offer without creating an alias', async () => {
const { body } = await postAlias({
action: 'dismiss',
from: 'tangled.sh',
to: 'tangled.org',
});
expect(body.aliases).toEqual({});
expect(body.dismissedPairs).toEqual(['tangled.sh>tangled.org']);
});
it('rejects an alias that is not a hostname, or an unknown action', async () => {
for (const payload of [
{ action: 'combine', from: 'not a host', to: 'tangled.org' },
{ action: 'combine', from: 'tangled.sh', to: 'tangled.sh' },
{ action: 'shrug', from: 'tangled.sh', to: 'tangled.org' },
]) {
const { status } = await postAlias(payload);
expect(status, JSON.stringify(payload)).toBe(400);
}
});
it('never lets a chain of aliases form', async () => {
// b already folds into c; combining a into b flattens b's own fold away.
await postAlias({ action: 'combine', from: 'app.org', to: 'app.dev' });
const { body } = await postAlias({
action: 'combine',
from: 'app.sh',
to: 'app.org',
});
expect(body.aliases).toEqual({ 'app.sh': 'app.org' });
});
/** @param {Record<string, unknown>} payload */
async function postAppearance(payload) {
const response = await pds.fetch(
new Request('https://pds.example.com/account/api/settings/appearance', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify(payload),
}),
);
return { status: response.status, body: await response.json() };
}
it('defaults the appearance setting to system', async () => {
const { body } = await api('/overview');
expect(body.theme).toBe('system');
expect(body.features.settings).toBe(true);
});
it('stores an appearance choice and serves it back everywhere', async () => {
const { status, body } = await postAppearance({ theme: 'dark' });
expect(status).toBe(200);
expect(body.theme).toBe('dark');
const overview = await api('/overview');
expect(overview.body.theme).toBe('dark');
// The signed-in page bootstrap carries the choice for first paint.
const { html } = await getPage(pds, '/account', cookie);
expect(html).toContain('window.__PDS__');
expect(html).toContain('dark');
});
it('rejects a theme outside system, light, dark', async () => {
const { status } = await postAppearance({ theme: 'sepia' });
expect(status).toBe(400);
});
// ── /account/api/password ──
/** @param {object} payload */
async function postPassword(payload) {
const response = await pds.fetch(
new Request('https://pds.example.com/account/api/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify(payload),
}),
);
return { status: response.status, body: await response.json() };
}
it('changes the password and the old one stops signing in', async () => {
const { status, body } = await postPassword({
currentPassword: PASSWORD,
newPassword: 'brand-new-pw',
});
expect(status).toBe(200);
expect(body.ok).toBe(true);
const oldTry = await signIn(pds, { username: HANDLE, password: PASSWORD });
expect(oldTry.status).toBe(401);
const newTry = await signIn(pds, {
username: HANDLE,
password: 'brand-new-pw',
});
expect(newTry.status).toBe(303);
});
it('requires the current password, even inside a session', async () => {
const { status, body } = await postPassword({
currentPassword: 'nope',
newPassword: 'brand-new-pw',
});
expect(status).toBe(400);
expect(body.error).toContain('current password');
// Unchanged: the original still signs in.
const check = await signIn(pds, { username: HANDLE, password: PASSWORD });
expect(check.status).toBe(303);
});
it('rejects a short new password', async () => {
const { status } = await postPassword({
currentPassword: PASSWORD,
newPassword: 'short',
});
expect(status).toBe(400);
});
it('reports the feature in the overview', async () => {
const { body } = await api('/overview');
expect(body.features.passwordChange).toBe(true);
});
it('a rotated PDS_PASSWORD wins over a changed one', async () => {
// One storage, two server generations: the first changes the password
// through the API, the second starts with a rotated secret.
const actorStorage = /** @type {any} */ (createMockStorage());
const shared = /** @type {any} */ (createMockSharedStorage());
const first = new PersonalDataServer({
actorStorage,
sharedStorage: shared,
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
const firstCookie = await signedInCookie(first);
const change = await first.fetch(
new Request('https://pds.example.com/account/api/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: firstCookie },
body: JSON.stringify({
currentPassword: PASSWORD,
newPassword: 'brand-new-pw',
}),
}),
);
expect(change.status).toBe(200);
const second = new PersonalDataServer({
actorStorage,
sharedStorage: shared,
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: 'rotated-pw',
});
const changed = await signIn(second, {
username: HANDLE,
password: 'brand-new-pw',
});
expect(changed.status).toBe(401);
const rotated = await signIn(second, {
username: HANDLE,
password: 'rotated-pw',
});
expect(rotated.status).toBe(303);
});
// ── /account/api/version, with the npm registry stubbed ──
/** @param {unknown} manifest - Registry answer, or an Error to throw */
function stubRegistry(manifest) {
const stub = vi.fn(async (/** @type {string} */ _url) => {
if (manifest instanceof Error) throw manifest;
return new Response(JSON.stringify(manifest));
});
vi.stubGlobal('fetch', stub);
return stub;
}
afterEach(() => {
vi.unstubAllGlobals();
});
/** The version _health reports, which is what the endpoint compares against. */
async function runningVersion() {
const response = await pds.fetch(
new Request('https://pds.example.com/xrpc/_health'),
);
return (await response.json()).version;
}
it('reports an available update, once per cache window', async () => {
const [major, minor, patch] = (await runningVersion())
.split('.')
.map(Number);
const newer = `${major}.${minor}.${patch + 1}`;
const stub = stubRegistry({ version: newer });
const { status, body } = await api('/version');
expect(status).toBe(200);
expect(body.latest).toBe(newer);
expect(body.updateAvailable).toBe(true);
expect(body.releaseNotesUrl).toContain('CHANGELOG');
// No update page or script name is configured on this server, so the
// account page has nowhere to link.
expect(body.updateUrl).toBe(null);
expect(body.scriptName).toBe(null);
// Cached: a second request asks the registry nothing.
await api('/version');
expect(stub).toHaveBeenCalledTimes(1);
});
it('names the configured script and update page', async () => {
pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (createMockSharedStorage()),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
updateUrl: 'https://start.example.com/update',
scriptName: 'pds',
});
cookie = await signedInCookie(pds);
stubRegistry({ version: await runningVersion() });
const { body } = await api('/version');
expect(body.updateUrl).toBe('https://start.example.com/update');
expect(body.scriptName).toBe('pds');
});
it('stays quiet when the registry answer is the running release', async () => {
const current = await runningVersion();
stubRegistry({ version: current });
const { body } = await api('/version');
expect(body.current).toBe(current);
expect(body.latest).toBe(current);
expect(body.updateAvailable).toBe(false);
});
it('answers without an update when the registry cannot be reached', async () => {
stubRegistry(new Error('offline'));
const { status, body } = await api('/version');
expect(status).toBe(200);
expect(body.latest).toBe(null);
expect(body.updateAvailable).toBe(false);
});
it('treats a registry answer with no version string as no known update', async () => {
stubRegistry({ 'dist-tags': {} });
const { body } = await api('/version');
expect(body.latest).toBe(null);
expect(body.updateAvailable).toBe(false);
});
// ── the release channel setting ──
/** @param {Record<string, unknown>} payload */
async function postChannel(payload) {
const response = await pds.fetch(
new Request(
'https://pds.example.com/account/api/settings/release-channel',
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify(payload),
},
),
);
return { status: response.status, body: await response.json() };
}
it('follows stable by default', async () => {
stubRegistry({ version: await runningVersion() });
const { body } = await api('/version');
expect(body.channel).toBe('stable');
expect(body.releaseNotesUrl).toContain('CHANGELOG');
});
it('asks the artifacts site once the canary channel is chosen', async () => {
const canary = `${await runningVersion()}-canary.abc1234`;
const stub = stubRegistry({ version: canary });
const set = await postChannel({ channel: 'canary' });
expect(set.status).toBe(200);
expect(set.body).toEqual({ channel: 'canary' });
const { body } = await api('/version');
expect(body.channel).toBe('canary');
expect(body.latest).toBe(canary);
// A canary build differs from the running release, so it is an update
// even though its dotted parts compare as older.
expect(body.updateAvailable).toBe(true);
expect(body.releaseNotesUrl).not.toContain('CHANGELOG');
expect(stub.mock.calls[0][0]).toContain('/canary/manifest.json');
});
it('stays quiet when the canary build is the running one', async () => {
// What a canary deployment reports: the stamped build version matches
// the channel's manifest exactly.
stubRegistry({ version: await runningVersion() });
await postChannel({ channel: 'canary' });
const { body } = await api('/version');
expect(body.updateAvailable).toBe(false);
});
it('caches each channel on its own clock', async () => {
const stub = stubRegistry({ version: await runningVersion() });
await api('/version');
await postChannel({ channel: 'canary' });
// The canary answer is not served from the stable cache.
await api('/version');
expect(stub).toHaveBeenCalledTimes(2);
expect(stub.mock.calls[0][0]).toContain('registry.npmjs.org');
expect(stub.mock.calls[1][0]).toContain('/canary/manifest.json');
});
it('rejects a channel outside stable and canary', async () => {
const { status } = await postChannel({ channel: 'nightly' });
expect(status).toBe(400);
});
});
describe('Sign out all logins', () => {
it('revokes every session and leaves the account page signed in', async () => {
const storage = createMockSharedStorage();
const pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (createMockStorage()),
sharedStorage: /** @type {any} */ (storage),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
password: PASSWORD,
});
const now = new Date().toISOString();
for (const jti of ['a', 'b', 'c']) {
await storage.putSession({
jti,
did: DID,
label: 'Account password',
scope: 'atproto',
userAgent: 'node',
createdAt: now,
refreshedAt: null,
});
}
const cookie = await signedInCookie(pds);
const revoke = await pds.fetch(
new Request('https://pds.example.com/account/api/sessions/revoke-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: '{}',
}),
);
expect(revoke.status).toBe(200);
expect(await revoke.json()).toEqual({ revoked: 3 });
// The logins are gone, but the same cookie still reaches the account API,
// so the caller was not signed out of the page.
const after = await pds.fetch(
new Request('https://pds.example.com/account/api/sessions', {
headers: { Cookie: cookie },
}),
);
expect(after.status).toBe(200);
expect((await after.json()).sessions).toEqual([]);
});
});