Pds: MapAtProtoPds cannot exclude or override individual XRPC endpoints #39

Closed
opened 2026-06-10 22:46:51 +00:00 by Grandiras · 2 comments
Owner

MapAtProtoPds() maps all PDS XRPC endpoints unconditionally. There is no way to exclude, replace, or wrap a single endpoint — attempting to map the same route yourself produces an ambiguous-match conflict.

Concrete need: Updraft must own com.atproto.server.createAccount to enforce real invite-code validation (see the invite-codes issue). The only way today is terminal middleware registered before MapAtProtoPds() that matches the path manually and never calls next() — workable, but it bypasses endpoint routing (no endpoint metadata, no route-level auth policies, easy to get subtly wrong).

Suggestion, either of:

  • MapAtProtoPds(options => options.Exclude("com.atproto.server.createAccount")) so consumers can map their own implementation, or
  • first-class hooks on PdsService (e.g. an IAccountCreationGuard invoked inside CreateAccountAsync) so the default endpoint stays but policy is pluggable.

The hook approach composes better with the invite-store suggestion; the exclude approach is more general.

`MapAtProtoPds()` maps all PDS XRPC endpoints unconditionally. There is no way to exclude, replace, or wrap a single endpoint — attempting to map the same route yourself produces an ambiguous-match conflict. Concrete need: Updraft must own `com.atproto.server.createAccount` to enforce real invite-code validation (see the invite-codes issue). The only way today is terminal middleware registered before `MapAtProtoPds()` that matches the path manually and never calls `next()` — workable, but it bypasses endpoint routing (no endpoint metadata, no route-level auth policies, easy to get subtly wrong). Suggestion, either of: - `MapAtProtoPds(options => options.Exclude("com.atproto.server.createAccount"))` so consumers can map their own implementation, or - first-class hooks on `PdsService` (e.g. an `IAccountCreationGuard` invoked inside `CreateAccountAsync`) so the default endpoint stays but policy is pluggable. The hook approach composes better with the invite-store suggestion; the exclude approach is more general.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/44); the reply will link the full session transcript.
Collaborator

Implemented the exclude/override option (the more general of the two suggestions) on branch claude/issue-39.

What changed

New overload MapAtProtoPds(Action<PdsEndpointOptions>) — the parameterless MapAtProtoPds() is untouched and still maps everything, so this is source- and binary-compatible.

app.MapAtProtoPds(options => options.Exclude(PdsEndpointNames.CreateAccount));

// Your handler now owns the route — a real endpoint with routing metadata
// and route-level auth, not terminal middleware.
app.MapPost("/xrpc/com.atproto.server.createAccount", MyCreateAccount);

PdsEndpointOptions surface:

  • Exclude(params string[] nsids) — skips mapping those endpoints, so mapping your own on the same path produces no ambiguous-match conflict.
  • Only(params string[] nsids) — maps just the listed subset (e.g. a read-only mirror). Exclude wins over Only.
  • Configure(nsid, builder => …) / ConfigureAll((nsid, builder) => …) — apply route conventions to the endpoints that are mapped: authorization policies, endpoint filters, rate limiting, metadata. This covers the "no route-level auth policies" half of the complaint without excluding anything.
  • IsMapped(nsid) — query the resolved configuration.

PdsEndpointNames holds constants for all twelve NSIDs plus All. Every NSID passed to the options is validated against that list and throws ArgumentException at startup — a typo like createAcount fails loudly instead of silently leaving the built-in endpoint mapped. Mapped endpoints also now carry their NSID as the endpoint display name (previously the generated HTTP: POST /xrpc/…), so they're identifiable in logs and diagnostics.

On the hook alternative

I did not add IAccountCreationGuard. The exclude approach unblocks the concrete Updraft need on its own, and a PdsService policy hook is better designed together with the invite-store issue it composes with — adding a half-shaped hook now would likely need reworking there. Worth noting: with the exclude route, a custom createAccount handler still has to call PdsService.CreateAccountAsync itself for the non-invite part, which it can (the service is a public singleton).

Verification

  • dotnet build on the full solution: 0 errors, no new warnings in ATProtoNet.Pds.
  • dotnet test tests/ATProtoNet.Tests/: 1029 passed, 0 failed — including 17 new tests in PdsEndpointOptionsTests, which spin up a TestServer and assert that an excluded endpoint 404s, that a host-mapped replacement on the same route actually serves the request (the ambiguous-match case), Only/Exclude precedence, and that Configure/ConfigureAll metadata plus the NSID display name reach the matched endpoint at request time.

Docs updated: new "Excluding or Overriding Individual Endpoints" section in docs/pds.md, a cross-reference in docs/xrpc-handlers.md, and CHANGELOG.md under [Unreleased] (Added + Changed).


⏱ 7m 11s · 53 turns · tokens: 87.3k in / 29.1k out (+3818.3k cache-read)
📜 Full session transcript
→ Opened PR: #59

Implemented the **exclude/override** option (the more general of the two suggestions) on branch **`claude/issue-39`**. ## What changed New overload `MapAtProtoPds(Action<PdsEndpointOptions>)` — the parameterless `MapAtProtoPds()` is untouched and still maps everything, so this is source- and binary-compatible. ```csharp app.MapAtProtoPds(options => options.Exclude(PdsEndpointNames.CreateAccount)); // Your handler now owns the route — a real endpoint with routing metadata // and route-level auth, not terminal middleware. app.MapPost("/xrpc/com.atproto.server.createAccount", MyCreateAccount); ``` `PdsEndpointOptions` surface: - **`Exclude(params string[] nsids)`** — skips mapping those endpoints, so mapping your own on the same path produces no ambiguous-match conflict. - **`Only(params string[] nsids)`** — maps just the listed subset (e.g. a read-only mirror). `Exclude` wins over `Only`. - **`Configure(nsid, builder => …)`** / **`ConfigureAll((nsid, builder) => …)`** — apply route conventions to the endpoints that *are* mapped: authorization policies, endpoint filters, rate limiting, metadata. This covers the "no route-level auth policies" half of the complaint without excluding anything. - **`IsMapped(nsid)`** — query the resolved configuration. `PdsEndpointNames` holds constants for all twelve NSIDs plus `All`. Every NSID passed to the options is validated against that list and throws `ArgumentException` at startup — a typo like `createAcount` fails loudly instead of silently leaving the built-in endpoint mapped. Mapped endpoints also now carry their NSID as the endpoint display name (previously the generated `HTTP: POST /xrpc/…`), so they're identifiable in logs and diagnostics. ## On the hook alternative I did **not** add `IAccountCreationGuard`. The exclude approach unblocks the concrete Updraft need on its own, and a `PdsService` policy hook is better designed together with the invite-store issue it composes with — adding a half-shaped hook now would likely need reworking there. Worth noting: with the exclude route, a custom `createAccount` handler still has to call `PdsService.CreateAccountAsync` itself for the non-invite part, which it can (the service is a public singleton). ## Verification - `dotnet build` on the full solution: 0 errors, no new warnings in `ATProtoNet.Pds`. - `dotnet test tests/ATProtoNet.Tests/`: **1029 passed, 0 failed** — including 17 new tests in `PdsEndpointOptionsTests`, which spin up a `TestServer` and assert that an excluded endpoint 404s, that a host-mapped replacement on the same route actually serves the request (the ambiguous-match case), `Only`/`Exclude` precedence, and that `Configure`/`ConfigureAll` metadata plus the NSID display name reach the matched endpoint at request time. Docs updated: new "Excluding or Overriding Individual Endpoints" section in `docs/pds.md`, a cross-reference in `docs/xrpc-handlers.md`, and `CHANGELOG.md` under `[Unreleased]` (`Added` + `Changed`). --- ⏱ 7m 11s · 53 turns · tokens: 87.3k in / 29.1k out (+3818.3k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-39/run-44.md) → Opened PR: https://git.grandiras.net/Grandiras/ATProto.NET/pulls/59
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/ATProto.NET#39
No description provided.