Native Invite Codes: Gated Account Creation

Account creation on self.surf is controlled by the PDS's native invite code system. Your backend silently mints a single-use code, uses it to create the account, then sets up the profile — the user never sees or types a code.

Architecture

User → your app (backend) → createInviteCode (admin auth) → self.surf PDS
                      → createAccount (with invite code) → self.surf PDS
                      → putRecord (create profile) → self.surf PDS

Internet → Cloudflare Tunnel → pds:3000       (AT Protocol paths)
                             → self-surf.pages.dev  (everything else, via catch-all)

Backend Flow (Three Steps)

The PDS requires invite codes by default (PDS_INVITE_REQUIRED=true). Your backend silently mints a single-use invite code then immediately uses it. The user never sees or types a code.

const PDS_URL = process.env.PDS_URL; // https://self.surf
const PDS_ADMIN_PASSWORD = process.env.PDS_ADMIN_PASSWORD; // store as a secret

// Step 1: Mint a single-use invite code
const inviteRes = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Basic ${Buffer.from(`admin:${PDS_ADMIN_PASSWORD}`).toString('base64')}`,
  },
  body: JSON.stringify({ useCount: 1 }),
});
const { code } = await inviteRes.json(); // e.g. "self-surf-xxxx-xxxx-xxxx"

// Step 2: Create the account using the invite code
const createRes = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createAccount`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email, handle, password, inviteCode: code }),
});
const account = await createRes.json(); // { did, handle, accessJwt, refreshJwt }

// Step 3: Create an empty profile record (prevents "Profile not found" errors)
await fetch(`${PDS_URL}/xrpc/com.atproto.repo.putRecord`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${account.accessJwt}`,
  },
  body: JSON.stringify({
    repo: account.did,
    collection: 'app.bsky.actor.profile',
    rkey: 'self',
    record: { '$type': 'app.bsky.actor.profile' },
  }),
});

Environment Variables

VariableWhereNotes
PDS_ADMIN_PASSWORDBackend only (Vercel / Cloudflare Pages secret)Same value as on the server — never expose client-side
PDS_URLBackendhttps://self.surf

Verification

# PDS is healthy
curl https://self.surf/xrpc/_health
# → {"version":"0.4.x"}

# Account creation without invite code is blocked by PDS natively
curl -X POST https://self.surf/xrpc/com.atproto.server.createAccount \
  -H "Content-Type: application/json" \
  -d '{"email":"test@test.com","handle":"test.self.surf","password":"testtest"}'
# → {"error":"InvalidInviteCode","message":"No invite code provided"}

Note: This uses the official PDS Docker image unmodified — no source code changes, full AT Protocol compatibility and federation.