chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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();
});
});