diff --git a/astro-atproto-loader/__examples__/04-single-entry/README.md b/astro-atproto-loader/__examples__/04-single-entry/README.md
index e89b49c..e8316c6 100644
--- a/astro-atproto-loader/__examples__/04-single-entry/README.md
+++ b/astro-atproto-loader/__examples__/04-single-entry/README.md
@@ -29,16 +29,16 @@ npm install
npm run dev
```
-Then open `http://127.0.0.1:4321` for the static page and `http://127.0.0.1:4321/live`
+Then open `http://127.0.0.1:4321` for the static page and `http://127.0.0.1:4321/live`
for the live page.
> [!WARNING]
->
+>
> If you haven't done so already, you'll first need to build `astro-atproto-loader` itself.
> If you don't remember if you have, don't worry: there's no harm in doing so again.
->
+>
> From the `astro-atproto-loader` folder—**not this one!**—run:
->
+>
> 1. `npm install`
> 2. `npm run build`
>
diff --git a/msw-atproto/LICENSE b/msw-atproto/LICENSE
new file mode 100644
index 0000000..a48a70a
--- /dev/null
+++ b/msw-atproto/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 FujoCoded LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/msw-atproto/README.md b/msw-atproto/README.md
new file mode 100644
index 0000000..9babb1f
--- /dev/null
+++ b/msw-atproto/README.md
@@ -0,0 +1,386 @@
+# `@fujocoded/msw-atproto`
+
+
+
+[MSW](https://mswjs.io/) request handlers for tests that talk to ATproto
+services...or at least _believe_ they do.
+
+
+
+## What is `@fujocoded/msw-atproto`?
+
+`@fujocoded/msw-atproto` uses the power of [MSW](https://mswjs.io/) to give
+you(r tests) fake, stateful ATproto accounts that respond to HTTP
+requests exactly like real PDSes on the real network would! Set a DID, a handle,
+and the records for each of these accounts, then just let your code read and
+write through normal ATproto PDS endpoints: it will do so while staying
+completely offline. With `@fujocoded/msw-atproto` your Atproto tests can stay
+fast and deterministic, while overrides remain as close as possible to the source
+of truth, which makes them easier to reason about.
+
+Thanks to this library, you can (pretend to):
+
+1. Serve DID documents from the PLC directory, the public registry for
+ `did:plc:...` accounts
+2. Serve `.well-known/atproto-did`, the handle lookup URL your code needs to know
+ an account's DID
+3. Serve PDS record endpoints like `listRecords`, `getRecord`, `createRecord`,
+ `putRecord`, `deleteRecord`, and `applyWrites`
+4. Serve PDS blob endpoints for uploads and reads, so records can point at
+ image or file data
+5. Store successful writes in memory, so a later read sees what the test wrote
+
+> [!IMPORTANT]
+>
+> This package currently covers PDS record reads/writes, PDS blob reads, and
+> identity resolution. It does **not** cover firehose methods like
+> `com.atproto.sync.subscribeRepos`, the whole OAuth dance, or AppView
+> endpoints, like anything under `app.bsky.*`.
+
+## What can **you** do with `@fujocoded/msw-atproto`?
+
+- **Test code that reads Bluesky posts or custom ATproto records** through a
+ real `AtpAgent` or `Client`, including for long lists that need pagination
+- **Test code that writes to a PDS**, such as creating a Bluesky post, updating
+ a profile, or deleting a record. These persist across requests in the same test.
+- **Test records that point at blobs**, such as image refs, sprite sheets, or
+ thumbnails, without hosting a real file server
+- **Test identity resolution**, including handle lookup, PLC lookup, missing
+ DIDs, and handle changes
+- **Test PLC document updates**, such as adding a verification method or moving
+ an account to a new PDS
+- **Test failure modes** like a missing record, a missing blob, a `.well-known`
+ 404, or one flaky `getRecord` request
+
+## What's included in `@fujocoded/msw-atproto`?
+
+In this package, you'll find:
+
+- `useMockAtprotoRepo()` and `createMockAtprotoRepo()`: Create one fake account
+ with identity, record, and blob handlers
+- `useMockRepoIdentity()` and `createMockRepoIdentity()`: Create one fake
+ identity (without record or blob handlers)
+- `useMockPlcOperationFlow()` and `createMockPlcOperationFlow()`: Create the
+ fake network calls used when code updates a DID document
+- `createDnsMock(importActual)`: Makes handle resolution fall back to the HTTP
+ path MSW can intercept (a must-have when using handles in your tests!)
+- `createIdentityPassthrough()`: Lets one test mix fake accounts with real
+ handles
+- `FAKE_CID`: A placeholder CID for when the exact value does not matter
+- `fakeCid(input)` and `cidForRecord(...)`: Give you stable CIDs for assertions
+
+## Setup (a.k.a. okay, how do I _actually_ do this stuff?)
+
+1. Run the following command:
+
+```bash
+npm add --save-dev @fujocoded/msw-atproto msw
+```
+
+2. Create one MSW server for your tests:
+
+```ts
+// __tests__/msw/server.ts
+import { setupServer } from "msw/node";
+
+export const server = setupServer();
+```
+
+3. Start MSW and install the DNS helper in your test setup:
+
+```ts
+// __tests__/setup.ts
+import { afterAll, afterEach, beforeAll, vi } from "vitest";
+
+import { server } from "./msw/server.ts";
+
+// THIS IS IMPORTANT!
+// MAKE SURE YOU HAVE THIS IF YOU NEED TO RESOLVE HANDLES!
+vi.mock("node:dns/promises", async (importActual) => {
+ const { createDnsMock } = await import("@fujocoded/msw-atproto");
+ return createDnsMock(importActual);
+});
+
+beforeAll(() => {
+ server.listen({ onUnhandledRequest: "error" });
+});
+
+afterEach(() => {
+ server.resetHandlers();
+});
+
+afterAll(() => {
+ server.close();
+});
+```
+
+> [!IMPORTANT]
+>
+> Keep `onUnhandledRequest: "error"` on in your test setup. Missing ATproto
+> fakes should fail the test loudly. Without that flag, MSW lets an unmatched
+> request through and your test may call the real network.
+>
+> If your tests still do real HTTP calls, you may need to allow certain requests
+> to punch through.
+
+1. Wire the setup file into Vitest:
+
+```ts
+// vitest.config.ts
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ setupFiles: ["./__tests__/setup.ts"],
+ },
+});
+```
+
+5. In a test, create a fake account, then just use a real client:
+
+```ts
+// __tests__/example.test.ts
+import { AtpAgent } from "@atproto/api";
+import { expect, test } from "vitest";
+import { useMockAtprotoRepo } from "@fujocoded/msw-atproto";
+
+import { server } from "./msw/server.ts";
+
+test("loads records for one account", async () => {
+ const repo = useMockAtprotoRepo(server, {
+ did: "did:plc:bobatan",
+ handle: "bobatan.fujocoded.com",
+ records: {
+ "app.bsky.feed.post": [
+ { rkey: "whatever", value: { text: "hello fujin!" } },
+ ],
+ },
+ });
+
+ // The exact same code as production...
+ const agent = new AtpAgent({ service: repo.pds });
+ const { data } = await agent.com.atproto.repo.listRecords({
+ repo: "did:plc:bobatan",
+ collection: "app.bsky.feed.post",
+ });
+
+ // ...but our test's own special result!
+ expect(data.records).toHaveLength(1);
+});
+```
+
+> [!NOTE]
+>
+> By default, `msw-atproto` takes over every ATproto handle in your tests: the
+> `createDnsMock` DNS helper makes all `_atproto.` TXT lookups fail with
+> `ENODATA`, to force the ATproto identity library to check
+> `https:///.well-known/atproto-did` instead (which MSW can intercept).
+> If your tests need a mix of fake and real handles together, see
+> [`identity-passthrough.test.ts`](https://github.com/FujoWebDev/fujocoded-plugins/blob/main/msw-atproto/__tests__/identity-passthrough.test.ts).
+
+## Examples You Can Run (and Copy)
+
+Runnable examples live in
+[`__examples__/`](https://github.com/FujoWebDev/fujocoded-plugins/tree/main/msw-atproto/__examples__).
+
+Run them from the repo root:
+
+```sh
+npm test --workspace @fujocoded/msw-atproto -- __examples__
+```
+
+Pick the file that matches your needs:
+
+- [`01-stateful-repo.test.ts`](https://github.com/FujoWebDev/fujocoded-plugins/blob/main/msw-atproto/__examples__/01-stateful-repo.test.ts):
+ one fake Bluesky account, real `AtpAgent` calls, seeded records, writes,
+ reads, and blobs
+- [`02-repo-boundaries.test.ts`](https://github.com/FujoWebDev/fujocoded-plugins/blob/main/msw-atproto/__examples__/02-repo-boundaries.test.ts):
+ empty collections, several accounts on one PDS, identity changes, and
+ cursor-based pagination
+- [`03-empty-repo-and-raw-msw.test.ts`](https://github.com/FujoWebDev/fujocoded-plugins/blob/main/msw-atproto/__examples__/03-empty-repo-and-raw-msw.test.ts):
+ an intentionally empty account, plus raw MSW for server behavior this package
+ does not model
+- [`04-shared-fixture.test.ts`](https://github.com/FujoWebDev/fujocoded-plugins/blob/main/msw-atproto/__examples__/04-shared-fixture.test.ts):
+ one shared setup pattern for a suite where every test starts from the same
+ state
+
+## Customize Your Fake Account
+
+### `useMockAtprotoRepo`: faking a PDS
+
+`useMockAtprotoRepo(server, { did, pds?, handle?, records?, blobs? })` creates
+one fake account and registers its handlers with the MSW server.
+
+#### Config options
+
+- **`did`** (required): The account DID, like `did:plc:bobatan`. This identifies
+ the repo, that is the account's PDS, on every XRPC call
+- **`pds`** (optional): The fake PDS URL. Defaults to
+ `https://pds.fujocoded.test`. The returned fake exposes the final value as
+ `repo.pds`
+- **`handle`** (optional): The account handle, like
+ `bobatan.fujocoded.com`. When set, the library will also serve
+ `https:///.well-known/atproto-did`. Record reads and writes also
+ accept this handle in the XRPC `repo` parameter
+- **`records`** (optional): Seed records, grouped by collection NSID, like
+ `app.bsky.feed.post`. Each seed record has `{ rkey, value, cid? }`. When `cid`
+ is omitted, the library derives a stable CID from the repo DID, collection, rkey,
+ and value
+- **`blobs`** (optional): Blob bodies the fake PDS can serve from
+ `com.atproto.sync.getBlob`. Each seed has `{ cid, body?, contentType? }`
+
+When `records` is omitted, no collections are declared. A `listRecords` request
+for an undeclared collection fails the test under `onUnhandledRequest: "error"`.
+Seed `[]` when an empty collection is the expected result:
+
+```ts
+const repo = useMockAtprotoRepo(server, { did: "did:plc:bobatan" });
+repo.seed("app.bsky.feed.post", []);
+```
+
+#### `MockAtprotoRepo` properties
+
+- **`pds`**: The URL the handlers answer on
+- **`did`** and **`handle`**: The identity values the fake was created with
+- **`handlers()`**: The MSW handlers for manual registration. Includes
+ identity handlers, all six record endpoints, and both blob endpoints
+- **`records()`**: A snapshot of the current stored records
+- **`writes()`**: Successful write requests captured so far
+- **`deletes()`**: Successful delete requests captured so far
+- **`seed(collection, records)`**: Declares a collection and adds or replaces
+ records in it. Pass `[]` to declare a collection as intentionally empty
+- **`seedBlob(blob)`**: Adds or replaces one hosted blob
+- **`clear()`**: Clears records, blobs, declared collections, captured writes,
+ captured deletes, queued failures, and generated counters
+- **`identity.*`**: One-off identity handlers and mutators for missing PLC
+ documents, custom DID documents, handle changes, and verification methods
+- **`failOnce.*`**: One-shot ATproto-shaped failure handles for the next matching
+ endpoint request
+
+### `useMockRepoIdentity`: faking an identity
+
+Use `useMockRepoIdentity(server, { did, pds?, handle? })` when your test only
+needs account identity. It serves the PLC DID document, and (when you pass a
+`handle`) serves `.well-known/atproto-did`.
+
+When the test already has a fake account, you can simply use `repo.identity`
+from `useMockAtprotoRepo(...)` .
+
+#### `MockRepoIdentity` properties
+
+- **`pds`**: The PDS URL advertised by the fake identity
+- **`did`** and **`handle`**: The identity values the fake was created with
+- **`handlers()`**: The MSW handlers for manual registration
+- **`plcNotFound()`**: Makes PLC lookup return `404 NotFound`
+- **`didDocument(doc)`**: Serves a custom DID document
+- **`wellKnownNotFound()`**: Makes handle lookup return `404`
+- **`handleResolvesTo(otherDid)`**: Makes the configured handle point at another
+ DID
+- **`setDidDocument(doc)`**: Replaces the stored DID document without
+ registering a new MSW handler
+- **`updateDidDocument(fn)`**: Updates the stored DID document
+- **`setVerificationMethod(name, didKeyOrMultibase)`**: Adds or replaces a DID
+ document verification method
+- **`setHandleDid(nextDid)`**: Changes the DID returned by the configured handle
+- **`reset()`**: Restores the original DID document and handle result
+
+## `useMockPlcOperationFlow`: faking updates to DID documents
+
+Use `useMockPlcOperationFlow(server, { did, pds?, operation, signedOperation?,
+onSign?, onSubmit? })` when code asks a PDS to update a DID document.
+
+Use `createMockPlcOperationFlow(...)` when you want the flow fake without
+registering it right away. It returns the same object, including `handlers()`.
+
+This covers the three network calls involved in an update to the PLC (the
+directory that stores the current DID document for `did:plc:...` accounts).
+
+The flow fake registers three handlers:
+
+- Serves the current PLC operation from the account's audit log
+- Serves the PDS signing endpoint and returns `{ operation: signedOperation }`,
+ or echoes the submitted body with `signed: true`
+- Accepts the signed operation at the PLC directory and returns `{}`
+
+`onSign(body)` and `onSubmit(body)` let your test assert on the payload sent to
+each step. `plcDirectoryUrl` defaults to `https://plc.directory`.
+
+## Simulate failures
+
+To return a ATproto-shaped errors, queue a one-shot failure on the fakes:
+
+```ts
+repo.failOnce.getRecord({ status: 404 });
+```
+
+The next matching `getRecord` request returns:
+
+```json
+{ "error": "RecordNotFound", "message": "Record not found" }
+```
+
+...then it all goes back to normal.
+
+### More failures!
+
+Each `failOnce.*` method accepts an optional `status`, `error`, and `message`,
+plus filters for that endpoint:
+
+- `listRecords` and `createRecord` => `collection`
+- `getRecord`, `putRecord`, and `deleteRecord` => `collection` and `rkey`
+- `getBlob` => `cid`
+
+For example, fail the next `listRecords` request for one collection:
+
+```ts
+repo.failOnce.listRecords({
+ collection: "app.bsky.feed.post",
+ status: 503,
+});
+```
+
+Fail the next `getRecord` request for one record:
+
+```ts
+repo.failOnce.getRecord({
+ collection: "app.bsky.feed.post",
+ rkey: "3k2jxqj7m4s2a",
+ status: 404,
+});
+```
+
+Fail the next blob read for one CID:
+
+```ts
+repo.failOnce.getBlob({
+ cid: avatarCid,
+ status: 404,
+ message: "Avatar blob is missing",
+});
+```
+
+To test a network failure or malformed response (or other, pernicious
+cases), use raw MSW:
+
+```ts
+import { http, HttpResponse } from "msw";
+
+server.use(
+ http.get(`${repo.pds}/xrpc/com.atproto.repo.getRecord`, () =>
+ HttpResponse.error(),
+ ),
+);
+```
+
+## Faking CIDs
+
+When a seed record has no `cid`, `useMockAtprotoRepo(...)` derives one from the
+repo DID, collection, rkey, and `JSON.stringify(value)`. If object key order
+matters to your test, pass an explicit `cid`.
+
+- Use `FAKE_CID` when a test needs one valid placeholder CID and does not care
+ about content
+- Use `fakeCid(input)` when several records or blobs need stable but different
+ CIDs
+- Use `cidForRecord({ repo, collection, rkey, value })` when a test seeds a
+ record without `cid` and later wants to assert on the CID the fake generated
diff --git a/msw-atproto/__examples__/01-stateful-repo.test.ts b/msw-atproto/__examples__/01-stateful-repo.test.ts
new file mode 100644
index 0000000..d41919a
--- /dev/null
+++ b/msw-atproto/__examples__/01-stateful-repo.test.ts
@@ -0,0 +1,86 @@
+import { AtpAgent } from "@atproto/api";
+import { AtUri } from "@atproto/syntax";
+import { describe, expect, test } from "vitest";
+
+import { cidForRecord, useMockAtprotoRepo, FAKE_CID } from "../src/index.ts";
+import { server } from "./msw/server.ts";
+
+describe("stateful repo fake", () => {
+ test("the smallest end-to-end fake of one Bluesky account, real client included", async () => {
+ const did = "did:plc:bobatan";
+ const handle = "bobatan.fujocoded.com";
+ const avatarCid = FAKE_CID;
+ const avatarBytes = new Uint8Array([137, 80, 78, 71]);
+
+ const repo = useMockAtprotoRepo(server, {
+ did,
+ handle,
+ records: {
+ "app.bsky.feed.post": [
+ { rkey: "seeded", value: { text: "already here" } },
+ ],
+ },
+ blobs: [
+ {
+ cid: avatarCid,
+ body: avatarBytes,
+ contentType: "image/png",
+ },
+ ],
+ });
+ const agent = new AtpAgent({ service: repo.pds });
+
+ const { data: created } = await agent.com.atproto.repo.createRecord({
+ repo: did,
+ collection: "app.bsky.feed.post",
+ record: { text: "created during the test" },
+ });
+
+ const { data: listed } = await agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection: "app.bsky.feed.post",
+ });
+
+ const createdUri = new AtUri(created.uri);
+ const { data: fetched } = await agent.com.atproto.repo.getRecord({
+ repo: createdUri.hostname,
+ collection: createdUri.collection,
+ rkey: createdUri.rkey,
+ });
+ const avatar = await fetch(
+ `${repo.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(
+ did,
+ )}&cid=${avatarCid}`,
+ );
+
+ expect(created).toMatchObject({
+ uri: AtUri.make(did, "app.bsky.feed.post", "3kgenerated1").toString(),
+ cid: cidForRecord({
+ repo: did,
+ collection: "app.bsky.feed.post",
+ rkey: "3kgenerated1",
+ value: { text: "created during the test" },
+ }),
+ });
+ expect(listed).toMatchObject({
+ records: [
+ { uri: AtUri.make(did, "app.bsky.feed.post", "seeded").toString() },
+ {
+ uri: AtUri.make(did, "app.bsky.feed.post", "3kgenerated1").toString(),
+ },
+ ],
+ });
+ expect(fetched).toMatchObject({
+ value: { text: "created during the test" },
+ });
+ expect(avatar.headers.get("content-type")).toBe("image/png");
+ expect(new Uint8Array(await avatar.arrayBuffer())).toEqual(avatarBytes);
+
+ await expect(
+ agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection: "app.bsky.graph.follow",
+ }),
+ ).rejects.toThrow();
+ });
+});
diff --git a/msw-atproto/__examples__/02-repo-boundaries.test.ts b/msw-atproto/__examples__/02-repo-boundaries.test.ts
new file mode 100644
index 0000000..4abc5dc
--- /dev/null
+++ b/msw-atproto/__examples__/02-repo-boundaries.test.ts
@@ -0,0 +1,142 @@
+import { AtpAgent } from "@atproto/api";
+import { AtUri } from "@atproto/syntax";
+import { describe, expect, test } from "vitest";
+
+import { useMockAtprotoRepo } from "../src/index.ts";
+import { server } from "./msw/server.ts";
+
+describe("repo boundaries", () => {
+ test("use this when tests need to tell 'no records yet' apart from 'I forgot to seed'", async () => {
+ const did = "did:plc:bobatan";
+ const pds = "https://pds.fujocoded.test";
+ const repo = useMockAtprotoRepo(server, { did, pds });
+
+ repo.seed("app.bsky.feed.post", []);
+
+ const agent = new AtpAgent({ service: pds });
+
+ const { data: empty } = await agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection: "app.bsky.feed.post",
+ });
+ expect(empty).toEqual({ records: [] });
+
+ await expect(
+ agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection: "app.bsky.graph.follow",
+ }),
+ ).rejects.toThrow();
+ });
+
+ test("use this when code reads from several accounts on one PDS", async () => {
+ const mainDid = "did:plc:bobatan";
+ const altDid = "did:plc:bobatan-alt";
+ const pds = "https://pds.fujocoded.test";
+ useMockAtprotoRepo(server, {
+ did: mainDid,
+ pds,
+ records: {
+ "app.bsky.feed.post": [{ rkey: "main-post", value: { text: "main" } }],
+ },
+ });
+ useMockAtprotoRepo(server, {
+ did: altDid,
+ pds,
+ records: {
+ "app.bsky.feed.post": [{ rkey: "alt-post", value: { text: "alt" } }],
+ },
+ });
+
+ const agent = new AtpAgent({ service: pds });
+
+ const { data: mainList } = await agent.com.atproto.repo.listRecords({
+ repo: mainDid,
+ collection: "app.bsky.feed.post",
+ });
+ const { data: altList } = await agent.com.atproto.repo.listRecords({
+ repo: altDid,
+ collection: "app.bsky.feed.post",
+ });
+
+ expect(mainList).toMatchObject({
+ records: [
+ {
+ uri: AtUri.make(
+ mainDid,
+ "app.bsky.feed.post",
+ "main-post",
+ ).toString(),
+ },
+ ],
+ });
+ expect(altList).toMatchObject({
+ records: [
+ {
+ uri: AtUri.make(altDid, "app.bsky.feed.post", "alt-post").toString(),
+ },
+ ],
+ });
+ });
+
+ test("re-resolving a handle reflects an identity override registered mid-test", async () => {
+ const handle = "bobatan.fujocoded.com";
+ const originalDid = "did:plc:bobatan";
+ const newOwnerDid = "did:plc:bobatan-alt";
+ const pds = "https://pds.fujocoded.test";
+ const repo = useMockAtprotoRepo(server, { did: originalDid, handle, pds });
+
+ const before = await fetch(`https://${handle}/.well-known/atproto-did`);
+ expect(await before.text()).toBe(originalDid);
+
+ server.use(repo.identity.handleResolvesTo(newOwnerDid));
+
+ const after = await fetch(`https://${handle}/.well-known/atproto-did`);
+ expect(await after.text()).toBe(newOwnerDid);
+ });
+
+ test("use this when the code under test walks a multi-page collection by following cursors", async () => {
+ const did = "did:plc:bobatan";
+ const repo = useMockAtprotoRepo(server, {
+ did,
+ records: {
+ "app.bsky.feed.post": [
+ { rkey: "post-1", value: { text: "one" } },
+ { rkey: "post-2", value: { text: "two" } },
+ { rkey: "post-3", value: { text: "three" } },
+ { rkey: "post-4", value: { text: "four" } },
+ { rkey: "post-5", value: { text: "five" } },
+ ],
+ },
+ });
+ const agent = new AtpAgent({ service: repo.pds });
+
+ const pages: string[][] = [];
+ let cursor: string | undefined;
+ let lastCursor: string | undefined;
+ do {
+ const { data } = await agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection: "app.bsky.feed.post",
+ limit: 2,
+ cursor,
+ });
+ pages.push(data.records.map((rec) => rec.uri));
+ cursor = data.cursor;
+ lastCursor = data.cursor;
+ } while (cursor);
+
+ expect(pages).toEqual([
+ [
+ AtUri.make(did, "app.bsky.feed.post", "post-1").toString(),
+ AtUri.make(did, "app.bsky.feed.post", "post-2").toString(),
+ ],
+ [
+ AtUri.make(did, "app.bsky.feed.post", "post-3").toString(),
+ AtUri.make(did, "app.bsky.feed.post", "post-4").toString(),
+ ],
+ [AtUri.make(did, "app.bsky.feed.post", "post-5").toString()],
+ ]);
+ expect(lastCursor).toBeUndefined();
+ });
+});
diff --git a/msw-atproto/__examples__/03-empty-repo-and-raw-msw.test.ts b/msw-atproto/__examples__/03-empty-repo-and-raw-msw.test.ts
new file mode 100644
index 0000000..3b3cbc5
--- /dev/null
+++ b/msw-atproto/__examples__/03-empty-repo-and-raw-msw.test.ts
@@ -0,0 +1,72 @@
+import { AtpAgent } from "@atproto/api";
+import { AtUri } from "@atproto/syntax";
+import { http, HttpResponse } from "msw";
+import { describe, expect, test } from "vitest";
+
+import { useMockAtprotoRepo, fakeCid } from "../src/index.ts";
+import { server } from "./msw/server.ts";
+
+// Use the stateful repo fake for normal ATproto behavior, even when the
+// starting state is empty. Use raw MSW only for server behavior this package
+// does not model, such as a cursor loop.
+describe("empty repo and raw MSW for impossible server behavior", () => {
+ test("use an empty stateful repo stub when a collection should have no records yet", async () => {
+ const did = "did:plc:bobatan";
+ const collection = "app.bsky.feed.post";
+ const repo = useMockAtprotoRepo(server, { did });
+
+ repo.seed(collection, []);
+
+ const agent = new AtpAgent({ service: repo.pds });
+ const { data } = await agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection,
+ });
+
+ expect(data).toEqual({ records: [] });
+ });
+
+ test("use raw MSW for impossible pagination behavior such as a cursor loop", async () => {
+ const did = "did:plc:bobatan";
+ const collection = "app.bsky.feed.post";
+ const rkey = "duplicate";
+ const repo = useMockAtprotoRepo(server, { did });
+ const record = {
+ uri: AtUri.make(did, collection, rkey).toString(),
+ cid: fakeCid("duplicate-page-record"),
+ value: { text: "same record on every page" },
+ };
+
+ // Register the normal fake first, then a hand-authored endpoint override.
+ // The repo fake handles the ordinary cases; raw MSW keeps weird server
+ // behavior local to the one test that needs it.
+ server.use(
+ http.get(`${repo.pds}/xrpc/com.atproto.repo.listRecords`, () =>
+ HttpResponse.json({
+ records: [record],
+ cursor: "again",
+ }),
+ ),
+ );
+
+ const listUrl = new URL(`${repo.pds}/xrpc/com.atproto.repo.listRecords`);
+ listUrl.searchParams.set("repo", did);
+ listUrl.searchParams.set("collection", collection);
+
+ const first = await fetch(listUrl);
+ const firstBody = await first.json();
+
+ listUrl.searchParams.set("cursor", firstBody.cursor);
+ const second = await fetch(listUrl);
+ const secondBody = await second.json();
+
+ expect(firstBody).toEqual({
+ records: [record],
+ cursor: "again",
+ });
+ expect(secondBody).toEqual({
+ records: [record],
+ cursor: "again",
+ });
+ });
+});
diff --git a/msw-atproto/__examples__/04-shared-fixture.test.ts b/msw-atproto/__examples__/04-shared-fixture.test.ts
new file mode 100644
index 0000000..0cc84c1
--- /dev/null
+++ b/msw-atproto/__examples__/04-shared-fixture.test.ts
@@ -0,0 +1,70 @@
+import { AtpAgent } from "@atproto/api";
+import { AtUri } from "@atproto/syntax";
+import { beforeEach, describe, expect, test } from "vitest";
+
+import { useMockAtprotoRepo } from "../src/index.ts";
+import { server } from "./msw/server.ts";
+
+describe("shared fixture", () => {
+ const did = "did:plc:bobatan";
+ // The agent has to be created in `beforeEach`, not at describe time:
+ // `AtpAgent` captures the `globalThis.fetch` reference it sees during
+ // construction, and MSW only patches that reference inside `server.listen`
+ // (which runs in `beforeAll`). An agent built too early holds the
+ // unpatched fetch and bypasses every handler.
+ let repo: ReturnType;
+ let agent: AtpAgent;
+
+ beforeEach(() => {
+ repo = useMockAtprotoRepo(server, { did });
+ repo.seed("app.bsky.feed.post", [
+ { rkey: "seed", value: { text: "fresh seed" } },
+ ]);
+ agent = new AtpAgent({ service: repo.pds });
+ });
+
+ test("each test sees the seed and nothing else", async () => {
+ const { data: listed } = await agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection: "app.bsky.feed.post",
+ });
+
+ expect(listed).toMatchObject({
+ records: [
+ { uri: AtUri.make(did, "app.bsky.feed.post", "seed").toString() },
+ ],
+ });
+ });
+
+ test("writes in one test do not leak into the next", async () => {
+ await agent.com.atproto.repo.createRecord({
+ repo: did,
+ collection: "app.bsky.feed.post",
+ record: { text: "written in test A" },
+ });
+
+ const { data: listed } = await agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection: "app.bsky.feed.post",
+ });
+
+ expect(listed.records).toHaveLength(2);
+ expect(repo.writes()).toHaveLength(1);
+ });
+
+ test("a fresh write also persists on a clean slate", async () => {
+ await agent.com.atproto.repo.createRecord({
+ repo: did,
+ collection: "app.bsky.feed.post",
+ record: { text: "written in test B" },
+ });
+
+ const { data: listed } = await agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection: "app.bsky.feed.post",
+ });
+
+ expect(listed.records).toHaveLength(2);
+ expect(repo.writes()).toHaveLength(1);
+ });
+});
diff --git a/msw-atproto/__examples__/README.md b/msw-atproto/__examples__/README.md
new file mode 100644
index 0000000..5680473
--- /dev/null
+++ b/msw-atproto/__examples__/README.md
@@ -0,0 +1,23 @@
+# `@fujocoded/msw-atproto` testing examples
+
+These tests use [Vitest](https://vitest.dev/) and [MSW](https://mswjs.io/) to show
+you how to use `@fujocoded/msw-atproto` in your own tests!
+
+Pick the file that matches your test:
+
+- [`01-stateful-repo.test.ts`](./01-stateful-repo.test.ts): One fake Bluesky
+ account, real `AtpAgent` calls, seeded records, writes, reads, and blobs
+- [`02-repo-boundaries.test.ts`](./02-repo-boundaries.test.ts): Empty
+ collections, several accounts on one PDS, identity changes, and cursor-based
+ pagination
+- [`03-empty-repo-and-raw-msw.test.ts`](./03-empty-repo-and-raw-msw.test.ts):
+ An intentionally empty account, plus raw MSW for server behavior this package
+ does not model
+- [`04-shared-fixture.test.ts`](./04-shared-fixture.test.ts): One shared setup
+ pattern for a suite where every test starts from the same state
+
+Run the example suite from the repo root:
+
+```sh
+npm test --workspace @fujocoded/msw-atproto -- __examples__
+```
diff --git a/msw-atproto/__examples__/msw/server.ts b/msw-atproto/__examples__/msw/server.ts
new file mode 100644
index 0000000..bd0bda5
--- /dev/null
+++ b/msw-atproto/__examples__/msw/server.ts
@@ -0,0 +1,3 @@
+import { setupServer } from "msw/node";
+
+export const server = setupServer();
diff --git a/msw-atproto/__examples__/package-lock.json b/msw-atproto/__examples__/package-lock.json
new file mode 100644
index 0000000..98c9246
--- /dev/null
+++ b/msw-atproto/__examples__/package-lock.json
@@ -0,0 +1,155 @@
+{
+ "name": "msw-atproto-examples",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "msw-atproto-examples",
+ "version": "0.0.0",
+ "dependencies": {
+ "@atproto/api": "^0.19.16",
+ "@atproto/syntax": "^0.5.4"
+ }
+ },
+ "node_modules/@atproto/api": {
+ "version": "0.19.16",
+ "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.19.16.tgz",
+ "integrity": "sha512-OAnY0+JDucUjPuPIrRVNT3h1PMi8txHd+lOdzenCcDAhtEsMRrUEWv4/IpEHRMclUAnX4ekfJDLlYleaOCQcng==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/common-web": "^0.4.21",
+ "@atproto/lexicon": "^0.6.2",
+ "@atproto/syntax": "^0.5.4",
+ "@atproto/xrpc": "^0.7.7",
+ "await-lock": "^2.2.2",
+ "multiformats": "^9.9.0",
+ "tlds": "^1.234.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/common-web": {
+ "version": "0.4.21",
+ "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.21.tgz",
+ "integrity": "sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/lex-data": "^0.0.15",
+ "@atproto/lex-json": "^0.0.16",
+ "@atproto/syntax": "^0.5.4",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/lex-data": {
+ "version": "0.0.15",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.15.tgz",
+ "integrity": "sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==",
+ "license": "MIT",
+ "dependencies": {
+ "multiformats": "^9.9.0",
+ "tslib": "^2.8.1",
+ "uint8arrays": "3.0.0",
+ "unicode-segmenter": "^0.14.0"
+ }
+ },
+ "node_modules/@atproto/lex-json": {
+ "version": "0.0.16",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.16.tgz",
+ "integrity": "sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/lex-data": "^0.0.15",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@atproto/lexicon": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.2.tgz",
+ "integrity": "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/common-web": "^0.4.18",
+ "@atproto/syntax": "^0.5.0",
+ "iso-datestring-validator": "^2.2.2",
+ "multiformats": "^9.9.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/syntax": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.4.tgz",
+ "integrity": "sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@atproto/xrpc": {
+ "version": "0.7.7",
+ "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz",
+ "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/lexicon": "^0.6.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/await-lock": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
+ "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==",
+ "license": "MIT"
+ },
+ "node_modules/iso-datestring-validator": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz",
+ "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==",
+ "license": "MIT"
+ },
+ "node_modules/multiformats": {
+ "version": "9.9.0",
+ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
+ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
+ "license": "(Apache-2.0 AND MIT)"
+ },
+ "node_modules/tlds": {
+ "version": "1.261.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
+ "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
+ "license": "MIT",
+ "bin": {
+ "tlds": "bin.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/uint8arrays": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
+ "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
+ "license": "MIT",
+ "dependencies": {
+ "multiformats": "^9.4.2"
+ }
+ },
+ "node_modules/unicode-segmenter": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz",
+ "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==",
+ "license": "MIT"
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/msw-atproto/__examples__/package.json b/msw-atproto/__examples__/package.json
new file mode 100644
index 0000000..2a83e1e
--- /dev/null
+++ b/msw-atproto/__examples__/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "msw-atproto-examples",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@atproto/api": "^0.19.16",
+ "@atproto/syntax": "^0.5.4"
+ }
+}
diff --git a/msw-atproto/__examples__/setup.ts b/msw-atproto/__examples__/setup.ts
new file mode 100644
index 0000000..f5292c1
--- /dev/null
+++ b/msw-atproto/__examples__/setup.ts
@@ -0,0 +1,18 @@
+import { afterAll, afterEach, beforeAll, vi } from "vitest";
+
+import { server } from "./msw/server.ts";
+
+vi.mock("node:dns/promises", async (importActual) => {
+ // In your own project, replace this relative import with:
+ // const { createDnsMock } = await import("@fujocoded/msw-atproto");
+ const { createDnsMock } = await import("../src/index.ts");
+ return createDnsMock(importActual);
+});
+
+beforeAll(async () => {
+ server.listen({ onUnhandledRequest: "error" });
+});
+afterEach(async () => {
+ server.resetHandlers();
+});
+afterAll(() => server.close());
diff --git a/msw-atproto/__tests__/cid.test.ts b/msw-atproto/__tests__/cid.test.ts
new file mode 100644
index 0000000..94ae673
--- /dev/null
+++ b/msw-atproto/__tests__/cid.test.ts
@@ -0,0 +1,14 @@
+import { CID } from "multiformats";
+import { describe, expect, it } from "vitest";
+
+import { fakeCid } from "../src/index.ts";
+
+describe("fakeCid", () => {
+ it("returns the same parseable CIDv1 for the same input", () => {
+ const input = 'did:plc:repo/app.bsky.feed.post/rkey:{"text":"hi"}';
+ const cid = fakeCid(input);
+
+ expect(fakeCid(input)).toBe(cid);
+ expect(CID.parse(cid).version).toBe(1);
+ });
+});
diff --git a/msw-atproto/__tests__/dns.test.ts b/msw-atproto/__tests__/dns.test.ts
new file mode 100644
index 0000000..ef1b02e
--- /dev/null
+++ b/msw-atproto/__tests__/dns.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from "vitest";
+
+import { createDnsMock } from "../src/index.ts";
+import { createDnsStub } from "../src/dns.ts";
+
+describe("createDnsStub", () => {
+ type DnsPromisesModule = typeof import("node:dns/promises");
+
+ const buildFakeActual = () => {
+ const topLevelCalls: string[] = [];
+ const setServersCalls: string[][] = [];
+ const resolverCalls: string[] = [];
+
+ class FakeResolver {
+ setServers(servers: readonly string[]) {
+ setServersCalls.push([...servers]);
+ }
+ resolveTxt(hostname: string) {
+ resolverCalls.push(hostname);
+ return Promise.resolve([["from-resolver"]]);
+ }
+ }
+
+ const resolveTxt = ((hostname: string) => {
+ topLevelCalls.push(hostname);
+ return Promise.resolve([["from-top-level"]]);
+ }) as DnsPromisesModule["resolveTxt"];
+
+ const actual = {
+ resolveTxt,
+ Resolver: FakeResolver,
+ } as unknown as DnsPromisesModule;
+
+ return { actual, topLevelCalls, setServersCalls, resolverCalls };
+ };
+
+ it("rejects `_atproto.` TXT lookups on the top-level resolveTxt without calling through", async () => {
+ const { actual, topLevelCalls } = buildFakeActual();
+ const stub = createDnsStub(actual);
+
+ await expect(
+ stub.resolveTxt("_atproto.bobatan.fujocoded.test"),
+ ).rejects.toMatchObject({ code: "ENODATA" });
+ await expect(
+ stub.default.resolveTxt("_atproto.bobatan.fujocoded.test"),
+ ).rejects.toMatchObject({ code: "ENODATA" });
+
+ expect(topLevelCalls).toEqual([]);
+ });
+
+ it("normalizes DNS hostnames before deciding whether to intercept", async () => {
+ const { actual, topLevelCalls } = buildFakeActual();
+ const seenHandles: string[] = [];
+ const stub = createDnsStub(actual, {
+ shouldInterceptHandle: (handle) => {
+ seenHandles.push(handle);
+ return handle === "bobatan.fujocoded.test";
+ },
+ });
+
+ await expect(
+ stub.resolveTxt("_ATPROTO.Bobatan.FujoCoded.Test."),
+ ).rejects.toMatchObject({ code: "ENODATA" });
+
+ expect(seenHandles).toEqual(["bobatan.fujocoded.test"]);
+ expect(topLevelCalls).toEqual([]);
+ });
+
+ it("delegates non-atproto TXT lookups to the real resolveTxt", async () => {
+ const { actual, topLevelCalls } = buildFakeActual();
+ const stub = createDnsStub(actual);
+
+ await expect(stub.resolveTxt("fujocoded.test")).resolves.toEqual([
+ ["from-top-level"],
+ ]);
+ expect(topLevelCalls).toEqual(["fujocoded.test"]);
+ });
+
+ it("forwards `_atproto.*` queries to real DNS when shouldInterceptHandle returns false", async () => {
+ const { actual, topLevelCalls, resolverCalls } = buildFakeActual();
+ const stub = createDnsStub(actual, {
+ shouldInterceptHandle: (handle) => handle.endsWith(".fujocoded.test"),
+ });
+
+ await expect(
+ stub.resolveTxt("_atproto.bobatan.fujocoded.test"),
+ ).rejects.toMatchObject({ code: "ENODATA" });
+
+ await expect(
+ stub.resolveTxt("_atproto.alice.bsky.social"),
+ ).resolves.toEqual([["from-top-level"]]);
+ expect(topLevelCalls).toEqual(["_atproto.alice.bsky.social"]);
+
+ const resolver = new stub.Resolver();
+ await expect(
+ resolver.resolveTxt("_atproto.bobatan.fujocoded.test"),
+ ).rejects.toMatchObject({ code: "ENODATA" });
+ await expect(
+ resolver.resolveTxt("_atproto.alice.bsky.social"),
+ ).resolves.toEqual([["from-resolver"]]);
+ expect(resolverCalls).toEqual(["_atproto.alice.bsky.social"]);
+ });
+
+ it("rejects `_atproto.*` from a Resolver instance and delegates everything else", async () => {
+ const { actual, setServersCalls, resolverCalls } = buildFakeActual();
+ const stub = createDnsStub(actual);
+ const resolver = new stub.Resolver();
+
+ resolver.setServers(["1.1.1.1"]);
+ expect(setServersCalls).toEqual([["1.1.1.1"]]);
+
+ await expect(
+ resolver.resolveTxt("_atproto.bobatan.fujocoded.test"),
+ ).rejects.toMatchObject({ code: "ENODATA" });
+ expect(resolverCalls).toEqual([]);
+
+ await expect(resolver.resolveTxt("fujocoded.test")).resolves.toEqual([
+ ["from-resolver"],
+ ]);
+ expect(resolverCalls).toEqual(["fujocoded.test"]);
+ });
+
+ it("exposes an async Vitest mock factory for setup files", async () => {
+ const { actual, topLevelCalls } = buildFakeActual();
+
+ const stub = await createDnsMock(async () => actual);
+
+ await expect(stub.resolveTxt("fujocoded.test")).resolves.toEqual([
+ ["from-top-level"],
+ ]);
+ await expect(
+ stub.resolveTxt("_atproto.bobatan.fujocoded.test"),
+ ).rejects.toMatchObject({ code: "ENODATA" });
+ expect(topLevelCalls).toEqual(["fujocoded.test"]);
+ });
+});
diff --git a/msw-atproto/__tests__/identity-passthrough.test.ts b/msw-atproto/__tests__/identity-passthrough.test.ts
new file mode 100644
index 0000000..405bab6
--- /dev/null
+++ b/msw-atproto/__tests__/identity-passthrough.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it, vi } from "vitest";
+import { http, HttpResponse } from "msw";
+
+import {
+ createIdentityPassthrough,
+ createMockRepoIdentity,
+} from "../src/index.ts";
+import { DID, HANDLE, PDS } from "./support.ts";
+import { server } from "./msw/server.ts";
+
+describe("createIdentityPassthrough", () => {
+ type DnsPromisesModule = typeof import("node:dns/promises");
+
+ const buildFakeActual = () => {
+ class FakeResolver {
+ getServers() {
+ return [];
+ }
+ setServers() {}
+ cancel() {}
+ resolveTxt() {
+ return Promise.resolve([["from-resolver"]]);
+ }
+ }
+
+ const resolveTxt = (() =>
+ Promise.resolve([["from-top-level"]])) as DnsPromisesModule["resolveTxt"];
+
+ return {
+ resolveTxt,
+ Resolver: FakeResolver,
+ } as unknown as DnsPromisesModule;
+ };
+
+ it("flips the DNS-stub predicate so selected handles passthrough and the rest stay mocked", async () => {
+ const passthrough = createIdentityPassthrough({
+ shouldPassthrough: (handle) => handle === "selected.example.test",
+ });
+ const dns = await passthrough.dnsMock(async () => buildFakeActual());
+
+ await expect(
+ dns.resolveTxt("_atproto.mocked.example.test"),
+ ).rejects.toMatchObject({ code: "ENODATA" });
+ await expect(
+ dns.resolveTxt("_atproto.selected.example.test"),
+ ).resolves.toEqual([["from-top-level"]]);
+ });
+
+ it("well-known handler passthroughs selected hostnames and falls through otherwise", async () => {
+ const passthrough = createIdentityPassthrough({
+ shouldPassthrough: (handle) => handle === "selected.invalid",
+ });
+ server.use(
+ ...passthrough.handlers,
+ http.get("https://mocked.invalid/.well-known/atproto-did", () =>
+ HttpResponse.text("did:plc:mocked"),
+ ),
+ http.get("https://selected.invalid/.well-known/atproto-did", () =>
+ HttpResponse.text("did:plc:selected"),
+ ),
+ );
+
+ await expect(
+ fetch("https://mocked.invalid/.well-known/atproto-did").then((response) =>
+ response.text(),
+ ),
+ ).resolves.toBe("did:plc:mocked");
+ await expect(
+ fetch("https://selected.invalid/.well-known/atproto-did"),
+ ).rejects.toBeInstanceOf(TypeError);
+ });
+
+ it("routes @atproto/identity handle resolution onto the MSW well-known handler", async () => {
+ vi.resetModules();
+ const passthrough = createIdentityPassthrough();
+ vi.doMock("node:dns/promises", passthrough.dnsMock);
+
+ try {
+ const { HandleResolver } = await import("@atproto/identity");
+ server.use(
+ ...passthrough.handlers,
+ ...createMockRepoIdentity({
+ did: DID,
+ handle: HANDLE,
+ pds: PDS,
+ }).handlers(),
+ );
+
+ const resolver = new HandleResolver({ timeout: 50 });
+
+ await expect(resolver.resolve(HANDLE)).resolves.toBe(DID);
+ } finally {
+ vi.doUnmock("node:dns/promises");
+ vi.resetModules();
+ }
+ });
+});
diff --git a/msw-atproto/__tests__/identity.test.ts b/msw-atproto/__tests__/identity.test.ts
new file mode 100644
index 0000000..c39d17d
--- /dev/null
+++ b/msw-atproto/__tests__/identity.test.ts
@@ -0,0 +1,256 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createMockAtprotoRepo,
+ createMockRepoIdentity,
+ useMockAtprotoRepo,
+ useMockRepoIdentity,
+} from "../src/index.ts";
+import { DID, HANDLE, PDS } from "./support.ts";
+import { server } from "./msw/server.ts";
+
+describe("createMockRepoIdentity", () => {
+ it("serves both the PLC doc and well-known DID response", async () => {
+ const identity = useMockRepoIdentity(server, {
+ did: DID,
+ pds: PDS,
+ handle: HANDLE,
+ });
+
+ const plcResponse = await fetch(
+ `https://plc.directory/${encodeURIComponent(DID)}`,
+ );
+ const didResponse = await fetch(
+ `https://${HANDLE}/.well-known/atproto-did`,
+ );
+
+ expect(plcResponse.ok).toBe(true);
+ await expect(plcResponse.json()).resolves.toMatchObject({
+ id: DID,
+ alsoKnownAs: [`at://${HANDLE}`],
+ service: [
+ expect.objectContaining({
+ serviceEndpoint: PDS,
+ }),
+ ],
+ });
+ await expect(didResponse.text()).resolves.toBe(DID);
+ expect(identity).toMatchObject({ did: DID, pds: PDS, handle: HANDLE });
+ });
+
+ it("synthesizes a default handle and skips well-known when handle is omitted", async () => {
+ const identity = createMockRepoIdentity({ did: DID, pds: PDS });
+ server.use(...identity.handlers());
+ const expectedHandle = `${DID.split(":").pop()}.example.test`;
+
+ const plcResponse = await fetch(
+ `https://plc.directory/${encodeURIComponent(DID)}`,
+ );
+ await expect(plcResponse.json()).resolves.toMatchObject({
+ alsoKnownAs: [`at://${expectedHandle}`],
+ });
+
+ await expect(
+ fetch(`https://${expectedHandle}/.well-known/atproto-did`).catch(
+ (error) => error,
+ ),
+ ).resolves.toBeInstanceOf(Error);
+ });
+
+ it("exposes identity override handlers that compose through server.use", async () => {
+ const identity = createMockRepoIdentity({
+ did: DID,
+ pds: PDS,
+ handle: HANDLE,
+ });
+ server.use(...identity.handlers());
+ server.use(identity.wellKnownNotFound());
+
+ const missingHandle = await fetch(
+ `https://${HANDLE}/.well-known/atproto-did`,
+ );
+
+ expect(missingHandle.status).toBe(404);
+
+ server.use(identity.handleResolvesTo("did:plc:other"));
+ const changedHandle = await fetch(
+ `https://${HANDLE}/.well-known/atproto-did`,
+ );
+
+ await expect(changedHandle.text()).resolves.toBe("did:plc:other");
+ });
+});
+
+describe("repo identity controls", () => {
+ it("includes identity handlers in repo.handlers()", async () => {
+ useMockAtprotoRepo(server, { did: DID, handle: HANDLE, pds: PDS });
+
+ const didResponse = await fetch(
+ `https://${HANDLE}/.well-known/atproto-did`,
+ );
+
+ await expect(didResponse.text()).resolves.toBe(DID);
+ });
+
+ it("exposes identity override handlers that compose through server.use", async () => {
+ const repo = createMockAtprotoRepo({ did: DID, handle: HANDLE, pds: PDS });
+ server.use(...repo.handlers());
+ server.use(repo.identity.plcNotFound());
+
+ const response = await fetch(
+ `https://plc.directory/${encodeURIComponent(DID)}`,
+ );
+
+ expect(response.status).toBe(404);
+
+ server.use(
+ repo.identity.didDocument({
+ id: DID,
+ alsoKnownAs: ["at://custom.example.test"],
+ }),
+ );
+ const custom = await fetch(
+ `https://plc.directory/${encodeURIComponent(DID)}`,
+ );
+
+ await expect(custom.json()).resolves.toEqual({
+ id: DID,
+ alsoKnownAs: ["at://custom.example.test"],
+ });
+ });
+
+ it("keeps mutable identity baseline across handler resets while temporary overrides reset away", async () => {
+ const repo = createMockAtprotoRepo({ did: DID, handle: HANDLE, pds: PDS });
+ const baselineDocument = {
+ id: DID,
+ alsoKnownAs: [`at://${HANDLE}`],
+ service: [
+ {
+ id: "#atproto_pds",
+ type: "AtprotoPersonalDataServer",
+ serviceEndpoint: PDS,
+ },
+ ],
+ verificationMethod: [
+ {
+ id: `${DID}#atproto`,
+ type: "Multikey",
+ controller: DID,
+ publicKeyMultibase: "zBaselineKey",
+ },
+ ],
+ };
+ server.resetHandlers(...repo.handlers());
+
+ repo.identity.setDidDocument(baselineDocument);
+ server.use(repo.identity.plcNotFound());
+
+ const override = await fetch(
+ `https://plc.directory/${encodeURIComponent(DID)}`,
+ );
+ expect(override.status).toBe(404);
+
+ server.resetHandlers();
+ const baseline = await fetch(
+ `https://plc.directory/${encodeURIComponent(DID)}`,
+ );
+
+ await expect(baseline.json()).resolves.toEqual(baselineDocument);
+ });
+
+ it("updates the current baseline DID document and verification methods", async () => {
+ const repo = createMockAtprotoRepo({ did: DID, handle: HANDLE, pds: PDS });
+ server.use(...repo.handlers());
+
+ repo.identity.setDidDocument({
+ id: DID,
+ alsoKnownAs: [`at://${HANDLE}`],
+ verificationMethod: [
+ {
+ id: `${DID}#atproto`,
+ type: "Multikey",
+ controller: DID,
+ publicKeyMultibase: "zOldAtprotoKey",
+ },
+ ],
+ });
+ repo.identity.updateDidDocument((document) => ({
+ ...document,
+ alsoKnownAs: [...(document.alsoKnownAs ?? []), "at://updated.test"],
+ }));
+ repo.identity.setVerificationMethod("atproto", "zUpdatedAtprotoKey");
+ repo.identity.setVerificationMethod("rotation", "did:key:zRotationKey");
+
+ const response = await fetch(
+ `https://plc.directory/${encodeURIComponent(DID)}`,
+ );
+ const document = (await response.json()) as {
+ alsoKnownAs?: string[];
+ verificationMethod?: Array>;
+ };
+
+ expect(document.alsoKnownAs).toEqual([
+ `at://${HANDLE}`,
+ "at://updated.test",
+ ]);
+ expect(document.verificationMethod).toEqual([
+ expect.objectContaining({
+ id: `${DID}#atproto`,
+ publicKeyMultibase: "zUpdatedAtprotoKey",
+ }),
+ expect.objectContaining({
+ id: `${DID}#rotation`,
+ type: "Multikey",
+ controller: DID,
+ publicKeyMultibase: "zRotationKey",
+ }),
+ ]);
+ });
+
+ it("updates the well-known handle baseline and resets identity state", async () => {
+ const repo = createMockAtprotoRepo({ did: DID, handle: HANDLE, pds: PDS });
+ server.use(...repo.handlers());
+
+ repo.identity.setDidDocument({
+ id: DID,
+ alsoKnownAs: ["at://changed.example.test"],
+ });
+ repo.identity.setHandleDid("did:plc:other");
+
+ const changedDocument = await fetch(
+ `https://plc.directory/${encodeURIComponent(DID)}`,
+ );
+ const changedHandle = await fetch(
+ `https://${HANDLE}/.well-known/atproto-did`,
+ );
+
+ await expect(changedDocument.json()).resolves.toEqual({
+ id: DID,
+ alsoKnownAs: ["at://changed.example.test"],
+ });
+ await expect(changedHandle.text()).resolves.toBe("did:plc:other");
+
+ repo.identity.reset();
+ const resetDocument = await fetch(
+ `https://plc.directory/${encodeURIComponent(DID)}`,
+ );
+ const resetHandle = await fetch(
+ `https://${HANDLE}/.well-known/atproto-did`,
+ );
+ const resetBody = (await resetDocument.json()) as Record;
+
+ expect(resetBody).toMatchObject({
+ id: DID,
+ alsoKnownAs: [`at://${HANDLE}`],
+ service: [
+ expect.objectContaining({
+ serviceEndpoint: PDS,
+ }),
+ ],
+ });
+ expect(resetBody).not.toMatchObject({
+ alsoKnownAs: ["at://changed.example.test"],
+ });
+ await expect(resetHandle.text()).resolves.toBe(DID);
+ });
+});
diff --git a/msw-atproto/__tests__/msw/server.ts b/msw-atproto/__tests__/msw/server.ts
new file mode 100644
index 0000000..bd0bda5
--- /dev/null
+++ b/msw-atproto/__tests__/msw/server.ts
@@ -0,0 +1,3 @@
+import { setupServer } from "msw/node";
+
+export const server = setupServer();
diff --git a/msw-atproto/__tests__/plc.test.ts b/msw-atproto/__tests__/plc.test.ts
new file mode 100644
index 0000000..b25901c
--- /dev/null
+++ b/msw-atproto/__tests__/plc.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { useMockPlcOperationFlow } from "../src/index.ts";
+import { DID, fetchJson, HANDLE, PDS } from "./support.ts";
+import { server } from "./msw/server.ts";
+
+describe("createMockPlcOperationFlow", () => {
+ it("serves PLC audit state, signs via the PDS, and captures PLC submission", async () => {
+ const onSign = vi.fn();
+ const onSubmit = vi.fn();
+ const operation = {
+ verificationMethods: { atproto: "did:key:zAtproto" },
+ rotationKeys: ["did:key:zRotation"],
+ alsoKnownAs: [`at://${HANDLE}`],
+ services: { atproto_pds: { type: "AtprotoPersonalDataServer" } },
+ };
+ const signedOperation = { type: "plc_operation", sig: "signed" };
+ const flow = useMockPlcOperationFlow(server, {
+ did: DID,
+ pds: PDS,
+ operation,
+ signedOperation,
+ onSign,
+ onSubmit,
+ });
+
+ const audit = await fetchJson(`https://plc.directory/${DID}/log/audit`);
+ const signed = await fetchJson(
+ `${PDS}/xrpc/com.atproto.identity.signPlcOperation`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ token: "123456",
+ verificationMethods: {
+ atproto: "did:key:zAtproto",
+ attestations: "did:key:zAttestation",
+ },
+ rotationKeys: operation.rotationKeys,
+ alsoKnownAs: operation.alsoKnownAs,
+ services: operation.services,
+ }),
+ },
+ );
+ const submitted = await fetch(`https://plc.directory/${DID}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(signedOperation),
+ });
+
+ expect(audit.body).toEqual([{ operation }]);
+ expect(flow).toMatchObject({
+ did: DID,
+ pds: PDS,
+ plcDirectoryUrl: "https://plc.directory",
+ });
+ expect(signed.body).toEqual({ operation: signedOperation });
+ expect(submitted.ok).toBe(true);
+ expect(onSign).toHaveBeenCalledWith({
+ token: "123456",
+ verificationMethods: {
+ atproto: "did:key:zAtproto",
+ attestations: "did:key:zAttestation",
+ },
+ rotationKeys: operation.rotationKeys,
+ alsoKnownAs: operation.alsoKnownAs,
+ services: operation.services,
+ });
+ expect(onSubmit).toHaveBeenCalledWith(signedOperation);
+ });
+});
diff --git a/msw-atproto/__tests__/repo-apply-writes.test.ts b/msw-atproto/__tests__/repo-apply-writes.test.ts
new file mode 100644
index 0000000..8d17257
--- /dev/null
+++ b/msw-atproto/__tests__/repo-apply-writes.test.ts
@@ -0,0 +1,243 @@
+import { AtUri } from "@atproto/syntax";
+import { describe, expect, it } from "vitest";
+
+import {
+ COLLECTION,
+ DID,
+ expectedRepoCid,
+ fetchJson,
+ HANDLE,
+ OTHER_COLLECTION,
+ PDS,
+ setupRepo,
+} from "./support.ts";
+
+type ApplyWritesResult =
+ | {
+ $type: "com.atproto.repo.applyWrites#createResult";
+ uri: string;
+ cid: string;
+ }
+ | {
+ $type: "com.atproto.repo.applyWrites#updateResult";
+ uri: string;
+ cid: string;
+ }
+ | { $type: "com.atproto.repo.applyWrites#deleteResult" };
+
+type ApplyWritesBody = { results: ApplyWritesResult[] };
+
+const applyWrites = (repo: string, writes: Array>) =>
+ fetchJson(`${PDS}/xrpc/com.atproto.repo.applyWrites`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ repo, writes }),
+ });
+
+describe("applyWrites", () => {
+ it("creates a record from a single #create entry and exposes it through reads", async () => {
+ const repo = setupRepo({ did: DID, pds: PDS });
+ const value = { text: "hello" };
+
+ const response = await applyWrites(DID, [
+ {
+ $type: "com.atproto.repo.applyWrites#create",
+ collection: COLLECTION,
+ value,
+ },
+ ]);
+
+ expect(response.body.results).toHaveLength(1);
+ const [result] = response.body.results;
+ expect(result).toMatchObject({
+ $type: "com.atproto.repo.applyWrites#createResult",
+ });
+ if (result?.$type !== "com.atproto.repo.applyWrites#createResult") {
+ throw new Error("expected createResult");
+ }
+ const rkey = new AtUri(result.uri).rkey;
+ expect(result.cid).toBe(expectedRepoCid(rkey, value));
+ expect(repo.records()).toEqual([
+ {
+ collection: COLLECTION,
+ rkey,
+ uri: result.uri,
+ cid: result.cid,
+ value,
+ },
+ ]);
+ });
+
+ it("honors an explicit rkey on #create", async () => {
+ setupRepo({ did: DID, pds: PDS });
+
+ const response = await applyWrites(DID, [
+ {
+ $type: "com.atproto.repo.applyWrites#create",
+ collection: COLLECTION,
+ rkey: "explicit",
+ value: { text: "explicit" },
+ },
+ ]);
+
+ const [result] = response.body.results;
+ if (result?.$type !== "com.atproto.repo.applyWrites#createResult") {
+ throw new Error("expected createResult");
+ }
+ expect(result.uri).toBe(`at://${DID}/${COLLECTION}/explicit`);
+ });
+
+ it("updates an existing record through #update and overwrites its value", async () => {
+ const repo = setupRepo({
+ did: DID,
+ pds: PDS,
+ records: { [COLLECTION]: [{ rkey: "seeded", value: { text: "old" } }] },
+ });
+
+ const response = await applyWrites(DID, [
+ {
+ $type: "com.atproto.repo.applyWrites#update",
+ collection: COLLECTION,
+ rkey: "seeded",
+ value: { text: "new" },
+ },
+ ]);
+
+ const [result] = response.body.results;
+ expect(result).toEqual({
+ $type: "com.atproto.repo.applyWrites#updateResult",
+ uri: `at://${DID}/${COLLECTION}/seeded`,
+ cid: expectedRepoCid("seeded", { text: "new" }),
+ });
+ expect(repo.records()).toEqual([
+ {
+ collection: COLLECTION,
+ rkey: "seeded",
+ uri: `at://${DID}/${COLLECTION}/seeded`,
+ cid: expectedRepoCid("seeded", { text: "new" }),
+ value: { text: "new" },
+ },
+ ]);
+ });
+
+ it("removes a record through #delete and returns deleteResult", async () => {
+ const repo = setupRepo({
+ did: DID,
+ pds: PDS,
+ records: { [COLLECTION]: [{ rkey: "seeded", value: { text: "old" } }] },
+ });
+
+ const response = await applyWrites(DID, [
+ {
+ $type: "com.atproto.repo.applyWrites#delete",
+ collection: COLLECTION,
+ rkey: "seeded",
+ },
+ ]);
+
+ expect(response.body.results).toEqual([
+ { $type: "com.atproto.repo.applyWrites#deleteResult" },
+ ]);
+ expect(repo.records()).toEqual([]);
+ });
+
+ it("applies a mixed batch (create + update + delete) in order and reports results in matching order", async () => {
+ const repo = setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [
+ { rkey: "to-update", value: { text: "old" } },
+ { rkey: "to-delete", value: { text: "doomed" } },
+ ],
+ },
+ });
+
+ const response = await applyWrites(DID, [
+ {
+ $type: "com.atproto.repo.applyWrites#create",
+ collection: OTHER_COLLECTION,
+ rkey: "new",
+ value: { text: "fresh" },
+ },
+ {
+ $type: "com.atproto.repo.applyWrites#update",
+ collection: COLLECTION,
+ rkey: "to-update",
+ value: { text: "updated" },
+ },
+ {
+ $type: "com.atproto.repo.applyWrites#delete",
+ collection: COLLECTION,
+ rkey: "to-delete",
+ },
+ ]);
+
+ expect(response.body.results.map((r) => r.$type)).toEqual([
+ "com.atproto.repo.applyWrites#createResult",
+ "com.atproto.repo.applyWrites#updateResult",
+ "com.atproto.repo.applyWrites#deleteResult",
+ ]);
+ const remainingKeys = repo
+ .records()
+ .map(
+ (r: { collection: string; rkey: string }) =>
+ `${r.collection}/${r.rkey}`,
+ )
+ .sort();
+ expect(remainingKeys).toEqual(
+ [`${COLLECTION}/to-update`, `${OTHER_COLLECTION}/new`].sort(),
+ );
+ });
+
+ it("captures every applyWrites entry as a write or delete on the repo log", async () => {
+ const repo = setupRepo({
+ did: DID,
+ pds: PDS,
+ records: { [COLLECTION]: [{ rkey: "stay", value: {} }] },
+ });
+
+ await applyWrites(DID, [
+ {
+ $type: "com.atproto.repo.applyWrites#create",
+ collection: COLLECTION,
+ rkey: "added",
+ value: { text: "added" },
+ },
+ {
+ $type: "com.atproto.repo.applyWrites#delete",
+ collection: COLLECTION,
+ rkey: "stay",
+ },
+ ]);
+
+ expect(repo.writes()).toEqual([
+ {
+ action: "create",
+ uri: `at://${DID}/${COLLECTION}/added`,
+ cid: expectedRepoCid("added", { text: "added" }),
+ record: { text: "added" },
+ },
+ ]);
+ expect(repo.deletes()).toEqual([{ uri: `at://${DID}/${COLLECTION}/stay` }]);
+ });
+
+ it("accepts handle as repo identifier", async () => {
+ setupRepo({ did: DID, handle: HANDLE, pds: PDS });
+
+ const response = await applyWrites(HANDLE, [
+ {
+ $type: "com.atproto.repo.applyWrites#create",
+ collection: COLLECTION,
+ rkey: "via-handle",
+ value: { text: "by handle" },
+ },
+ ]);
+
+ const [result] = response.body.results;
+ if (result?.$type !== "com.atproto.repo.applyWrites#createResult") {
+ throw new Error("expected createResult");
+ }
+ expect(result.uri).toBe(`at://${DID}/${COLLECTION}/via-handle`);
+ });
+});
diff --git a/msw-atproto/__tests__/repo-failures.test.ts b/msw-atproto/__tests__/repo-failures.test.ts
new file mode 100644
index 0000000..17c919e
--- /dev/null
+++ b/msw-atproto/__tests__/repo-failures.test.ts
@@ -0,0 +1,320 @@
+import { describe, expect, it } from "vitest";
+
+import { FAKE_CID } from "../src/index.ts";
+import {
+ COLLECTION,
+ DID,
+ expectedRepoCid,
+ fetchJson,
+ OTHER_CID,
+ OTHER_COLLECTION,
+ PDS,
+ setupRepo,
+} from "./support.ts";
+
+const listRecordsUrl = (collection = COLLECTION) =>
+ `${PDS}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(
+ DID,
+ )}&collection=${collection}`;
+
+const getRecordUrl = (collection = COLLECTION, rkey = "seeded") =>
+ `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(
+ DID,
+ )}&collection=${collection}&rkey=${rkey}`;
+
+const getBlobUrl = (cid = FAKE_CID) =>
+ `${PDS}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(
+ DID,
+ )}&cid=${cid}`;
+
+const createRecord = (collection = COLLECTION) =>
+ fetch(`${PDS}/xrpc/com.atproto.repo.createRecord`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ repo: DID,
+ collection,
+ record:
+ collection === COLLECTION
+ ? { text: "created" }
+ : { subject: "created" },
+ }),
+ });
+
+const putRecord = (collection = COLLECTION, rkey = "seeded") =>
+ fetch(`${PDS}/xrpc/com.atproto.repo.putRecord`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ repo: DID,
+ collection,
+ rkey,
+ record:
+ collection === COLLECTION
+ ? { text: "updated" }
+ : { subject: "updated" },
+ }),
+ });
+
+const deleteRecord = (collection = COLLECTION, rkey = "seeded") =>
+ fetch(`${PDS}/xrpc/com.atproto.repo.deleteRecord`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ repo: DID, collection, rkey }),
+ });
+
+const setupFailureRepo = () =>
+ setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "seeded", value: { text: "seeded" } }],
+ [OTHER_COLLECTION]: [{ rkey: "like", value: { subject: "seeded" } }],
+ },
+ blobs: [
+ { cid: FAKE_CID, body: new Uint8Array([1]) },
+ { cid: OTHER_CID, body: new Uint8Array([2]) },
+ ],
+ });
+
+describe("failOnce error shapes", () => {
+ const failureCases = [
+ {
+ name: "listRecords returns AuthRequired for 401",
+ queue: (repo: ReturnType) =>
+ repo.failOnce.listRecords({ status: 401 }),
+ request: () => fetchJson(listRecordsUrl()),
+ expected: {
+ response: { status: 401 },
+ body: { error: "AuthRequired", message: "Authentication required" },
+ },
+ },
+ {
+ name: "getRecord returns RecordNotFound for 404",
+ queue: (repo: ReturnType) =>
+ repo.failOnce.getRecord({ status: 404 }),
+ request: () => fetchJson(getRecordUrl()),
+ expected: {
+ response: { status: 404 },
+ body: { error: "RecordNotFound", message: "Record not found" },
+ },
+ },
+ {
+ name: "getBlob returns NotFound for 404",
+ queue: (repo: ReturnType) =>
+ repo.failOnce.getBlob({ status: 404 }),
+ request: () => fetchJson(getBlobUrl()),
+ expected: {
+ response: { status: 404 },
+ body: { error: "NotFound", message: "Blob not found" },
+ },
+ },
+ {
+ name: "createRecord returns RateLimitExceeded for 429",
+ queue: (repo: ReturnType) =>
+ repo.failOnce.createRecord({ status: 429 }),
+ request: () =>
+ fetchJson(`${PDS}/xrpc/com.atproto.repo.createRecord`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ repo: DID,
+ collection: COLLECTION,
+ record: { text: "failed create" },
+ }),
+ }),
+ expected: {
+ response: { status: 429 },
+ body: { error: "RateLimitExceeded", message: "Rate limit exceeded" },
+ },
+ },
+ {
+ name: "putRecord returns InternalServerError for 503",
+ queue: (repo: ReturnType) =>
+ repo.failOnce.putRecord({ status: 503 }),
+ request: () =>
+ fetchJson(`${PDS}/xrpc/com.atproto.repo.putRecord`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ repo: DID,
+ collection: COLLECTION,
+ rkey: "seeded",
+ record: { text: "failed put" },
+ }),
+ }),
+ expected: {
+ response: { status: 503 },
+ body: {
+ error: "InternalServerError",
+ message: "Internal server error",
+ },
+ },
+ },
+ {
+ name: "deleteRecord allows a custom error and message",
+ queue: (repo: ReturnType) =>
+ repo.failOnce.deleteRecord({
+ status: 418,
+ error: "Teapot",
+ message: "Short and stout",
+ }),
+ request: () =>
+ fetchJson(`${PDS}/xrpc/com.atproto.repo.deleteRecord`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ repo: DID,
+ collection: COLLECTION,
+ rkey: "seeded",
+ }),
+ }),
+ expected: {
+ response: { status: 418 },
+ body: { error: "Teapot", message: "Short and stout" },
+ },
+ },
+ ];
+
+ for (const { name, queue, request, expected } of failureCases) {
+ it(name, async () => {
+ const repo = setupFailureRepo();
+ queue(repo);
+
+ await expect(request()).resolves.toMatchObject(expected);
+ });
+ }
+
+ it("does not record failed writes in writes(), deletes(), or records()", async () => {
+ const repo = setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "seeded", value: { text: "seeded" } }],
+ },
+ });
+
+ repo.failOnce.createRecord({ status: 429 });
+ repo.failOnce.putRecord({ status: 503 });
+ repo.failOnce.deleteRecord({ status: 418 });
+
+ await createRecord();
+ await putRecord();
+ await deleteRecord();
+
+ expect(repo.writes()).toEqual([]);
+ expect(repo.deletes()).toEqual([]);
+ expect(repo.records()).toEqual([
+ {
+ collection: COLLECTION,
+ rkey: "seeded",
+ uri: `at://${DID}/${COLLECTION}/seeded`,
+ cid: expectedRepoCid("seeded", { text: "seeded" }),
+ value: { text: "seeded" },
+ },
+ ]);
+ });
+
+ it("defaults failOnce failures to 500 InternalServerError", async () => {
+ const repo = setupFailureRepo();
+ repo.failOnce.getRecord();
+
+ const failed = await fetchJson(getRecordUrl());
+
+ expect(failed).toMatchObject({
+ response: { status: 500 },
+ body: {
+ error: "InternalServerError",
+ message: "Internal server error",
+ },
+ });
+ });
+});
+
+describe("failOnce filters", () => {
+ it("filters by collection", async () => {
+ const repo = setupFailureRepo();
+
+ repo.failOnce.listRecords({ collection: OTHER_COLLECTION });
+ repo.failOnce.createRecord({ collection: OTHER_COLLECTION });
+
+ const listPost = await fetch(listRecordsUrl());
+ const listLike = await fetch(listRecordsUrl(OTHER_COLLECTION));
+ const createPost = await createRecord();
+ const createLike = await createRecord(OTHER_COLLECTION);
+
+ expect(listPost.status).toBe(200);
+ expect(listLike.status).toBe(500);
+ expect(createPost.status).toBe(200);
+ expect(createLike.status).toBe(500);
+ });
+
+ it("filters by rkey", async () => {
+ const repo = setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [
+ { rkey: "post", value: { text: "post" } },
+ { rkey: "other", value: { text: "other" } },
+ ],
+ },
+ });
+
+ repo.failOnce.getRecord({ rkey: "post" });
+ repo.failOnce.putRecord({ rkey: "post" });
+ repo.failOnce.deleteRecord({ rkey: "post" });
+
+ const getOther = await fetch(getRecordUrl(COLLECTION, "other"));
+ const getPost = await fetch(getRecordUrl(COLLECTION, "post"));
+ const putOther = await putRecord(COLLECTION, "other");
+ const putPost = await putRecord(COLLECTION, "post");
+ const deleteOther = await deleteRecord(COLLECTION, "other");
+ const deletePost = await deleteRecord(COLLECTION, "post");
+
+ expect(getOther.status).toBe(200);
+ expect(getPost.status).toBe(500);
+ expect(putOther.status).toBe(200);
+ expect(putPost.status).toBe(500);
+ expect(deleteOther.status).toBe(200);
+ expect(deletePost.status).toBe(500);
+ });
+
+ it("filters getBlob by cid", async () => {
+ const repo = setupFailureRepo();
+
+ repo.failOnce.getBlob({ cid: OTHER_CID });
+
+ const defaultBlob = await fetch(getBlobUrl());
+ const otherBlob = await fetch(getBlobUrl(OTHER_CID));
+
+ expect(defaultBlob.status).toBe(200);
+ expect(otherBlob.status).toBe(500);
+ });
+});
+
+describe("failOnce consumption", () => {
+ it("clears queued failOnce failures", async () => {
+ const repo = setupRepo({ did: DID, pds: PDS });
+ repo.failOnce.createRecord({ status: 500 });
+
+ repo.clear();
+ const afterClear = await createRecord();
+
+ expect(afterClear.status).toBe(200);
+ });
+
+ it("consumes at request entry so only one concurrent match fails", async () => {
+ const repo = setupFailureRepo();
+ repo.failOnce.getRecord({ status: 500 });
+
+ const responses = await Promise.all([
+ fetch(getRecordUrl()),
+ fetch(getRecordUrl()),
+ ]);
+
+ expect(
+ responses.map((response) => response.status).sort((a, b) => a - b),
+ ).toEqual([200, 500]);
+ });
+});
diff --git a/msw-atproto/__tests__/repo-records.test.ts b/msw-atproto/__tests__/repo-records.test.ts
new file mode 100644
index 0000000..f48b2b0
--- /dev/null
+++ b/msw-atproto/__tests__/repo-records.test.ts
@@ -0,0 +1,504 @@
+import { AtUri } from "@atproto/syntax";
+import { CID } from "multiformats";
+import { describe, expect, it } from "vitest";
+
+import { createMockAtprotoRepo, FAKE_CID } from "../src/index.ts";
+import {
+ COLLECTION,
+ DID,
+ expectedRepoCid,
+ fetchJson,
+ HANDLE,
+ OTHER_CID,
+ OTHER_COLLECTION,
+ PDS,
+ setupRepo,
+} from "./support.ts";
+import { server } from "./msw/server.ts";
+
+type RecordBody = {
+ uri: string;
+ cid: string;
+ value: Record;
+};
+
+type ListRecordsBody = {
+ records: RecordBody[];
+ cursor?: string;
+};
+
+type WriteRecordBody = {
+ uri: string;
+ cid: string;
+};
+
+const listRecordsUrl = (repo = DID, collection = COLLECTION) =>
+ `${PDS}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(
+ repo,
+ )}&collection=${collection}`;
+
+const getRecordUrl = (repo = DID, collection = COLLECTION, rkey = "seeded") =>
+ `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(
+ repo,
+ )}&collection=${collection}&rkey=${rkey}`;
+
+const getBlobUrl = (did = DID, cid = FAKE_CID) =>
+ `${PDS}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(
+ did,
+ )}&cid=${cid}`;
+
+const createRecord = (
+ repo = DID,
+ record: Record = { text: "created" },
+ collection = COLLECTION,
+) =>
+ fetchJson(`${PDS}/xrpc/com.atproto.repo.createRecord`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ repo, collection, record }),
+ });
+
+const putRecord = (
+ repo = DID,
+ rkey = "seeded",
+ record: Record = { text: "updated" },
+ collection = COLLECTION,
+) =>
+ fetchJson(`${PDS}/xrpc/com.atproto.repo.putRecord`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ repo, collection, rkey, record }),
+ });
+
+const deleteRecord = (repo = DID, rkey = "seeded", collection = COLLECTION) =>
+ fetch(`${PDS}/xrpc/com.atproto.repo.deleteRecord`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ repo, collection, rkey }),
+ });
+
+describe("createMockAtprotoRepo record reads", () => {
+ it("serves seeded records through listRecords", async () => {
+ const value = { text: "hi" };
+ setupRepo({
+ did: DID,
+ handle: HANDLE,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "first", value }],
+ },
+ });
+ const cid = expectedRepoCid("first", value);
+
+ const list = await fetchJson(listRecordsUrl());
+
+ expect(list.body).toEqual({
+ records: [{ uri: `at://${DID}/${COLLECTION}/first`, cid, value }],
+ });
+ expect(CID.parse(list.body.records[0]!.cid).version).toBe(1);
+ });
+
+ it("uses explicit fixture CIDs as-is", async () => {
+ const explicitCid =
+ "bafkreics4felvw3rnpwriv7avyvb3qdfutobmovlrskpbxshlmjkers6b4";
+ setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [
+ { rkey: "explicit", value: { text: "fixture" }, cid: explicitCid },
+ ],
+ },
+ });
+
+ const list = await fetchJson(listRecordsUrl());
+
+ expect(list.body.records).toMatchObject([{ cid: explicitCid }]);
+ });
+
+ it("serves seeded records through getRecord", async () => {
+ const value = { text: "hi" };
+ setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "first", value }],
+ },
+ });
+ const cid = expectedRepoCid("first", value);
+
+ const get = await fetchJson(
+ getRecordUrl(DID, COLLECTION, "first"),
+ );
+
+ expect(get.body).toEqual({
+ uri: `at://${DID}/${COLLECTION}/first`,
+ cid,
+ value,
+ });
+ });
+
+ it("exposes read seeded records through repo.records()", () => {
+ const value = { text: "hi" };
+ const repo = setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "first", value }],
+ },
+ });
+
+ const records = repo.records();
+ const cid = expectedRepoCid("first", value);
+
+ expect(records).toEqual([
+ {
+ collection: COLLECTION,
+ rkey: "first",
+ uri: `at://${DID}/${COLLECTION}/first`,
+ cid,
+ value,
+ },
+ ]);
+ });
+});
+
+describe("stateful writes", () => {
+ it("mutates state through createRecord and exposes created records through reads", async () => {
+ setupRepo({ did: DID, pds: PDS });
+ const value = { text: "created" };
+
+ const create = await createRecord(DID, value);
+ const uri = create.body.uri;
+ const rkey = new AtUri(uri).rkey;
+ const cid = expectedRepoCid(rkey, value);
+ const list = await fetchJson(listRecordsUrl());
+ const get = await fetchJson(
+ getRecordUrl(DID, COLLECTION, rkey),
+ );
+
+ expect(rkey).toBe("3kgenerated1");
+ expect(create.body).toMatchObject({ uri, cid });
+ expect(list.body).toMatchObject({ records: [{ uri, cid, value }] });
+ expect(get.body).toMatchObject({ uri, cid, value });
+ });
+
+ it("captures successful create calls with rkey, uri, and cid", async () => {
+ const repo = setupRepo({ did: DID, handle: HANDLE, pds: PDS });
+
+ const created = await createRecord(HANDLE, { text: "created" });
+ const createdRkey = new AtUri(created.body.uri).rkey;
+
+ expect(repo.writes()).toEqual([
+ {
+ action: "create",
+ uri: `at://${DID}/${COLLECTION}/${createdRkey}`,
+ cid: created.body.cid,
+ record: { text: "created" },
+ },
+ ]);
+ });
+
+ it("captures successful put calls and updates records", async () => {
+ const repo = setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "seeded", value: { text: "seeded" } }],
+ },
+ });
+
+ const put = await putRecord(DID, "seeded", { text: "updated" });
+
+ expect(repo.records()).toEqual([
+ {
+ collection: COLLECTION,
+ rkey: "seeded",
+ uri: `at://${DID}/${COLLECTION}/seeded`,
+ cid: expectedRepoCid("seeded", { text: "updated" }),
+ value: { text: "updated" },
+ },
+ ]);
+ expect(repo.writes()).toEqual([
+ {
+ action: "put",
+ uri: `at://${DID}/${COLLECTION}/seeded`,
+ cid: put.body.cid,
+ record: { text: "updated" },
+ },
+ ]);
+ });
+
+ it("captures successful delete calls and removes records", async () => {
+ const repo = setupRepo({
+ did: DID,
+ handle: HANDLE,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "seeded", value: { text: "seeded" } }],
+ },
+ });
+
+ await deleteRecord(HANDLE, "seeded");
+
+ expect(repo.records()).toEqual([]);
+ expect(repo.deletes()).toEqual([
+ { uri: `at://${DID}/${COLLECTION}/seeded` },
+ ]);
+ });
+
+ it("preserves listRecords insertion order when putRecord overwrites an existing record", async () => {
+ setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [
+ { rkey: "first", value: { text: "first version" } },
+ { rkey: "second", value: { text: "second" } },
+ ],
+ },
+ });
+
+ await putRecord(DID, "first", { text: "updated first" });
+ const listed = await fetchJson(listRecordsUrl());
+
+ expect(listed.body).toMatchObject({
+ records: [
+ {
+ uri: `at://${DID}/${COLLECTION}/first`,
+ value: { text: "updated first" },
+ },
+ { uri: `at://${DID}/${COLLECTION}/second`, value: { text: "second" } },
+ ],
+ });
+ });
+});
+
+describe("DID and handle matching", () => {
+ it("matches stateful reads by configured DID or handle", async () => {
+ setupRepo({
+ did: DID,
+ handle: HANDLE,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "seeded", value: { text: "old" } }],
+ },
+ });
+
+ const listedByHandle = await fetchJson(
+ listRecordsUrl(HANDLE),
+ );
+ const fetchedByHandle = await fetchJson(
+ getRecordUrl(HANDLE, COLLECTION, "seeded"),
+ );
+
+ expect(listedByHandle.body).toMatchObject({
+ records: [{ uri: `at://${DID}/${COLLECTION}/seeded` }],
+ });
+ expect(fetchedByHandle.body).toMatchObject({
+ uri: `at://${DID}/${COLLECTION}/seeded`,
+ value: { text: "old" },
+ });
+ });
+
+ it("matches stateful writes by configured DID or handle", async () => {
+ setupRepo({
+ did: DID,
+ handle: HANDLE,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "seeded", value: { text: "old" } }],
+ },
+ });
+
+ const createdByHandle = await createRecord(HANDLE, { text: "created" });
+ const createdByHandleRkey = new AtUri(createdByHandle.body.uri).rkey;
+ await putRecord(HANDLE, "seeded", { text: "updated" });
+ await deleteRecord(HANDLE, createdByHandleRkey);
+ const listedByDid = await fetchJson(listRecordsUrl(DID));
+ const deletedByDid = await fetchJson(
+ getRecordUrl(DID, COLLECTION, createdByHandleRkey),
+ );
+
+ expect(createdByHandle.body).toMatchObject({
+ uri: `at://${DID}/${COLLECTION}/${createdByHandleRkey}`,
+ });
+ expect(listedByDid.body).toMatchObject({
+ records: [
+ { uri: `at://${DID}/${COLLECTION}/seeded`, value: { text: "updated" } },
+ ],
+ });
+ expect(deletedByDid.response.status).toBe(404);
+ });
+});
+
+describe("repo boundaries", () => {
+ it("keeps unknown repos loud", async () => {
+ setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "post", value: { text: "hi" } }],
+ },
+ });
+
+ await expect(
+ fetch(listRecordsUrl("did:plc:other", COLLECTION)),
+ ).rejects.toThrow();
+ });
+
+ it("keeps undeclared collections loud", async () => {
+ setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "post", value: { text: "hi" } }],
+ },
+ });
+
+ await expect(
+ fetch(listRecordsUrl(DID, "app.unseeded.collection")),
+ ).rejects.toThrow();
+ });
+
+ it("allows declared empty collections", async () => {
+ const repo = setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "post", value: { text: "hi" } }],
+ },
+ });
+ repo.seed(OTHER_COLLECTION, []);
+
+ const posts = await fetchJson(listRecordsUrl());
+ const empty = await fetchJson(
+ listRecordsUrl(DID, OTHER_COLLECTION),
+ );
+
+ expect(posts.body).toMatchObject({
+ records: [{ uri: `at://${DID}/${COLLECTION}/post` }],
+ });
+ expect(empty.body).toEqual({ records: [] });
+ });
+
+ it("routes same-PDS writes to the matching repo fixture", async () => {
+ const firstRepo = createMockAtprotoRepo({ did: DID, pds: PDS });
+ const secondDid = "did:plc:secondrepo";
+ const secondRepo = createMockAtprotoRepo({ did: secondDid, pds: PDS });
+ server.use(...firstRepo.handlers(), ...secondRepo.handlers());
+
+ const created = await createRecord(secondDid, { text: "second" });
+ const firstList = await fetch(listRecordsUrl(DID)).catch((error) => error);
+ const secondList = await fetchJson(
+ listRecordsUrl(secondDid),
+ );
+ const createdRkey = new AtUri(created.body.uri).rkey;
+
+ expect(created.body).toMatchObject({
+ uri: `at://${secondDid}/${COLLECTION}/${createdRkey}`,
+ });
+ expect(firstList).toBeInstanceOf(Error);
+ expect(secondList.body).toMatchObject({
+ records: [{ uri: `at://${secondDid}/${COLLECTION}/${createdRkey}` }],
+ });
+ });
+});
+
+describe("clear()", () => {
+ it("resets records, writes, and deletes", async () => {
+ const repo = setupRepo({
+ did: DID,
+ pds: PDS,
+ records: {
+ [COLLECTION]: [{ rkey: "existing", value: { text: "old" } }],
+ },
+ });
+
+ await putRecord(DID, "existing", { text: "new" });
+ await deleteRecord(DID, "existing");
+ repo.seed(COLLECTION, [{ rkey: "after-delete", value: { text: "again" } }]);
+ repo.clear();
+
+ expect(repo.records()).toEqual([]);
+ expect(repo.writes()).toEqual([]);
+ expect(repo.deletes()).toEqual([]);
+ });
+
+ it("removes declared empty collections", async () => {
+ const repo = setupRepo({ did: DID, pds: PDS });
+ repo.seed(OTHER_COLLECTION, []);
+
+ const beforeClear = await fetchJson(
+ listRecordsUrl(DID, OTHER_COLLECTION),
+ );
+ repo.clear();
+
+ await expect(
+ fetch(listRecordsUrl(DID, OTHER_COLLECTION)),
+ ).rejects.toThrow();
+ expect(beforeClear.body).toEqual({ records: [] });
+ });
+
+ it("resets generated rkeys", async () => {
+ const repo = setupRepo({ did: DID, pds: PDS });
+ const beforeClear = await createRecord(DID, { text: "before clear" });
+ const beforeClearRkey = new AtUri(beforeClear.body.uri).rkey;
+
+ repo.clear();
+ const afterClear = await createRecord(DID, { text: "after clear" });
+ const afterClearRkey = new AtUri(afterClear.body.uri).rkey;
+
+ expect(afterClearRkey).toBe(beforeClearRkey);
+ });
+});
+
+describe("blobs", () => {
+ it("serves seeded blobs through getBlob", async () => {
+ const bytes = new Uint8Array([1, 2, 3]);
+ setupRepo({
+ did: DID,
+ pds: PDS,
+ blobs: [{ cid: FAKE_CID, body: bytes, contentType: "image/png" }],
+ });
+
+ const response = await fetch(getBlobUrl());
+
+ expect(response.headers.get("content-type")).toBe("image/png");
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes);
+ });
+
+ it("supports adding blobs after the repo fixture is created", async () => {
+ const bytes = new Uint8Array([4, 5, 6]);
+ const repo = setupRepo({ did: DID, pds: PDS });
+ repo.seedBlob({ cid: FAKE_CID, body: bytes, contentType: "image/jpeg" });
+
+ const response = await fetch(getBlobUrl());
+
+ expect(response.headers.get("content-type")).toBe("image/jpeg");
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes);
+ });
+
+ it("keeps blob reads partitioned by repo DID when fakes share one PDS", async () => {
+ const firstBlob = new Uint8Array([1]);
+ const secondBlob = new Uint8Array([2]);
+ const firstRepo = createMockAtprotoRepo({
+ did: DID,
+ pds: PDS,
+ blobs: [{ cid: FAKE_CID, body: firstBlob }],
+ });
+ const secondDid = "did:plc:secondrepo";
+ const secondRepo = createMockAtprotoRepo({
+ did: secondDid,
+ pds: PDS,
+ blobs: [{ cid: OTHER_CID, body: secondBlob }],
+ });
+ server.use(...firstRepo.handlers(), ...secondRepo.handlers());
+
+ const first = await fetch(getBlobUrl(DID, FAKE_CID));
+ const second = await fetch(getBlobUrl(secondDid, OTHER_CID));
+
+ expect(new Uint8Array(await first.arrayBuffer())).toEqual(firstBlob);
+ expect(new Uint8Array(await second.arrayBuffer())).toEqual(secondBlob);
+ });
+});
diff --git a/msw-atproto/__tests__/repo-upload-blob.test.ts b/msw-atproto/__tests__/repo-upload-blob.test.ts
new file mode 100644
index 0000000..7eaf289
--- /dev/null
+++ b/msw-atproto/__tests__/repo-upload-blob.test.ts
@@ -0,0 +1,91 @@
+import { CID } from "multiformats";
+import { describe, expect, it } from "vitest";
+
+import { DID, fetchJson, PDS, setupRepo } from "./support.ts";
+
+type BlobRef = {
+ $type: "blob";
+ ref: { $link: string };
+ mimeType: string;
+ size: number;
+};
+
+type UploadBlobBody = { blob: BlobRef };
+
+const uploadBlob = (body: BodyInit, contentType: string) =>
+ fetchJson(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
+ method: "POST",
+ headers: { "content-type": contentType },
+ body,
+ });
+
+const getBlobUrl = (cid: string, did = DID) =>
+ `${PDS}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(
+ did,
+ )}&cid=${cid}`;
+
+describe("uploadBlob", () => {
+ it("returns a parseable CIDv1 BlobRef and reports the byte size", async () => {
+ setupRepo({ did: DID, pds: PDS });
+ const bytes = new Uint8Array([1, 2, 3, 4, 5]);
+
+ const upload = await uploadBlob(bytes, "image/png");
+
+ expect(upload.body.blob).toMatchObject({
+ $type: "blob",
+ mimeType: "image/png",
+ size: bytes.byteLength,
+ });
+ expect(upload.body.blob.ref.$link).toMatch(/^baf/);
+ expect(CID.parse(upload.body.blob.ref.$link).version).toBe(1);
+ });
+
+ it("makes the uploaded blob retrievable through com.atproto.sync.getBlob", async () => {
+ setupRepo({ did: DID, pds: PDS });
+ const bytes = new Uint8Array([10, 20, 30]);
+
+ const upload = await uploadBlob(bytes, "application/octet-stream");
+ const cid = upload.body.blob.ref.$link;
+
+ const response = await fetch(getBlobUrl(cid));
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe(
+ "application/octet-stream",
+ );
+ const fetched = new Uint8Array(await response.arrayBuffer());
+ expect(Array.from(fetched)).toEqual([10, 20, 30]);
+ });
+
+ it("produces stable CIDs for identical content and contentType", async () => {
+ setupRepo({ did: DID, pds: PDS });
+ const bytes = new Uint8Array([7, 7, 7]);
+
+ const first = await uploadBlob(bytes, "image/jpeg");
+ const second = await uploadBlob(new Uint8Array([7, 7, 7]), "image/jpeg");
+
+ expect(second.body.blob.ref.$link).toBe(first.body.blob.ref.$link);
+ });
+
+ it("produces different CIDs when the contentType differs", async () => {
+ setupRepo({ did: DID, pds: PDS });
+ const bytes = new Uint8Array([1, 2, 3]);
+
+ const png = await uploadBlob(bytes, "image/png");
+ const jpeg = await uploadBlob(bytes, "image/jpeg");
+
+ expect(jpeg.body.blob.ref.$link).not.toBe(png.body.blob.ref.$link);
+ });
+
+ it("defaults to application/octet-stream when no content-type is sent", async () => {
+ setupRepo({ did: DID, pds: PDS });
+ const bytes = new Uint8Array([42]);
+
+ const response = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
+ method: "POST",
+ body: bytes,
+ });
+ const body = (await response.json()) as UploadBlobBody;
+
+ expect(body.blob.mimeType).toBe("application/octet-stream");
+ });
+});
diff --git a/msw-atproto/__tests__/setup.ts b/msw-atproto/__tests__/setup.ts
new file mode 100644
index 0000000..f549c24
--- /dev/null
+++ b/msw-atproto/__tests__/setup.ts
@@ -0,0 +1,7 @@
+import { afterAll, afterEach, beforeAll } from "vitest";
+
+import { server } from "./msw/server.ts";
+
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
diff --git a/msw-atproto/__tests__/support.ts b/msw-atproto/__tests__/support.ts
new file mode 100644
index 0000000..730afaa
--- /dev/null
+++ b/msw-atproto/__tests__/support.ts
@@ -0,0 +1,41 @@
+import { cidForRecord, createMockAtprotoRepo } from "../src/index.ts";
+
+import { server } from "./msw/server.ts";
+
+export const DID = "did:plc:mswtest123456789012345";
+export const HANDLE = "msw.example.test";
+export const PDS = "https://pds.fujocoded.test";
+export const COLLECTION = "app.bsky.feed.post";
+export const OTHER_COLLECTION = "app.bsky.feed.like";
+export const OTHER_CID =
+ "bafkreics4felvw3rnpwriv7avyvb3qdfutobmovlrskpbxshlmjkers6b4";
+
+export const expectedRepoCid = (
+ rkey: string,
+ value: Record,
+): string =>
+ cidForRecord({
+ repo: DID,
+ collection: COLLECTION,
+ rkey,
+ value,
+ });
+
+export const fetchJson = async (
+ input: string,
+ init?: RequestInit,
+) => {
+ const response = await fetch(input, init);
+ return {
+ response,
+ body: (await response.json()) as Body,
+ };
+};
+
+export const setupRepo = (
+ options: Parameters[0],
+) => {
+ const repo = createMockAtprotoRepo(options);
+ server.use(...repo.handlers());
+ return repo;
+};
diff --git a/msw-atproto/package.json b/msw-atproto/package.json
new file mode 100644
index 0000000..a66a1b2
--- /dev/null
+++ b/msw-atproto/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@fujocoded/msw-atproto",
+ "version": "0.0.1",
+ "description": "MSW-based ATProto testing helpers: mock identity (PLC), record reads/writes, and DNS stubs for vitest setup.",
+ "keywords": [
+ "atproto",
+ "bluesky",
+ "mock",
+ "msw",
+ "testing",
+ "vitest"
+ ],
+ "homepage": "https://github.com/FujoWebDev/fujocoded-plugins#readme",
+ "bugs": {
+ "url": "https://github.com/FujoWebDev/fujocoded-plugins/issues"
+ },
+ "license": "MIT",
+ "author": "FujoCoded LLC",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/FujoWebDev/fujocoded-plugins.git"
+ },
+ "files": [
+ "dist",
+ "LICENSE",
+ "README.md",
+ "package.json"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "module": "dist/index.js",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsdown",
+ "dev": "tsdown --watch src/",
+ "test": "vitest --run",
+ "typecheck": "tsc --noEmit",
+ "validate": "npx publint"
+ },
+ "dependencies": {
+ "@atproto/common-web": "^0.4.7",
+ "@atproto/crypto": "^0.4.5",
+ "@atproto/syntax": "^0.4.2",
+ "multiformats": "^13.4.2"
+ },
+ "devDependencies": {
+ "msw": "^2.13.4",
+ "tsdown": "^0.14.2",
+ "vitest": "^3.2.4"
+ },
+ "peerDependencies": {
+ "msw": "^2.0.0",
+ "vitest": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ }
+}
diff --git a/msw-atproto/src/cid.ts b/msw-atproto/src/cid.ts
new file mode 100644
index 0000000..b9bdfa2
--- /dev/null
+++ b/msw-atproto/src/cid.ts
@@ -0,0 +1,51 @@
+import { CID } from "multiformats/cid";
+import * as raw from "multiformats/codecs/raw";
+import { create as createDigest } from "multiformats/hashes/digest";
+import { sha256 } from "multiformats/hashes/sha2";
+import { createHash } from "node:crypto";
+
+/**
+ * A valid CIDv1 string that parses with `multiformats/cid`.
+ *
+ * Use this when a test needs a single placeholder CID and does not care about
+ * content. Use `fakeCid(input)` instead when several values need stable but
+ * different CIDs.
+ */
+export const FAKE_CID =
+ "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a";
+
+/**
+ * Builds a stable, parseable CIDv1 string from a test input.
+ *
+ * The same input always returns the same CID, so two records seeded with the
+ * same content get the same CID. Use this when test assertions compare CIDs
+ * across reads, or when fake records need to look different from one another.
+ */
+export const fakeCid = (input: string): string =>
+ CID.createV1(
+ raw.code,
+ createDigest(sha256.code, createHash("sha256").update(input).digest()),
+ ).toString();
+
+/**
+ * Returns the same CID `createMockAtprotoRepo` derives for any record with no
+ * explicit `cid`, whether seeded up front or written during the test via
+ * `createRecord`, `putRecord`, or `applyWrites`. Use this in assertions that
+ * compare against the CID the fake generates.
+ *
+ * Two records with the same `{ repo, collection, rkey, value }` get the same
+ * CID, so a follow-up read can be matched without round-tripping the CID
+ * through the fake first.
+ */
+export const cidForRecord = ({
+ repo,
+ collection,
+ rkey,
+ value,
+}: {
+ repo: string;
+ collection: string;
+ rkey: string;
+ value: Record;
+}): string =>
+ fakeCid([repo, collection, rkey, JSON.stringify(value)].join("\n"));
diff --git a/msw-atproto/src/dns.ts b/msw-atproto/src/dns.ts
new file mode 100644
index 0000000..f8c486e
--- /dev/null
+++ b/msw-atproto/src/dns.ts
@@ -0,0 +1,120 @@
+export type DnsPromisesModule = typeof import("node:dns/promises");
+export type ImportActualDnsPromises = () => Promise;
+
+/**
+ * `_atproto.` is the only DNS query `@atproto/identity` makes for
+ * handle resolution. Failing just that query (rather than every DNS call)
+ * keeps real DNS available for everything else the test process might do.
+ */
+const ATPROTO_HANDLE_TXT_PREFIX = "_atproto.";
+
+const handleLookupEnodata = () =>
+ Object.assign(new Error("ENODATA (atproto handle lookup stub)"), {
+ code: "ENODATA",
+ });
+
+export type CreateDnsStubOptions = {
+ /**
+ * Low-level hook deciding whether a given `_atproto.` TXT query
+ * should be failed with `ENODATA` or forwarded to real DNS. Receives just
+ * the handle, with the `_atproto.` prefix already stripped. Defaults to
+ * intercepting every `_atproto.*` query.
+ *
+ * Use `createIdentityPassthrough` instead when one test mixes fake accounts
+ * with real handles. It pairs the DNS bypass with the matching
+ * `.well-known/atproto-did` HTTP passthrough under one predicate, so a handle
+ * cannot end up half real and half fake.
+ */
+ shouldInterceptHandle?: (handle: string) => boolean;
+};
+
+/**
+ * Returns a partial `node:dns/promises` mock that fails the
+ * `_atproto.` TXT lookup for handles your predicate selects, and
+ * passes every other DNS call through to the real module.
+ * `@atproto/identity` checks DNS before falling back to the HTTP
+ * `.well-known/atproto-did` path, so failing that single query is enough to
+ * route handle resolution onto HTTP where MSW can intercept it. Most Vitest
+ * suites should use `createDnsMock(importActual)` instead.
+ *
+ * Default behavior intercepts every `_atproto.*` query. See
+ * `CreateDnsStubOptions.shouldInterceptHandle` for narrowing the scope when
+ * a test mixes fake accounts with real handles.
+ */
+export const createDnsStub = (
+ actual: DnsPromisesModule,
+ { shouldInterceptHandle = () => true }: CreateDnsStubOptions = {},
+) => {
+ const normalizeHostname = (hostname: string): string =>
+ hostname.endsWith(".")
+ ? hostname.slice(0, -1).toLowerCase()
+ : hostname.toLowerCase();
+
+ const handleToIntercept = (hostname: string): string | null => {
+ const normalizedHostname = normalizeHostname(hostname);
+ if (!normalizedHostname.startsWith(ATPROTO_HANDLE_TXT_PREFIX)) return null;
+ const handle = normalizedHostname.slice(ATPROTO_HANDLE_TXT_PREFIX.length);
+ return shouldInterceptHandle(handle) ? handle : null;
+ };
+
+ const resolveTxtWith = (
+ actualResolveTxt: (...args: unknown[]) => Promise,
+ ) => {
+ return (hostname: string, ...rest: unknown[]) => {
+ if (handleToIntercept(hostname) !== null) {
+ return Promise.reject(handleLookupEnodata());
+ }
+ return actualResolveTxt(hostname, ...rest);
+ };
+ };
+
+ const resolveTxt = resolveTxtWith(
+ actual.resolveTxt as (...args: unknown[]) => Promise,
+ ) as DnsPromisesModule["resolveTxt"];
+
+ // Compose instead of extending: `Resolver.resolveTxt` is typed as a property
+ // in `@types/node` for `dns/promises`, which makes `class extends` reject the
+ // override. Constructing the real Resolver and patching the one method keeps
+ // every other surface (`cancel`, `setServers`, `getServers`, ...) intact.
+ const StubResolver = function StubResolver(
+ options?: ConstructorParameters[0],
+ ) {
+ const resolver = new actual.Resolver(options);
+ const original = resolver.resolveTxt.bind(resolver) as (
+ ...args: unknown[]
+ ) => Promise;
+ resolver.resolveTxt = resolveTxtWith(
+ original,
+ ) as typeof resolver.resolveTxt;
+ return resolver;
+ } as unknown as typeof actual.Resolver;
+
+ const actualDefault =
+ (actual as { default?: DnsPromisesModule }).default ?? actual;
+
+ return {
+ ...actual,
+ resolveTxt,
+ Resolver: StubResolver,
+ default: {
+ ...actualDefault,
+ resolveTxt,
+ Resolver: StubResolver,
+ },
+ };
+};
+
+/**
+ * Vitest-friendly DNS factory that intercepts every `_atproto.` query.
+ * Use `createIdentityPassthrough` instead when a test mixes fake accounts with
+ * real handles that should hit real DNS and real HTTP.
+ *
+ * ```ts
+ * vi.mock("node:dns/promises", async (importActual) => {
+ * const { createDnsMock } = await import("@fujocoded/msw-atproto");
+ * return createDnsMock(importActual);
+ * });
+ * ```
+ */
+export const createDnsMock = async (importActual: ImportActualDnsPromises) =>
+ createDnsStub(await importActual());
diff --git a/msw-atproto/src/identity/mock.ts b/msw-atproto/src/identity/mock.ts
new file mode 100644
index 0000000..429d1b7
--- /dev/null
+++ b/msw-atproto/src/identity/mock.ts
@@ -0,0 +1,296 @@
+import type { DidDocument } from "@atproto/common-web";
+import { P256Keypair } from "@atproto/crypto";
+import { AtUri } from "@atproto/syntax";
+import { http, HttpResponse, type HttpHandler } from "msw";
+
+export type { DidDocument };
+
+export type RepoIdentity = {
+ did: string;
+ /**
+ * PDS URL advertised in the DID document, like `https://pds.fujocoded.test`.
+ * Defaults to the package's built-in example PDS URL.
+ */
+ pds?: string;
+ /**
+ * Optional handle served from `https:///.well-known/atproto-did`.
+ */
+ handle?: string;
+};
+
+export type MockPlcOperationFlowConfig = RepoIdentity & {
+ /**
+ * PLC directory base URL. Defaults to `https://plc.directory`.
+ */
+ plcDirectoryUrl?: string;
+ operation: {
+ verificationMethods?: Record;
+ rotationKeys?: string[];
+ alsoKnownAs?: string[];
+ services?: Record;
+ };
+ /**
+ * Signed operation returned by the fake PDS. Defaults to the submitted body
+ * with `signed: true`.
+ */
+ signedOperation?: Record;
+ /**
+ * Runs when the client asks the PDS to sign a PLC operation.
+ */
+ onSign?: (body: Record) => void;
+ /**
+ * Runs when the client submits the signed operation to the PLC directory.
+ */
+ onSubmit?: (body: Record) => void;
+};
+
+export type MockRepoIdentity = Required> &
+ Pick & {
+ handlers(): HttpHandler[];
+ } & ReturnType["controls"];
+
+export type MockPlcOperationFlow = Required<
+ Pick
+> & {
+ handlers(): HttpHandler[];
+};
+
+// `@atproto/identity` insists on a well-formed DID document: signingKey,
+// handle, and pds are all required even when callers only care about pds.
+const createDidDocument = async ({
+ did,
+ pds = "https://pds.fujocoded.test",
+ handle,
+}: RepoIdentity): Promise => {
+ const advertisedHandle = handle ?? `${did.split(":").pop()}.example.test`;
+ const keypair = await P256Keypair.create();
+ const publicKeyMultibase = keypair.did().slice("did:key:".length);
+
+ return {
+ id: did,
+ alsoKnownAs: [AtUri.make(advertisedHandle).origin],
+ verificationMethod: [
+ {
+ id: `${did}#atproto`,
+ type: "Multikey",
+ controller: did,
+ publicKeyMultibase,
+ },
+ ],
+ service: [
+ {
+ id: "#atproto_pds",
+ type: "AtprotoPersonalDataServer",
+ serviceEndpoint: pds,
+ },
+ ],
+ };
+};
+
+const definePlcDidDocumentRoute = (
+ did: string,
+ resolveDocument: () => DidDocument | Promise,
+): HttpHandler =>
+ http.get(`https://plc.directory/${encodeURIComponent(did)}`, async () =>
+ HttpResponse.json(await resolveDocument()),
+ );
+
+export const createMutableRepoIdentity = ({
+ did,
+ pds = "https://pds.fujocoded.test",
+ handle,
+}: RepoIdentity) => {
+ const initialDidDocument = createDidDocument({ did, pds, handle });
+ let didDocument = initialDidDocument.then((document) =>
+ structuredClone(document),
+ );
+ let handleDid = did;
+
+ return {
+ handlers: (): HttpHandler[] => {
+ const handlers: HttpHandler[] = [
+ definePlcDidDocumentRoute(did, () => didDocument),
+ ];
+
+ if (handle) {
+ handlers.push(
+ http.get(`https://${handle}/.well-known/atproto-did`, () =>
+ HttpResponse.text(handleDid),
+ ),
+ );
+ }
+
+ return handlers;
+ },
+ controls: {
+ plcNotFound: (): HttpHandler =>
+ http.get(`https://plc.directory/${encodeURIComponent(did)}`, () =>
+ HttpResponse.json(
+ { error: "NotFound", message: "DID not found" },
+ { status: 404 },
+ ),
+ ),
+ didDocument: (document: DidDocument): HttpHandler =>
+ http.get(`https://plc.directory/${encodeURIComponent(did)}`, () =>
+ HttpResponse.json(document),
+ ),
+ wellKnownNotFound: (): HttpHandler => {
+ if (!handle) {
+ throw new Error("wellKnownNotFound requires a repo handle");
+ }
+ return http.get(`https://${handle}/.well-known/atproto-did`, () =>
+ HttpResponse.text("Not found", { status: 404 }),
+ );
+ },
+ handleResolvesTo: (otherDid: string): HttpHandler => {
+ if (!handle) {
+ throw new Error("handleResolvesTo requires a repo handle");
+ }
+ return http.get(`https://${handle}/.well-known/atproto-did`, () =>
+ HttpResponse.text(otherDid),
+ );
+ },
+ setDidDocument(document: DidDocument) {
+ didDocument = Promise.resolve(structuredClone(document));
+ },
+ updateDidDocument(update: (document: DidDocument) => DidDocument) {
+ didDocument = didDocument.then((document) =>
+ structuredClone(update(structuredClone(document))),
+ );
+ },
+ setVerificationMethod(name: string, didKeyOrMultibase: string) {
+ didDocument = didDocument.then((document) => {
+ const next = structuredClone(document);
+ const methods = Array.isArray(next.verificationMethod)
+ ? next.verificationMethod
+ : [];
+ const id = `${did}#${name}`;
+ const existing = methods.find((method) => method.id === id);
+ const publicKeyMultibase = didKeyOrMultibase.startsWith("did:key:")
+ ? didKeyOrMultibase.slice("did:key:".length)
+ : didKeyOrMultibase;
+
+ if (existing) {
+ existing.publicKeyMultibase = publicKeyMultibase;
+ next.verificationMethod = methods;
+ return next;
+ }
+
+ next.verificationMethod = [
+ ...methods,
+ { id, type: "Multikey", controller: did, publicKeyMultibase },
+ ];
+ return next;
+ });
+ },
+ setHandleDid(nextDid: string) {
+ if (!handle) {
+ throw new Error(`setHandleDid requires a repo handle`);
+ }
+ handleDid = nextDid;
+ },
+ reset() {
+ didDocument = initialDidDocument.then((document) =>
+ structuredClone(document),
+ );
+ handleDid = did;
+ },
+ },
+ };
+};
+
+/**
+ * Builds one fake ATproto identity for tests that only need DID or handle
+ * lookup.
+ *
+ * Use `createMockAtprotoRepo` when the test also reads or writes records. That
+ * helper includes these identity handlers, the PDS handlers, and the same
+ * identity controls for changing behavior mid-test.
+ */
+export const createMockRepoIdentity = ({
+ did,
+ pds = "https://pds.fujocoded.test",
+ handle,
+}: RepoIdentity): MockRepoIdentity => {
+ const identity = createMutableRepoIdentity({ did, pds, handle });
+
+ return {
+ did,
+ pds,
+ handle,
+ handlers: identity.handlers,
+ ...identity.controls,
+ };
+};
+
+export const useMockRepoIdentity = (
+ server: { use(...handlers: HttpHandler[]): void },
+ config: RepoIdentity,
+): MockRepoIdentity => {
+ const identity = createMockRepoIdentity(config);
+ server.use(...identity.handlers());
+ return identity;
+};
+
+/**
+ * Builds the fake flow used when code asks a PDS to update a DID document.
+ *
+ * The flow runs in order:
+ *
+ * 1. The PDS reads the previous op from the PLC audit log
+ * 2. The PDS signs the proposed op
+ * 3. The signed op is submitted back to the PLC directory
+ *
+ * `onSign` and `onSubmit` fire so the test can assert on each payload.
+ * `signedOperation` lets the test choose the value the fake PDS returns from
+ * step 2. When omitted, the fake echoes the submitted body with
+ * `signed: true` stamped on.
+ */
+export const createMockPlcOperationFlow = ({
+ did,
+ pds = "https://pds.fujocoded.test",
+ plcDirectoryUrl = "https://plc.directory",
+ operation,
+ signedOperation,
+ onSign,
+ onSubmit,
+}: MockPlcOperationFlowConfig): MockPlcOperationFlow => ({
+ did,
+ pds,
+ plcDirectoryUrl,
+ handlers: () => [
+ http.get(
+ ({ request }) => request.url === `${plcDirectoryUrl}/${did}/log/audit`,
+ () => HttpResponse.json([{ operation }]),
+ ),
+ http.post(
+ `${pds}/xrpc/com.atproto.identity.signPlcOperation`,
+ async ({ request }) => {
+ const body = (await request.json()) as Record;
+ onSign?.(body);
+
+ return HttpResponse.json({
+ operation: signedOperation ?? { ...body, signed: true },
+ });
+ },
+ ),
+ http.post(
+ ({ request }) => request.url === `${plcDirectoryUrl}/${did}`,
+ async ({ request }) => {
+ const body = (await request.json()) as Record;
+ onSubmit?.(body);
+
+ return HttpResponse.json({});
+ },
+ ),
+ ],
+});
+
+export const useMockPlcOperationFlow = (
+ server: { use(...handlers: HttpHandler[]): void },
+ config: MockPlcOperationFlowConfig,
+): MockPlcOperationFlow => {
+ const flow = createMockPlcOperationFlow(config);
+ server.use(...flow.handlers());
+ return flow;
+};
diff --git a/msw-atproto/src/identity/passthrough.ts b/msw-atproto/src/identity/passthrough.ts
new file mode 100644
index 0000000..30e51c7
--- /dev/null
+++ b/msw-atproto/src/identity/passthrough.ts
@@ -0,0 +1,69 @@
+import { http, passthrough, type HttpHandler } from "msw";
+
+import type { DnsPromisesModule, ImportActualDnsPromises } from "../dns.ts";
+import { createDnsStub } from "../dns.ts";
+
+export type CreateIdentityPassthroughOptions = {
+ /**
+ * Returns `true` for handles that should be left alone end-to-end: their
+ * `_atproto.` DNS query runs against real DNS, and their
+ * `https:///.well-known/atproto-did` HTTP follow-up is passed
+ * through MSW to the real server. Handles for which this returns `false`
+ * (the default) are intercepted: DNS fails with `ENODATA` and the
+ * `.well-known` request must be answered by an MSW handler.
+ *
+ * Use this when a single test mixes mocked accounts with one or two real
+ * handles (e.g. a real `*.bsky.social` while everything else is fake):
+ *
+ * ```ts
+ * createIdentityPassthrough({
+ * shouldPassthrough: (handle) => handle.endsWith(".bsky.social"),
+ * });
+ * ```
+ */
+ shouldPassthrough?: (handle: string) => boolean;
+};
+
+/**
+ * Pairs the DNS bypass and the `.well-known/atproto-did` HTTP passthrough
+ * under a single predicate. A handle is either fully mocked (DNS returns
+ * `ENODATA`, MSW expects a handler) or fully real (real DNS, real HTTP);
+ * the two layers always move together.
+ */
+export const createIdentityPassthrough = ({
+ shouldPassthrough = () => false,
+}: CreateIdentityPassthroughOptions = {}): {
+ /**
+ * Vitest-friendly DNS module factory. Pass to `vi.mock("node:dns/promises")`:
+ *
+ * ```ts
+ * vi.mock("node:dns/promises", passthrough.dnsMock);
+ * ```
+ */
+ dnsMock: (
+ importActual: ImportActualDnsPromises,
+ ) => Promise;
+ /**
+ * MSW handlers that passthrough `.well-known/atproto-did` for handles the
+ * predicate selects. Register before any per-test repo handlers so
+ * mocked handles still match first:
+ *
+ * ```ts
+ * server.use(...passthrough.handlers);
+ * ```
+ */
+ handlers: HttpHandler[];
+} => ({
+ dnsMock: async (importActual) =>
+ createDnsStub(await importActual(), {
+ shouldInterceptHandle: (handle) => !shouldPassthrough(handle),
+ }),
+ handlers: [
+ http.get("https://*/.well-known/atproto-did", ({ request }) => {
+ const { hostname } = new URL(request.url);
+ if (shouldPassthrough(hostname)) return passthrough();
+ // Fall through to other handlers (per-test fake repo, etc.).
+ return;
+ }),
+ ],
+});
diff --git a/msw-atproto/src/index.ts b/msw-atproto/src/index.ts
new file mode 100644
index 0000000..06bd062
--- /dev/null
+++ b/msw-atproto/src/index.ts
@@ -0,0 +1,25 @@
+export { cidForRecord, FAKE_CID, fakeCid } from "./cid.ts";
+export { createDnsMock, createDnsStub } from "./dns.ts";
+export {
+ createIdentityPassthrough,
+ type CreateIdentityPassthroughOptions,
+} from "./identity/passthrough.ts";
+export {
+ createMockPlcOperationFlow,
+ createMockRepoIdentity,
+ useMockPlcOperationFlow,
+ useMockRepoIdentity,
+ type DidDocument,
+ type MockPlcOperationFlow,
+ type MockPlcOperationFlowConfig,
+ type MockRepoIdentity,
+ type RepoIdentity,
+} from "./identity/mock.ts";
+export {
+ createMockAtprotoRepo,
+ useMockAtprotoRepo,
+ type MockAtprotoBlob,
+ type MockAtprotoRecord,
+ type MockAtprotoRepoConfig,
+ type RepoFailureOpts,
+} from "./repo/index.ts";
diff --git a/msw-atproto/src/repo/failures.ts b/msw-atproto/src/repo/failures.ts
new file mode 100644
index 0000000..bd39b5b
--- /dev/null
+++ b/msw-atproto/src/repo/failures.ts
@@ -0,0 +1,174 @@
+import { HttpResponse, type HttpHandler } from "msw";
+
+import type { RepoFailureOpts } from "./index.ts";
+import { defineXrpcRoute, type XrpcRouteContext } from "./xrpc.ts";
+
+export type FailOnceEndpoint =
+ | "listRecords"
+ | "getRecord"
+ | "getBlob"
+ | "createRecord"
+ | "putRecord"
+ | "deleteRecord";
+
+export type FailOnceConfigByEndpoint = {
+ listRecords: RepoFailureOpts<"collection">;
+ getRecord: RepoFailureOpts<"collection" | "rkey">;
+ getBlob: RepoFailureOpts<"cid">;
+ createRecord: RepoFailureOpts<"collection">;
+ putRecord: RepoFailureOpts<"collection" | "rkey">;
+ deleteRecord: RepoFailureOpts<"collection" | "rkey">;
+};
+
+export type FailureMatcher = (
+ failure: FailOnceConfigByEndpoint[Endpoint],
+) => boolean;
+
+export type FailOnceController = {
+ clear: () => void;
+ defineRoute: (config: {
+ endpoint: Endpoint;
+ method: string;
+ httpMethod?: "get" | "post";
+ matches: (
+ context: XrpcRouteContext,
+ ) =>
+ | FailureMatcher
+ | undefined
+ | Promise | undefined>;
+ }) => HttpHandler;
+ queue: (
+ endpoint: Endpoint,
+ config?: FailOnceConfigByEndpoint[Endpoint],
+ ) => void;
+};
+
+const defaultFailures = {
+ invalidRequest: { error: "InvalidRequest", message: "Invalid request" },
+ notFound: { error: "NotFound", message: "Blob not found" },
+ recordNotFound: {
+ error: "RecordNotFound",
+ message: "Record not found",
+ },
+ internalServerError: {
+ error: "InternalServerError",
+ message: "Internal server error",
+ },
+} as const;
+
+const defaultFailureByStatus: Record<
+ number,
+ { error: string; message: string }
+> = {
+ 401: { error: "AuthRequired", message: "Authentication required" },
+ 403: { error: "Forbidden", message: "Forbidden" },
+ 429: { error: "RateLimitExceeded", message: "Rate limit exceeded" },
+};
+
+const defaultAtprotoFailure = (endpoint: FailOnceEndpoint, status: number) => {
+ if (status === 404) {
+ return endpoint === "getBlob"
+ ? defaultFailures.notFound
+ : defaultFailures.recordNotFound;
+ }
+ if (status >= 500 && status <= 599) {
+ return defaultFailures.internalServerError;
+ }
+ return defaultFailureByStatus[status] ?? defaultFailures.invalidRequest;
+};
+
+const failOnceResponse = (
+ endpoint: FailOnceEndpoint,
+ failure: RepoFailureOpts,
+) => {
+ const status = failure.status ?? 500;
+ const defaults = defaultAtprotoFailure(endpoint, status);
+
+ return HttpResponse.json(
+ {
+ error: failure.error ?? defaults.error,
+ message: failure.message ?? defaults.message,
+ },
+ { status },
+ );
+};
+
+export const matchesFields =
+ (actual: Record) =>
+ (failure: Partial>): boolean =>
+ (Object.entries(actual) as [Fields, string][]).every(
+ ([field, value]) =>
+ failure[field] === undefined || failure[field] === value,
+ );
+
+export const createFailOnceController = ({
+ pds,
+}: {
+ pds: string;
+}): FailOnceController => {
+ const pendingFailures: {
+ [Endpoint in FailOnceEndpoint]: FailOnceConfigByEndpoint[Endpoint][];
+ } = {
+ listRecords: [],
+ getRecord: [],
+ getBlob: [],
+ createRecord: [],
+ putRecord: [],
+ deleteRecord: [],
+ };
+
+ const consume = (
+ endpoint: Endpoint,
+ matchesFailure: FailureMatcher,
+ ) => {
+ const failures = pendingFailures[endpoint];
+ const index = failures.findIndex(matchesFailure);
+
+ if (index === -1) {
+ return;
+ }
+
+ return failures.splice(index, 1)[0];
+ };
+
+ return {
+ clear() {
+ for (const failures of Object.values(pendingFailures)) {
+ failures.splice(0);
+ }
+ },
+ defineRoute({ endpoint, method, httpMethod, matches }) {
+ const matchedFailures = new WeakMap();
+
+ return defineXrpcRoute({
+ pds,
+ method,
+ httpMethod,
+ async matches(context) {
+ if (pendingFailures[endpoint].length === 0) {
+ return false;
+ }
+
+ const matchesFailure = await matches(context);
+ if (!matchesFailure) {
+ return false;
+ }
+
+ const failure = consume(endpoint, matchesFailure);
+ if (!failure) {
+ return false;
+ }
+
+ matchedFailures.set(context.request, failure);
+ return true;
+ },
+ resolve({ request }) {
+ return failOnceResponse(endpoint, matchedFailures.get(request) ?? {});
+ },
+ });
+ },
+ queue(endpoint, config = {}) {
+ pendingFailures[endpoint].push(config);
+ },
+ };
+};
diff --git a/msw-atproto/src/repo/handlers.ts b/msw-atproto/src/repo/handlers.ts
new file mode 100644
index 0000000..d131b68
--- /dev/null
+++ b/msw-atproto/src/repo/handlers.ts
@@ -0,0 +1,578 @@
+import { HttpResponse, type HttpHandler } from "msw";
+
+import type { createMutableRepoIdentity } from "../identity/mock.ts";
+import type { FailOnceController } from "./failures.ts";
+import { matchesFields } from "./failures.ts";
+import type { createRepoModel } from "./model.ts";
+import { defineXrpcRoute, readXrpcJsonBody } from "./xrpc.ts";
+
+type MutableRepoIdentity = ReturnType;
+type RepoModel = ReturnType;
+
+type WriteRecordBody = {
+ collection: string;
+ record: Record;
+ repo: string;
+ rkey?: string;
+};
+
+type DeleteRecordBody = {
+ collection: string;
+ repo: string;
+ rkey: string;
+};
+
+type ApplyWriteCreate = {
+ $type: "com.atproto.repo.applyWrites#create";
+ collection: string;
+ rkey?: string;
+ value: Record;
+};
+
+type ApplyWriteUpdate = {
+ $type: "com.atproto.repo.applyWrites#update";
+ collection: string;
+ rkey: string;
+ value: Record;
+};
+
+type ApplyWriteDelete = {
+ $type: "com.atproto.repo.applyWrites#delete";
+ collection: string;
+ rkey: string;
+};
+
+type ApplyWriteEntry = ApplyWriteCreate | ApplyWriteUpdate | ApplyWriteDelete;
+
+type ApplyWritesBody = {
+ repo: string;
+ writes: ApplyWriteEntry[];
+};
+
+const asString = (value: unknown): string | undefined =>
+ typeof value === "string" ? value : undefined;
+
+const asRecordObject = (value: unknown): Record | undefined =>
+ typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : undefined;
+
+const parseWriteRecordBody = ({
+ body,
+ requiresRkey,
+}: {
+ body: Record | undefined;
+ requiresRkey: boolean;
+}): WriteRecordBody | undefined => {
+ const repo = asString(body?.repo);
+ const collection = asString(body?.collection);
+ const rkey = asString(body?.rkey);
+ const record = asRecordObject(body?.record);
+
+ if (!repo || !collection || !record) return;
+ if (body?.rkey !== undefined && rkey === undefined) return;
+ if (requiresRkey && !rkey) return;
+
+ return {
+ repo,
+ collection,
+ record: { ...record },
+ ...(rkey !== undefined && { rkey }),
+ };
+};
+
+const parseDeleteRecordBody = (
+ body: Record | undefined,
+): DeleteRecordBody | undefined => {
+ const repo = asString(body?.repo);
+ const collection = asString(body?.collection);
+ const rkey = asString(body?.rkey);
+ if (!repo || !collection || !rkey) return;
+ return { repo, collection, rkey };
+};
+
+const APPLY_WRITES_TYPES = {
+ create: "com.atproto.repo.applyWrites#create",
+ update: "com.atproto.repo.applyWrites#update",
+ delete: "com.atproto.repo.applyWrites#delete",
+} as const;
+
+const parseApplyWriteEntry = (entry: unknown): ApplyWriteEntry | undefined => {
+ const obj = asRecordObject(entry);
+ if (!obj) return;
+
+ const type = asString(obj.$type);
+ const collection = asString(obj.collection);
+ if (!type || !collection) return;
+
+ const rkey = asString(obj.rkey);
+ const value = asRecordObject(obj.value);
+
+ if (type === APPLY_WRITES_TYPES.create) {
+ if (!value) return;
+ return {
+ $type: type,
+ collection,
+ value,
+ ...(rkey !== undefined && { rkey }),
+ };
+ }
+
+ if (type === APPLY_WRITES_TYPES.update) {
+ if (!rkey || !value) return;
+ return { $type: type, collection, rkey, value };
+ }
+
+ if (type === APPLY_WRITES_TYPES.delete) {
+ if (!rkey) return;
+ return { $type: type, collection, rkey };
+ }
+
+ return;
+};
+
+const parseApplyWritesBody = (
+ body: Record | undefined,
+): ApplyWritesBody | undefined => {
+ const repo = asString(body?.repo);
+ if (!repo || !Array.isArray(body?.writes)) return;
+
+ const writes: ApplyWriteEntry[] = [];
+ for (const raw of body.writes) {
+ const entry = parseApplyWriteEntry(raw);
+ if (!entry) return;
+ writes.push(entry);
+ }
+
+ return { repo, writes };
+};
+
+const readMatchingBody = async ({
+ model,
+ request,
+}: {
+ model: RepoModel;
+ request: Request;
+}) => {
+ const body = await readXrpcJsonBody(request);
+ return typeof body?.repo === "string" && model.hasRepo(body.repo)
+ ? body
+ : undefined;
+};
+
+const listRecordsHandler = ({
+ pds,
+ model,
+}: {
+ pds: string;
+ model: RepoModel;
+}): HttpHandler =>
+ defineXrpcRoute({
+ pds,
+ method: "com.atproto.repo.listRecords",
+ matches({ url }) {
+ const repo = url.searchParams.get("repo");
+ const collection = url.searchParams.get("collection");
+ return (
+ repo !== null &&
+ collection !== null &&
+ model.hasCollection({ repo, collection })
+ );
+ },
+ resolve({ url }) {
+ const repo = url.searchParams.get("repo")!;
+ const collection = url.searchParams.get("collection")!;
+ const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "", 10);
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 50;
+ const offset = Number.parseInt(url.searchParams.get("cursor") ?? "0", 10);
+ const records = model.recordsFor({ repo, collection }) ?? [];
+ const safeOffset = Number.isFinite(offset) && offset > 0 ? offset : 0;
+ const page = records.slice(safeOffset, safeOffset + limit);
+ const nextOffset = safeOffset + limit;
+
+ return HttpResponse.json({
+ records: page,
+ ...(nextOffset < records.length && { cursor: String(nextOffset) }),
+ });
+ },
+ });
+
+const getRecordHandler = ({
+ pds,
+ model,
+}: {
+ pds: string;
+ model: RepoModel;
+}): HttpHandler =>
+ defineXrpcRoute({
+ pds,
+ method: "com.atproto.repo.getRecord",
+ matches({ url }) {
+ const repo = url.searchParams.get("repo");
+ const collection = url.searchParams.get("collection");
+ const rkey = url.searchParams.get("rkey");
+ return (
+ repo !== null &&
+ collection !== null &&
+ rkey !== null &&
+ model.hasCollection({ repo, collection })
+ );
+ },
+ resolve({ url }) {
+ const record = model.findRecord({
+ repo: url.searchParams.get("repo")!,
+ collection: url.searchParams.get("collection")!,
+ rkey: url.searchParams.get("rkey")!,
+ });
+
+ return record
+ ? HttpResponse.json(record)
+ : HttpResponse.json(
+ { error: "RecordNotFound", message: "Record not found" },
+ { status: 404 },
+ );
+ },
+ });
+
+const getBlobHandler = ({
+ pds,
+ model,
+}: {
+ pds: string;
+ model: RepoModel;
+}): HttpHandler =>
+ defineXrpcRoute({
+ pds,
+ method: "com.atproto.sync.getBlob",
+ matches({ url }) {
+ const did = url.searchParams.get("did");
+ const cid = url.searchParams.get("cid");
+ if (!did || !cid) {
+ return false;
+ }
+
+ return model.hasBlob({ did, cid });
+ },
+ resolve({ url }) {
+ const blob = model.getBlob(url.searchParams.get("cid")!);
+ return blob
+ ? new HttpResponse(blob.body, {
+ headers: { "content-type": blob.contentType },
+ })
+ : HttpResponse.json(
+ { error: "NotFound", message: "Blob not found" },
+ { status: 404 },
+ );
+ },
+ });
+
+const writeRecordHandler = ({
+ pds,
+ model,
+ method,
+ action,
+ requiresRkey,
+}: {
+ pds: string;
+ model: RepoModel;
+ method: "com.atproto.repo.createRecord" | "com.atproto.repo.putRecord";
+ action: "create" | "put";
+ requiresRkey: boolean;
+}): HttpHandler =>
+ defineXrpcRoute({
+ pds,
+ method,
+ httpMethod: "post",
+ async matches(context) {
+ const body = await readXrpcJsonBody(context.request);
+ const write = parseWriteRecordBody({ body, requiresRkey });
+ context.parsed = write;
+ return write !== undefined && model.hasRepo(write.repo);
+ },
+ resolve({ parsed }) {
+ if (!parsed) {
+ throw new Error(`Matched ${method} request is missing a valid body`);
+ }
+
+ return HttpResponse.json(
+ model.writeRecord({
+ action,
+ uri: {
+ repo: parsed.repo,
+ collection: parsed.collection,
+ rkey: parsed.rkey,
+ },
+ record: parsed.record,
+ }),
+ );
+ },
+ });
+
+const deleteRecordHandler = ({
+ pds,
+ model,
+}: {
+ pds: string;
+ model: RepoModel;
+}): HttpHandler =>
+ defineXrpcRoute({
+ pds,
+ method: "com.atproto.repo.deleteRecord",
+ httpMethod: "post",
+ async matches(context) {
+ const body = await readXrpcJsonBody(context.request);
+ const deletion = parseDeleteRecordBody(body);
+
+ context.parsed = deletion;
+ return (
+ deletion !== undefined &&
+ model.hasCollection({
+ repo: deletion.repo,
+ collection: deletion.collection,
+ })
+ );
+ },
+ resolve({ parsed }) {
+ if (!parsed) {
+ throw new Error(
+ "Matched com.atproto.repo.deleteRecord request is missing a valid body",
+ );
+ }
+
+ model.deleteRecord(parsed);
+
+ return HttpResponse.json({});
+ },
+ });
+
+const applyWritesHandler = ({
+ pds,
+ model,
+}: {
+ pds: string;
+ model: RepoModel;
+}): HttpHandler =>
+ defineXrpcRoute({
+ pds,
+ method: "com.atproto.repo.applyWrites",
+ httpMethod: "post",
+ async matches(context) {
+ const body = await readXrpcJsonBody(context.request);
+ const parsed = parseApplyWritesBody(body);
+ context.parsed = parsed;
+ return parsed !== undefined && model.hasRepo(parsed.repo);
+ },
+ resolve({ parsed }) {
+ if (!parsed) {
+ throw new Error(
+ "Matched com.atproto.repo.applyWrites request is missing a valid body",
+ );
+ }
+
+ const results = parsed.writes.map((entry) => {
+ if (entry.$type === APPLY_WRITES_TYPES.delete) {
+ model.deleteRecord({
+ repo: parsed.repo,
+ collection: entry.collection,
+ rkey: entry.rkey,
+ });
+ return { $type: "com.atproto.repo.applyWrites#deleteResult" };
+ }
+
+ const action =
+ entry.$type === APPLY_WRITES_TYPES.create ? "create" : "put";
+ const { uri, cid } = model.writeRecord({
+ action,
+ uri: {
+ repo: parsed.repo,
+ collection: entry.collection,
+ ...(entry.rkey !== undefined && { rkey: entry.rkey }),
+ },
+ record: entry.value,
+ });
+
+ return {
+ $type:
+ action === "create"
+ ? "com.atproto.repo.applyWrites#createResult"
+ : "com.atproto.repo.applyWrites#updateResult",
+ uri,
+ cid,
+ };
+ });
+
+ return HttpResponse.json({ results });
+ },
+ });
+
+const uploadBlobHandler = ({
+ pds,
+ model,
+}: {
+ pds: string;
+ model: RepoModel;
+}): HttpHandler =>
+ defineXrpcRoute({
+ pds,
+ method: "com.atproto.repo.uploadBlob",
+ httpMethod: "post",
+ resolve: async ({ request }) => {
+ const contentType =
+ request.headers.get("content-type") ?? "application/octet-stream";
+ const buffer = new Uint8Array(await request.arrayBuffer());
+ const blob = model.addBlob({ body: buffer, contentType });
+
+ return HttpResponse.json({
+ blob: {
+ $type: "blob",
+ ref: { $link: blob.cid },
+ mimeType: contentType,
+ size: buffer.byteLength,
+ },
+ });
+ },
+ });
+
+const createFailureRoutes = ({
+ did,
+ model,
+ failures,
+}: {
+ did: string;
+ model: RepoModel;
+ failures: FailOnceController;
+}): HttpHandler[] => [
+ failures.defineRoute({
+ endpoint: "listRecords",
+ method: "com.atproto.repo.listRecords",
+ matches: ({ url }) => {
+ const repo = url.searchParams.get("repo");
+ const collection = url.searchParams.get("collection");
+
+ if (repo === null || collection === null || !model.hasRepo(repo)) {
+ return;
+ }
+
+ return matchesFields({ collection });
+ },
+ }),
+ failures.defineRoute({
+ endpoint: "getRecord",
+ method: "com.atproto.repo.getRecord",
+ matches: ({ url }) => {
+ const repo = url.searchParams.get("repo");
+ const collection = url.searchParams.get("collection");
+ const rkey = url.searchParams.get("rkey");
+
+ if (!repo || !model.hasRepo(repo)) {
+ return;
+ }
+
+ if (!collection || !rkey) {
+ return;
+ }
+
+ return matchesFields({ collection, rkey });
+ },
+ }),
+ failures.defineRoute({
+ endpoint: "getBlob",
+ method: "com.atproto.sync.getBlob",
+ matches: ({ url }) => {
+ const requestDid = url.searchParams.get("did");
+ const cid = url.searchParams.get("cid");
+
+ if (requestDid !== did || !cid) {
+ return;
+ }
+
+ return matchesFields({ cid });
+ },
+ }),
+ failures.defineRoute({
+ endpoint: "createRecord",
+ method: "com.atproto.repo.createRecord",
+ httpMethod: "post",
+ matches: async ({ request }) => {
+ const body = await readMatchingBody({ model, request });
+ const collection = body?.collection;
+
+ if (typeof collection !== "string") {
+ return;
+ }
+
+ return matchesFields({ collection });
+ },
+ }),
+ failures.defineRoute({
+ endpoint: "putRecord",
+ method: "com.atproto.repo.putRecord",
+ httpMethod: "post",
+ matches: async ({ request }) => {
+ const body = await readMatchingBody({ model, request });
+ const collection = body?.collection;
+ const rkey = body?.rkey;
+
+ if (typeof collection !== "string" || typeof rkey !== "string") {
+ return;
+ }
+
+ return matchesFields({ collection, rkey });
+ },
+ }),
+ failures.defineRoute({
+ endpoint: "deleteRecord",
+ method: "com.atproto.repo.deleteRecord",
+ httpMethod: "post",
+ matches: async ({ request }) => {
+ const body = await readMatchingBody({ model, request });
+ const collection = body?.collection;
+ const rkey = body?.rkey;
+
+ if (typeof collection !== "string" || typeof rkey !== "string") {
+ return;
+ }
+
+ return matchesFields({ collection, rkey });
+ },
+ }),
+];
+
+export const createRepoHandlers = ({
+ did,
+ identity,
+ model,
+ pds,
+ failures,
+}: {
+ did: string;
+ identity: MutableRepoIdentity;
+ model: RepoModel;
+ pds: string;
+ failures: FailOnceController;
+}): HttpHandler[] => [
+ ...identity.handlers(),
+ // Failure routes must precede success handlers: MSW matches first-to-last, and a
+ // success handler that responds will prevent the failure route from consuming a queued failOnce.
+ ...createFailureRoutes({ did, model, failures }),
+ listRecordsHandler({ pds, model }),
+ getRecordHandler({ pds, model }),
+ getBlobHandler({ pds, model }),
+ writeRecordHandler({
+ pds,
+ model,
+ method: "com.atproto.repo.createRecord",
+ action: "create",
+ requiresRkey: false,
+ }),
+ writeRecordHandler({
+ pds,
+ model,
+ method: "com.atproto.repo.putRecord",
+ action: "put",
+ requiresRkey: true,
+ }),
+ deleteRecordHandler({ pds, model }),
+ applyWritesHandler({ pds, model }),
+ uploadBlobHandler({ pds, model }),
+];
diff --git a/msw-atproto/src/repo/index.ts b/msw-atproto/src/repo/index.ts
new file mode 100644
index 0000000..bc136e5
--- /dev/null
+++ b/msw-atproto/src/repo/index.ts
@@ -0,0 +1,106 @@
+import type { HttpHandler } from "msw";
+
+import { createMutableRepoIdentity } from "../identity/mock.ts";
+import { createFailOnceController } from "./failures.ts";
+import { createRepoHandlers } from "./handlers.ts";
+import { createRepoModel } from "./model.ts";
+
+export type MockAtprotoRecord = {
+ rkey: string;
+ value: Record;
+ cid?: string;
+};
+
+export type MockAtprotoBlob = {
+ cid: string;
+ body?: BodyInit;
+ contentType?: string;
+};
+
+/**
+ * Options accepted by every `failOnce.*` method.
+ *
+ * Omit `status` for a `500 InternalServerError`. Pass `status` to pick the
+ * default ATproto error body for that HTTP status. For example, `401` becomes
+ * `AuthRequired`, `404` on a record becomes `RecordNotFound`, and `5xx`
+ * becomes `InternalServerError`.
+ *
+ * Pass `error` or `message` to override that body. Endpoint-specific fields
+ * such as `collection`, `rkey`, or `cid` narrow which request consumes the
+ * queued failure.
+ */
+export type RepoFailureOpts = {
+ status?: number;
+ error?: string;
+ message?: string;
+} & Partial>;
+
+export type MockAtprotoRepoConfig = {
+ did: string;
+ pds?: string;
+ handle?: string;
+ records?: Record;
+ blobs?: MockAtprotoBlob[];
+};
+
+/**
+ * Builds one stateful fake ATproto account for tests.
+ *
+ * Use this first when your test reads or writes records through a real client.
+ * The returned handlers cover identity resolution, all six record endpoints,
+ * and both blob endpoints. Writes update the same in-memory store that later
+ * reads use, so a `createRecord` followed by `listRecords` returns the new
+ * record.
+ */
+export const createMockAtprotoRepo = ({
+ did,
+ pds = "https://pds.fujocoded.test",
+ handle,
+ records = {},
+ blobs = [],
+}: MockAtprotoRepoConfig) => {
+ const identity = createMutableRepoIdentity({ did, pds, handle });
+ const model = createRepoModel({ did, handle, records, blobs });
+ const failures = createFailOnceController({ pds });
+
+ return {
+ did,
+ handle,
+ pds,
+ identity: identity.controls,
+
+ records: model.currentRecords,
+ writes: model.currentWrites,
+ deletes: model.currentDeletes,
+ seed: model.seed,
+ seedBlob: model.seedBlob,
+ handlers: () => createRepoHandlers({ did, identity, model, pds, failures }),
+ failOnce: {
+ listRecords: (config?: RepoFailureOpts<"collection">) =>
+ failures.queue("listRecords", config),
+ getRecord: (config?: RepoFailureOpts<"collection" | "rkey">) =>
+ failures.queue("getRecord", config),
+ getBlob: (config?: RepoFailureOpts<"cid">) =>
+ failures.queue("getBlob", config),
+ createRecord: (config?: RepoFailureOpts<"collection">) =>
+ failures.queue("createRecord", config),
+ putRecord: (config?: RepoFailureOpts<"collection" | "rkey">) =>
+ failures.queue("putRecord", config),
+ deleteRecord: (config?: RepoFailureOpts<"collection" | "rkey">) =>
+ failures.queue("deleteRecord", config),
+ },
+ clear() {
+ model.clear();
+ failures.clear();
+ },
+ };
+};
+
+export const useMockAtprotoRepo = (
+ server: { use(...handlers: HttpHandler[]): void },
+ config: MockAtprotoRepoConfig,
+): ReturnType => {
+ const repo = createMockAtprotoRepo(config);
+ server.use(...repo.handlers());
+ return repo;
+};
diff --git a/msw-atproto/src/repo/model.ts b/msw-atproto/src/repo/model.ts
new file mode 100644
index 0000000..aee2a1c
--- /dev/null
+++ b/msw-atproto/src/repo/model.ts
@@ -0,0 +1,338 @@
+import { AtUri } from "@atproto/syntax";
+
+import { cidForRecord, fakeCid } from "../cid.ts";
+import type { MockAtprotoBlob, MockAtprotoRecord } from "./index.ts";
+
+type RepoWrite = {
+ action: "create" | "put";
+ uri: string;
+ cid: string;
+ record: Record;
+};
+
+type RepoDelete = {
+ uri: string;
+};
+
+type StoredAtprotoRecord = {
+ uri: AtUri;
+ cid: string;
+ value: Record;
+ createdAtOrdinal: number;
+};
+
+type RecordUriParts = {
+ repo: string;
+ collection: string;
+ rkey?: string;
+};
+
+type RecordUriInput = string | AtUri | RecordUriParts;
+
+const GENERATED_RKEY_PREFIX = "3kgenerated";
+
+const byCreatedAtOrdinal = (
+ left: StoredAtprotoRecord,
+ right: StoredAtprotoRecord,
+) => left.createdAtOrdinal - right.createdAtOrdinal;
+
+const toAtUri = (input: RecordUriInput): AtUri =>
+ input instanceof AtUri
+ ? input
+ : typeof input === "string"
+ ? new AtUri(input)
+ : AtUri.make(input.repo, input.collection, input.rkey);
+
+const createBlobStore = ({ blobs = [] }: { blobs?: MockAtprotoBlob[] }) => {
+ const blobStore = new Map();
+
+ const seedBlob = ({
+ cid,
+ body = new Uint8Array(),
+ contentType = "application/octet-stream",
+ }: MockAtprotoBlob) => {
+ blobStore.set(cid, { body, contentType });
+ };
+
+ for (const blob of blobs) {
+ seedBlob(blob);
+ }
+
+ return {
+ clear: () => blobStore.clear(),
+ getBlob: (cid: string) => blobStore.get(cid),
+ hasBlob: ({
+ did,
+ expectedDid,
+ cid,
+ }: {
+ did: string;
+ expectedDid: string;
+ cid: string;
+ }) => did === expectedDid && blobStore.has(cid),
+ seedBlob,
+ };
+};
+
+const createRecordStore = ({ did }: { did: string }) => {
+ const records = new Map();
+ const declaredCollections = new Set();
+ let ordinal = 0;
+ let generatedRkey = 0;
+
+ const writeRecord = ({
+ uri,
+ value,
+ cid,
+ }: {
+ uri: AtUri;
+ value: Record;
+ cid?: string;
+ }) => {
+ const key = uri.toString();
+ const existing = records.get(key);
+ declaredCollections.add(uri.collection);
+ const data: StoredAtprotoRecord = {
+ uri,
+ cid:
+ cid ??
+ cidForRecord({
+ repo: uri.host,
+ collection: uri.collection,
+ rkey: uri.rkey,
+ value,
+ }),
+ value,
+ createdAtOrdinal: existing?.createdAtOrdinal ?? ordinal++,
+ };
+ records.set(key, data);
+ return data;
+ };
+
+ return {
+ clear() {
+ records.clear();
+ declaredCollections.clear();
+ ordinal = 0;
+ generatedRkey = 0;
+ },
+ delete: (uri: AtUri) => {
+ records.delete(uri.toString());
+ return uri;
+ },
+ find: (uri: AtUri) => {
+ if (!declaredCollections.has(uri.collection)) return;
+ return records.get(uri.toString());
+ },
+ hasCollection: (collection: string) => declaredCollections.has(collection),
+ listAll: () => Array.from(records.values()).sort(byCreatedAtOrdinal),
+ listIn: (collection: string) => {
+ if (!declaredCollections.has(collection)) return null;
+ return Array.from(records.values())
+ .filter((record) => record.uri.collection === collection)
+ .sort(byCreatedAtOrdinal);
+ },
+ nextGeneratedRkey: () => `${GENERATED_RKEY_PREFIX}${++generatedRkey}`,
+ seed: (collection: string, entries: MockAtprotoRecord[]) => {
+ declaredCollections.add(collection);
+ for (const entry of entries) {
+ writeRecord({
+ uri: AtUri.make(did, collection, entry.rkey),
+ value: entry.value,
+ cid: entry.cid,
+ });
+ }
+ },
+ writeRecord,
+ };
+};
+
+type RecordStore = ReturnType;
+type BlobStore = ReturnType;
+
+const createLifecycleApi = ({
+ recordStore,
+ blobStore,
+ writes,
+ deletes,
+}: {
+ recordStore: RecordStore;
+ blobStore: BlobStore;
+ writes: RepoWrite[];
+ deletes: RepoDelete[];
+}) => ({
+ clear() {
+ recordStore.clear();
+ blobStore.clear();
+ writes.splice(0);
+ deletes.splice(0);
+ },
+ currentRecords: () =>
+ recordStore.listAll().map((record) => ({
+ collection: record.uri.collection,
+ rkey: record.uri.rkey,
+ uri: record.uri.toString(),
+ cid: record.cid,
+ value: record.value,
+ })),
+ currentWrites: () => writes.map((write) => ({ ...write })),
+ currentDeletes: () => deletes.map((deleted) => ({ ...deleted })),
+});
+
+const createRecordApi = ({
+ did,
+ recordStore,
+ writes,
+ deletes,
+ handle,
+}: {
+ did: string;
+ recordStore: RecordStore;
+ writes: RepoWrite[];
+ deletes: RepoDelete[];
+ handle?: string;
+}) => {
+ const hasRepo = (repo: string) => repo === did || repo === handle;
+
+ const normalizeRecordUri = (
+ input: RecordUriInput,
+ { generateRkey = false }: { generateRkey?: boolean } = {},
+ ): AtUri | undefined => {
+ try {
+ const uri = toAtUri(input);
+ if (!hasRepo(uri.host) || !uri.collection) return;
+ const rkey =
+ uri.rkey || (generateRkey ? recordStore.nextGeneratedRkey() : "");
+ if (!rkey) return;
+ return AtUri.make(did, uri.collection, rkey);
+ } catch {
+ return;
+ }
+ };
+
+ return {
+ hasRepo,
+ hasCollection: ({
+ repo,
+ collection,
+ }: {
+ repo: string;
+ collection: string;
+ }) => hasRepo(repo) && recordStore.hasCollection(collection),
+ nextGeneratedRkey: recordStore.nextGeneratedRkey,
+ seed: recordStore.seed,
+ findRecord(uri: RecordUriInput) {
+ const recordUri = normalizeRecordUri(uri);
+ if (!recordUri) return;
+ const record = recordStore.find(recordUri);
+ return record
+ ? {
+ uri: record.uri.toString(),
+ cid: record.cid,
+ value: record.value,
+ }
+ : undefined;
+ },
+ recordsFor({ repo, collection }: { repo: string; collection: string }) {
+ if (!hasRepo(repo)) return null;
+ const list = recordStore.listIn(collection);
+ return list
+ ? list.map((record) => ({
+ uri: record.uri.toString(),
+ cid: record.cid,
+ value: record.value,
+ }))
+ : null;
+ },
+ writeRecord({
+ action,
+ uri,
+ record,
+ }: {
+ action: RepoWrite["action"];
+ uri: RecordUriInput;
+ record: Record;
+ }) {
+ const recordUri = normalizeRecordUri(uri, { generateRkey: true });
+ if (!recordUri) {
+ throw new Error("Cannot write a record for an unknown repo or URI");
+ }
+ const stored = recordStore.writeRecord({ uri: recordUri, value: record });
+ const uriString = stored.uri.toString();
+ writes.push({ action, uri: uriString, cid: stored.cid, record });
+ return { uri: uriString, cid: stored.cid };
+ },
+ deleteRecord(input: RecordUriInput) {
+ const recordUri = normalizeRecordUri(input);
+ if (!recordUri) return;
+ const uri = recordStore.delete(recordUri);
+ deletes.push({ uri: uri.toString() });
+ },
+ };
+};
+
+const bodyToString = (body: BodyInit): string => {
+ if (typeof body === "string") return body;
+ if (body instanceof Uint8Array) return Buffer.from(body).toString("base64");
+ if (body instanceof ArrayBuffer)
+ return Buffer.from(new Uint8Array(body)).toString("base64");
+ if (ArrayBuffer.isView(body))
+ return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString(
+ "base64",
+ );
+ if (body instanceof URLSearchParams) return body.toString();
+ throw new Error(
+ "Cannot derive a stable blob CID from a Blob, FormData, or stream body. Pass an explicit `cid` when seeding this blob.",
+ );
+};
+
+const createBlobApi = ({
+ did,
+ blobStore,
+}: {
+ did: string;
+ blobStore: BlobStore;
+}) => ({
+ getBlob: blobStore.getBlob,
+ hasBlob: ({ did: requestDid, cid }: { did: string; cid: string }) =>
+ blobStore.hasBlob({ did: requestDid, expectedDid: did, cid }),
+ seedBlob: blobStore.seedBlob,
+ addBlob: ({
+ body,
+ contentType = "application/octet-stream",
+ }: {
+ body: BodyInit;
+ contentType?: string;
+ }) => {
+ const cid = fakeCid(`blob:${contentType}:${bodyToString(body)}`);
+ blobStore.seedBlob({ cid, body, contentType });
+ return { cid };
+ },
+});
+
+export const createRepoModel = ({
+ did,
+ handle,
+ records = {},
+ blobs = [],
+}: {
+ did: string;
+ handle?: string;
+ records?: Record;
+ blobs?: MockAtprotoBlob[];
+}) => {
+ const recordStore = createRecordStore({ did });
+ const blobStore = createBlobStore({ blobs });
+ const writes: RepoWrite[] = [];
+ const deletes: RepoDelete[] = [];
+
+ for (const [collection, seedRecords] of Object.entries(records)) {
+ recordStore.seed(collection, seedRecords);
+ }
+
+ return {
+ ...createLifecycleApi({ recordStore, blobStore, writes, deletes }),
+ ...createRecordApi({ did, handle, recordStore, writes, deletes }),
+ ...createBlobApi({ did, blobStore }),
+ };
+};
diff --git a/msw-atproto/src/repo/xrpc.ts b/msw-atproto/src/repo/xrpc.ts
new file mode 100644
index 0000000..88576b7
--- /dev/null
+++ b/msw-atproto/src/repo/xrpc.ts
@@ -0,0 +1,73 @@
+import { http, type HttpHandler } from "msw";
+
+export type XrpcRouteContext = {
+ request: Request;
+ url: URL;
+ parsed?: Parsed;
+};
+
+/**
+ * Defines one XRPC MSW route on a PDS.
+ *
+ * The handler first checks origin and path, then lets the route-specific
+ * matcher inspect query params or JSON body. Returning `false` from the matcher
+ * lets another handler for the same PDS and endpoint answer instead.
+ */
+export const defineXrpcRoute = ({
+ pds,
+ method,
+ httpMethod = "get",
+ matches,
+ resolve,
+}: {
+ pds: string;
+ method: string;
+ httpMethod?: "get" | "post";
+ matches?: (context: XrpcRouteContext) => boolean | Promise;
+ resolve: (context: XrpcRouteContext) => Response | Promise;
+}): HttpHandler => {
+ const matchedContexts = new WeakMap>();
+
+ return http[httpMethod](
+ async ({ request }) => {
+ const url = new URL(request.url);
+ const endpoint = new URL(`/xrpc/${method}`, pds);
+
+ if (
+ url.origin !== endpoint.origin ||
+ url.pathname !== endpoint.pathname
+ ) {
+ return false;
+ }
+
+ const context = { request, url };
+ const didMatch = await (matches?.(context) ?? true);
+ if (didMatch) {
+ matchedContexts.set(request, context);
+ }
+
+ return didMatch;
+ },
+ ({ request }) => {
+ const context = matchedContexts.get(request);
+ if (!context) {
+ throw new Error(`Missing matched XRPC context for ${method}`);
+ }
+
+ return resolve(context);
+ },
+ );
+};
+
+export const readXrpcJsonBody = async (
+ request: Request,
+): Promise | undefined> => {
+ try {
+ const body = await request.clone().json();
+ return typeof body === "object" && body !== null && !Array.isArray(body)
+ ? (body as Record)
+ : undefined;
+ } catch {
+ return;
+ }
+};
diff --git a/msw-atproto/tsconfig.json b/msw-atproto/tsconfig.json
new file mode 100644
index 0000000..085d28e
--- /dev/null
+++ b/msw-atproto/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "target": "es2022",
+ "lib": ["es2022", "DOM"],
+ "allowJs": true,
+ "resolveJsonModule": true,
+ "moduleDetection": "force",
+ "isolatedModules": true,
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "moduleResolution": "NodeNext",
+ "module": "NodeNext",
+ "outDir": "dist",
+ "sourceMap": true,
+ "declaration": true,
+ "allowImportingTsExtensions": true,
+ "noEmit": true
+ }
+}
diff --git a/msw-atproto/tsdown.config.ts b/msw-atproto/tsdown.config.ts
new file mode 100644
index 0000000..4031f72
--- /dev/null
+++ b/msw-atproto/tsdown.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from "tsdown";
+
+export default defineConfig([
+ {
+ name: "msw-atproto",
+ dts: true,
+ clean: true,
+ unbundle: true,
+ entry: ["src/index.ts"],
+ },
+]);
diff --git a/msw-atproto/vitest.config.ts b/msw-atproto/vitest.config.ts
new file mode 100644
index 0000000..41c5072
--- /dev/null
+++ b/msw-atproto/vitest.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: "tests",
+ include: ["__tests__/**/*.test.ts"],
+ setupFiles: ["./__tests__/setup.ts"],
+ },
+ },
+ {
+ test: {
+ name: "examples",
+ include: ["__examples__/**/*.test.ts"],
+ setupFiles: ["./__examples__/setup.ts"],
+ },
+ },
+ ],
+ },
+});
diff --git a/package-lock.json b/package-lock.json
index d75d05d..a7e8997 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3080,6 +3080,10 @@
"resolved": "expressive-code-output",
"link": true
},
+ "node_modules/@fujocoded/msw-atproto": {
+ "resolved": "msw-atproto",
+ "link": true
+ },
"node_modules/@fujocoded/rehype-code-params": {
"resolved": "rehype-code-params",
"link": true
@@ -19781,6 +19785,32 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "msw-atproto": {
+ "name": "@fujocoded/msw-atproto",
+ "version": "0.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/common-web": "^0.4.7",
+ "@atproto/crypto": "^0.4.5",
+ "@atproto/syntax": "^0.4.2",
+ "multiformats": "^13.4.2"
+ },
+ "devDependencies": {
+ "msw": "^2.13.4",
+ "tsdown": "^0.14.2",
+ "vitest": "^3.2.4"
+ },
+ "peerDependencies": {
+ "msw": "^2.0.0",
+ "vitest": "^3.0.0"
+ }
+ },
+ "msw-atproto/node_modules/multiformats": {
+ "version": "13.4.2",
+ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz",
+ "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==",
+ "license": "Apache-2.0 OR MIT"
+ },
"rehype-code-params": {
"name": "@fujocoded/rehype-code-params",
"version": "0.0.5",