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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1464 lines · 42.1 KB Raw History
# Split pds.test.js Into Module-Specific Test Files

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Split the monolithic `test/pds.test.js` into 7 separate test files in `packages/core/test/`, each corresponding to its source module.

**Architecture:** Extract describe blocks from the single test file into module-specific test files. Each new file imports only from its corresponding source module. No source file changes.

**Tech Stack:** Vitest, ES modules

---

## File Mapping Reference

| New Test File | Describe Blocks |
|---------------|-----------------|
| `packages/core/test/crypto.test.js` | Base32 Encoding, P-256 Signing, JWT Base64URL, JWK Thumbprint |
| `packages/core/test/repo.test.js` | CBOR Encoding, CBOR Decoding, CID Generation, TID Generation, CAR File Builder, MIME Type Sniffing, Blob Ref Detection |
| `packages/core/test/auth.test.js` | JWT Creation, JWT Verification |
| `packages/core/test/scope.test.js` | Scope Parsing, ScopePermissions, parseScopesForDisplay |
| `packages/core/test/oauth.test.js` | Client Metadata |
| `packages/core/test/pds.test.js` | Proxy Utilities |
| `packages/core/test/mst.test.js` | MST Key Depth |

---

### Task 1: Create test directory and crypto.test.js

**Files:**
- Create: `packages/core/test/crypto.test.js`

**Step 1: Create test directory**

Run: `mkdir -p packages/core/test`

**Step 2: Create crypto.test.js**

```javascript
import { describe, expect, test } from 'vitest';
import {
  base32Decode,
  base32Encode,
  base64UrlDecode,
  base64UrlEncode,
  bytesToHex,
  computeJwkThumbprint,
  generateKeyPair,
  hexToBytes,
  importPrivateKey,
  sign,
} from '@pdsjs/core/crypto';

describe('Base32 Encoding', () => {
  test('encodes bytes to base32lower', () => {
    const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]);
    const encoded = base32Encode(bytes);
    expect(typeof encoded).toBe('string');
    expect(encoded).toMatch(/^[a-z2-7]+$/);
  });

  test('base32 encode/decode roundtrip', () => {
    const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]);
    const encoded = base32Encode(original);
    const decoded = base32Decode(encoded);
    expect(decoded).toEqual(original);
  });
});

describe('P-256 Signing', () => {
  test('generates key pair with correct sizes', async () => {
    const kp = await generateKeyPair();

    expect(kp.privateKey.length).toBe(32);
    expect(kp.publicKey.length).toBe(33); // compressed
    expect(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03).toBe(true);
  });

  test('can sign data with generated key', async () => {
    const kp = await generateKeyPair();
    const key = await importPrivateKey(kp.privateKey);
    const data = new TextEncoder().encode('test message');
    const sig = await sign(key, data);

    expect(sig.length).toBe(64); // r (32) + s (32)
  });

  test('different messages produce different signatures', async () => {
    const kp = await generateKeyPair();
    const key = await importPrivateKey(kp.privateKey);

    const sig1 = await sign(key, new TextEncoder().encode('message 1'));
    const sig2 = await sign(key, new TextEncoder().encode('message 2'));

    expect(sig1).not.toEqual(sig2);
  });

  test('bytesToHex and hexToBytes roundtrip', () => {
    const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]);
    const hex = bytesToHex(original);
    const back = hexToBytes(hex);

    expect(hex).toBe('000ff0ffabcd');
    expect(back).toEqual(original);
  });

  test('importPrivateKey rejects invalid key lengths', async () => {
    // Too short
    await expect(() => importPrivateKey(new Uint8Array(31))).rejects.toThrow(
      /expected 32 bytes, got 31/,
    );

    // Too long
    await expect(() => importPrivateKey(new Uint8Array(33))).rejects.toThrow(
      /expected 32 bytes, got 33/,
    );

    // Empty
    await expect(() => importPrivateKey(new Uint8Array(0))).rejects.toThrow(
      /expected 32 bytes, got 0/,
    );
  });

  test('importPrivateKey rejects non-Uint8Array input', async () => {
    // Arrays have .length but aren't Uint8Array
    // @ts-expect-error - Testing runtime validation with invalid type
    await expect(() => importPrivateKey([1, 2, 3])).rejects.toThrow(
      /Invalid private key/,
    );

    // Strings don't work either
    // @ts-expect-error - Testing runtime validation with invalid type
    await expect(() => importPrivateKey('not bytes')).rejects.toThrow(
      /Invalid private key/,
    );

    // null/undefined
    // @ts-expect-error - Testing runtime validation with invalid type
    await expect(() => importPrivateKey(null)).rejects.toThrow(
      /Invalid private key/,
    );
  });
});

describe('JWT Base64URL', () => {
  test('base64UrlEncode encodes bytes correctly', () => {
    const input = new TextEncoder().encode('hello world');
    const encoded = base64UrlEncode(input);
    expect(encoded).toBe('aGVsbG8gd29ybGQ');
    expect(encoded.includes('+')).toBe(false);
    expect(encoded.includes('/')).toBe(false);
    expect(encoded.includes('=')).toBe(false);
  });

  test('base64UrlDecode decodes string correctly', () => {
    const decoded = base64UrlDecode('aGVsbG8gd29ybGQ');
    const str = new TextDecoder().decode(decoded);
    expect(str).toBe('hello world');
  });

  test('base64url roundtrip', () => {
    const original = new Uint8Array([0, 1, 2, 255, 254, 253]);
    const encoded = base64UrlEncode(original);
    const decoded = base64UrlDecode(encoded);
    expect(decoded).toEqual(original);
  });
});

describe('JWK Thumbprint', () => {
  test('computes deterministic thumbprint for EC key', async () => {
    // Test vector: known JWK and its expected thumbprint
    const jwk = {
      kty: 'EC',
      crv: 'P-256',
      x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY',
      y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ',
    };

    const jkt1 = await computeJwkThumbprint(jwk);
    const jkt2 = await computeJwkThumbprint(jwk);

    // Thumbprint must be deterministic
    expect(jkt1).toBe(jkt2);
    // Must be base64url-encoded SHA-256 (43 chars)
    expect(jkt1.length).toBe(43);
    // Must only contain base64url characters
    expect(jkt1).toMatch(/^[A-Za-z0-9_-]+$/);
  });

  test('produces different thumbprints for different keys', async () => {
    const jwk1 = {
      kty: 'EC',
      crv: 'P-256',
      x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY',
      y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ',
    };
    const jwk2 = {
      kty: 'EC',
      crv: 'P-256',
      x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
      y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
    };

    const jkt1 = await computeJwkThumbprint(jwk1);
    const jkt2 = await computeJwkThumbprint(jwk2);

    expect(jkt1).not.toBe(jkt2);
  });
});
```

