~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
357 lines · 11.0 KB Raw History
import {
  bareSpaceTypes,
  expandSpaceScopes,
  parseScopesForDisplay,
  parseSpaceScope,
  ScopePermissions,
  spaceScopeAllowsManage,
  spaceScopeMatches,
  spaceTypeCollections,
} from '@pdsjs/core/scope';
import { describe, expect, it } from 'vitest';

const USER = 'did:plc:user';
const AUTHORITY = 'did:plc:authority';

/**
 * parseSpaceScope returns null for scopes it does not recognise; these cases
 * all expect a grant, so assert that here rather than at every use.
 * @param {string} scope
 * @returns {any}
 */
function parse(scope) {
  const grant = parseSpaceScope(scope);
  if (!grant) throw new Error(`expected a space grant: ${scope}`);
  return grant;
}

describe('parseSpaceScope', () => {
  it('ignores scopes of other kinds', () => {
    expect(parseSpaceScope('repo:com.example.post')).toBeNull();
    expect(parseSpaceScope('atproto')).toBeNull();
  });

  it('defaults authority to self and skey to *', () => {
    const g = parse('space:com.example.forum');
    expect(g.spaceType).toBe('com.example.forum');
    expect(g.authority).toBe('self');
    expect(g.skey).toBe('*');
  });

  it('defaults actions to everything but read_self', () => {
    const g = parse('space:com.example.forum');
    expect(g.actions).toEqual(['read', 'create', 'update', 'delete']);
    expect(g.actions).not.toContain('read_self');
  });

  it('leaves collections null when omitted', () => {
    // Null means "the space type's declared collections" — only the space type
    // can resolve that, so the grant cannot.
    expect(parse('space:com.example.forum').collections).toBeNull();
  });

  it('parses every parameter', () => {
    const g = parse(
      `space:com.example.forum?authority=${AUTHORITY}&skey=general` +
        '&collection=com.example.post&collection=com.example.reply' +
        '&action=read&action=create&manage=delete',
    );
    expect(g).toEqual({
      spaceType: 'com.example.forum',
      authority: AUTHORITY,
      skey: 'general',
      collections: ['com.example.post', 'com.example.reply'],
      actions: ['read', 'create'],
      manage: ['delete'],
    });
  });

  it('accepts wildcards', () => {
    const g = parse('space:*?authority=*&skey=*&collection=*');
    expect(g.spaceType).toBe('*');
    expect(g.authority).toBe('*');
    expect(g.collections).toEqual(['*']);
  });

  it('rejects a space type that is not an NSID', () => {
    expect(parseSpaceScope('space:nodots')).toBeNull();
  });

  it('rejects a malformed authority', () => {
    expect(parseSpaceScope('space:com.e.f?authority=alice.test')).toBeNull();
  });

  it('rejects an over-long skey', () => {
    const skey = 'x'.repeat(513);
    expect(parseSpaceScope(`space:com.e.f?skey=${skey}`)).toBeNull();
  });

  it('rejects a scope whose actions are all unrecognised', () => {
    expect(parseSpaceScope('space:com.e.f?action=frobnicate')).toBeNull();
  });

  it('drops unrecognised manage verbs', () => {
    expect(parse('space:com.e.f?manage=frobnicate').manage).toEqual([]);
  });
});

