The Social Login for cavu.games and cavu.page

Files
README.md

cavu-pds #

The cavu account server: vlpds at https://account.cavu.social, hosting *.cavu.social handles, deployed as the Fly app cavu-pds in the cavu org.

It's the OAuth sign-in server for cavu.games and cavu.page accounts, and it stores large blobs (up to 1 GiB) so cavu.games can distribute games over atproto.

How sign-in works #

Each cavu site has its own sign-in page with one field (handle or email) and a "Sign in with Google" button:

  • A handle goes through standard atproto OAuth to whichever PDS hosts it. A bare name with no dot (chris) means chris.cavu.social.
  • An email starts OAuth at https://account.cavu.social with the email as login_hint. Only cavu.social accounts can sign in by email, since atproto can't find a PDS from an email address.
  • Google starts OAuth at https://account.cavu.social and adds provider=google to the authorize URL, which skips the sign-in page and goes straight to Google. A new Google identity gets the sign-up form, with a random handle filled in that the person can change.

Google is a vlpds sign-in provider (upstream's OpenID Connect support, see upstream/docs/operations/sign-in-providers.md), configured at runtime, not in fly.toml.

Build #

vlpds is built from source. upstream is a submodule pinned to a main commit (there are no release tags), and patches/*.patch is a git am series on top.

scripts/prepare.sh        # .build = upstream + patches, one commit each
# edit and commit in .build, then:
scripts/save-patches.sh   # export .build's commits back to patches/
cd .build && cargo test --test all

To bump vlpds, check out a newer commit in upstream, run scripts/prepare.sh, and fix any patch that no longer applies. If git am can't 3-way merge (it needs the blobs the patch was made against), apply the series on the old base in .build and git rebase it onto the new one instead.

Each patch is written so it could go upstream: behavior behind a flag or config, defaults matching upstream, and tests in tests/all/.

Patches #

  • 0001 first-party clients. VLPDS_FIRST_PARTY_CLIENTS lists cavu.games' and cavu.page's OAuth client_ids. Their tokens are trusted like a password session for account security, with a sign-in in the last VLPDS_FIRST_PARTY_REAUTH_SECS needed to change how an account signs in. From poasters.club.

  • 0002 passkey related origins. VLPDS_PASSKEY_RELATED_ORIGINS, served at /.well-known/webauthn. From poasters.club; unset here for now.

  • 0003 bare names and email hints. A sign-in identifier with no dot is a handle under the primary domain (chris is chris.cavu.social), on the OAuth page, in login_hint and in createSession. login_hint may be an account's email. No flag: bare names never resolved before.

  • 0004 suggested sign-up handles. VLPDS_SUGGEST_SIGNUP_HANDLE fills in sign-up forms (the OAuth page's and a sign-in provider's) with a random available handle like brave-otter-42, which the person can keep or change before the account is created.

  • 0005 provider= on the authorize URL. /oauth/authorize?…&provider=google skips the sign-in page and goes straight to that sign-in provider, for sites with their own "Sign in with Google" button.

  • 0006 Profile page. /account/profile in the account app edits app.bsky.actor.profile (display name, bio, photo, banner) with standard XRPC calls. UI only.

  • 0007 controlled accounts. VLPDS_CONTROLLED_ACCOUNTS: accounts with controller DIDs instead of a password and an email (game characters, NPCs). A controller creates them (vlpds.account.createControlledAccount, up to VLPDS_CONTROLLED_ACCOUNTS_PER_CONTROLLER, overridable per controller in the console) and makes a character password: an app password with characters (and optional collection scopes) that signs in as those characters with a plain createSession(characterHandle, password), never as the controller and never for account or identity changes. The operator converts existing accounts with vlpds.admin.setAccountControllers (removePassword drops a stopgap password). PLC and deletion codes go to the controller's email. Account app: a Characters page. Blob usage is not pooled with the controller's quota. Details: docs/operations/controlled-accounts.md in the patched tree.

  • 0008 confirm sign-up. VLPDS_CONFIRM_SIGNUP asks "Create a new account?" before an OAuth sign-in leads to one, naming the app (its client_name): a sign-in provider identity no account uses (Create a new account / Use an existing account, back to the sign-in page for the same request), or an email or handle here with no account, as login_hint or typed on the sign-in page (Create a new account, prefilled / Try a different email or handle). prompt=create skips it. It tells the visitor an email has no account here, which createAccount already reveals; off by default upstream.

  • 0009 sign-up methods. VLPDS_SIGNUP_METHODS makes the OAuth sign-up page offer, in order, Continue with Google (each sign-in provider whose signup is open or invite), Create with a passkey (handle and email, no password) and Or use a password (the old form, folded away only where a passkey can be made, so it's the whole page without JavaScript or WebAuthn). Continue with … from this page skips 0008's question for a new identity and just signs in a linked one. The passkey runs every createAccount check first, then mints the DID ahead (a discoverable passkey's user handle is the DID; key held as a reserved key, PLC genesis op kept unsent on the request) and asks the browser for the passkey; the account is created only after the passkey verifies, with it as the only way in (no password, like a provider signup). Cancel, Back or a failed ceremony creates nothing. Off by default upstream; the account app's /account/signup is unchanged.

  • 0010 login_hint on the authorize URL. /oauth/authorize?…&login_hint= sets the hint when the pushed request didn't, checked as PAR's is (0003) and kept on the request. @atproto/oauth-client-browser overwrites login_hint in the PAR, so cavu.page can append it to the authorize URL instead of patching the PAR body.

  • 0011–0012 theming (upstream-first). Operator theming from upstream-patches/theming/ (the copy for submitting upstream), carried here until it lands: a runtime theme (vlpds.admin.putTheme, vlpds admin theme, the console's System > Theme) with a name, logo, favicon, light/dark colors, a font from a fixed list and home/terms/privacy/support links, rendered on every OAuth page (including 0008's "Create a new account?" and 0009's sign-up methods and "Save your passkey", the theme's stylesheet hash joining the CSP next to the sign-up script's), the account app (0006's Profile and 0007's Characters included), /migrate, the landing page and account email. 0011 is the server, docs and tests; 0012 the UI. Our theme is theme/cavu-theme.json with theme/cavu-logo.png and theme/cavu-favicon.png; VLPDS_ADMIN_TOKEN=… theme/apply.sh http://localhost:2583 (through fly proxy) puts it. Details: docs/operations/theming.md in the patched tree.

  • 0013 confirm the email before the account. VLPDS_REQUIRE_EMAIL_CONFIRMATION creates an account only once its email is confirmed. A password or passkey sign-up on the OAuth page (and a Google sign-up whose email Google didn't vouch for, or that the person changed) becomes a pending sign-up after every createAccount check: its DID minted ahead with a reserved key and an unsent PLC genesis op (as 0009's passkey does), its handle and email held, the password hashed; no account, repo, firehose event or PLC op yet. "Check your email" takes the mailed 6-digit code (or the mail's link, in any browser, whose page has one Confirm button) and then the account is created, email confirmed, and the OAuth flow goes on to consent. Resend (a minute apart), Use a different email (keeps the handle), five wrong codes cancel it, 24 hours lapse it (handle, email and key released); a refused mail (spent mail budget) keeps it with "We couldn't send the email right now". Plain com.atproto.server.createAccount sign-ups are refused (migrations with service auth, the admin token, vlpds.admin.createAccount and createControlledAccount still work); the account app's /account/signup uses vlpds.account.startSignup/confirmSignup. Buckets signup-pending-ip (5/h) and signup-mail-address (5/day). Details: docs/operations/email-and-moderation.md#confirming-the-email-before-the-account in the patched tree.

0003–0013 are written for upstream: each is one feature with its own tests, off by default where behavior changes. Sign-in with Google itself is upstream's sign-in providers, so poasters.club's linked sign-ins patches aren't carried here.

prompt=create (open the sign-up form) is already upstream.

Deploy #

scripts/deploy.sh
fly logs --app cavu-pds
fly proxy 2583 --app cavu-pds   # console: http://localhost:2583/admin (VLPDS_ADMIN_TOKEN)

Caddy listens on 8080 behind Fly's edge and returns 404 for the operator paths (/admin, /xrpc/vlpds.admin.*, /metrics, /internal/*). vlpds listens on [::]:2583, reachable only over the private network.

Spaces (VLPDS_SPACES, AT Protocol permissioned data) is on: cavu.page's reading groups and other cavu features need it. It's an alpha that changes weekly, so bump upstream often, and once space data exists never roll back to a vlpds build from before Spaces. Checks: vlpds admin check-space <did> <space> and the vlpds_space_* metrics (upstream/docs/spaces/operating.md).

Tiny profile: one node, one shard, a 60 s lease, on a shared-cpu-2x with 2 GB. Never run two machines. Everything durable is in the Tigris bucket cavu-pds under the prefix pds.

Storage limits #

Sign-up is open, so the per-account storage quota (VLPDS_BLOB_QUOTA_GB, 2 GB) is what stops it from being free file hosting. Raise it per publisher in the console. Single blobs can be up to 1 GiB (VLPDS_MAX_BLOB_MB).