**Step 3: Run tests to verify**

Run: `pnpm test packages/core/test/crypto.test.js`
Expected: All tests PASS

**Step 4: Commit**

```bash
git add packages/core/test/crypto.test.js
git commit -m "test: extract crypto tests from pds.test.js"
```

---

### Task 2: Create repo.test.js

**Files:**
- Create: `packages/core/test/repo.test.js`

**Step 1: Create repo.test.js**

```javascript
import { describe, expect, test } from 'vitest';
import {
  buildCarFile,
  cborDecode,
  cborEncode,
  cidToString,
  createBlobCid,
  createCid,
  createTid,
  findBlobRefs,
  sniffMimeType,
  varint,
} from '@pdsjs/core/repo';
import { base32Decode, base32Encode } from '@pdsjs/core/crypto';

describe('CBOR Encoding', () => {
  test('encodes simple map', () => {
    const encoded = cborEncode({ hello: 'world', num: 42 });
    // Expected: a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a
    const expected = new Uint8Array([
      0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c,
      0x64, 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a,
    ]);
    expect(encoded).toEqual(expected);
  });

  test('encodes null', () => {
    const encoded = cborEncode(null);
    expect(encoded).toEqual(new Uint8Array([0xf6]));
  });

  test('encodes booleans', () => {
    expect(cborEncode(true)).toEqual(new Uint8Array([0xf5]));
    expect(cborEncode(false)).toEqual(new Uint8Array([0xf4]));
  });

  test('encodes small integers', () => {
    expect(cborEncode(0)).toEqual(new Uint8Array([0x00]));
    expect(cborEncode(1)).toEqual(new Uint8Array([0x01]));
    expect(cborEncode(23)).toEqual(new Uint8Array([0x17]));
  });

  test('encodes integers >= 24', () => {
    expect(cborEncode(24)).toEqual(new Uint8Array([0x18, 0x18]));
    expect(cborEncode(255)).toEqual(new Uint8Array([0x18, 0xff]));
  });

  test('encodes negative integers', () => {
    expect(cborEncode(-1)).toEqual(new Uint8Array([0x20]));
    expect(cborEncode(-10)).toEqual(new Uint8Array([0x29]));
  });

  test('encodes strings', () => {
    const encoded = cborEncode('hello');
    // 0x65 = text string of length 5
    expect(encoded).toEqual(
      new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f]),
    );
  });

  test('encodes byte strings', () => {
    const bytes = new Uint8Array([1, 2, 3]);
    const encoded = cborEncode(bytes);
    // 0x43 = byte string of length 3
    expect(encoded).toEqual(new Uint8Array([0x43, 1, 2, 3]));
  });

  test('encodes arrays', () => {
    const encoded = cborEncode([1, 2, 3]);
    // 0x83 = array of length 3
    expect(encoded).toEqual(new Uint8Array([0x83, 0x01, 0x02, 0x03]));
  });

  test('sorts map keys deterministically', () => {
    const encoded1 = cborEncode({ z: 1, a: 2 });
    const encoded2 = cborEncode({ a: 2, z: 1 });
    expect(encoded1).toEqual(encoded2);
    // First key should be 'a' (0x61)
    expect(encoded1[1]).toBe(0x61);
  });

  test('encodes large integers >= 2^31 without overflow', () => {
    // 2^31 would overflow with bitshift operators (treated as signed 32-bit)
    const twoTo31 = 2147483648;
    const encoded = cborEncode(twoTo31);
    const decoded = cborDecode(encoded);
    expect(decoded).toBe(twoTo31);

    // 2^32 - 1 (max unsigned 32-bit)
    const maxU32 = 4294967295;
    const encoded2 = cborEncode(maxU32);
    const decoded2 = cborDecode(encoded2);
    expect(decoded2).toBe(maxU32);
  });

  test('encodes 2^31 with correct byte format', () => {
    // 2147483648 = 0x80000000
    // CBOR: major type 0 (unsigned int), additional info 26 (4-byte follows)
    const encoded = cborEncode(2147483648);
    expect(encoded[0]).toBe(0x1a); // type 0 | info 26
    expect(encoded[1]).toBe(0x80);
    expect(encoded[2]).toBe(0x00);
    expect(encoded[3]).toBe(0x00);
    expect(encoded[4]).toBe(0x00);
  });
});

describe('CBOR Decoding', () => {
  test('decodes what encode produces (roundtrip)', () => {
    const original = { hello: 'world', num: 42 };
    const encoded = cborEncode(original);
    const decoded = cborDecode(encoded);
    expect(decoded).toEqual(original);
  });

  test('decodes null', () => {
    const encoded = cborEncode(null);
    const decoded = cborDecode(encoded);
    expect(decoded).toBe(null);
  });

  test('decodes booleans', () => {
    expect(cborDecode(cborEncode(true))).toBe(true);
    expect(cborDecode(cborEncode(false))).toBe(false);
  });

  test('decodes integers', () => {
    expect(cborDecode(cborEncode(0))).toBe(0);
    expect(cborDecode(cborEncode(42))).toBe(42);
    expect(cborDecode(cborEncode(255))).toBe(255);
    expect(cborDecode(cborEncode(-1))).toBe(-1);
    expect(cborDecode(cborEncode(-10))).toBe(-10);
  });

  test('decodes strings', () => {
    expect(cborDecode(cborEncode('hello'))).toBe('hello');
    expect(cborDecode(cborEncode(''))).toBe('');
  });

  test('decodes arrays', () => {
    expect(cborDecode(cborEncode([1, 2, 3]))).toEqual([1, 2, 3]);
    expect(cborDecode(cborEncode([]))).toEqual([]);
  });

  test('decodes nested structures', () => {
    const original = { arr: [1, { nested: true }], str: 'test' };
    const decoded = cborDecode(cborEncode(original));
    expect(decoded).toEqual(original);
  });
});

describe('CID Generation', () => {
  test('createCid uses dag-cbor codec', async () => {
    const data = cborEncode({ test: 'data' });
    const cid = await createCid(data);

    expect(cid.length).toBe(36); // 2 prefix + 2 multihash header + 32 hash
    expect(cid[0]).toBe(0x01); // CIDv1
    expect(cid[1]).toBe(0x71); // dag-cbor
    expect(cid[2]).toBe(0x12); // sha-256
    expect(cid[3]).toBe(0x20); // 32 bytes
  });

  test('createBlobCid uses raw codec', async () => {
    const data = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); // JPEG magic bytes
    const cid = await createBlobCid(data);

    expect(cid.length).toBe(36);
    expect(cid[0]).toBe(0x01); // CIDv1
    expect(cid[1]).toBe(0x55); // raw codec
    expect(cid[2]).toBe(0x12); // sha-256
    expect(cid[3]).toBe(0x20); // 32 bytes
  });

  test('same bytes produce different CIDs with different codecs', async () => {
    const data = new Uint8Array([1, 2, 3, 4]);
    const dagCborCid = cidToString(await createCid(data));
    const rawCid = cidToString(await createBlobCid(data));

    expect(dagCborCid).not.toBe(rawCid);
  });

  test('cidToString returns base32lower with b prefix', async () => {
    const data = cborEncode({ test: 'data' });
    const cid = await createCid(data);
    const cidStr = cidToString(cid);

    expect(cidStr[0]).toBe('b');
    expect(cidStr).toMatch(/^b[a-z2-7]+$/);
  });

  test('same input produces same CID', async () => {
    const data1 = cborEncode({ test: 'data' });
    const data2 = cborEncode({ test: 'data' });
    const cid1 = cidToString(await createCid(data1));
    const cid2 = cidToString(await createCid(data2));

    expect(cid1).toBe(cid2);
  });

  test('different input produces different CID', async () => {
    const cid1 = cidToString(await createCid(cborEncode({ a: 1 })));
    const cid2 = cidToString(await createCid(cborEncode({ a: 2 })));

    expect(cid1).not.toBe(cid2);
  });
});

describe('TID Generation', () => {
  test('creates 13-character TIDs', () => {
    const tid = createTid();
    expect(tid.length).toBe(13);
  });

  test('uses valid base32-sort characters', () => {
    const tid = createTid();
    expect(tid).toMatch(/^[234567abcdefghijklmnopqrstuvwxyz]+$/);
  });

  test('generates monotonically increasing TIDs', () => {
    const tid1 = createTid();
    const tid2 = createTid();
    const tid3 = createTid();

    expect(tid1 < tid2).toBe(true);
    expect(tid2 < tid3).toBe(true);
  });

  test('generates unique TIDs', () => {
    const tids = new Set();
    for (let i = 0; i < 100; i++) {
      tids.add(createTid());
    }
    expect(tids.size).toBe(100);
  });
});

describe('CAR File Builder', () => {
  test('varint encodes small numbers', () => {
    expect(varint(0)).toEqual(new Uint8Array([0]));
    expect(varint(1)).toEqual(new Uint8Array([1]));
    expect(varint(127)).toEqual(new Uint8Array([127]));
  });

  test('varint encodes multi-byte numbers', () => {
    // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01]
    expect(varint(128)).toEqual(new Uint8Array([0x80, 0x01]));
    // 300 = 0x12c -> [0xac, 0x02]
    expect(varint(300)).toEqual(new Uint8Array([0xac, 0x02]));
  });

  test('buildCarFile produces valid structure', async () => {
    const data = cborEncode({ test: 'data' });
    const cid = await createCid(data);
    const cidStr = cidToString(cid);

    const car = buildCarFile(cidStr, [{ cid: cidStr, data }]);

    expect(car instanceof Uint8Array).toBe(true);
    expect(car.length > 0).toBe(true);
    // First byte should be varint of header length
    expect(car[0] > 0).toBe(true);
  });
});

describe('MIME Type Sniffing', () => {
  test('detects JPEG', () => {
    const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
    expect(sniffMimeType(bytes)).toBe('image/jpeg');
  });

  test('detects PNG', () => {
    const bytes = new Uint8Array([
      0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
    ]);
    expect(sniffMimeType(bytes)).toBe('image/png');
  });

  test('detects GIF', () => {
    const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
    expect(sniffMimeType(bytes)).toBe('image/gif');
  });

  test('detects WebP', () => {
    const bytes = new Uint8Array([
      0x52,
      0x49,
      0x46,
      0x46, // RIFF
      0x00,
      0x00,
      0x00,
      0x00, // size (ignored)
      0x57,
      0x45,
      0x42,
      0x50, // WEBP
    ]);
    expect(sniffMimeType(bytes)).toBe('image/webp');
  });

  test('detects MP4', () => {
    const bytes = new Uint8Array([
      0x00,
      0x00,
      0x00,
      0x18, // size
      0x66,
      0x74,
      0x79,
      0x70, // ftyp
      0x69,
      0x73,
      0x6f,
      0x6d, // isom brand
    ]);
    expect(sniffMimeType(bytes)).toBe('video/mp4');
  });

  test('detects AVIF', () => {
    const bytes = new Uint8Array([
      0x00,
      0x00,
      0x00,
      0x1c, // size
      0x66,
      0x74,
      0x79,
      0x70, // ftyp
      0x61,
      0x76,
      0x69,
      0x66, // avif brand
    ]);
    expect(sniffMimeType(bytes)).toBe('image/avif');
  });

  test('detects HEIC', () => {
    const bytes = new Uint8Array([
      0x00,
      0x00,
      0x00,
      0x18, // size
      0x66,
      0x74,
      0x79,
      0x70, // ftyp
      0x68,
      0x65,
      0x69,
      0x63, // heic brand
    ]);
    expect(sniffMimeType(bytes)).toBe('image/heic');
  });

  test('returns null for unknown', () => {
    const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
    expect(sniffMimeType(bytes)).toBe(null);
  });
});

describe('Blob Ref Detection', () => {
  test('finds blob ref in simple object', () => {
    const record = {
      $type: 'app.bsky.feed.post',
      text: 'Hello',
      embed: {
        $type: 'app.bsky.embed.images',
        images: [
          {
            image: {
              $type: 'blob',
              ref: { $link: 'bafkreiabc123' },
              mimeType: 'image/jpeg',
              size: 1234,
            },
            alt: 'test image',
          },
        ],
      },
    };
    const refs = findBlobRefs(record);
    expect(refs).toEqual(['bafkreiabc123']);
  });

  test('finds multiple blob refs', () => {
    const record = {
      images: [
        {
          image: {
            $type: 'blob',
            ref: { $link: 'cid1' },
            mimeType: 'image/png',
            size: 100,
          },
        },
        {
          image: {
            $type: 'blob',
            ref: { $link: 'cid2' },
            mimeType: 'image/png',
            size: 200,
          },
        },
      ],
    };
    const refs = findBlobRefs(record);
    expect(refs).toEqual(['cid1', 'cid2']);
  });

  test('returns empty array when no blobs', () => {
    const record = { text: 'Hello world', count: 42 };
    const refs = findBlobRefs(record);
    expect(refs).toEqual([]);
  });

  test('handles null and primitives', () => {
    expect(findBlobRefs(null)).toEqual([]);
    expect(findBlobRefs('string')).toEqual([]);
    expect(findBlobRefs(42)).toEqual([]);
  });
});
```