describe('spaceScopeMatches', () => {
  const req = (over = {}) => ({
    spaceType: 'com.example.forum',
    authority: AUTHORITY,
    skey: 'general',
    action: 'read',
    userDid: USER,
    ...over,
  });

  it('matches on the full tuple', () => {
    const g = parse(
      `space:com.example.forum?authority=${AUTHORITY}&skey=general`,
    );
    expect(spaceScopeMatches(g, req())).toBe(true);
    expect(spaceScopeMatches(g, req({ skey: 'other' }))).toBe(false);
    expect(spaceScopeMatches(g, req({ spaceType: 'com.other.kind' }))).toBe(
      false,
    );
    expect(spaceScopeMatches(g, req({ authority: 'did:plc:other' }))).toBe(
      false,
    );
  });

  it('resolves self to the granting user', () => {
    const g = parse('space:com.example.forum');
    expect(spaceScopeMatches(g, req({ authority: USER }))).toBe(true);
    // The space belongs to someone else, so a `self` grant does not reach it.
    expect(spaceScopeMatches(g, req({ authority: AUTHORITY }))).toBe(false);
  });

  it('honours wildcards', () => {
    const g = parse('space:*?authority=*&skey=*');
    expect(spaceScopeMatches(g, req({ spaceType: 'anything.at.all' }))).toBe(
      true,
    );
  });

  it('lets read imply read_self but not the reverse', () => {
    const readOnly = parse(
      `space:com.example.forum?authority=${AUTHORITY}&action=read`,
    );
    expect(spaceScopeMatches(readOnly, req({ action: 'read_self' }))).toBe(
      true,
    );

    const selfOnly = parse(
      `space:com.example.forum?authority=${AUTHORITY}&action=read_self`,
    );
    expect(spaceScopeMatches(selfOnly, req({ action: 'read' }))).toBe(false);
    expect(spaceScopeMatches(selfOnly, req({ action: 'read_self' }))).toBe(
      true,
    );
  });

  it('constrains writes by collection', () => {
    const g = parse(
      `space:com.example.forum?authority=${AUTHORITY}&collection=com.example.post`,
    );
    expect(
      spaceScopeMatches(
        g,
        req({ action: 'create', collection: 'com.example.post' }),
      ),
    ).toBe(true);
    expect(
      spaceScopeMatches(
        g,
        req({ action: 'create', collection: 'com.example.reply' }),
      ),
    ).toBe(false);
  });

  it('falls back to the space type declared collections', () => {
    const g = parse(`space:com.example.forum?authority=${AUTHORITY}`);
    expect(
      spaceScopeMatches(
        g,
        req({
          action: 'create',
          collection: 'com.example.post',
          declaredCollections: ['com.example.post'],
        }),
      ),
    ).toBe(true);
    // No declared collections and none granted means no write targets at all.
    expect(
      spaceScopeMatches(g, req({ action: 'create', collection: 'com.e.p' })),
    ).toBe(false);
  });

  it('does not let a record action imply management', () => {
    const g = parse(`space:com.example.forum?authority=${AUTHORITY}`);
    expect(
      spaceScopeAllowsManage(g, {
        spaceType: 'com.example.forum',
        authority: AUTHORITY,
        skey: 'general',
        op: 'delete',
        userDid: USER,
      }),
    ).toBe(false);
  });

  it('permits management when the verb is granted', () => {
    const g = parse(
      `space:com.example.forum?authority=${AUTHORITY}&manage=create&manage=delete`,
    );
    const base = {
      spaceType: 'com.example.forum',
      authority: AUTHORITY,
      skey: 'general',
      userDid: USER,
    };
    expect(spaceScopeAllowsManage(g, { ...base, op: 'create' })).toBe(true);
    expect(spaceScopeAllowsManage(g, { ...base, op: 'update' })).toBe(false);
  });
});

