chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import {
matchesMime,
parseBlobScope,
parseRepoScope,
parseRpcScope,
parseScopesForDisplay,
ScopePermissions,
} from '@pdsjs/core/scope';
import { describe, expect, test } from 'vitest';
describe('Scope Parsing', () => {
describe('parseRepoScope', () => {
test('parses repo scope with query parameter action', () => {
const result = parseRepoScope('repo:app.bsky.feed.post?action=create');
expect(result).toEqual({
collection: 'app.bsky.feed.post',
actions: ['create'],
});
});
test('parses repo scope with multiple query parameter actions', () => {
const result = parseRepoScope(
'repo:app.bsky.feed.post?action=create&action=update',
);
expect(result).toEqual({
collection: 'app.bsky.feed.post',
actions: ['create', 'update'],
});
});
test('parses repo scope without actions as all actions', () => {
const result = parseRepoScope('repo:app.bsky.feed.post');
expect(result).toEqual({
collection: 'app.bsky.feed.post',
actions: ['create', 'update', 'delete'],
});
});
test('parses wildcard collection with action', () => {
const result = parseRepoScope('repo:*?action=create');
expect(result).toEqual({
collection: '*',
actions: ['create'],
});
});
test('parses query-only format', () => {
const result = parseRepoScope(
'repo?collection=app.bsky.feed.post&action=create',
);
expect(result).toEqual({
collection: 'app.bsky.feed.post',
actions: ['create'],
});
});
test('deduplicates repeated actions', () => {
const result = parseRepoScope(
'repo:app.bsky.feed.post?action=create&action=create&action=update',
);
expect(result).toEqual({
collection: 'app.bsky.feed.post',
actions: ['create', 'update'],
});
});
test('returns null for non-repo scope', () => {
expect(parseRepoScope('atproto')).toBe(null);
expect(parseRepoScope('blob:image/*')).toBe(null);
expect(parseRepoScope('transition:generic')).toBe(null);
});
test('returns null for invalid repo scope', () => {
expect(parseRepoScope('repo:')).toBe(null);
expect(parseRepoScope('repo?')).toBe(null);
});
});
describe('parseBlobScope', () => {
test('parses wildcard MIME', () => {
const result = parseBlobScope('blob:*/*');
expect(result).toEqual({ accept: ['*/*'] });
});
test('parses type wildcard', () => {
const result = parseBlobScope('blob:image/*');
expect(result).toEqual({ accept: ['image/*'] });
});
test('parses specific MIME', () => {
const result = parseBlobScope('blob:image/png');
expect(result).toEqual({ accept: ['image/png'] });
});
test('parses multiple MIMEs', () => {
const result = parseBlobScope('blob:image/png,image/jpeg');
expect(result).toEqual({ accept: ['image/png', 'image/jpeg'] });
});
test('returns null for non-blob scope', () => {
expect(parseBlobScope('atproto')).toBe(null);
expect(parseBlobScope('repo:*:create')).toBe(null);
});
});
describe('matchesMime', () => {
test('wildcard matches everything', () => {
expect(matchesMime('*/*', 'image/png')).toBe(true);
expect(matchesMime('*/*', 'video/mp4')).toBe(true);
});
test('type wildcard matches same type', () => {
expect(matchesMime('image/*', 'image/png')).toBe(true);
expect(matchesMime('image/*', 'image/jpeg')).toBe(true);
expect(matchesMime('image/*', 'video/mp4')).toBe(false);
});
test('exact match', () => {
expect(matchesMime('image/png', 'image/png')).toBe(true);
expect(matchesMime('image/png', 'image/jpeg')).toBe(false);
});
test('case insensitive', () => {
expect(matchesMime('image/PNG', 'image/png')).toBe(true);
expect(matchesMime('IMAGE/*', 'image/png')).toBe(true);
});
});
});
describe('ScopePermissions', () => {
describe('static scopes', () => {
test('atproto grants full access', () => {
const perms = new ScopePermissions('atproto');
expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
expect(perms.allowsRepo('any.collection', 'delete')).toBe(true);
expect(perms.allowsBlob('image/png')).toBe(true);
expect(perms.allowsBlob('video/mp4')).toBe(true);
});
test('transition:generic grants full repo/blob access', () => {
const perms = new ScopePermissions('transition:generic');
expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
expect(perms.allowsRepo('any.collection', 'delete')).toBe(true);
expect(perms.allowsBlob('image/png')).toBe(true);
});
});
// An app-password scope string names no granular permissions. The reference
// PDS asserts repo, blob and rpc permissions for OAuth credentials only, and
// bounds an app password by other means.
describe('app-password sessions', () => {
for (const scope of [
'com.atproto.appPass',
'com.atproto.appPassPrivileged',
]) {
test(`${scope} writes any collection and uploads any blob`, () => {
const perms = new ScopePermissions(scope);
expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
expect(perms.allowsRepo('app.bsky.feed.post', 'update')).toBe(true);
expect(perms.allowsRepo('any.collection', 'delete')).toBe(true);
expect(perms.allowsBlob('image/png')).toBe(true);
expect(perms.allowsRpc('app.bsky.feed.getFeed', 'did:web:x#y')).toBe(
true,
);
});
test(`${scope} is not full access, so it never carries the email`, () => {
const perms = new ScopePermissions(scope);
expect(perms.hasFullAccess()).toBe(false);
expect(perms.hasEmailAccess()).toBe(false);
});
test(`${scope} does not reach space scopes`, () => {
const perms = new ScopePermissions(scope);
expect(perms.isAppPasswordSession()).toBe(true);
expect(
perms.allowsSpace({
spaceType: 'com.example.space',
authority: 'did:plc:authority234567890ab',
skey: 'skey',
action: 'read',
}),
).toBe(false);
});
}
});
describe('repo scopes', () => {
test('wildcard collection allows any collection', () => {
const perms = new ScopePermissions('repo:*?action=create');
expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
expect(perms.allowsRepo('app.bsky.feed.like', 'create')).toBe(true);
expect(perms.allowsRepo('app.bsky.feed.post', 'delete')).toBe(false);
});
test('specific collection restricts to that collection', () => {
const perms = new ScopePermissions(
'repo:app.bsky.feed.post?action=create',
);
expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
expect(perms.allowsRepo('app.bsky.feed.like', 'create')).toBe(false);
});
test('multiple actions', () => {
const perms = new ScopePermissions('repo:*?action=create&action=update');
expect(perms.allowsRepo('x', 'create')).toBe(true);
expect(perms.allowsRepo('x', 'update')).toBe(true);
expect(perms.allowsRepo('x', 'delete')).toBe(false);
});
test('multiple scopes combine', () => {
const perms = new ScopePermissions(
'repo:app.bsky.feed.post?action=create repo:app.bsky.feed.like?action=delete',
);
expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
expect(perms.allowsRepo('app.bsky.feed.like', 'delete')).toBe(true);
expect(perms.allowsRepo('app.bsky.feed.post', 'delete')).toBe(false);
});
test('allowsRepo with query param format scopes', () => {
const perms = new ScopePermissions(
'atproto repo:app.bsky.feed.post?action=create',
);
expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true);
expect(perms.allowsRepo('app.bsky.feed.post', 'delete')).toBe(true); // atproto grants full access
});
});
describe('blob scopes', () => {
test('wildcard allows any MIME', () => {
const perms = new ScopePermissions('blob:*/*');
expect(perms.allowsBlob('image/png')).toBe(true);
expect(perms.allowsBlob('video/mp4')).toBe(true);
});
test('type wildcard restricts to type', () => {
const perms = new ScopePermissions('blob:image/*');
expect(perms.allowsBlob('image/png')).toBe(true);
expect(perms.allowsBlob('image/jpeg')).toBe(true);
expect(perms.allowsBlob('video/mp4')).toBe(false);
});
test('specific MIME restricts exactly', () => {
const perms = new ScopePermissions('blob:image/png');
expect(perms.allowsBlob('image/png')).toBe(true);
expect(perms.allowsBlob('image/jpeg')).toBe(false);
});
});
describe('empty/no scope', () => {
test('no scope denies everything', () => {
const perms = new ScopePermissions('');
expect(perms.allowsRepo('x', 'create')).toBe(false);
expect(perms.allowsBlob('image/png')).toBe(false);
});
test('undefined scope denies everything', () => {
const perms = new ScopePermissions(undefined);
expect(perms.allowsRepo('x', 'create')).toBe(false);
});
});
describe('assertRepo', () => {
test('throws ScopeMissingError when denied', () => {
const perms = new ScopePermissions(
'repo:app.bsky.feed.post?action=create',
);
expect(() => perms.assertRepo('app.bsky.feed.like', 'create')).toThrow(
/Missing required scope/,
);
});
test('does not throw when allowed', () => {
const perms = new ScopePermissions(
'repo:app.bsky.feed.post?action=create',
);
expect(() =>
perms.assertRepo('app.bsky.feed.post', 'create'),
).not.toThrow();
});
});
describe('assertBlob', () => {
test('throws ScopeMissingError when denied', () => {
const perms = new ScopePermissions('blob:image/*');
expect(() => perms.assertBlob('video/mp4')).toThrow(
/Missing required scope/,
);
});
test('does not throw when allowed', () => {
const perms = new ScopePermissions('blob:image/*');
expect(() => perms.assertBlob('image/png')).not.toThrow();
});
});
});
describe('parseScopesForDisplay', () => {
test('parses identity-only scope', () => {
const result = parseScopesForDisplay('atproto');
expect(result.hasAtproto).toBe(true);
expect(result.hasTransitionGeneric).toBe(false);
expect(result.repoPermissions.size).toBe(0);
expect(result.blobPermissions).toEqual([]);
});
test('parses granular repo scopes', () => {
const result = parseScopesForDisplay(
'atproto repo:app.bsky.feed.post?action=create&action=update',
);
expect(result.repoPermissions.size).toBe(1);
const postPerms = result.repoPermissions.get('app.bsky.feed.post');
expect(postPerms).toEqual({
create: true,
update: true,
delete: false,
});
});
test('merges multiple scopes for same collection', () => {
const result = parseScopesForDisplay(
'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete',
);
const postPerms = result.repoPermissions.get('app.bsky.feed.post');
expect(postPerms).toEqual({
create: true,
update: false,
delete: true,
});
});
test('parses blob scopes', () => {
const result = parseScopesForDisplay('atproto blob:image/*');
expect(result.blobPermissions).toEqual(['image/*']);
});
test('detects transition:generic', () => {
const result = parseScopesForDisplay('atproto transition:generic');
expect(result.hasTransitionGeneric).toBe(true);
});
test('handles empty scope string', () => {
const result = parseScopesForDisplay('');
expect(result.hasAtproto).toBe(false);
expect(result.hasTransitionGeneric).toBe(false);
expect(result.repoPermissions.size).toBe(0);
expect(result.blobPermissions).toEqual([]);
});
});
describe('parseRpcScope', () => {
test('parses positional lxm with aud query param', () => {
expect(
parseRpcScope(
'rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app%23bsky_appview',
),
).toEqual({
lxms: ['app.bsky.feed.getTimeline'],
aud: 'did:web:api.bsky.app#bsky_appview',
});
});
test('parses repeated lxm query params', () => {
expect(
parseRpcScope('rpc?lxm=com.example.a&lxm=com.example.b&aud=*'),
).toEqual({ lxms: ['com.example.a', 'com.example.b'], aud: '*' });
});
test('parses full wildcards', () => {
expect(parseRpcScope('rpc:*?aud=*')).toEqual({ lxms: '*', aud: '*' });
});
test('rejects missing aud', () => {
expect(parseRpcScope('rpc:com.example.method')).toBeNull();
expect(parseRpcScope('rpc?lxm=com.example.method')).toBeNull();
});
test('rejects missing lxm', () => {
expect(parseRpcScope('rpc?aud=*')).toBeNull();
});
test('returns null for non-rpc scopes', () => {
expect(parseRpcScope('repo:app.bsky.feed.post')).toBeNull();
});
});
describe('ScopePermissions.allowsRpc', () => {
test('full access scopes bypass rpc checks', () => {
const perms = new ScopePermissions('atproto');
expect(perms.allowsRpc('app.bsky.feed.getTimeline', 'did:web:x#svc')).toBe(
true,
);
});
test('granular scope without rpc permission denies', () => {
const perms = new ScopePermissions('repo:app.bsky.feed.post');
expect(perms.allowsRpc('app.bsky.feed.getTimeline', 'did:web:x#svc')).toBe(
false,
);
});
test('matching lxm and aud allows', () => {
const perms = new ScopePermissions(
'rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app%23bsky_appview',
);
expect(
perms.allowsRpc(
'app.bsky.feed.getTimeline',
'did:web:api.bsky.app#bsky_appview',
),
).toBe(true);
});
test('aud mismatch denies', () => {
const perms = new ScopePermissions(
'rpc:app.bsky.feed.getTimeline?aud=did:web:other%23svc',
);
expect(
perms.allowsRpc(
'app.bsky.feed.getTimeline',
'did:web:api.bsky.app#bsky_appview',
),
).toBe(false);
});
test('lxm matching is case-insensitive', () => {
const perms = new ScopePermissions('rpc:app.bsky.feed.getTimeline?aud=*');
expect(perms.allowsRpc('App.Bsky.Feed.GetTimeline', 'did:web:x#svc')).toBe(
true,
);
});
test('wildcard lxm with specific aud', () => {
const perms = new ScopePermissions('rpc:*?aud=did:web:x%23svc');
expect(perms.allowsRpc('com.anything.method', 'did:web:x#svc')).toBe(true);
expect(perms.allowsRpc('com.anything.method', 'did:web:y#svc')).toBe(false);
});
});