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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
192 lines · 6.4 KB Raw History
// The PDS proxies chat.bsky.* (DMs), labelers, and other first-party services,
// not just the AppView. Each needs a service-auth token addressed to that
// specific service, or it answers 400.

import { afterEach, describe, expect, it, vi } from 'vitest';
import { createAccessJwt } from '../packages/core/src/auth.js';
import {
  base64UrlDecode,
  generateKeyPair,
} from '../packages/core/src/crypto.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';

const DID = 'did:plc:proxyexampleaccount00000';
const JWT_SECRET = 'test-secret';

/** @param {string} jwt */
function decodeClaims(jwt) {
  return JSON.parse(
    new TextDecoder().decode(base64UrlDecode(jwt.split('.')[1])),
  );
}

/**
 * @param {Uint8Array} privateKey
 * @param {{didResolver?: (did: string) => Promise<any>}} [opts]
 */
function server(privateKey, opts = {}) {
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ ({
      async getDid() {
        return DID;
      },
      async getPrivateKey() {
        return privateKey;
      },
      async getHandle() {
        return 'alice.example.com';
      },
    }),
    sharedStorage: /** @type {any} */ ({}),
    blobs: /** @type {any} */ ({}),
    jwtSecret: JWT_SECRET,
    hostname: 'pds.example.com',
    didResolver: opts.didResolver,
  });
}

/** Capture the outbound proxied request. */
function captureFetch() {
  const calls = /** @type {{url: string, auth: string|null}[]} */ ([]);
  vi.stubGlobal(
    'fetch',
    vi.fn(async (/** @type {any} */ url, /** @type {any} */ init) => {
      calls.push({
        url: String(url),
        auth: init?.headers?.get?.('Authorization') ?? null,
      });
      return new Response(JSON.stringify({ convos: [] }), {
        headers: { 'Content-Type': 'application/json' },
      });
    }),
  );
  return calls;
}

describe('service proxying', () => {
  afterEach(() => vi.unstubAllGlobals());

  it('proxies chat.bsky.* to the chat service with a service token for it', async () => {
    const { privateKey } = await generateKeyPair();
    const calls = captureFetch();
    const token = await createAccessJwt(DID, JWT_SECRET);

    const res = await server(privateKey).fetch(
      new Request('https://pds.example.com/xrpc/chat.bsky.convo.listConvos', {
        headers: {
          Authorization: `Bearer ${token}`,
          'atproto-proxy': 'did:web:api.bsky.chat#bsky_chat',
        },
      }),
    );

    expect(res.status).toBe(200);
    expect(calls).toHaveLength(1);
    // Forwarded to the chat service's host, same method.
    expect(calls[0].url).toBe(
      'https://api.bsky.chat/xrpc/chat.bsky.convo.listConvos',
    );
    // With a service token addressed to the chat service, signed as the user.
    expect(calls[0].auth).toMatch(/^Bearer /);
    const claims = decodeClaims((calls[0].auth || '').slice('Bearer '.length));
    expect(claims.iss).toBe(DID);
    expect(claims.aud).toBe('did:web:api.bsky.chat');
    expect(claims.lxm).toBe('chat.bsky.convo.listConvos');
  });

  it('forwards an unauthenticated request unsigned (the service decides)', async () => {
    const { privateKey } = await generateKeyPair();
    const calls = captureFetch();
    await server(privateKey).fetch(
      new Request('https://pds.example.com/xrpc/chat.bsky.convo.listConvos', {
        headers: { 'atproto-proxy': 'did:web:api.bsky.chat#bsky_chat' },
      }),
    );
    // A public read is forwarded as-is; no account token is minted for it.
    expect(calls).toHaveLength(1);
    expect(calls[0].auth).toBeNull();
  });

  it('resolves and proxies to a feed generator via its DID document', async () => {
    const { privateKey } = await generateKeyPair();
    const calls = captureFetch();
    const token = await createAccessJwt(DID, JWT_SECRET);
    // The feed generator's DID document declares its #bsky_fg endpoint.
    const didResolver = async (/** @type {string} */ did) =>
      did === 'did:web:foryou.club'
        ? {
            service: [
              {
                id: '#bsky_fg',
                type: 'BskyFeedGenerator',
                serviceEndpoint: 'https://foryou.club',
              },
            ],
          }
        : null;

    const res = await server(privateKey, { didResolver }).fetch(
      new Request(
        'https://pds.example.com/xrpc/app.bsky.feed.sendInteractions',
        {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${token}`,
            'atproto-proxy': 'did:web:foryou.club#bsky_fg',
            'content-type': 'application/json',
          },
          body: '{"interactions":[]}',
        },
      ),
    );

    expect(res.status).toBe(200);
    expect(calls).toHaveLength(1);
    expect(calls[0].url).toBe(
      'https://foryou.club/xrpc/app.bsky.feed.sendInteractions',
    );
    // A service token addressed to the feed generator, signed as the user.
    const claims = decodeClaims((calls[0].auth || '').slice('Bearer '.length));
    expect(claims.iss).toBe(DID);
    expect(claims.aud).toBe('did:web:foryou.club');
    expect(claims.lxm).toBe('app.bsky.feed.sendInteractions');
  });

  it('rejects a service the DID document does not declare', async () => {
    const { privateKey } = await generateKeyPair();
    const calls = captureFetch();
    const token = await createAccessJwt(DID, JWT_SECRET);
    // Resolves, but has no #bsky_fg entry — nothing to proxy to.
    const didResolver = async () => ({
      service: [{ id: '#other', serviceEndpoint: 'https://foryou.club' }],
    });
    const res = await server(privateKey, { didResolver }).fetch(
      new Request(
        'https://pds.example.com/xrpc/app.bsky.feed.sendInteractions',
        {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${token}`,
            'atproto-proxy': 'did:web:foryou.club#bsky_fg',
          },
        },
      ),
    );
    expect(res.status).toBe(400);
    expect(calls).toHaveLength(0);
  });

  it('rejects an unresolvable proxy service', async () => {
    const { privateKey } = await generateKeyPair();
    const calls = captureFetch();
    // No didResolver injected — an unknown service cannot be resolved.
    const res = await server(privateKey).fetch(
      new Request('https://pds.example.com/xrpc/com.atproto.repo.getRecord', {
        headers: { 'atproto-proxy': 'did:web:evil.example#service' },
      }),
    );
    expect(res.status).toBe(400);
    // Never contacted — no signed token leaves for a service we can't resolve.
    expect(calls).toHaveLength(0);
  });
});