**Step 2: Run tests to verify**

Run: `pnpm test packages/core/test/repo.test.js`
Expected: All tests PASS

**Step 3: Commit**

```bash
git add packages/core/test/repo.test.js
git commit -m "test: extract repo tests from pds.test.js"
```

---

### Task 3: Create auth.test.js

**Files:**
- Create: `packages/core/test/auth.test.js`

**Step 1: Create auth.test.js**

```javascript
import { describe, expect, test } from 'vitest';
import {
  createAccessJwt,
  createRefreshJwt,
  verifyAccessJwt,
  verifyRefreshJwt,
} from '@pdsjs/core/auth';
import { base64UrlDecode } from '@pdsjs/core/crypto';

describe('JWT Creation', () => {
  test('createAccessJwt creates valid JWT structure', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createAccessJwt(did, secret);

    const parts = jwt.split('.');
    expect(parts.length).toBe(3);

    // Decode header
    const header = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(parts[0])),
    );
    expect(header.typ).toBe('at+jwt');
    expect(header.alg).toBe('HS256');

    // Decode payload
    const payload = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(parts[1])),
    );
    expect(payload.scope).toBe('com.atproto.access');
    expect(payload.sub).toBe(did);
    expect(payload.aud).toBe(did);
    expect(payload.iat > 0).toBe(true);
    expect(payload.exp > payload.iat).toBe(true);
  });

  test('createRefreshJwt creates valid JWT with jti', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createRefreshJwt(did, secret);

    const parts = jwt.split('.');
    const header = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(parts[0])),
    );
    expect(header.typ).toBe('refresh+jwt');

    const payload = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(parts[1])),
    );
    expect(payload.scope).toBe('com.atproto.refresh');
    expect(payload.jti).toBeTruthy(); // has unique token ID
  });
});

describe('JWT Verification', () => {
  test('verifyAccessJwt returns payload for valid token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createAccessJwt(did, secret);

    const payload = await verifyAccessJwt(jwt, secret);
    expect(payload.sub).toBe(did);
    expect(payload.scope).toBe('com.atproto.access');
  });

  test('verifyAccessJwt throws for wrong secret', async () => {
    const did = 'did:web:test.example';
    const jwt = await createAccessJwt(did, 'correct-secret');

    await expect(() => verifyAccessJwt(jwt, 'wrong-secret')).rejects.toThrow(
      /invalid signature/i,
    );
  });

  test('verifyAccessJwt throws for expired token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    // Create token that expired 1 second ago
    const jwt = await createAccessJwt(did, secret, -1);

    await expect(() => verifyAccessJwt(jwt, secret)).rejects.toThrow(
      /expired/i,
    );
  });

  test('verifyAccessJwt throws for refresh token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createRefreshJwt(did, secret);

    await expect(() => verifyAccessJwt(jwt, secret)).rejects.toThrow(
      /invalid token type/i,
    );
  });

  test('verifyRefreshJwt returns payload for valid token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createRefreshJwt(did, secret);

    const payload = await verifyRefreshJwt(jwt, secret);
    expect(payload.sub).toBe(did);
    expect(payload.scope).toBe('com.atproto.refresh');
    expect(payload.jti).toBeTruthy(); // has token ID
  });

  test('verifyRefreshJwt throws for wrong secret', async () => {
    const did = 'did:web:test.example';
    const jwt = await createRefreshJwt(did, 'correct-secret');

    await expect(() => verifyRefreshJwt(jwt, 'wrong-secret')).rejects.toThrow(
      /invalid signature/i,
    );
  });

  test('verifyRefreshJwt throws for expired token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    // Create token that expired 1 second ago
    const jwt = await createRefreshJwt(did, secret, -1);

    await expect(() => verifyRefreshJwt(jwt, secret)).rejects.toThrow(
      /expired/i,
    );
  });

  test('verifyRefreshJwt throws for access token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createAccessJwt(did, secret);

    await expect(() => verifyRefreshJwt(jwt, secret)).rejects.toThrow(
      /invalid token type/i,
    );
  });

  test('verifyAccessJwt throws for malformed JWT', async () => {
    const secret = 'test-secret-key';

    // Not a JWT at all
    await expect(() => verifyAccessJwt('not-a-jwt', secret)).rejects.toThrow(
      /Invalid JWT format/i,
    );

    // Only two parts
    await expect(() => verifyAccessJwt('two.parts', secret)).rejects.toThrow(
      /Invalid JWT format/i,
    );

    // Four parts
    await expect(() =>
      verifyAccessJwt('one.two.three.four', secret),
    ).rejects.toThrow(/Invalid JWT format/i);
  });

  test('verifyRefreshJwt throws for malformed JWT', async () => {
    const secret = 'test-secret-key';

    await expect(() => verifyRefreshJwt('not-a-jwt', secret)).rejects.toThrow(
      /Invalid JWT format/i,
    );

    await expect(() => verifyRefreshJwt('two.parts', secret)).rejects.toThrow(
      /Invalid JWT format/i,
    );
  });
});
```

