this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

goat account migration (#768)

This PR adds support for account migration using the goat CLI tool two
different ways:

- a bunch of account helper commands which individually enable doing an
account migration: creating a new account using service auth and old
DID; migrating all the data; swapping identity around
- a helper command that automates all this (starting with a PLC op auth
token)

The hope is that the migrate command should work in common cases, but
the individual commands allow cleaning up in the case of partial
success, and doing more complex stuff.

Missing pieces as future work:

- `did:web` functionality
- detection of current progress and only continue work that needs
continuing (eg, don't try to re-create new account if it already exists
and can auth with provided credentials)
- proper support for inserting a recovery key when signing PLC op
- more safety validation and guardrails

authored by

bnewbold and committed by
GitHub
00b60c68 6d11dbf0

+1063 -37
+333 -26
cmd/goat/account.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "fmt" 7 + "strings" 8 + "time" 6 9 7 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 8 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 17 20 Flags: []cli.Flag{}, 18 21 Subcommands: []*cli.Command{ 19 22 &cli.Command{ 20 - Name: "check", 21 - Usage: "verifies current auth session is functional", 22 - Action: runAccountCheck, 23 - }, 24 - &cli.Command{ 25 23 Name: "login", 26 24 Usage: "create session with PDS instance", 27 25 Flags: []cli.Flag{ ··· 39 37 Usage: "password (app password recommended)", 40 38 EnvVars: []string{"ATP_AUTH_PASSWORD"}, 41 39 }, 40 + &cli.StringFlag{ 41 + Name: "pds-host", 42 + Usage: "URL of the PDS to create account on (overrides DID doc)", 43 + EnvVars: []string{"ATP_PDS_HOST"}, 44 + }, 42 45 }, 43 46 Action: runAccountLogin, 44 47 }, ··· 48 51 Action: runAccountLogout, 49 52 }, 50 53 &cli.Command{ 51 - Name: "status", 52 - Usage: "show account status at PDS", 54 + Name: "activate", 55 + Usage: "(re)activate current account", 56 + Action: runAccountActivate, 57 + }, 58 + &cli.Command{ 59 + Name: "deactivate", 60 + Usage: "deactivate current account", 61 + Action: runAccountDeactivate, 62 + }, 63 + &cli.Command{ 64 + Name: "lookup", 65 + Usage: "show basic account hosting status for any account", 53 66 ArgsUsage: `<at-identifier>`, 54 - Action: runAccountStatus, 67 + Action: runAccountLookup, 68 + }, 69 + &cli.Command{ 70 + Name: "update-handle", 71 + Usage: "change handle for current account", 72 + ArgsUsage: `<handle>`, 73 + Action: runAccountUpdateHandle, 74 + }, 75 + &cli.Command{ 76 + Name: "status", 77 + Usage: "show current account status at PDS", 78 + Action: runAccountStatus, 79 + }, 80 + &cli.Command{ 81 + Name: "missing-blobs", 82 + Usage: "list any missing blobs for current account", 83 + Action: runAccountMissingBlobs, 55 84 }, 85 + &cli.Command{ 86 + Name: "service-auth", 87 + Usage: "create service auth token", 88 + Flags: []cli.Flag{ 89 + &cli.StringFlag{ 90 + Name: "endpoint", 91 + Aliases: []string{"lxm"}, 92 + Usage: "restrict token to API endpoint (NSID, optional)", 93 + }, 94 + &cli.StringFlag{ 95 + Name: "audience", 96 + Aliases: []string{"aud"}, 97 + Required: true, 98 + Usage: "DID of service that will receive and validate token", 99 + }, 100 + &cli.IntFlag{ 101 + Name: "duration-sec", 102 + Value: 60, 103 + Usage: "validity time window of token (seconds)", 104 + }, 105 + }, 106 + Action: runAccountServiceAuth, 107 + }, 108 + &cli.Command{ 109 + Name: "create", 110 + Usage: "create a new account on the indicated PDS host", 111 + Flags: []cli.Flag{ 112 + &cli.StringFlag{ 113 + Name: "pds-host", 114 + Usage: "URL of the PDS to create account on", 115 + Required: true, 116 + EnvVars: []string{"ATP_PDS_HOST"}, 117 + }, 118 + &cli.StringFlag{ 119 + Name: "handle", 120 + Usage: "handle for new account", 121 + Required: true, 122 + EnvVars: []string{"ATP_AUTH_HANDLE"}, 123 + }, 124 + &cli.StringFlag{ 125 + Name: "password", 126 + Usage: "initial account password", 127 + Required: true, 128 + EnvVars: []string{"ATP_AUTH_PASSWORD"}, 129 + }, 130 + &cli.StringFlag{ 131 + Name: "invite-code", 132 + Usage: "invite code for account signup", 133 + }, 134 + &cli.StringFlag{ 135 + Name: "email", 136 + Usage: "email address for new account", 137 + }, 138 + &cli.StringFlag{ 139 + Name: "existing-did", 140 + Usage: "an existing DID to use (eg, non-PLC DID, or migration)", 141 + }, 142 + &cli.StringFlag{ 143 + Name: "recovery-key", 144 + Usage: "public cryptographic key (did:key) to add as PLC recovery", 145 + }, 146 + &cli.StringFlag{ 147 + Name: "service-auth", 148 + Usage: "service auth token (for account migration)", 149 + }, 150 + }, 151 + Action: runAccountCreate, 152 + }, 153 + cmdAccountMigrate, 154 + cmdAccountPlc, 56 155 }, 57 156 } 58 157 59 - func runAccountCheck(cctx *cli.Context) error { 60 - ctx := context.Background() 61 - 62 - client, err := loadAuthClient(ctx) 63 - if err == ErrNoAuthSession { 64 - return fmt.Errorf("auth required, but not logged in") 65 - } else if err != nil { 66 - return err 67 - } 68 - // TODO: more explicit check? 69 - fmt.Printf("DID: %s\n", client.Auth.Did) 70 - fmt.Printf("PDS: %s\n", client.Host) 71 - 72 - return nil 73 - } 74 - 75 158 func runAccountLogin(cctx *cli.Context) error { 76 159 ctx := context.Background() 77 160 ··· 80 163 return err 81 164 } 82 165 83 - _, err = refreshAuthSession(ctx, *username, cctx.String("app-password")) 166 + _, err = refreshAuthSession(ctx, *username, cctx.String("app-password"), cctx.String("pds-host")) 84 167 return err 85 168 } 86 169 ··· 88 171 return wipeAuthSession() 89 172 } 90 173 91 - func runAccountStatus(cctx *cli.Context) error { 174 + func runAccountLookup(cctx *cli.Context) error { 92 175 ctx := context.Background() 93 176 username := cctx.Args().First() 94 177 if username == "" { ··· 122 205 } 123 206 return nil 124 207 } 208 + 209 + func runAccountStatus(cctx *cli.Context) error { 210 + ctx := context.Background() 211 + 212 + client, err := loadAuthClient(ctx) 213 + if err == ErrNoAuthSession { 214 + return fmt.Errorf("auth required, but not logged in") 215 + } else if err != nil { 216 + return err 217 + } 218 + 219 + status, err := comatproto.ServerCheckAccountStatus(ctx, client) 220 + if err != nil { 221 + return fmt.Errorf("failed checking account status: %w", err) 222 + } 223 + 224 + b, err := json.MarshalIndent(status, "", " ") 225 + if err != nil { 226 + return err 227 + } 228 + fmt.Printf("DID: %s\n", client.Auth.Did) 229 + fmt.Printf("Host: %s\n", client.Host) 230 + fmt.Println(string(b)) 231 + 232 + return nil 233 + } 234 + 235 + func runAccountMissingBlobs(cctx *cli.Context) error { 236 + ctx := context.Background() 237 + 238 + client, err := loadAuthClient(ctx) 239 + if err == ErrNoAuthSession { 240 + return fmt.Errorf("auth required, but not logged in") 241 + } else if err != nil { 242 + return err 243 + } 244 + 245 + cursor := "" 246 + for { 247 + resp, err := comatproto.RepoListMissingBlobs(ctx, client, cursor, 500) 248 + if err != nil { 249 + return err 250 + } 251 + for _, missing := range resp.Blobs { 252 + fmt.Printf("%s\t%s\n", missing.Cid, missing.RecordUri) 253 + } 254 + if resp.Cursor != nil && *resp.Cursor != "" { 255 + cursor = *resp.Cursor 256 + } else { 257 + break 258 + } 259 + } 260 + return nil 261 + } 262 + 263 + func runAccountActivate(cctx *cli.Context) error { 264 + ctx := context.Background() 265 + 266 + client, err := loadAuthClient(ctx) 267 + if err == ErrNoAuthSession { 268 + return fmt.Errorf("auth required, but not logged in") 269 + } else if err != nil { 270 + return err 271 + } 272 + 273 + err = comatproto.ServerActivateAccount(ctx, client) 274 + if err != nil { 275 + return fmt.Errorf("failed activating account: %w", err) 276 + } 277 + 278 + return nil 279 + } 280 + 281 + func runAccountDeactivate(cctx *cli.Context) error { 282 + ctx := context.Background() 283 + 284 + client, err := loadAuthClient(ctx) 285 + if err == ErrNoAuthSession { 286 + return fmt.Errorf("auth required, but not logged in") 287 + } else if err != nil { 288 + return err 289 + } 290 + 291 + err = comatproto.ServerDeactivateAccount(ctx, client, &comatproto.ServerDeactivateAccount_Input{}) 292 + if err != nil { 293 + return fmt.Errorf("failed deactivating account: %w", err) 294 + } 295 + 296 + return nil 297 + } 298 + 299 + func runAccountUpdateHandle(cctx *cli.Context) error { 300 + ctx := context.Background() 301 + 302 + raw := cctx.Args().First() 303 + if raw == "" { 304 + return fmt.Errorf("need to provide new handle as argument") 305 + } 306 + handle, err := syntax.ParseHandle(raw) 307 + if err != nil { 308 + return err 309 + } 310 + 311 + client, err := loadAuthClient(ctx) 312 + if err == ErrNoAuthSession { 313 + return fmt.Errorf("auth required, but not logged in") 314 + } else if err != nil { 315 + return err 316 + } 317 + 318 + err = comatproto.IdentityUpdateHandle(ctx, client, &comatproto.IdentityUpdateHandle_Input{ 319 + Handle: handle.String(), 320 + }) 321 + if err != nil { 322 + return fmt.Errorf("failed updating handle: %w", err) 323 + } 324 + 325 + return nil 326 + } 327 + 328 + func runAccountServiceAuth(cctx *cli.Context) error { 329 + ctx := context.Background() 330 + 331 + client, err := loadAuthClient(ctx) 332 + if err == ErrNoAuthSession { 333 + return fmt.Errorf("auth required, but not logged in") 334 + } else if err != nil { 335 + return err 336 + } 337 + 338 + lxm := cctx.String("endpoint") 339 + if lxm != "" { 340 + _, err := syntax.ParseNSID(lxm) 341 + if err != nil { 342 + return fmt.Errorf("lxm argument must be a valid NSID: %w", err) 343 + } 344 + } 345 + 346 + aud := cctx.String("audience") 347 + // TODO: can aud DID have a fragment? 348 + _, err = syntax.ParseDID(aud) 349 + if err != nil { 350 + return fmt.Errorf("aud argument must be a valid DID: %w", err) 351 + } 352 + 353 + durSec := cctx.Int("duration-sec") 354 + expTimestamp := time.Now().Unix() + int64(durSec) 355 + 356 + resp, err := comatproto.ServerGetServiceAuth(ctx, client, aud, expTimestamp, lxm) 357 + if err != nil { 358 + return fmt.Errorf("failed updating handle: %w", err) 359 + } 360 + 361 + fmt.Println(resp.Token) 362 + 363 + return nil 364 + } 365 + 366 + func runAccountCreate(cctx *cli.Context) error { 367 + ctx := context.Background() 368 + 369 + // validate args 370 + pdsHost := cctx.String("pds-host") 371 + if !strings.Contains(pdsHost, "://") { 372 + return fmt.Errorf("PDS host is not a url: %s", pdsHost) 373 + } 374 + handle := cctx.String("handle") 375 + _, err := syntax.ParseHandle(handle) 376 + if err != nil { 377 + return err 378 + } 379 + password := cctx.String("password") 380 + params := &comatproto.ServerCreateAccount_Input{ 381 + Handle: handle, 382 + Password: &password, 383 + } 384 + raw := cctx.String("existing-did") 385 + if raw != "" { 386 + _, err := syntax.ParseDID(raw) 387 + if err != nil { 388 + return err 389 + } 390 + s := raw 391 + params.Did = &s 392 + } 393 + raw = cctx.String("email") 394 + if raw != "" { 395 + s := raw 396 + params.Email = &s 397 + } 398 + raw = cctx.String("invite-code") 399 + if raw != "" { 400 + s := raw 401 + params.InviteCode = &s 402 + } 403 + raw = cctx.String("recovery-key") 404 + if raw != "" { 405 + s := raw 406 + params.RecoveryKey = &s 407 + } 408 + 409 + // create a new API client to connect to the account's PDS 410 + xrpcc := xrpc.Client{ 411 + Host: pdsHost, 412 + } 413 + 414 + raw = cctx.String("service-auth") 415 + if raw != "" && params.Did != nil { 416 + xrpcc.Auth = &xrpc.AuthInfo{ 417 + Did: *params.Did, 418 + AccessJwt: raw, 419 + } 420 + } 421 + 422 + resp, err := comatproto.ServerCreateAccount(ctx, &xrpcc, params) 423 + if err != nil { 424 + return fmt.Errorf("failed to create account: %w", err) 425 + } 426 + 427 + fmt.Println("Success!") 428 + fmt.Printf("DID: %s\n", resp.Did) 429 + fmt.Printf("Handle: %s\n", resp.Handle) 430 + return nil 431 + }
+261
cmd/goat/account_migrate.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "strings" 10 + "time" 11 + 12 + comatproto "github.com/bluesky-social/indigo/api/atproto" 13 + appbsky "github.com/bluesky-social/indigo/api/bsky" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 16 + 17 + "github.com/urfave/cli/v2" 18 + ) 19 + 20 + var cmdAccountMigrate = &cli.Command{ 21 + Name: "migrate", 22 + Usage: "move account to a new PDS. requires full auth.", 23 + Flags: []cli.Flag{ 24 + &cli.StringFlag{ 25 + Name: "pds-host", 26 + Usage: "URL of the new PDS to create account on", 27 + Required: true, 28 + EnvVars: []string{"ATP_PDS_HOST"}, 29 + }, 30 + &cli.StringFlag{ 31 + Name: "new-handle", 32 + Required: true, 33 + Usage: "handle on new PDS", 34 + EnvVars: []string{"NEW_ACCOUNT_HANDLE"}, 35 + }, 36 + &cli.StringFlag{ 37 + Name: "new-password", 38 + Required: true, 39 + Usage: "password on new PDS", 40 + EnvVars: []string{"NEW_ACCOUNT_PASSWORD"}, 41 + }, 42 + &cli.StringFlag{ 43 + Name: "plc-token", 44 + Required: true, 45 + Usage: "token from old PDS authorizing token signature", 46 + EnvVars: []string{"PLC_SIGN_TOKEN"}, 47 + }, 48 + &cli.StringFlag{ 49 + Name: "invite-code", 50 + Usage: "invite code for account signup", 51 + }, 52 + &cli.StringFlag{ 53 + Name: "new-email", 54 + Usage: "email address for new account", 55 + }, 56 + }, 57 + Action: runAccountMigrate, 58 + } 59 + 60 + func runAccountMigrate(cctx *cli.Context) error { 61 + // NOTE: this could check rev / commit before and after and ensure last-minute content additions get lost 62 + ctx := context.Background() 63 + 64 + oldClient, err := loadAuthClient(ctx) 65 + if err == ErrNoAuthSession { 66 + return fmt.Errorf("auth required, but not logged in") 67 + } else if err != nil { 68 + return err 69 + } 70 + did := oldClient.Auth.Did 71 + 72 + newHostURL := cctx.String("pds-host") 73 + if !strings.Contains(newHostURL, "://") { 74 + return fmt.Errorf("PDS host is not a url: %s", newHostURL) 75 + } 76 + newHandle := cctx.String("new-handle") 77 + _, err = syntax.ParseHandle(newHandle) 78 + if err != nil { 79 + return err 80 + } 81 + newPassword := cctx.String("new-password") 82 + plcToken := cctx.String("plc-token") 83 + inviteCode := cctx.String("invite-code") 84 + newEmail := cctx.String("new-email") 85 + 86 + newClient := xrpc.Client{ 87 + Host: newHostURL, 88 + } 89 + 90 + // connect to new host to discover service DID 91 + newHostDesc, err := comatproto.ServerDescribeServer(ctx, &newClient) 92 + if err != nil { 93 + return fmt.Errorf("failed connecting to new host: %w", err) 94 + } 95 + newHostDID, err := syntax.ParseDID(newHostDesc.Did) 96 + if err != nil { 97 + return err 98 + } 99 + slog.Info("new host", "serviceDID", newHostDID, "url", newHostURL) 100 + 101 + // 1. Create New Account 102 + slog.Info("creating account on new host", "handle", newHandle, "host", newHostURL) 103 + 104 + // get service auth token from old host 105 + // args: (ctx, client, aud string, exp int64, lxm string) 106 + expTimestamp := time.Now().Unix() + 60 107 + createAuthResp, err := comatproto.ServerGetServiceAuth(ctx, oldClient, newHostDID.String(), expTimestamp, "com.atproto.server.createAccount") 108 + if err != nil { 109 + return fmt.Errorf("failed getting service auth token from old host: %w", err) 110 + } 111 + 112 + // then create the new account 113 + createParams := comatproto.ServerCreateAccount_Input{ 114 + Did: &did, 115 + Handle: newHandle, 116 + Password: &newPassword, 117 + } 118 + if newEmail != "" { 119 + createParams.Email = &newEmail 120 + } 121 + if inviteCode != "" { 122 + createParams.InviteCode = &inviteCode 123 + } 124 + 125 + // use service auth for access token, temporarily 126 + newClient.Auth = &xrpc.AuthInfo{ 127 + Did: did, 128 + Handle: newHandle, 129 + AccessJwt: createAuthResp.Token, 130 + RefreshJwt: createAuthResp.Token, 131 + } 132 + createAccountResp, err := comatproto.ServerCreateAccount(ctx, &newClient, &createParams) 133 + if err != nil { 134 + return fmt.Errorf("failed creating new account: %w", err) 135 + } 136 + 137 + if createAccountResp.Did != did { 138 + return fmt.Errorf("new account DID not a match: %s != %s", createAccountResp.Did, did) 139 + } 140 + newClient.Auth.AccessJwt = createAccountResp.AccessJwt 141 + newClient.Auth.RefreshJwt = createAccountResp.RefreshJwt 142 + 143 + // login client on the new host 144 + sess, err := comatproto.ServerCreateSession(ctx, &newClient, &comatproto.ServerCreateSession_Input{ 145 + Identifier: did, 146 + Password: newPassword, 147 + }) 148 + if err != nil { 149 + return fmt.Errorf("failed login to newly created account on new host: %w", err) 150 + } 151 + newClient.Auth = &xrpc.AuthInfo{ 152 + Did: did, 153 + AccessJwt: sess.AccessJwt, 154 + RefreshJwt: sess.RefreshJwt, 155 + } 156 + 157 + // 2. Migrate Data 158 + slog.Info("migrating repo") 159 + repoBytes, err := comatproto.SyncGetRepo(ctx, oldClient, did, "") 160 + if err != nil { 161 + return fmt.Errorf("failed exporting repo: %w", err) 162 + } 163 + err = comatproto.RepoImportRepo(ctx, &newClient, bytes.NewReader(repoBytes)) 164 + if err != nil { 165 + return fmt.Errorf("failed importing repo: %w", err) 166 + } 167 + 168 + slog.Info("migrating preferences") 169 + // TODO: service proxy header for AppView? 170 + prefResp, err := appbsky.ActorGetPreferences(ctx, oldClient) 171 + if err != nil { 172 + return fmt.Errorf("failed fetching old preferences: %w", err) 173 + } 174 + err = appbsky.ActorPutPreferences(ctx, &newClient, &appbsky.ActorPutPreferences_Input{ 175 + Preferences: prefResp.Preferences, 176 + }) 177 + if err != nil { 178 + return fmt.Errorf("failed importing preferences: %w", err) 179 + } 180 + 181 + slog.Info("migrating blobs") 182 + blobCursor := "" 183 + for { 184 + listResp, err := comatproto.SyncListBlobs(ctx, oldClient, blobCursor, did, 100, "") 185 + if err != nil { 186 + return fmt.Errorf("failed listing blobs: %w", err) 187 + } 188 + for _, blobCID := range listResp.Cids { 189 + blobBytes, err := comatproto.SyncGetBlob(ctx, oldClient, blobCID, did) 190 + if err != nil { 191 + slog.Warn("failed downloading blob", "cid", blobCID, "err", err) 192 + continue 193 + } 194 + _, err = comatproto.RepoUploadBlob(ctx, &newClient, bytes.NewReader(blobBytes)) 195 + if err != nil { 196 + slog.Warn("failed uploading blob", "cid", blobCID, "err", err, "size", len(blobBytes)) 197 + } 198 + slog.Info("transferred blob", "cid", blobCID, "size", len(blobBytes)) 199 + } 200 + if listResp.Cursor == nil || *listResp.Cursor == "" { 201 + break 202 + } 203 + blobCursor = *listResp.Cursor 204 + } 205 + 206 + // display migration status 207 + // NOTE: this could check between the old PDS and new PDS, polling in a loop showing progress until all records have been indexed 208 + statusResp, err := comatproto.ServerCheckAccountStatus(ctx, &newClient) 209 + if err != nil { 210 + return fmt.Errorf("failed checking account status: %w", err) 211 + } 212 + slog.Info("account migration status", "status", statusResp) 213 + 214 + // 3. Migrate Identity 215 + // NOTE: to work with did:web or non-PDS-managed did:plc, need to do manual migraiton process 216 + slog.Info("updating identity to new host") 217 + 218 + credsResp, err := IdentityGetRecommendedDidCredentials(ctx, &newClient) 219 + if err != nil { 220 + return fmt.Errorf("failed fetching new credentials: %w", err) 221 + } 222 + credsBytes, err := json.Marshal(credsResp) 223 + if err != nil { 224 + return nil 225 + } 226 + 227 + var unsignedOp IdentitySignPlcOperation_Input 228 + if err = json.Unmarshal(credsBytes, &unsignedOp); err != nil { 229 + return fmt.Errorf("failed parsing PLC op: %w", err) 230 + } 231 + unsignedOp.Token = &plcToken 232 + 233 + // NOTE: could add additional sanity checks here that any extra rotation keys were retained, and that old alsoKnownAs and service entries are retained? The stakes aren't super high for the later, as PLC has the full history. PLC and the new PDS already implement some basic sanity checks. 234 + 235 + signedPlcOpResp, err := IdentitySignPlcOperation(ctx, oldClient, &unsignedOp) 236 + if err != nil { 237 + return fmt.Errorf("failed requesting PLC operation signature: %w", err) 238 + } 239 + 240 + err = IdentitySubmitPlcOperation(ctx, &newClient, &IdentitySubmitPlcOperation_Input{ 241 + Operation: signedPlcOpResp.Operation, 242 + }) 243 + if err != nil { 244 + return fmt.Errorf("failed submitting PLC operation: %w", err) 245 + } 246 + 247 + // 4. Finalize Migration 248 + slog.Info("activating new account") 249 + 250 + err = comatproto.ServerActivateAccount(ctx, &newClient) 251 + if err != nil { 252 + return fmt.Errorf("failed activating new host: %w", err) 253 + } 254 + err = comatproto.ServerDeactivateAccount(ctx, oldClient, &comatproto.ServerDeactivateAccount_Input{}) 255 + if err != nil { 256 + return fmt.Errorf("failed deactivating old host: %w", err) 257 + } 258 + 259 + slog.Info("account migration completed") 260 + return nil 261 + }
+169
cmd/goat/account_plc.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + 11 + "github.com/urfave/cli/v2" 12 + ) 13 + 14 + var cmdAccountPlc = &cli.Command{ 15 + Name: "plc", 16 + Usage: "sub-commands for managing PLC DID via PDS host", 17 + Subcommands: []*cli.Command{ 18 + &cli.Command{ 19 + Name: "recommended", 20 + Usage: "list recommended DID fields for current account", 21 + Action: runAccountPlcRecommended, 22 + }, 23 + &cli.Command{ 24 + Name: "request-token", 25 + Usage: "request a 2FA token (by email) for signing op", 26 + Action: runAccountPlcRequestToken, 27 + }, 28 + &cli.Command{ 29 + Name: "sign", 30 + Usage: "sign a PLC operation", 31 + ArgsUsage: `<json-file>`, 32 + Action: runAccountPlcSign, 33 + Flags: []cli.Flag{ 34 + &cli.StringFlag{ 35 + Name: "token", 36 + Usage: "2FA token for signing request", 37 + }, 38 + }, 39 + }, 40 + &cli.Command{ 41 + Name: "submit", 42 + Usage: "submit a PLC operation (via PDS)", 43 + ArgsUsage: `<json-file>`, 44 + Action: runAccountPlcSubmit, 45 + }, 46 + }, 47 + } 48 + 49 + func runAccountPlcRecommended(cctx *cli.Context) error { 50 + ctx := context.Background() 51 + 52 + xrpcc, err := loadAuthClient(ctx) 53 + if err == ErrNoAuthSession { 54 + return fmt.Errorf("auth required, but not logged in") 55 + } else if err != nil { 56 + return err 57 + } 58 + 59 + resp, err := IdentityGetRecommendedDidCredentials(ctx, xrpcc) 60 + if err != nil { 61 + return err 62 + } 63 + 64 + b, err := json.MarshalIndent(resp, "", " ") 65 + if err != nil { 66 + return err 67 + } 68 + 69 + fmt.Println(string(b)) 70 + return nil 71 + } 72 + 73 + func runAccountPlcRequestToken(cctx *cli.Context) error { 74 + ctx := context.Background() 75 + 76 + xrpcc, err := loadAuthClient(ctx) 77 + if err == ErrNoAuthSession { 78 + return fmt.Errorf("auth required, but not logged in") 79 + } else if err != nil { 80 + return err 81 + } 82 + 83 + err = comatproto.IdentityRequestPlcOperationSignature(ctx, xrpcc) 84 + if err != nil { 85 + return err 86 + } 87 + 88 + fmt.Println("Success; check email for token.") 89 + return nil 90 + } 91 + 92 + func runAccountPlcSign(cctx *cli.Context) error { 93 + ctx := context.Background() 94 + 95 + opPath := cctx.Args().First() 96 + if opPath == "" { 97 + return fmt.Errorf("need to provide JSON file path as an argument") 98 + } 99 + 100 + xrpcc, err := loadAuthClient(ctx) 101 + if err == ErrNoAuthSession { 102 + return fmt.Errorf("auth required, but not logged in") 103 + } else if err != nil { 104 + return err 105 + } 106 + 107 + fileBytes, err := os.ReadFile(opPath) 108 + if err != nil { 109 + return err 110 + } 111 + 112 + var body IdentitySignPlcOperation_Input 113 + if err = json.Unmarshal(fileBytes, &body); err != nil { 114 + return fmt.Errorf("failed decoding PLC op JSON: %w", err) 115 + } 116 + 117 + token := cctx.String("token") 118 + if token != "" { 119 + body.Token = &token 120 + } 121 + 122 + resp, err := IdentitySignPlcOperation(ctx, xrpcc, &body) 123 + if err != nil { 124 + return err 125 + } 126 + 127 + b, err := json.MarshalIndent(resp.Operation, "", " ") 128 + if err != nil { 129 + return err 130 + } 131 + 132 + fmt.Println(string(b)) 133 + return nil 134 + } 135 + 136 + func runAccountPlcSubmit(cctx *cli.Context) error { 137 + ctx := context.Background() 138 + 139 + opPath := cctx.Args().First() 140 + if opPath == "" { 141 + return fmt.Errorf("need to provide JSON file path as an argument") 142 + } 143 + 144 + xrpcc, err := loadAuthClient(ctx) 145 + if err == ErrNoAuthSession { 146 + return fmt.Errorf("auth required, but not logged in") 147 + } else if err != nil { 148 + return err 149 + } 150 + 151 + fileBytes, err := os.ReadFile(opPath) 152 + if err != nil { 153 + return err 154 + } 155 + 156 + var op json.RawMessage 157 + if err = json.Unmarshal(fileBytes, &op); err != nil { 158 + return fmt.Errorf("failed decoding PLC op JSON: %w", err) 159 + } 160 + 161 + err = IdentitySubmitPlcOperation(ctx, xrpcc, &IdentitySubmitPlcOperation_Input{ 162 + Operation: &op, 163 + }) 164 + if err != nil { 165 + return fmt.Errorf("failed submitting PLC op via PDS: %w", err) 166 + } 167 + 168 + return nil 169 + }
+29 -11
cmd/goat/auth.go
··· 79 79 if err != nil { 80 80 // TODO: if failure, try creating a new session from password 81 81 fmt.Println("trying to refresh auth from password...") 82 - as, err := refreshAuthSession(ctx, sess.DID.AtIdentifier(), sess.Password) 82 + as, err := refreshAuthSession(ctx, sess.DID.AtIdentifier(), sess.Password, sess.PDS) 83 83 if err != nil { 84 84 return nil, err 85 85 } ··· 96 96 return &client, nil 97 97 } 98 98 99 - func refreshAuthSession(ctx context.Context, username syntax.AtIdentifier, password string) (*AuthSession, error) { 100 - dir := identity.DefaultDirectory() 101 - ident, err := dir.Lookup(ctx, username) 102 - if err != nil { 103 - return nil, err 104 - } 99 + func refreshAuthSession(ctx context.Context, username syntax.AtIdentifier, password, pdsURL string) (*AuthSession, error) { 105 100 106 - pdsURL := ident.PDSEndpoint() 101 + var did syntax.DID 107 102 if pdsURL == "" { 108 - return nil, fmt.Errorf("empty PDS URL") 103 + dir := identity.DefaultDirectory() 104 + ident, err := dir.Lookup(ctx, username) 105 + if err != nil { 106 + return nil, err 107 + } 108 + 109 + pdsURL = ident.PDSEndpoint() 110 + if pdsURL == "" { 111 + return nil, fmt.Errorf("empty PDS URL") 112 + } 113 + did = ident.DID 114 + } 115 + 116 + if did == "" && username.IsDID() { 117 + did, _ = username.AsDID() 109 118 } 110 119 111 120 client := xrpc.Client{ 112 121 Host: pdsURL, 113 122 } 114 123 sess, err := comatproto.ServerCreateSession(ctx, &client, &comatproto.ServerCreateSession_Input{ 115 - Identifier: ident.DID.String(), 124 + Identifier: username.String(), 116 125 Password: password, 117 126 }) 118 127 if err != nil { ··· 121 130 122 131 // TODO: check account status? 123 132 // TODO: warn if email isn't verified? 133 + // TODO: check that sess.Did matches username 134 + if did == "" { 135 + did, err = syntax.ParseDID(sess.Did) 136 + if err != nil { 137 + return nil, err 138 + } 139 + } else if sess.Did != did.String() { 140 + return nil, fmt.Errorf("session DID didn't match expected: %s != %s", sess.Did, did) 141 + } 124 142 125 143 authSession := AuthSession{ 126 - DID: ident.DID, 144 + DID: did, 127 145 Password: password, 128 146 PDS: pdsURL, 129 147 RefreshToken: sess.RefreshJwt,
+1
cmd/goat/bsky.go
··· 23 23 ArgsUsage: `<text>`, 24 24 Action: runBskyPost, 25 25 }, 26 + cmdBskyPrefs, 26 27 }, 27 28 } 28 29
+92
cmd/goat/bsky_prefs.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + 9 + appbsky "github.com/bluesky-social/indigo/api/bsky" 10 + 11 + "github.com/urfave/cli/v2" 12 + ) 13 + 14 + var cmdBskyPrefs = &cli.Command{ 15 + Name: "prefs", 16 + Usage: "sub-commands for preferences", 17 + Flags: []cli.Flag{}, 18 + Subcommands: []*cli.Command{ 19 + &cli.Command{ 20 + Name: "export", 21 + Usage: "dump preferences out as JSON", 22 + Action: runBskyPrefsExport, 23 + }, 24 + &cli.Command{ 25 + Name: "import", 26 + Usage: "upload preferences from JSON file", 27 + ArgsUsage: `<file>`, 28 + Action: runBskyPrefsImport, 29 + }, 30 + }, 31 + } 32 + 33 + func runBskyPrefsExport(cctx *cli.Context) error { 34 + ctx := context.Background() 35 + 36 + xrpcc, err := loadAuthClient(ctx) 37 + if err == ErrNoAuthSession { 38 + return fmt.Errorf("auth required, but not logged in") 39 + } else if err != nil { 40 + return err 41 + } 42 + 43 + // TODO: does indigo API code crash with unsupported preference '$type'? Eg "Lexicon decoder" with unsupported type. 44 + resp, err := appbsky.ActorGetPreferences(ctx, xrpcc) 45 + if err != nil { 46 + return fmt.Errorf("failed fetching old preferences: %w", err) 47 + } 48 + 49 + b, err := json.MarshalIndent(resp.Preferences, "", " ") 50 + if err != nil { 51 + return err 52 + } 53 + fmt.Println(string(b)) 54 + 55 + return nil 56 + } 57 + 58 + func runBskyPrefsImport(cctx *cli.Context) error { 59 + ctx := context.Background() 60 + prefsPath := cctx.Args().First() 61 + if prefsPath == "" { 62 + return fmt.Errorf("need to provide file path as an argument") 63 + } 64 + 65 + xrpcc, err := loadAuthClient(ctx) 66 + if err == ErrNoAuthSession { 67 + return fmt.Errorf("auth required, but not logged in") 68 + } else if err != nil { 69 + return err 70 + } 71 + 72 + prefsBytes, err := os.ReadFile(prefsPath) 73 + if err != nil { 74 + return err 75 + } 76 + 77 + var prefsArray []appbsky.ActorDefs_Preferences_Elem 78 + err = json.Unmarshal(prefsBytes, &prefsArray) 79 + if err != nil { 80 + return err 81 + } 82 + 83 + // WARNING: might clobber off-Lexicon or new-Lexicon data fields (which don't round-trip deserialization) 84 + err = appbsky.ActorPutPreferences(ctx, xrpcc, &appbsky.ActorPutPreferences_Input{ 85 + Preferences: prefsArray, 86 + }) 87 + if err != nil { 88 + return fmt.Errorf("failed fetching old preferences: %w", err) 89 + } 90 + 91 + return nil 92 + }
+23
cmd/goat/identitygetRecommendedDidCredentials.go
··· 1 + // Copied from indigo:api/atproto/identitygetRecommendedDidCredentials.go 2 + 3 + package main 4 + 5 + // schema: com.atproto.identity.getRecommendedDidCredentials 6 + 7 + import ( 8 + "context" 9 + "encoding/json" 10 + 11 + "github.com/bluesky-social/indigo/xrpc" 12 + ) 13 + 14 + // IdentityGetRecommendedDidCredentials calls the XRPC method "com.atproto.identity.getRecommendedDidCredentials". 15 + func IdentityGetRecommendedDidCredentials(ctx context.Context, c *xrpc.Client) (*json.RawMessage, error) { 16 + var out json.RawMessage 17 + 18 + if err := c.Do(ctx, xrpc.Query, "", "com.atproto.identity.getRecommendedDidCredentials", nil, nil, &out); err != nil { 19 + return nil, err 20 + } 21 + 22 + return &out, nil 23 + }
+38
cmd/goat/identitysignPlcOperation.go
··· 1 + // Copied from indigo:api/atproto/identitysignPlcOperation.go 2 + 3 + package main 4 + 5 + // schema: com.atproto.identity.signPlcOperation 6 + 7 + import ( 8 + "context" 9 + "encoding/json" 10 + 11 + "github.com/bluesky-social/indigo/xrpc" 12 + ) 13 + 14 + // IdentitySignPlcOperation_Input is the input argument to a com.atproto.identity.signPlcOperation call. 15 + type IdentitySignPlcOperation_Input struct { 16 + AlsoKnownAs []string `json:"alsoKnownAs,omitempty" cborgen:"alsoKnownAs,omitempty"` 17 + RotationKeys []string `json:"rotationKeys,omitempty" cborgen:"rotationKeys,omitempty"` 18 + Services *json.RawMessage `json:"services,omitempty" cborgen:"services,omitempty"` 19 + // token: A token received through com.atproto.identity.requestPlcOperationSignature 20 + Token *string `json:"token,omitempty" cborgen:"token,omitempty"` 21 + VerificationMethods *json.RawMessage `json:"verificationMethods,omitempty" cborgen:"verificationMethods,omitempty"` 22 + } 23 + 24 + // IdentitySignPlcOperation_Output is the output of a com.atproto.identity.signPlcOperation call. 25 + type IdentitySignPlcOperation_Output struct { 26 + // operation: A signed DID PLC operation. 27 + Operation *json.RawMessage `json:"operation" cborgen:"operation"` 28 + } 29 + 30 + // IdentitySignPlcOperation calls the XRPC method "com.atproto.identity.signPlcOperation". 31 + func IdentitySignPlcOperation(ctx context.Context, c *xrpc.Client, input *IdentitySignPlcOperation_Input) (*IdentitySignPlcOperation_Output, error) { 32 + var out IdentitySignPlcOperation_Output 33 + if err := c.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.identity.signPlcOperation", nil, input, &out); err != nil { 34 + return nil, err 35 + } 36 + 37 + return &out, nil 38 + }
+26
cmd/goat/identitysubmitPlcOperation.go
··· 1 + // Copied from indigo:api/atproto/identitysubmitPlcOperation.go 2 + 3 + package main 4 + 5 + // schema: com.atproto.identity.submitPlcOperation 6 + 7 + import ( 8 + "context" 9 + "encoding/json" 10 + 11 + "github.com/bluesky-social/indigo/xrpc" 12 + ) 13 + 14 + // IdentitySubmitPlcOperation_Input is the input argument to a com.atproto.identity.submitPlcOperation call. 15 + type IdentitySubmitPlcOperation_Input struct { 16 + Operation *json.RawMessage `json:"operation" cborgen:"operation"` 17 + } 18 + 19 + // IdentitySubmitPlcOperation calls the XRPC method "com.atproto.identity.submitPlcOperation". 20 + func IdentitySubmitPlcOperation(ctx context.Context, c *xrpc.Client, input *IdentitySubmitPlcOperation_Input) error { 21 + if err := c.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.identity.submitPlcOperation", nil, input, nil); err != nil { 22 + return err 23 + } 24 + 25 + return nil 26 + }
+1
cmd/goat/main.go
··· 37 37 cmdRecord, 38 38 cmdSyntax, 39 39 cmdCrypto, 40 + cmdPds, 40 41 } 41 42 return app.Run(args) 42 43 }
+55
cmd/goat/pds.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "strings" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + 12 + "github.com/urfave/cli/v2" 13 + ) 14 + 15 + var cmdPds = &cli.Command{ 16 + Name: "pds", 17 + Usage: "sub-commands for pds hosts", 18 + Flags: []cli.Flag{}, 19 + Subcommands: []*cli.Command{ 20 + &cli.Command{ 21 + Name: "describe", 22 + Usage: "shows info about a PDS info", 23 + ArgsUsage: `<url>`, 24 + Action: runPdsDescribe, 25 + }, 26 + }, 27 + } 28 + 29 + func runPdsDescribe(cctx *cli.Context) error { 30 + ctx := context.Background() 31 + 32 + pdsHost := cctx.Args().First() 33 + if pdsHost == "" { 34 + return fmt.Errorf("need to provide new handle as argument") 35 + } 36 + if !strings.Contains(pdsHost, "://") { 37 + return fmt.Errorf("PDS host is not a url: %s", pdsHost) 38 + } 39 + client := xrpc.Client{ 40 + Host: pdsHost, 41 + } 42 + 43 + resp, err := comatproto.ServerDescribeServer(ctx, &client) 44 + if err != nil { 45 + return err 46 + } 47 + 48 + b, err := json.MarshalIndent(resp, "", " ") 49 + if err != nil { 50 + return err 51 + } 52 + fmt.Println(string(b)) 53 + 54 + return nil 55 + }
+35
cmd/goat/repo.go
··· 1 1 package main 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "encoding/json" 6 7 "fmt" ··· 35 36 }, 36 37 }, 37 38 Action: runRepoExport, 39 + }, 40 + &cli.Command{ 41 + Name: "import", 42 + Usage: "upload CAR file for current account", 43 + ArgsUsage: `<path>`, 44 + Action: runRepoImport, 38 45 }, 39 46 &cli.Command{ 40 47 Name: "ls", ··· 102 109 return err 103 110 } 104 111 return os.WriteFile(carPath, repoBytes, 0666) 112 + } 113 + 114 + func runRepoImport(cctx *cli.Context) error { 115 + ctx := context.Background() 116 + 117 + carPath := cctx.Args().First() 118 + if carPath == "" { 119 + return fmt.Errorf("need to provide CAR file path as an argument") 120 + } 121 + 122 + xrpcc, err := loadAuthClient(ctx) 123 + if err == ErrNoAuthSession { 124 + return fmt.Errorf("auth required, but not logged in") 125 + } else if err != nil { 126 + return err 127 + } 128 + 129 + fileBytes, err := os.ReadFile(carPath) 130 + if err != nil { 131 + return err 132 + } 133 + 134 + err = comatproto.RepoImportRepo(ctx, xrpcc, bytes.NewReader(fileBytes)) 135 + if err != nil { 136 + return fmt.Errorf("failed to import repo: %w", err) 137 + } 138 + 139 + return nil 105 140 } 106 141 107 142 func runRepoList(cctx *cli.Context) error {