Pds: invite codes are only presence-checked — no store, validation, single-use semantics, or admin endpoints #36

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

With PdsOptions.OpenRegistration = false, PdsService.CreateAccountAsync only checks that the invite code is non-empty (PdsService.cs:52-53) — any string passes:

if (!_options.OpenRegistration && string.IsNullOrEmpty(inviteCode))
    throw new PdsException("InvalidInviteCode", "An invite code is required to create an account.");

So "invite codes are required" is not actually enforceable with the package alone. There is no storage, validation, consumption (single-use semantics), or admin surface for codes.

While building Updraft's PDS container I had to implement all of this app-side:

  • An InviteCodes table + service with atomic claim/confirm/release (single-UPDATE claim so concurrent signups can't double-spend a code)
  • Terminal middleware that fully shadows POST /xrpc/com.atproto.server.createAccount (because the SDK's mapped endpoint would accept any non-empty string)
  • Admin endpoints: com.atproto.server.createInviteCode, com.atproto.admin.getInviteCodes, com.atproto.admin.disableInviteCodes with Basic admin auth

Suggestion: an IInviteCodeStore abstraction (with an in-memory default) that CreateAccountAsync consults + consumes when OpenRegistration == false, plus optional mapping of the standard invite admin XRPC endpoints (they exist in the com.atproto lexicons) guarded by a configurable admin credential. Happy to share our implementation as a starting point: https://git.grandiras.net/Grandiras/Updraft (src/Updraft.Pds).

With `PdsOptions.OpenRegistration = false`, `PdsService.CreateAccountAsync` only checks that the invite code is **non-empty** (PdsService.cs:52-53) — any string passes: ```csharp if (!_options.OpenRegistration && string.IsNullOrEmpty(inviteCode)) throw new PdsException("InvalidInviteCode", "An invite code is required to create an account."); ``` So "invite codes are required" is not actually enforceable with the package alone. There is no storage, validation, consumption (single-use semantics), or admin surface for codes. While building Updraft's PDS container I had to implement all of this app-side: - An `InviteCodes` table + service with atomic claim/confirm/release (single-UPDATE claim so concurrent signups can't double-spend a code) - Terminal middleware that fully shadows `POST /xrpc/com.atproto.server.createAccount` (because the SDK's mapped endpoint would accept any non-empty string) - Admin endpoints: `com.atproto.server.createInviteCode`, `com.atproto.admin.getInviteCodes`, `com.atproto.admin.disableInviteCodes` with Basic admin auth Suggestion: an `IInviteCodeStore` abstraction (with an in-memory default) that `CreateAccountAsync` consults + consumes when `OpenRegistration == false`, plus optional mapping of the standard invite admin XRPC endpoints (they exist in the com.atproto lexicons) guarded by a configurable admin credential. Happy to share our implementation as a starting point: https://git.grandiras.net/Grandiras/Updraft (src/Updraft.Pds).
Collaborator

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

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/53); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-36/run-53.md) — updating every ~5 minutes while the task runs.
Collaborator

Implemented invite codes as a real feature in ATProtoNet.Pds. Branch: claude/issue-36.

What changed

IInviteCodeStore (new) — the storage seam, with PdsInviteCode / PdsInviteCodeUse. Redemption is the three-step protocol you described:

  1. TryClaimAsync atomically reserves one use — false if the code is unknown, disabled, or exhausted
  2. ConfirmClaimAsync records the redeeming DID once the account exists
  3. ReleaseClaimAsync returns the reservation if creation fails afterwards (taken handle, store error), leaving the code usable

InMemoryInviteCodeStore is the default (development-grade — codes are lost on restart). For production, AddAtProtoPds<TAccountStore, TRepoStore, TInviteCodeStore>() registers your own; the docs include the single-UPDATE SQL sketch so TryClaimAsync stays atomic.

PdsService.CreateAccountAsync now claims → creates → confirms when OpenRegistration == false. Plus CreateInviteCodeAsync, CreateInviteCodesAsync, GetInviteCodesAsync, GetAccountInviteCodesAsync, DisableInviteCodesAsync. Generated codes follow the reference PDS shape (my-pds-example-com-a2b3c-d4e5f), from RandomNumberGenerator.

