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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
177 lines · 5.4 KB Raw History
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
  didDocumentUrl,
  documentHandle,
  identityOf,
  pdsEndpoint,
  resolveIdentity,
} from '#/lib/identity.js';

const DID = 'did:plc:c4igoshkxhzw62eme3iltpdi';

const document = {
  id: DID,
  alsoKnownAs: ['at://alice.example.com'],
  service: [
    {
      id: '#atproto_pds',
      type: 'AtprotoPersonalDataServer',
      serviceEndpoint: 'https://pds.example.com/',
    },
  ],
};

/**
 * Answer the two lookups a browser client makes, and nothing else.
 * @param {{txt?: string|null, doc?: object}} answers
 */
function stubNetwork({ txt = `"did=${DID}"`, doc = document } = {}) {
  vi.stubGlobal('fetch', async (input) => {
    const url = String(input?.url ?? input);
    if (url.startsWith('https://cloudflare-dns.com')) {
      return Response.json(txt === null ? {} : { Answer: [{ data: txt }] });
    }
    if (url.startsWith('https://plc.directory/')) {
      return doc ? Response.json(doc) : new Response('gone', { status: 404 });
    }
    return new Response('not found', { status: 404 });
  });
}

afterEach(() => vi.unstubAllGlobals());

describe('didDocumentUrl', () => {
  it('sends a did:plc to the directory', () => {
    expect(didDocumentUrl(DID)).toBe(`https://plc.directory/${DID}`);
  });

  it('reads a did:web as the host serving its own document', () => {
    expect(didDocumentUrl('did:web:example.com')).toBe(
      'https://example.com/.well-known/did.json',
    );
  });

  // A did:web carrying a port encodes the colon, and the document is served
  // from the host that colon belongs to.
  it('decodes a port in a did:web', () => {
    expect(didDocumentUrl('did:web:localhost%3A2583')).toBe(
      'https://localhost:2583/.well-known/did.json',
    );
  });

  it('refuses a method it cannot fetch', () => {
    expect(() => didDocumentUrl('did:key:zQ3s')).toThrow(/Unsupported/);
  });
});

describe('pdsEndpoint', () => {
  it('drops the trailing slash, so a path joins onto it once', () => {
    expect(pdsEndpoint(document)).toBe('https://pds.example.com');
  });

  it('refuses a document naming no PDS', () => {
    expect(() => pdsEndpoint({ id: DID, service: [] })).toThrow(/no PDS/);
  });
});

describe('documentHandle', () => {
  it('reads the at:// alias', () => {
    expect(documentHandle(document)).toBe('alice.example.com');
  });

  // An account mid-handle-change claims none, and reads as itself.
  it('falls back to the DID', () => {
    expect(documentHandle({ id: DID, alsoKnownAs: [] })).toBe(DID);
  });
});

describe('resolveIdentity', () => {
  it('resolves a handle through DNS and confirms the document claims it', async () => {
    stubNetwork();
    await expect(resolveIdentity('alice.example.com')).resolves.toMatchObject({
      did: DID,
      handle: 'alice.example.com',
      pds: 'https://pds.example.com',
    });
  });

  it('accepts a handle typed as an at:// URI or with an @', async () => {
    stubNetwork();
    await expect(
      resolveIdentity('  at://@Alice.Example.com/ '),
    ).resolves.toMatchObject({ did: DID });
  });

  // A TXT record is written by whoever holds the domain, and it can name any
  // DID at all. Only the document decides which handle is really its own.
  it('refuses a handle the document does not claim', async () => {
    stubNetwork({
      doc: { ...document, alsoKnownAs: ['at://somebody.else.example'] },
    });
    await expect(resolveIdentity('alice.example.com')).rejects.toThrow(
      /does not claim/,
    );
  });

  it('reports a handle that resolves nowhere', async () => {
    stubNetwork({ txt: null });
    await expect(resolveIdentity('alice.example.com')).rejects.toThrow(
      /Could not resolve/,
    );
  });

  it('takes a DID without asking DNS anything', async () => {
    stubNetwork({ txt: null });
    await expect(resolveIdentity(DID)).resolves.toMatchObject({
      did: DID,
      handle: 'alice.example.com',
    });
  });

  it('refuses an empty field', async () => {
    await expect(resolveIdentity('   ')).rejects.toThrow(/Enter a handle/);
  });
});

describe('identityOf', () => {
  it('confirms a handle that resolves back to the DID', async () => {
    stubNetwork();
    await expect(identityOf(DID)).resolves.toEqual({
      did: DID,
      handle: 'alice.example.com',
      verified: true,
    });
  });

  // Anybody may write any handle into their own DID document. Shown plainly it
  // would be somebody else's name on the page, so the round trip decides.
  it('reports a claimed handle that resolves to somebody else as unconfirmed', async () => {
    stubNetwork({ txt: '"did=did:plc:somebodyelse"' });
    await expect(identityOf(DID)).resolves.toEqual({
      did: DID,
      handle: 'alice.example.com',
      verified: false,
    });
  });

  it('reports a claimed handle that resolves nowhere as unconfirmed', async () => {
    stubNetwork({ txt: null });
    await expect(identityOf(DID)).resolves.toMatchObject({ verified: false });
  });

  // An account mid-handle-change claims none, and there is nothing to show but
  // the DID.
  it('answers no handle for a document claiming none', async () => {
    stubNetwork({ doc: { id: DID, alsoKnownAs: [] } });
    await expect(identityOf(DID)).resolves.toEqual({
      did: DID,
      handle: null,
      verified: false,
    });
  });

  it('propagates a DID that does not resolve at all', async () => {
    stubNetwork({ doc: null });
    await expect(identityOf('did:plc:missing')).rejects.toThrow();
  });
});