**Step 2: Run tests to verify**

Run: `pnpm test packages/core/test/auth.test.js`
Expected: All tests PASS

**Step 3: Commit**

```bash
git add packages/core/test/auth.test.js
git commit -m "test: extract auth tests from pds.test.js"
```

---

### Task 4: Create mst.test.js

**Files:**
- Create: `packages/core/test/mst.test.js`

**Step 1: Create mst.test.js**

```javascript
import { describe, expect, test } from 'vitest';
import { getKeyDepth } from '@pdsjs/core/repo';

describe('MST Key Depth', () => {
  test('returns a non-negative integer', async () => {
    const depth = await getKeyDepth('app.bsky.feed.post/abc123');
    expect(typeof depth).toBe('number');
    expect(depth >= 0).toBe(true);
  });

  test('is deterministic for same key', async () => {
    const key = 'app.bsky.feed.post/test123';
    const depth1 = await getKeyDepth(key);
    const depth2 = await getKeyDepth(key);
    expect(depth1).toBe(depth2);
  });

  test('different keys can have different depths', async () => {
    // Generate many keys and check we get some variation
    const depths = new Set();
    for (let i = 0; i < 100; i++) {
      depths.add(await getKeyDepth(`collection/key${i}`));
    }
    // Should have at least 1 unique depth (realistically more)
    expect(depths.size >= 1).toBe(true);
  });

  test('handles empty string', async () => {
    const depth = await getKeyDepth('');
    expect(typeof depth).toBe('number');
    expect(depth >= 0).toBe(true);
  });

  test('handles unicode strings', async () => {
    const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉');
    expect(typeof depth).toBe('number');
    expect(depth >= 0).toBe(true);
  });
});
```

**Step 2: Run tests to verify**

Run: `pnpm test packages/core/test/mst.test.js`
Expected: All tests PASS

**Step 3: Commit**

```bash
git add packages/core/test/mst.test.js
git commit -m "test: extract mst tests from pds.test.js"
```

---

### Task 5: Create scope.test.js

**Files:**
- Create: `packages/core/test/scope.test.js`

**Step 1: Create scope.test.js**

```javascript
import { describe, expect, test } from 'vitest';
import {
  matchesMime,
  parseBlobScope,
  parseRepoScope,
  parseScopesForDisplay,
  ScopePermissions,
} from '@pdsjs/core/scope';

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);
    });
  });

  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([]);
  });
});
```

**Step 2: Run tests to verify**

Run: `pnpm test packages/core/test/scope.test.js`
Expected: All tests PASS

**Step 3: Commit**

```bash
git add packages/core/test/scope.test.js
git commit -m "test: extract scope tests from pds.test.js"
```

---

### Task 6: Create oauth.test.js

**Files:**
- Create: `packages/core/test/oauth.test.js`

**Step 1: Create oauth.test.js**

```javascript
import { describe, expect, test } from 'vitest';
import {
  getLoopbackClientMetadata,
  isLoopbackClient,
  validateClientMetadata,
} from '@pdsjs/core/oauth';

describe('Client Metadata', () => {
  test('isLoopbackClient detects localhost', () => {
    expect(isLoopbackClient('http://localhost:8080')).toBe(true);
    expect(isLoopbackClient('http://127.0.0.1:3000')).toBe(true);
    expect(isLoopbackClient('https://example.com')).toBe(false);
  });

  test('getLoopbackClientMetadata returns permissive defaults', () => {
    const metadata = getLoopbackClientMetadata('http://localhost:8080');
    expect(metadata.client_id).toBe('http://localhost:8080');
    expect(metadata.grant_types.includes('authorization_code')).toBe(true);
    expect(metadata.dpop_bound_access_tokens).toBe(true);
  });

  test('validateClientMetadata rejects mismatched client_id', () => {
    const metadata = {
      client_id: 'https://other.com/metadata.json',
      redirect_uris: ['https://example.com/callback'],
      grant_types: ['authorization_code'],
      response_types: ['code'],
    };
    expect(() =>
      validateClientMetadata(metadata, 'https://example.com/metadata.json'),
    ).toThrow(/client_id mismatch/);
  });
});
```

**Step 2: Run tests to verify**

Run: `pnpm test packages/core/test/oauth.test.js`
Expected: All tests PASS

**Step 3: Commit**

```bash
git add packages/core/test/oauth.test.js
git commit -m "test: extract oauth tests from pds.test.js"
```

---

### Task 7: Create pds.test.js

**Files:**
- Create: `packages/core/test/pds.test.js`

**Step 1: Create pds.test.js**

```javascript
import { describe, expect, test } from 'vitest';
import {
  getKnownServiceUrl,
  parseAtprotoProxyHeader,
} from '@pdsjs/core/pds';

// Internal constant - not exported from pds.js due to Cloudflare Workers limitation
const BSKY_APPVIEW_URL = 'https://api.bsky.app';

describe('Proxy Utilities', () => {
  describe('parseAtprotoProxyHeader', () => {
    test('parses valid header', () => {
      const result = parseAtprotoProxyHeader(
        'did:web:api.bsky.app#bsky_appview',
      );
      expect(result).toEqual({
        did: 'did:web:api.bsky.app',
        serviceId: 'bsky_appview',
      });
    });

    test('parses header with did:plc', () => {
      const result = parseAtprotoProxyHeader(
        'did:plc:z72i7hdynmk6r22z27h6tvur#atproto_labeler',
      );
      expect(result).toEqual({
        did: 'did:plc:z72i7hdynmk6r22z27h6tvur',
        serviceId: 'atproto_labeler',
      });
    });

    test('returns null for null/undefined', () => {
      // @ts-expect-error - Testing runtime handling of null
      expect(parseAtprotoProxyHeader(null)).toBe(null);
      // @ts-expect-error - Testing runtime handling of undefined
      expect(parseAtprotoProxyHeader(undefined)).toBe(null);
      expect(parseAtprotoProxyHeader('')).toBe(null);
    });

    test('returns null for header without fragment', () => {
      expect(parseAtprotoProxyHeader('did:web:api.bsky.app')).toBe(null);
    });

    test('returns null for header with only fragment', () => {
      expect(parseAtprotoProxyHeader('#bsky_appview')).toBe(null);
    });

    test('returns null for header with trailing fragment', () => {
      expect(parseAtprotoProxyHeader('did:web:api.bsky.app#')).toBe(null);
    });
  });

  describe('getKnownServiceUrl', () => {
    test('returns URL for known Bluesky AppView', () => {
      const result = getKnownServiceUrl('did:web:api.bsky.app', 'bsky_appview');
      expect(result).toBe(BSKY_APPVIEW_URL);
    });

    test('returns null for unknown service DID', () => {
      const result = getKnownServiceUrl(
        'did:web:unknown.service',
        'bsky_appview',
      );
      expect(result).toBe(null);
    });

    test('returns null for unknown service ID', () => {
      const result = getKnownServiceUrl(
        'did:web:api.bsky.app',
        'unknown_service',
      );
      expect(result).toBe(null);
    });

    test('returns null for both unknown', () => {
      const result = getKnownServiceUrl('did:web:unknown', 'unknown');
      expect(result).toBe(null);
    });
  });
});
```

**Step 2: Run tests to verify**

Run: `pnpm test packages/core/test/pds.test.js`
Expected: All tests PASS

**Step 3: Commit**

```bash
git add packages/core/test/pds.test.js
git commit -m "test: extract pds tests from pds.test.js"
```

---

### Task 8: Run all new tests together

**Step 1: Run all core tests**

Run: `pnpm test packages/core/test/`
Expected: All tests PASS (same count as original file)

**Step 2: Verify test count matches original**

The original `test/pds.test.js` had tests across all describe blocks. Verify the total test count is the same.

---

### Task 9: Delete original test file and verify

**Files:**
- Delete: `test/pds.test.js`

**Step 1: Delete original file**

Run: `rm test/pds.test.js`

**Step 2: Run full test suite**

Run: `pnpm test`
Expected: All tests PASS (e2e.test.js + all new core tests)

**Step 3: Commit**

```bash
git add -A
git commit -m "refactor: remove original pds.test.js after splitting into modules"
```

---

### Task 10: Final verification

**Step 1: Run full test suite one more time**

Run: `pnpm test`
Expected: All tests PASS

**Step 2: Verify no regressions**

Check that test count matches expectations and no tests were lost in the split.