Five new mapped endpoints, all in PdsEndpointNames so Exclude/Only/Configure work on them: com.atproto.server.createInviteCode, createInviteCodes, getAccountInviteCodes (Bearer, caller's own codes), com.atproto.admin.getInviteCodes (sort=recent|usage, limit, cursor), com.atproto.admin.disableInviteCodes (by code and/or account).

Admin authPdsOptions.AdminPassword / AdminUsername (default admin), HTTP Basic, fixed-time compare. It fails closed: unconfigured means every admin request gets 401, not open access.

Two decisions worth flagging

  • PdsService gained a fifth constructor parameter (IInviteCodeStore? = null) — source-compatible, binary-breaking. Filed under Breaking changes in the CHANGELOG.
  • The behaviour change is itself breaking for closed-registration deployments: an arbitrary code no longer works, so operators must issue codes before closed registration will accept anyone. When no store is supplied at all, the service uses a private empty store and rejects everything rather than reverting to the presence check — fail closed. OpenRegistration = true is unaffected.

Updraft can now drop the shadowing middleware and the app-side table; if you'd rather keep your own admin surface, Exclude the four admin endpoints and delegate to PdsService.

Verified

Installed the .NET 10 SDK in the container (none was present). dotnet build across the solution: 0 errors, no new warnings from ATProtoNet.Pds. dotnet test tests/ATProtoNet.Tests/: 1092 passed, 0 failed, including 60 new tests — single/multi-use redemption, disabled and unknown codes, release-on-failure, 20 concurrent sign-ups against a 3-use code redeeming exactly 3 times, admin auth rejection paths (no credential, wrong password, wrong username, Bearer-instead-of-Basic, malformed base64, unconfigured password), and admin listing/paging/disable over HTTP.

CHANGELOG.md updated under Added, Breaking changes, and Security; docs/pds.md gains an "Invite Codes" section.

→ Opened PR: #64


⏱ 11m 52s · 65 turns · 📜 Full session transcript

(reply recovered from the transcript — the posting step in runs before v12.3 was broken by a shell-quoting bug)

Implemented invite codes as a real feature in `ATProtoNet.Pds`. Branch: **`claude/issue-36`**. ## What changed **`IInviteCodeStore`** (new) — the storage seam, with `PdsInviteCode` / `PdsInviteCodeUse`. Redemption is the three-step protocol you described: 1. `TryClaimAsync` atomically reserves one use — `false` if the code is unknown, disabled, or exhausted 2. `ConfirmClaimAsync` records the redeeming DID once the account exists 3. `ReleaseClaimAsync` returns the reservation if creation fails afterwards (taken handle, store error), leaving the code usable `InMemoryInviteCodeStore` is the default (development-grade — codes are lost on restart). For production, `AddAtProtoPds<TAccountStore, TRepoStore, TInviteCodeStore>()` registers your own; the docs include the single-`UPDATE` SQL sketch so `TryClaimAsync` stays atomic. **`PdsService.CreateAccountAsync`** now claims → creates → confirms when `OpenRegistration == false`. Plus `CreateInviteCodeAsync`, `CreateInviteCodesAsync`, `GetInviteCodesAsync`, `GetAccountInviteCodesAsync`, `DisableInviteCodesAsync`. Generated codes follow the reference PDS shape (`my-pds-example-com-a2b3c-d4e5f`), from `RandomNumberGenerator`. **Five new mapped endpoints**, all in `PdsEndpointNames` so `Exclude`/`Only`/`Configure` work on them: `com.atproto.server.createInviteCode`, `createInviteCodes`, `getAccountInviteCodes` (Bearer, caller's own codes), `com.atproto.admin.getInviteCodes` (`sort=recent|usage`, `limit`, `cursor`), `com.atproto.admin.disableInviteCodes` (by code and/or account). **Admin auth** — `PdsOptions.AdminPassword` / `AdminUsername` (default `admin`), HTTP Basic, fixed-time compare. It **fails closed**: unconfigured means every admin request gets `401`, not open access. ## Two decisions worth flagging - **`PdsService` gained a fifth constructor parameter** (`IInviteCodeStore? = null`) — source-compatible, binary-breaking. Filed under `Breaking changes` in the CHANGELOG. - **The behaviour change is itself breaking for closed-registration deployments**: an arbitrary code no longer works, so operators must issue codes before closed registration will accept anyone. When no store is supplied at all, the service uses a private empty store and rejects everything rather than reverting to the presence check — fail closed. `OpenRegistration = true` is unaffected. Updraft can now drop the shadowing middleware and the app-side table; if you'd rather keep your own admin surface, `Exclude` the four admin endpoints and delegate to `PdsService`. ## Verified Installed the .NET 10 SDK in the container (none was present). `dotnet build` across the solution: 0 errors, no new warnings from `ATProtoNet.Pds`. `dotnet test tests/ATProtoNet.Tests/`: **1092 passed, 0 failed**, including 60 new tests — single/multi-use redemption, disabled and unknown codes, release-on-failure, 20 concurrent sign-ups against a 3-use code redeeming exactly 3 times, admin auth rejection paths (no credential, wrong password, wrong username, Bearer-instead-of-Basic, malformed base64, unconfigured password), and admin listing/paging/disable over HTTP. `CHANGELOG.md` updated under `Added`, `Breaking changes`, and `Security`; `docs/pds.md` gains an "Invite Codes" section. → Opened PR: https://git.grandiras.net/Grandiras/ATProto.NET/pulls/64 --- ⏱ 11m 52s · 65 turns · 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-36/run-53.md) _(reply recovered from the transcript — the posting step in runs before v12.3 was broken by a shell-quoting bug)_
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#36
No description provided.