Tempest Navigator 4.0 - Reference Documentation

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:

  1. Move repository and blob data to Tempest.
  2. Move identity authority so the DID document's #atproto_pds service 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 createAccount receives a valid service-auth token for com.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 migrationReady state through com.atproto.server.checkAccountStatus.
  • List missing blobs through com.atproto.repo.listMissingBlobs.
  • Activate only after the local DID document exposes #atproto_pds for TEMPEST_PUBLIC_URL.
  • Suppress public repo, record, blob, and sync reads for inactive accounts.

To-Do

  • Full did:plc migration 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.getRecommendedDidCredentials must return the Tempest PDS service endpoint, repository signing key, handle, and recommended rotation keys.
  • com.atproto.identity.requestPlcOperationSignature must create an auditable strong-reauth challenge for PLC-sensitive operations.
  • com.atproto.identity.signPlcOperation must reject operations that remove recoverability or point #atproto_pds away from the intended service.
  • com.atproto.identity.submitPlcOperation must 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 emailed token plus rotationKeys, alsoKnownAs, verificationMethods, and services.
  • com/atproto/server/createSession.json: takes identifier, password, and optional authFactorToken.

Verification

Local lifecycle coverage:

hurl --test --jobs 1 \
  --variable base_url=http://localhost:4000 \
  test/smoke/migration-lifecycle.hurl

Compatibility tracking:

Start tempest docs / tempest.desertthunder.dev 2026-06-24 17:31:46Z