describe('ScopePermissions', () => {
  it('collects space grants alongside the others', () => {
    const p = new ScopePermissions(
      `repo:com.example.post space:com.example.forum?authority=${AUTHORITY}`,
    );
    expect(p.spacePermissions).toHaveLength(1);
    expect(p.repoPermissions).toHaveLength(1);
  });

  it('allowsSpace honours the grant', () => {
    const p = new ScopePermissions(
      `space:com.example.forum?authority=${AUTHORITY}&action=read`,
    );
    const req = {
      spaceType: 'com.example.forum',
      authority: AUTHORITY,
      skey: 'general',
      action: 'read',
      userDid: USER,
    };
    expect(p.allowsSpace(req)).toBe(true);
    expect(p.allowsSpace({ ...req, action: 'create' })).toBe(false);
  });

  /** @param {string} scope */
  const reaches = (scope) =>
    new ScopePermissions(scope).allowsSpace({
      spaceType: 'com.example.forum',
      authority: AUTHORITY,
      skey: 'k',
      action: 'delete',
      collection: 'com.e.p',
    });

  it('the account password session covers spaces', () => {
    expect(reaches('com.atproto.access')).toBe(true);
  });

  it('an OAuth session reaches no space it was not granted', () => {
    // `atproto` is in every OAuth authorization request, so a session that
    // reached spaces through it would give every application every space.
    // `transition:generic` predates permissioned data entirely.
    expect(reaches('atproto')).toBe(false);
    expect(reaches('atproto transition:generic')).toBe(false);
    expect(reaches('atproto repo:com.example.post blob:image/*')).toBe(false);
  });

  it('the same session reaches a space its scope names', () => {
    expect(
      reaches(
        `atproto space:com.example.forum?authority=${AUTHORITY}&collection=com.e.p`,
      ),
    ).toBe(true);
  });

  it('grants nothing without a space scope', () => {
    const p = new ScopePermissions('repo:com.example.post');
    expect(
      p.allowsSpace({
        spaceType: 'com.example.forum',
        authority: AUTHORITY,
        skey: 'general',
        action: 'read',
        userDid: USER,
      }),
    ).toBe(false);
  });
});

describe('parseScopesForDisplay', () => {
  it('surfaces space grants for the consent screen', () => {
    const display = parseScopesForDisplay(
      `space:com.example.forum?authority=${AUTHORITY}&action=read repo:com.example.post`,
    );
    expect(display.spacePermissions).toHaveLength(1);
    expect(display.spacePermissions[0].spaceType).toBe('com.example.forum');
    expect(display.spacePermissions[0].actions).toEqual(['read']);
  });
});

describe('space types without collections', () => {
  const FORUM = {
    lexicon: 1,
    id: 'com.example.forum',
    defs: {
      main: {
        type: 'space',
        collections: ['com.example.post', 'com.example.reply', '*', 'nodots'],
      },
    },
  };

  it('reads the collections a space type declares', () => {
    expect(spaceTypeCollections(FORUM)).toEqual([
      'com.example.post',
      'com.example.reply',
    ]);
    expect(spaceTypeCollections({ defs: { main: { type: 'space' } } })).toEqual(
      [],
    );
    expect(
      spaceTypeCollections({ defs: { main: { type: 'record' } } }),
    ).toBeNull();
    expect(spaceTypeCollections(null)).toBeNull();
  });

  it('names the space types a scope grants without a collection', () => {
    expect(
      bareSpaceTypes(
        'atproto space:com.example.forum?authority=* space:com.example.forum ' +
          'space:com.example.chat?collection=com.example.msg space:* repo:com.example.post',
      ),
    ).toEqual(['com.example.forum']);
  });

  it('writes the declared collections into those scopes', () => {
    const scope = expandSpaceScopes(
      'atproto space:com.example.forum?authority=*&manage=create ' +
        'space:com.example.forum space:com.example.chat?collection=com.example.msg ' +
        'space:com.example.empty',
      {
        'com.example.forum': ['com.example.post', 'com.example.reply'],
        'com.example.empty': [],
      },
    );
    expect(scope.split(' ')).toEqual([
      'atproto',
      'space:com.example.forum?authority=*&manage=create&collection=com.example.post&collection=com.example.reply',
      'space:com.example.forum?collection=com.example.post&collection=com.example.reply',
      'space:com.example.chat?collection=com.example.msg',
      'space:com.example.empty',
    ]);
    const grant = parse(scope.split(' ')[1]);
    expect(grant.collections).toEqual([
      'com.example.post',
      'com.example.reply',
    ]);
    expect(grant.authority).toBe('*');
    expect(grant.manage).toEqual(['create']);
  });
});