Reference file: account-migration.md
Account Migration
This page describes the migration path Tempest supports today and the identity
work still required for production did:plc account moves.
Account migration has two separate parts:
- Move repository and blob data to Tempest.
- Move identity authority so the DID document's
#atproto_pdsservice points at Tempest.
Tempest implements the repository and lifecycle side: existing-DID account creation with service auth, inactive imported accounts, CAR import, missing-blob checks, activation/deactivation, and account deletion. Activation is intentionally gated on identity correctness. Tempest must not publicly serve a migrated account until the DID document points at this PDS.
Current Support
Supported now:
- Create an inactive account for an existing DID when
createAccountreceives a valid service-auth token forcom.atproto.server.createAccount. - Import a repository CAR through
com.atproto.repo.importRepo. - Keep imported accounts inactive until activation.
- Report repository, record, blob, missing-blob, and
migrationReadystate throughcom.atproto.server.checkAccountStatus. - List missing blobs through
com.atproto.repo.listMissingBlobs. - Activate only after the local DID document exposes
#atproto_pdsforTEMPEST_PUBLIC_URL. - Suppress public repo, record, blob, and sync reads for inactive accounts.
To-Do
- Full
did:plcmigration still needs black-box migration-out coverage, but the public PLC identity endpoints are registered and locally tested against the PLC client boundary.
Self-controlled did:web accounts remain the simplest bring-your-own identity
path because the operator can update the DID document directly.
Migration-In Flow
The Python CLI mirrors the manual curl flow below. It reads configuration from
environment variables, writes artifacts into .sandbox/ by default, and defaults
to the full migration-in sequence:
export OLD_PDS="https://jellybaby.us-east.host.bsky.network"
export OLD_AUTH_PDS="$OLD_PDS"
export OLD_LOGIN_PDS="$OLD_AUTH_PDS"
export HANDLE="tempestpds.bsky.social"
export DID="did:plc:oga6ppys7zwxlheuqmcm7dac"
export TEMPEST="https://tempest.desertthunder.dev"
export TEMPEST_SERVICE_DID="did:web:tempest.desertthunder.dev"
export EMAIL="[email protected]"
export OLD_IDENTIFIER="$HANDLE"
read -rs OLD_PASSWORD
export OLD_PASSWORD
read -rs TEMPEST_PASSWORD
export TEMPEST_PASSWORD
# Optional, only if the source PDS requires an auth-factor token/code during
# createSession.
read -rs OLD_AUTH_FACTOR_TOKEN
export OLD_AUTH_FACTOR_TOKEN
uv run --project scripts tempest
Use read -rs or single quotes for passwords. Unquoted shell assignments can
expand characters like $, so a password containing $N will not be passed
literally.
Run individual steps when resuming or inspecting a failure:
uv run --project scripts tempest login-source
uv run --project scripts tempest service-auth
uv run --project scripts tempest source-session-status
uv run --project scripts tempest export-car
uv run --project scripts tempest list-source-blobs
uv run --project scripts tempest download-source-blobs
uv run --project scripts tempest create-account
uv run --project scripts tempest refresh-session
uv run --project scripts tempest import-repo
uv run --project scripts tempest status
uv run --project scripts tempest missing-blobs
uv run --project scripts tempest upload-missing-blobs
uv run --project scripts tempest plc-recommended
uv run --project scripts tempest plc-request-token
uv run --project scripts tempest plc-sign
uv run --project scripts tempest plc-submit
uv run --project scripts tempest activate
The same project also exposes the admin-token Argon2 helper as
tempest argon, with tempest ar and tempest arg2 aliases.
From the source PDS, export the account repository:
curl "$OLD_PDS/xrpc/com.atproto.sync.getRepo?did=$DID" -o repo.car
Ask the source PDS for service auth scoped to account creation on Tempest:
TEMPEST_SERVICE_DID=did:web:tempest.example.com
curl -H "Authorization: Bearer $OLD_ACCESS" \
"$OLD_PDS/xrpc/com.atproto.server.getServiceAuth?aud=$TEMPEST_SERVICE_DID&lxm=com.atproto.server.createAccount"
aud is the target service DID, not the HTTPS service endpoint. The HTTPS
endpoint still belongs in the public DID document's #atproto_pds
serviceEndpoint.
Create the account on Tempest with the existing DID:
curl -X POST "$TEMPEST/xrpc/com.atproto.server.createAccount" \
-H "Content-Type: application/json" \
-d '{
"did": "'"$DID"'",
"handle": "'"$HANDLE"'",
"email": "'"$EMAIL"'",
"password": "'"$PASSWORD"'",
"serviceAuth": "'"$SERVICE_AUTH"'"
}'
The response should include:
{ "did": "did:example:...", "active": false, "status": "deactivated" }
Import the CAR:
curl -X POST "$TEMPEST/xrpc/com.atproto.repo.importRepo" \
-H "Authorization: Bearer $TEMPEST_ACCESS" \
-H "Content-Type: application/vnd.ipld.car" \
--data-binary @repo.car
Check for missing blobs:
curl -H "Authorization: Bearer $TEMPEST_ACCESS" \
"$TEMPEST/xrpc/com.atproto.repo.listMissingBlobs"
Upload any missing blobs, then check readiness:
curl -H "Authorization: Bearer $TEMPEST_ACCESS" \
"$TEMPEST/xrpc/com.atproto.server.checkAccountStatus"
migrationReady should be true and missingBlobCount should be 0 before
activation.
Update identity so the account DID document points #atproto_pds at
TEMPEST_PUBLIC_URL. For did:web, update the hosted DID document. For
did:plc, use getRecommendedDidCredentials, requestPlcOperationSignature,
signPlcOperation, and submitPlcOperation to build, sign, and submit the PLC
operation through Tempest's PLC client boundary.
For the current did:plc migration, use the CLI helpers:
uv run --project scripts tempest refresh-session
uv run --project scripts tempest plc-recommended
uv run --project scripts tempest login-source
uv run --project scripts tempest plc-request-token
plc-request-token writes .sandbox/plc_token.json when the source PDS returns
a token directly. Some PDS implementations email a one-time code instead; in
that case export it before signing:
export PLC_TOKEN="code-from-email"
uv run --project scripts tempest plc-sign
If plc-request-token returns Bad token scope, refresh the source session with
the main account password rather than an app password. If the source PDS requires
an auth-factor token/code for high-risk account operations, set
OLD_AUTH_FACTOR_TOKEN and rerun login-source, then rerun plc-request-token.
If handle login returns Invalid identifier or password, set OLD_IDENTIFIER
to the account email address and retry login-source.
If the account's repository host rejects main-password login even though the
credentials work in Bluesky, keep OLD_PDS pointed at the repository host for
CAR/blob export, keep OLD_AUTH_PDS pointed at the old PDS for authenticated
PLC operations, and set OLD_LOGIN_PDS to the Bluesky entryway:
export OLD_PDS="https://jellybaby.us-east.host.bsky.network"
export OLD_AUTH_PDS="$OLD_PDS"
export OLD_LOGIN_PDS="https://bsky.social"
uv run --project scripts tempest login-source
uv run --project scripts tempest plc-request-token
If login succeeds but plc-request-token still returns Bad token scope, check
that the saved token works for ordinary old-PDS auth:
uv run --project scripts tempest source-session-status
If source-session-status succeeds while plc-request-token fails, the source
session is valid but the old PDS is refusing that session scope for PLC signing.
Before submitting, inspect the signed operation. The PDS service endpoint must be Tempest:
jq '.operation.services.atproto_pds.endpoint' .sandbox/plc_signed_operation.json
Then submit the signed PLC operation through Tempest:
uv run --project scripts tempest plc-submit
curl -fsS "https://plc.directory/$DID" | jq '.service'
The resolved DID document should include #atproto_pds with
serviceEndpoint equal to https://tempest.desertthunder.dev for the current
deployment.
Activate the account:
uv run --project scripts tempest refresh-session
uv run --project scripts tempest activate
uv run --project scripts tempest status
The equivalent curl call is:
curl -X POST "$TEMPEST/xrpc/com.atproto.server.activateAccount" \
-H "Authorization: Bearer $TEMPEST_ACCESS" \
-H "Content-Type: application/json" \
-d '{}'
After activation, public repo, record, blob, and sync reads become available.
Failure Behavior
Tempest fails closed during migration:
- Missing or invalid service auth prevents existing-DID account creation.
- Invalid CAR data is rejected without replacing the existing repository.
- CAR commits for a different DID are rejected.
- Invalid commit signatures are rejected.
- Missing referenced blocks are rejected.
- Missing referenced blobs keep
migrationReady=false. - Activation fails when the DID document does not point at Tempest.
PLC Operation Flow
Production did:plc migration uses the public identity operation flow, not only
repository import:
com.atproto.identity.getRecommendedDidCredentialsmust return the Tempest PDS service endpoint, repository signing key, handle, and recommended rotation keys.com.atproto.identity.requestPlcOperationSignaturemust create an auditable strong-reauth challenge for PLC-sensitive operations.com.atproto.identity.signPlcOperationmust reject operations that remove recoverability or point#atproto_pdsaway from the intended service.com.atproto.identity.submitPlcOperationmust submit through the PLC client, preserve migration event ordering, and record success or failure.
These endpoints deny app passwords and ordinary OAuth tokens unless a future spec
defines a high-assurance delegated scope. Configure TEMPEST_PLC_ROTATION_KEY
with private rotation-key material; optionally configure TEMPEST_PLC_RECOVERY_KEY
for an operator recovery key. Tempest derives public did:key rotation keys from
that material and does not reuse repository signing keys as PLC rotation keys.
The vendored lexicon files for this flow live in priv/lexicons/official:
com/atproto/identity/requestPlcOperationSignature.json: no input body; asks the old PDS to email a PLC operation code.com/atproto/identity/signPlcOperation.json: takes the emailedtokenplusrotationKeys,alsoKnownAs,verificationMethods, andservices.com/atproto/server/createSession.json: takesidentifier,password, and optionalauthFactorToken.
Verification
Local lifecycle coverage:
hurl --test --jobs 1 \
--variable base_url=http://localhost:4000 \
test/smoke/migration-lifecycle.hurl
Compatibility tracking: