#!/usr/bin/env bash # Registers an atproto account as an Ozone labeler by adding the # `#atproto_labeler` service and `#atproto_label` verification method to the # account's DID document (via PLC). # # This is the same `com.atproto.identity.signPlcOperation` / # `submitPlcOperation` flow that the Ozone UI's register-labeler screen uses, # driven from the shell with the `goat` CLI. # # Existing entries in the DID document (the `atproto` key, the `atproto_pds` # service, `alsoKnownAs`) are preserved; the labeler entries are merged in. # # Requires: curl, python3, and the `goat` CLI. goat is launched with # `nix run nixpkgs#atproto-goat --` unless the GOAT env var is set. # # Usage: # ./scripts/register-labeler.sh --public-url [--handle H] [--meta-url U] [--only-op] [--force] usage() { cat <<'EOF' Registers an atproto account as an Ozone labeler by adding the `#atproto_labeler` service and `#atproto_label` verification method to the account's DID document (via PLC). Requires: curl, python3, and the `goat` CLI (nix run nixpkgs#atproto-goat). Usage: ./scripts/register-labeler.sh --public-url [options] Options: --public-url URL Public URL where Ozone will be reachable; written to the DID document as the `#atproto_labeler` endpoint. Required. --handle HANDLE atproto handle of the labeler account. Default: akshay.tngl.sh --meta-url URL Base URL of a running Ozone instance used to read the labeler signing key from `/.well-known/ozone-metadata.json`. Default: http://localhost:3001 --only-op Build and print the PLC operation JSON, then exit without touching goat or sending any email. --force Re-register even if `#atproto_labeler` already exists (e.g. to update the endpoint after moving the service). EOF exit 1 } set -euo pipefail PUBLIC_URL="" HANDLE="akshay.tngl.sh" META_URL="http://localhost:3001" ONLY_OP=false FORCE=false while [[ $# -gt 0 ]]; do case "$1" in --public-url) PUBLIC_URL="${2:?}"; shift 2 ;; --handle) HANDLE="${2:?}"; shift 2 ;; --meta-url) META_URL="${2:?}"; shift 2 ;; --only-op) ONLY_OP=true; shift ;; --force) FORCE=true; shift ;; --help | -h) usage ;; *) usage ;; esac done [[ -n "$PUBLIC_URL" ]] || { echo "error: --public-url is required" >&2 usage } # Run a goat subcommand. GOAT, if set, must be a single executable path. run_goat() { if [[ -n "${GOAT:-}" ]]; then "$GOAT" "$@" else nix run nixpkgs#atproto-goat -- "$@" fi } # Run python3, falling back to a nix-built one when python3 is missing or is a # non-functional stub (e.g. macOS Xcode CLT placeholder). PY_CMD=(python3) if ! command -v python3 >/dev/null 2>&1 || ! python3 -c 'pass' >/dev/null 2>&1; then PY_CMD=(nix run nixpkgs#python3 --) fi run_python() { "${PY_CMD[@]}" "$@" } echo "== resolving $HANDLE" DID="$( curl -fsS \ "https://api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${HANDLE}" | run_python -c 'import sys, json; print(json.load(sys.stdin)["did"])' )" echo " did: $DID" echo "== fetching current DID document from plc.directory" DOC="$(curl -fsS "https://plc.directory/${DID}")" echo "== reading labeler signing key from ${META_URL}" META="$(curl -fsS "${META_URL}/.well-known/ozone-metadata.json")" META_DID="$(printf '%s' "$META" | run_python -c 'import sys, json; print(json.load(sys.stdin).get("did"))')" if [[ -n "$META_DID" && "$META_DID" != "$DID" ]]; then echo " !! warning: ozone service DID ($META_DID) differs from the labeler" \ "account DID ($DID); double-check OZONE_SERVER_DID points at this account" >&2 fi OP_JSON="$( PUBLIC_URL="$PUBLIC_URL" \ DOC="$DOC" \ META="$META" \ FORCE="$FORCE" \ run_python - <<'PY' import json, os, sys doc = json.loads(os.environ['DOC']) meta = json.loads(os.environ['META']) public_url = os.environ['PUBLIC_URL'] force = os.environ['FORCE'] == 'true' vm = {} for entry in doc.get('verificationMethod', []): key = entry['id'].rsplit('#', 1)[-1] # PLC operations carry `did:key:` strings, whereas the resolved DID document # splits them into `publicKeyMultibase` (the method-specific id) and a type. vm[key] = 'did:key:' + entry['publicKeyMultibase'] services = {} for entry in doc.get('service', []): key = entry['id'].lstrip('#') services[key] = {"type": entry['type'], "endpoint": entry['serviceEndpoint']} public_key = meta.get('publicKey') if not public_key or not public_key.startswith('did:key:'): sys.stderr.write( "error: no valid publicKey in ozone metadata. Is Ozone running with" " OZONE_SIGNING_KEY_HEX set?\n" ) sys.exit(1) if 'atproto_label' in vm and vm['atproto_label'] != public_key: sys.stderr.write( f"error: DID document already has an #atproto_label key ({vm['atproto_label']})\n" " that differs from the running Ozone key. Refusing to clobber it; align\n" " OZONE_SIGNING_KEY_HEX with that key or clear the entry manually.\n" ) sys.exit(2) if 'atproto_label' not in vm: vm['atproto_label'] = public_key if 'atproto_labeler' in services: current = services['atproto_labeler'].get('endpoint') if not force: sys.stderr.write( f"error: DID document already has an #atproto_labeler entry pointing at {current!r}\n" " pass --force to update it\n" ) sys.exit(2) if current != public_url: sys.stderr.write(f" updating #atproto_labeler endpoint: {current!r} -> {public_url!r}\n") else: services['atproto_labeler'] = {"type": "AtprotoLabeler", "endpoint": public_url} op = { "alsoKnownAs": doc.get('alsoKnownAs', []), "verificationMethods": vm, "services": services, } print(json.dumps(op, indent=2)) PY )" echo echo "== PLC operation to apply" printf '%s\n' "$OP_JSON" echo if [[ "$ONLY_OP" == true ]]; then echo "(--only-op: not signing or submitting anything)" exit 0 fi # goat signs the operation for whatever account it holds a session for, so a # stale session for a *different* account would silently apply the labeler # entries to the wrong DID document. Require a session for $DID specifically. SESSION_DID="$(run_goat account check-auth 2>/dev/null | sed -n 's/^DID: //p' || true)" if [[ "$SESSION_DID" != "$DID" ]]; then if [[ -n "$SESSION_DID" ]]; then echo "== goat is logged in as $SESSION_DID, not $HANDLE ($DID); logging out" run_goat account logout || true fi echo "== logging in as $HANDLE" echo " (You MUST use the full account password, not an app password:" echo " PLC operations are refused for app-password sessions.)" run_goat account login -u "$HANDLE" fi echo "== requesting a PLC update confirmation code (check your email)" run_goat account plc request-token read -rp "Enter the confirmation code from the email: " TOKEN [[ -n "$TOKEN" ]] || { echo "error: empty token" >&2 exit 1 } TMPDIR_WS="$(mktemp -d)" echo "$OP_JSON" >"$TMPDIR_WS/op.json" echo "== signing PLC operation" run_goat account plc sign --token "$TOKEN" "$TMPDIR_WS/op.json" >"$TMPDIR_WS/signed.json" echo "== submitting PLC operation" run_goat account plc submit "$TMPDIR_WS/signed.json" echo "== done" echo " signed op kept at: $TMPDIR_WS/signed.json" echo " Verify with: goat plc history $HANDLE" echo " Then restart Ozone so it re-resolves the DID document."