Repository: bluesky-social/indigo

Issue:
Can't migrate via goat, possibly because I'm an idiot
Hi friends! I'm trying to migrate to my own hosted PDS, which is up and running nicely (yay!). I'm trying to follow Bryan's guide to migrate via `goat`, but I'm running into an issue where:

- I run `goat account login`, which (fairly) throws up a 401 because it's emailing me a token (`error: XRPC ERROR 401: AuthFactorTokenRequired: A sign in code has been sent to your email address`).
- I then run `goat account migrate` with all their pertinent details, including the token I got via the email, but it just throws up an error (`error: auth required, but not logged in`).

I scrubbed through existing/closed issues and the guide, and I cannot for the life of me work out what I'm missing. Help? 😅 


Discussion:
@haileyok: @bnewbold @bnewbold: hrm, the basic account login should not usually require an email token. do you have "email 2FA" enabled for your account? is your account email verified?

I may not have tested this flow when the "email 2FA" flag is set. You might be able to disable that and try again? If that is what the issue is, let me know and i'll at least open a ticket to track the issue.@waferbaby: @bnewbold You are a scholar and a gentleman. Will give that a shot.@waferbaby: @bnewbold Progress! That worked, could get the correct code via email* - however, It's now telling me the API is sending me invalid data (wrong content type, maybe? assuming HTML instead of whatever it's expecting?).

```
error: failed connecting to new host: XRPC ERROR 404: failed to decode xrpc error message: invalid character '<' looking for beginning of value
```

\* Also just FYI, when it sends you that email, the message actually says:

```
To update the email for your account, enter the code below in the app along with your new email.
```

@waferbaby: @bnewbold Running through the manual migration steps via `goat`, it looks like that error is coming from my self-hosted server - logging in after account creation shows the same thing - here's the output in my local logs:

```json
{
    "level": 30,
    "time": 1730764573244,
    "pid": 7,
    "hostname": "homebase",
    "name": "pds",
    "req": {
        "id": 9140,
        "method": "POST",
        "url": "//xrpc/com.atproto.server.createSession",
        "query": {},
        "params": {},
        "headers": {
            "host": "wafer.baby",
            "user-agent": "indigo/v0.0.0-20241031232035-1a73c3fb6841",
            "content-length": "87",
            "accept-encoding": "gzip",
            "content-type": "application/json",
            "x-forwarded-for": "(ip address)",
            "x-forwarded-host": "wafer.baby",
            "x-forwarded-proto": "https"
        }
    },
    "res": {
        "statusCode": 404,
        "headers": {
            "x-powered-by": "Express",
            "access-control-allow-origin": "*",
            "content-security-policy": "default-src 'none'",
            "x-content-type-options": "nosniff",
            "content-type": "text/html; charset=utf-8",
            "content-length": 178,
            "vary": "Accept-Encoding"
        }
    },
    "responseTime": 3,
    "msg": "request completed"
}
```@waferbaby: Oh ha, it's because I left a trailing slash on my server name - removing that lets it work! FYI.@moll-dev: +1 I ended up patching goat to take a hardcoded 2FA@bnewbold: I think the root issue here was addressed; please re-open an issue in the new repo if you still have problems: https://github.com/bluesky-social/goat

Solution patch:
diff --git a/cmd/goat/actorgetPreferences.go b/api/agnostic/actorgetPreferences.go
similarity index 90%
rename from cmd/goat/actorgetPreferences.go
rename to api/agnostic/actorgetPreferences.go
index bd6e8a18..9ee242c0 100644
--- a/cmd/goat/actorgetPreferences.go
+++ b/api/agnostic/actorgetPreferences.go
@@ -1,6 +1,6 @@
-// Copied from indigo:api/atproto/actorgetPreferences.go
+// Copied from indigo:api/bsky/actorgetPreferences.go
 
-package main
+package agnostic
 
 // schema: app.bsky.actor.getPreferences
 
diff --git a/cmd/goat/actorputPreferences.go b/api/agnostic/actorputPreferences.go
similarity index 90%
rename from cmd/goat/actorputPreferences.go
rename to api/agnostic/actorputPreferences.go
index 24042236..3648e5bd 100644
--- a/cmd/goat/actorputPreferences.go
+++ b/api/agnostic/actorputPreferences.go
@@ -1,6 +1,6 @@
-// Copied from indigo:api/atproto/actorputPreferences.go
+// Copied from indigo:api/bsky/actorputPreferences.go
 
-package main
+package agnostic
 
 // schema: app.bsky.actor.putPreferences
 
diff --git a/api/agnostic/doc.go b/api/agnostic/doc.go
new file mode 100644
index 00000000..1b9cf632
--- /dev/null
+++ b/api/agnostic/doc.go
@@ -0,0 +1,4 @@
+// Package indigo/api/agnositc provides schema-agnostic helpers for fetching records from the network.
+//
+// These are variants of endpoints in indigo/api/atproto.
+package agnostic
diff --git a/cmd/goat/identitygetRecommendedDidCredentials.go b/api/agnostic/identitygetRecommendedDidCredentials.go
similarity index 97%
rename from cmd/goat/identitygetRecommendedDidCredentials.go
rename to api/agnostic/identitygetRecommendedDidCredentials.go
index 75abcc52..ea0a70a9 100644
--- a/cmd/goat/identitygetRecommendedDidCredentials.go
+++ b/api/agnostic/identitygetRecommendedDidCredentials.go
@@ -1,6 +1,6 @@
 // Copied from indigo:api/atproto/identitygetRecommendedDidCredentials.go
 
-package main
+package agnostic
 
 // schema: com.atproto.identity.getRecommendedDidCredentials
 
diff --git a/cmd/goat/identitysignPlcOperation.go b/api/agnostic/identitysignPlcOperation.go
similarity index 98%
rename from cmd/goat/identitysignPlcOperation.go
rename to api/agnostic/identitysignPlcOperation.go
index f9c96bea..59d83a23 100644
--- a/cmd/goat/identitysignPlcOperation.go
+++ b/api/agnostic/identitysignPlcOperation.go
@@ -1,6 +1,6 @@
 // Copied from indigo:api/atproto/identitysignPlcOperation.go
 
-package main
+package agnostic
 
 // schema: com.atproto.identity.signPlcOperation
 
diff --git a/cmd/goat/identitysubmitPlcOperation.go b/api/agnostic/identitysubmitPlcOperation.go
similarity index 97%
rename from cmd/goat/identitysubmitPlcOperation.go
rename to api/agnostic/identitysubmitPlcOperation.go
index 9f15e8ad..91f9cf0c 100644
--- a/cmd/goat/identitysubmitPlcOperation.go
+++ b/api/agnostic/identitysubmitPlcOperation.go
@@ -1,6 +1,6 @@
 // Copied from indigo:api/atproto/identitysubmitPlcOperation.go
 
-package main
+package agnostic
 
 // schema: com.atproto.identity.submitPlcOperation
 
diff --git a/api/agnostic/repoapplyWrites.go b/api/agnostic/repoapplyWrites.go
new file mode 100644
index 00000000..de0799ac
--- /dev/null
+++ b/api/agnostic/repoapplyWrites.go
@@ -0,0 +1,189 @@
+// Copied from indigo:api/atproto/repoapplyWrites.go
+
+package agnostic
+
+// schema: com.atproto.repo.applyWrites
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/bluesky-social/indigo/lex/util"
+	"github.com/bluesky-social/indigo/xrpc"
+)
+
+// RepoApplyWrites_Create is a "create" in the com.atproto.repo.applyWrites schema.
+//
+// Operation which creates a new record.
+//
+// RECORDTYPE: RepoApplyWrites_Create
+type RepoApplyWrites_Create struct {
+	LexiconTypeID string           `json:"$type,const=com.atproto.repo.applyWrites#create" cborgen:"$type,const=com.atproto.repo.applyWrites#create"`
+	Collection    string           `json:"collection" cborgen:"collection"`
+	Rkey          *string          `json:"rkey,omitempty" cborgen:"rkey,omitempty"`
+	Value         *json.RawMessage `json:"value" cborgen:"value"`
+}
+
+// RepoApplyWrites_CreateResult is a "createResult" in the com.atproto.repo.applyWrites schema.
+//
+// RECORDTYPE: RepoApplyWrites_CreateResult
+type RepoApplyWrites_CreateResult struct {
+	LexiconTypeID    string  `json:"$type,const=com.atproto.repo.applyWrites#createResult" cborgen:"$type,const=com.atproto.repo.applyWrites#createResult"`
+	Cid              string  `json:"cid" cborgen:"cid"`
+	Uri              string  `json:"uri" cborgen:"uri"`
+	ValidationStatus *string `json:"validationStatus,omitempty" cborgen:"validationStatus,omitempty"`
+}
+
+// RepoApplyWrites_Delete is a "delete" in the com.atproto.repo.applyWrites schema.
+//
+// Operation which deletes an existing record.
+//
+// RECORDTYPE: RepoApplyWrites_Delete
+type RepoApplyWrites_Delete struct {
+	LexiconTypeID string `json:"$type,const=com.atproto.repo.applyWrites#delete" cborgen:"$type,const=com.atproto.repo.applyWrites#delete"`
+	Collection    string `json:"collection" cborgen:"collection"`
+	Rkey          string `json:"rkey" cborgen:"rkey"`
+}
+
+// RepoApplyWrites_DeleteResult is a "deleteResult" in the com.atproto.repo.applyWrites schema.
+//
+// RECORDTYPE: RepoApplyWrites_DeleteResult
+type RepoApplyWrites_DeleteResult struct {
+	LexiconTypeID string `json:"$type,const=com.atproto.repo.applyWrites#deleteResult" cborgen:"$type,const=com.atproto.repo.applyWrites#deleteResult"`
+}
+
+// RepoApplyWrites_Input is the input argument to a com.atproto.repo.applyWrites call.
+type RepoApplyWrites_Input struct {
+	// repo: The handle or DID of the repo (aka, current account).
+	Repo string `json:"repo" cborgen:"repo"`
+	// swapCommit: If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.
+	SwapCommit *string `json:"swapCommit,omitempty" cborgen:"swapCommit,omitempty"`
+	// validate: Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons.
+	Validate *bool                                `json:"validate,omitempty" cborgen:"validate,omitempty"`
+	Writes   []*RepoApplyWrites_Input_Writes_Elem `json:"writes" cborgen:"writes"`
+}
+
+type RepoApplyWrites_Input_Writes_Elem struct {
+	RepoApplyWrites_Create *RepoApplyWrites_Create
+	RepoApplyWrites_Update *RepoApplyWrites_Update
+	RepoApplyWrites_Delete *RepoApplyWrites_Delete
+}
+
+func (t *RepoApplyWrites_Input_Writes_Elem) MarshalJSON() ([]byte, error) {
+	if t.RepoApplyWrites_Create != nil {
+		t.RepoApplyWrites_Create.LexiconTypeID = "com.atproto.repo.applyWrites#create"
+		return json.Marshal(t.RepoApplyWrites_Create)
+	}
+	if t.RepoApplyWrites_Update != nil {
+		t.RepoApplyWrites_Update.LexiconTypeID = "com.atproto.repo.applyWrites#update"
+		return json.Marshal(t.RepoApplyWrites_Update)
+	}
+	if t.RepoApplyWrites_Delete != nil {
+		t.RepoApplyWrites_Delete.LexiconTypeID = "com.atproto.repo.applyWrites#delete"
+		return json.Marshal(t.RepoApplyWrites_Delete)
+	}
+	return nil, fmt.Errorf("cannot marshal empty enum")
+}
+func (t *RepoApplyWrites_Input_Writes_Elem) UnmarshalJSON(b []byte) error {
+	typ, err := util.TypeExtract(b)
+	if err != nil {
+		return err
+	}
+
+	switch typ {
+	case "com.atproto.repo.applyWrites#create":
+		t.RepoApplyWrites_Create = new(RepoApplyWrites_Create)
+		return json.Unmarshal(b, t.RepoApplyWrites_Create)
+	case "com.atproto.repo.applyWrites#update":
+		t.RepoApplyWrites_Update = new(RepoApplyWrites_Update)
+		return json.Unmarshal(b, t.RepoApplyWrites_Update)
+	case "com.atproto.repo.applyWrites#delete":
+		t.RepoApplyWrites_Delete = new(RepoApplyWrites_Delete)
+		return json.Unmarshal(b, t.RepoApplyWrites_Delete)
+
+	default:
+		return fmt.Errorf("closed enums must have a matching value")
+	}
+}
+
+// RepoApplyWrites_Output is the output of a com.atproto.repo.applyWrites call.
+type RepoApplyWrites_Output struct {
+	Commit  *RepoDefs_CommitMeta                   `json:"commit,omitempty" cborgen:"commit,omitempty"`
+	Results []*RepoApplyWrites_Output_Results_Elem `json:"results,omitempty" cborgen:"results,omitempty"`
+}
+
+type RepoApplyWrites_Output_Results_Elem struct {
+	RepoApplyWrites_CreateResult *RepoApplyWrites_CreateResult
+	RepoApplyWrites_UpdateResult *RepoApplyWrites_UpdateResult
+	RepoApplyWrites_DeleteResult *RepoApplyWrites_DeleteResult
+}
+
+func (t *RepoApplyWrites_Output_Results_Elem) MarshalJSON() ([]byte, error) {
+	if t.RepoApplyWrites_CreateResult != nil {
+		t.RepoApplyWrites_CreateResult.LexiconTypeID = "com.atproto.repo.applyWrites#createResult"
+		return json.Marshal(t.RepoApplyWrites_CreateResult)
+	}
+	if t.RepoApplyWrites_UpdateResult != nil {
+		t.RepoApplyWrites_UpdateResult.LexiconTypeID = "com.atproto.repo.applyWrites#updateResult"
+		return json.Marshal(t.RepoApplyWrites_UpdateResult)
+	}
+	if t.RepoApplyWrites_DeleteResult != nil {
+		t.RepoApplyWrites_DeleteResult.LexiconTypeID = "com.atproto.repo.applyWrites#deleteResult"
+		return json.Marshal(t.RepoApplyWrites_DeleteResult)
+	}
+	return nil, fmt.Errorf("cannot marshal empty enum")
+}
+func (t *RepoApplyWrites_Output_Results_Elem) UnmarshalJSON(b []byte) error {
+	typ, err := util.TypeExtract(b)
+	if err != nil {
+		return err
+	}
+
+	switch typ {
+	case "com.atproto.repo.applyWrites#createResult":
+		t.RepoApplyWrites_CreateResult = new(RepoApplyWrites_CreateResult)
+		return json.Unmarshal(b, t.RepoApplyWrites_CreateResult)
+	case "com.atproto.repo.applyWrites#updateResult":
+		t.RepoApplyWrites_UpdateResult = new(RepoApplyWrites_UpdateResult)
+		return json.Unmarshal(b, t.RepoApplyWrites_UpdateResult)
+	case "com.atproto.repo.applyWrites#deleteResult":
+		t.RepoApplyWrites_DeleteResult = new(RepoApplyWrites_DeleteResult)
+		return json.Unmarshal(b, t.RepoApplyWrites_DeleteResult)
+
+	default:
+		return fmt.Errorf("closed enums must have a matching value")
+	}
+}
+
+// RepoApplyWrites_Update is a "update" in the com.atproto.repo.applyWrites schema.
+//
+// Operation which updates an existing record.
+//
+// RECORDTYPE: RepoApplyWrites_Update
+type RepoApplyWrites_Update struct {
+	LexiconTypeID string           `json:"$type,const=com.atproto.repo.applyWrites#update" cborgen:"$type,const=com.atproto.repo.applyWrites#update"`
+	Collection    string           `json:"collection" cborgen:"collection"`
+	Rkey          string           `json:"rkey" cborgen:"rkey"`
+	Value         *json.RawMessage `json:"value" cborgen:"value"`
+}
+
+// RepoApplyWrites_UpdateResult is a "updateResult" in the com.atproto.repo.applyWrites schema.
+//
+// RECORDTYPE: RepoApplyWrites_UpdateResult
+type RepoApplyWrites_UpdateResult struct {
+	LexiconTypeID    string  `json:"$type,const=com.atproto.repo.applyWrites#updateResult" cborgen:"$type,const=com.atproto.repo.applyWrites#updateResult"`
+	Cid              string  `json:"cid" cborgen:"cid"`
+	Uri              string  `json:"uri" cborgen:"uri"`
+	ValidationStatus *string `json:"validationStatus,omitempty" cborgen:"validationStatus,omitempty"`
+}
+
+// RepoApplyWrites calls the XRPC method "com.atproto.repo.applyWrites".
+func RepoApplyWrites(ctx context.Context, c *xrpc.Client, input *RepoApplyWrites_Input) (*RepoApplyWrites_Output, error) {
+	var out RepoApplyWrites_Output
+	if err := c.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil {
+		return nil, err
+	}
+
+	return &out, nil
+}
diff --git a/cmd/goat/repocreateRecord.go b/api/agnostic/repocreateRecord.go
similarity index 99%
rename from cmd/goat/repocreateRecord.go
rename to api/agnostic/repocreateRecord.go
index c1fa67a3..9dcddadb 100644
--- a/cmd/goat/repocreateRecord.go
+++ b/api/agnostic/repocreateRecord.go
@@ -1,6 +1,6 @@
 // Copied from indigo:api/atproto/repocreateRecords.go
 
-package main
+package agnostic
 
 // schema: com.atproto.repo.createRecord
 
diff --git a/cmd/astrolabe/repogetRecord.go b/api/agnostic/repogetRecord.go
similarity index 98%
rename from cmd/astrolabe/repogetRecord.go
rename to api/agnostic/repogetRecord.go
index e3b857cd..6025c959 100644
--- a/cmd/astrolabe/repogetRecord.go
+++ b/api/agnostic/repogetRecord.go
@@ -1,6 +1,6 @@
 // Copied from indigo:api/atproto/repolistRecords.go
 
-package main
+package agnostic
 
 // schema: com.atproto.repo.getRecord
 
diff --git a/cmd/astrolabe/repolistRecords.go b/api/agnostic/repolistRecords.go
similarity index 99%
rename from cmd/astrolabe/repolistRecords.go
rename to api/agnostic/repolistRecords.go
index 3cfdd9b2..5d32e865 100644
--- a/cmd/astrolabe/repolistRecords.go
+++ b/api/agnostic/repolistRecords.go
@@ -1,6 +1,6 @@
 // Copied from indigo:api/atproto/repolistRecords.go
 
-package main
+package agnostic
 
 // schema: com.atproto.repo.listRecords
 
diff --git a/cmd/goat/repoputRecord.go b/api/agnostic/repoputRecord.go
similarity index 99%
rename from cmd/goat/repoputRecord.go
rename to api/agnostic/repoputRecord.go
index 34011797..15e1452b 100644
--- a/cmd/goat/repoputRecord.go
+++ b/api/agnostic/repoputRecord.go
@@ -1,6 +1,6 @@
 // Copied from indigo:api/atproto/repoputRecords.go
 
-package main
+package agnostic
 
 // schema: com.atproto.repo.putRecord
 
diff --git a/atproto/syntax/path.go b/atproto/syntax/path.go
new file mode 100644
index 00000000..310bee17
--- /dev/null
+++ b/atproto/syntax/path.go
@@ -0,0 +1,26 @@
+package syntax
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+)
+
+// Parses an atproto repo path string in to "collection" (NSID) and record key parts.
+//
+// Does not return partial success: either both collection and record key are complete (and error is nil), or both are empty string (and error is not nil)
+func ParseRepoPath(raw string) (NSID, RecordKey, error) {
+	parts := strings.SplitN(raw, "/", 3)
+	if len(parts) != 2 {
+		return "", "", errors.New("expected path to have two parts, separated by single slash")
+	}
+	nsid, err := ParseNSID(parts[0])
+	if err != nil {
+		return "", "", fmt.Errorf("collection part of path not a valid NSID: %w", err)
+	}
+	rkey, err := ParseRecordKey(parts[1])
+	if err != nil {
+		return "", "", fmt.Errorf("record key part of path not valid: %w", err)
+	}
+	return nsid, rkey, nil
+}
diff --git a/cmd/astrolabe/handlers.go b/cmd/astrolabe/handlers.go
index f7732fe4..f974fcf4 100644
--- a/cmd/astrolabe/handlers.go
+++ b/cmd/astrolabe/handlers.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"strings"
 
+	"github.com/bluesky-social/indigo/api/agnostic"
 	comatproto "github.com/bluesky-social/indigo/api/atproto"
 	_ "github.com/bluesky-social/indigo/api/bsky"
 	"github.com/bluesky-social/indigo/atproto/data"
@@ -161,7 +162,7 @@ func (srv *Server) WebRepoCollection(c echo.Context) error {
 
 	cursor := c.QueryParam("cursor")
 	// collection string, cursor string, limit int64, repo string, reverse bool, rkeyEnd string, rkeyStart string
-	resp, err := RepoListRecords(ctx, &xrpcc, collection.String(), cursor, 100, ident.DID.String(), false, "", "")
+	resp, err := agnostic.RepoListRecords(ctx, &xrpcc, collection.String(), cursor, 100, ident.DID.String(), false, "", "")
 	if err != nil {
 		return err
 	}
@@ -218,7 +219,7 @@ func (srv *Server) WebRepoRecord(c echo.Context) error {
 	xrpcc := xrpc.Client{
 		Host: ident.PDSEndpoint(),
 	}
-	resp, err := RepoGetRecord(ctx, &xrpcc, "", collection.String(), ident.DID.String(), rkey.String())
+	resp, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", collection.String(), ident.DID.String(), rkey.String())
 	if err != nil {
 		return echo.NewHTTPError(400, fmt.Sprintf("failed to load record: %s", err))
 	}
diff --git a/cmd/goat/account.go b/cmd/goat/account.go
index 347ebc02..e077dca1 100644
--- a/cmd/goat/account.go
+++ b/cmd/goat/account.go
@@ -37,6 +37,11 @@ var cmdAccount = &cli.Command{
 					Usage:    "password (app password recommended)",
 					EnvVars:  []string{"ATP_AUTH_PASSWORD"},
 				},
+				&cli.StringFlag{
+					Name:    "auth-factor-token",
+					Usage:   "token required if password is used and 2fa is required",
+					EnvVars: []string{"ATP_AUTH_FACTOR_TOKEN"},
+				},
 				&cli.StringFlag{
 					Name:    "pds-host",
 					Usage:   "URL of the PDS to create account on (overrides DID doc)",
@@ -163,7 +168,7 @@ func runAccountLogin(cctx *cli.Context) error {
 		return err
 	}
 
-	_, err = refreshAuthSession(ctx, *username, cctx.String("app-password"), cctx.String("pds-host"))
+	_, err = refreshAuthSession(ctx, *username, cctx.String("app-password"), cctx.String("pds-host"), cctx.String("auth-factor-token"))
 	return err
 }
 
diff --git a/cmd/goat/account_migrate.go b/cmd/goat/account_migrate.go
index fa3d6053..08925f48 100644
--- a/cmd/goat/account_migrate.go
+++ b/cmd/goat/account_migrate.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/bluesky-social/indigo/api/agnostic"
 	comatproto "github.com/bluesky-social/indigo/api/atproto"
 	"github.com/bluesky-social/indigo/atproto/syntax"
 	"github.com/bluesky-social/indigo/xrpc"
@@ -166,11 +167,11 @@ func runAccountMigrate(cctx *cli.Context) error {
 
 	slog.Info("migrating preferences")
 	// TODO: service proxy header for AppView?
-	prefResp, err := ActorGetPreferences(ctx, oldClient)
+	prefResp, err := agnostic.ActorGetPreferences(ctx, oldClient)
 	if err != nil {
 		return fmt.Errorf("failed fetching old preferences: %w", err)
 	}
-	err = ActorPutPreferences(ctx, &newClient, &ActorPutPreferences_Input{
+	err = agnostic.ActorPutPreferences(ctx, &newClient, &agnostic.ActorPutPreferences_Input{
 		Preferences: prefResp.Preferences,
 	})
 	if err != nil {
@@ -214,7 +215,7 @@ func runAccountMigrate(cctx *cli.Context) error {
 	// NOTE: to work with did:web or non-PDS-managed did:plc, need to do manual migraiton process
 	slog.Info("updating identity to new host")
 
-	credsResp, err := IdentityGetRecommendedDidCredentials(ctx, &newClient)
+	credsResp, err := agnostic.IdentityGetRecommendedDidCredentials(ctx, &newClient)
 	if err != nil {
 		return fmt.Errorf("failed fetching new credentials: %w", err)
 	}
@@ -223,7 +224,7 @@ func runAccountMigrate(cctx *cli.Context) error {
 		return nil
 	}
 
-	var unsignedOp IdentitySignPlcOperation_Input
+	var unsignedOp agnostic.IdentitySignPlcOperation_Input
 	if err = json.Unmarshal(credsBytes, &unsignedOp); err != nil {
 		return fmt.Errorf("failed parsing PLC op: %w", err)
 	}
@@ -231,12 +232,12 @@ func runAccountMigrate(cctx *cli.Context) error {
 
 	// 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.
 
-	signedPlcOpResp, err := IdentitySignPlcOperation(ctx, oldClient, &unsignedOp)
+	signedPlcOpResp, err := agnostic.IdentitySignPlcOperation(ctx, oldClient, &unsignedOp)
 	if err != nil {
 		return fmt.Errorf("failed requesting PLC operation signature: %w", err)
 	}
 
-	err = IdentitySubmitPlcOperation(ctx, &newClient, &IdentitySubmitPlcOperation_Input{
+	err = agnostic.IdentitySubmitPlcOperation(ctx, &newClient, &agnostic.IdentitySubmitPlcOperation_Input{
 		Operation: signedPlcOpResp.Operation,
 	})
 	if err != nil {
diff --git a/cmd/goat/account_plc.go b/cmd/goat/account_plc.go
index a0dde653..5ce62903 100644
--- a/cmd/goat/account_plc.go
+++ b/cmd/goat/account_plc.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"os"
 
+	"github.com/bluesky-social/indigo/api/agnostic"
 	comatproto "github.com/bluesky-social/indigo/api/atproto"
 
 	"github.com/urfave/cli/v2"
@@ -56,7 +57,7 @@ func runAccountPlcRecommended(cctx *cli.Context) error {
 		return err
 	}
 
-	resp, err := IdentityGetRecommendedDidCredentials(ctx, xrpcc)
+	resp, err := agnostic.IdentityGetRecommendedDidCredentials(ctx, xrpcc)
 	if err != nil {
 		return err
 	}
@@ -109,7 +110,7 @@ func runAccountPlcSign(cctx *cli.Context) error {
 		return err
 	}
 
-	var body IdentitySignPlcOperation_Input
+	var body agnostic.IdentitySignPlcOperation_Input
 	if err = json.Unmarshal(fileBytes, &body); err != nil {
 		return fmt.Errorf("failed decoding PLC op JSON: %w", err)
 	}
@@ -119,7 +120,7 @@ func runAccountPlcSign(cctx *cli.Context) error {
 		body.Token = &token
 	}
 
-	resp, err := IdentitySignPlcOperation(ctx, xrpcc, &body)
+	resp, err := agnostic.IdentitySignPlcOperation(ctx, xrpcc, &body)
 	if err != nil {
 		return err
 	}
@@ -158,7 +159,7 @@ func runAccountPlcSubmit(cctx *cli.Context) error {
 		return fmt.Errorf("failed decoding PLC op JSON: %w", err)
 	}
 
-	err = IdentitySubmitPlcOperation(ctx, xrpcc, &IdentitySubmitPlcOperation_Input{
+	err = agnostic.IdentitySubmitPlcOperation(ctx, xrpcc, &agnostic.IdentitySubmitPlcOperation_Input{
 		Operation: &op,
 	})
 	if err != nil {
diff --git a/cmd/goat/auth.go b/cmd/goat/auth.go
index 5eaf2b29..cfa44126 100644
--- a/cmd/goat/auth.go
+++ b/cmd/goat/auth.go
@@ -77,9 +77,9 @@ func loadAuthClient(ctx context.Context) (*xrpc.Client, error) {
 	}
 	resp, err := comatproto.ServerRefreshSession(ctx, &client)
 	if err != nil {
-		// TODO: if failure, try creating a new session from password
+		// TODO: if failure, try creating a new session from password (2fa tokens are only valid once, so not reused)
 		fmt.Println("trying to refresh auth from password...")
-		as, err := refreshAuthSession(ctx, sess.DID.AtIdentifier(), sess.Password, sess.PDS)
+		as, err := refreshAuthSession(ctx, sess.DID.AtIdentifier(), sess.Password, sess.PDS, "")
 		if err != nil {
 			return nil, err
 		}
@@ -96,7 +96,7 @@ func loadAuthClient(ctx context.Context) (*xrpc.Client, error) {
 	return &client, nil
 }
 
-func refreshAuthSession(ctx context.Context, username syntax.AtIdentifier, password, pdsURL string) (*AuthSession, error) {
+func refreshAuthSession(ctx context.Context, username syntax.AtIdentifier, password, pdsURL, authFactorToken string) (*AuthSession, error) {
 
 	var did syntax.DID
 	if pdsURL == "" {
@@ -120,9 +120,14 @@ func refreshAuthSession(ctx context.Context, username syntax.AtIdentifier, passw
 	client := xrpc.Client{
 		Host: pdsURL,
 	}
+	var token *string
+	if authFactorToken != "" {
+		token = &authFactorToken
+	}
 	sess, err := comatproto.ServerCreateSession(ctx, &client, &comatproto.ServerCreateSession_Input{
-		Identifier: username.String(),
-		Password:   password,
+		Identifier:      username.String(),
+		Password:        password,
+		AuthFactorToken: token,
 	})
 	if err != nil {
 		return nil, err
diff --git a/cmd/goat/bsky_prefs.go b/cmd/goat/bsky_prefs.go
index 72507234..78960912 100644
--- a/cmd/goat/bsky_prefs.go
+++ b/cmd/goat/bsky_prefs.go
@@ -6,6 +6,8 @@ import (
 	"fmt"
 	"os"
 
+	"github.com/bluesky-social/indigo/api/agnostic"
+
 	"github.com/urfave/cli/v2"
 )
 
@@ -39,7 +41,7 @@ func runBskyPrefsExport(cctx *cli.Context) error {
 	}
 
 	// TODO: does indigo API code crash with unsupported preference '$type'? Eg "Lexicon decoder" with unsupported type.
-	resp, err := ActorGetPreferences(ctx, xrpcc)
+	resp, err := agnostic.ActorGetPreferences(ctx, xrpcc)
 	if err != nil {
 		return fmt.Errorf("failed fetching old preferences: %w", err)
 	}
@@ -77,7 +79,7 @@ func runBskyPrefsImport(cctx *cli.Context) error {
 		return err
 	}
 
-	err = ActorPutPreferences(ctx, xrpcc, &ActorPutPreferences_Input{
+	err = agnostic.ActorPutPreferences(ctx, xrpcc, &agnostic.ActorPutPreferences_Input{
 		Preferences: prefsArray,
 	})
 	if err != nil {
diff --git a/cmd/goat/net.go b/cmd/goat/net.go
index d7bb8e8f..f0f2d9c4 100644
--- a/cmd/goat/net.go
+++ b/cmd/goat/net.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"log/slog"
 
+	"github.com/bluesky-social/indigo/api/agnostic"
 	"github.com/bluesky-social/indigo/atproto/data"
 	"github.com/bluesky-social/indigo/atproto/identity"
 	"github.com/bluesky-social/indigo/atproto/syntax"
@@ -17,7 +18,7 @@ func fetchRecord(ctx context.Context, ident identity.Identity, aturi syntax.ATUR
 	xrpcc := xrpc.Client{
 		Host: ident.PDSEndpoint(),
 	}
-	resp, err := RepoGetRecord(ctx, &xrpcc, "", aturi.Collection().String(), ident.DID.String(), aturi.RecordKey().String())
+	resp, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", aturi.Collection().String(), ident.DID.String(), aturi.RecordKey().String())
 	if err != nil {
 		return nil, err
 	}
diff --git a/cmd/goat/record.go b/cmd/goat/record.go
index 013913aa..0caee855 100644
--- a/cmd/goat/record.go
+++ b/cmd/goat/record.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"os"
 
+	"github.com/bluesky-social/indigo/api/agnostic"
 	comatproto "github.com/bluesky-social/indigo/api/atproto"
 	"github.com/bluesky-social/indigo/atproto/data"
 	"github.com/bluesky-social/indigo/atproto/identity"
@@ -179,7 +180,7 @@ func runRecordList(cctx *cli.Context) error {
 		cursor := ""
 		for {
 			// collection string, cursor string, limit int64, repo string, reverse bool, rkeyEnd string, rkeyStart string
-			resp, err := RepoListRecords(ctx, &xrpcc, nsid, cursor, 100, ident.DID.String(), false, "", "")
+			resp, err := agnostic.RepoListRecords(ctx, &xrpcc, nsid, cursor, 100, ident.DID.String(), false, "", "")
 			if err != nil {
 				return err
 			}
@@ -246,7 +247,7 @@ func runRecordCreate(cctx *cli.Context) error {
 	}
 	validate := !cctx.Bool("no-validate")
 
-	resp, err := RepoCreateRecord(ctx, xrpcc, &RepoCreateRecord_Input{
+	resp, err := agnostic.RepoCreateRecord(ctx, xrpcc, &agnostic.RepoCreateRecord_Input{
 		Collection: nsid,
 		Repo:       xrpcc.Auth.Did,
 		Record:     recordVal,
@@ -293,7 +294,7 @@ func runRecordUpdate(cctx *cli.Context) error {
 	rkey := cctx.String("rkey")
 
 	// NOTE: need to fetch existing record CID to perform swap. this is optional in theory, but golang can't deal with "optional" and "nullable", so we always need to set this (?)
-	existing, err := RepoGetRecord(ctx, xrpcc, "", nsid, xrpcc.Auth.Did, rkey)
+	existing, err := agnostic.RepoGetRecord(ctx, xrpcc, "", nsid, xrpcc.Auth.Did, rkey)
 	if err != nil {
 		return err
 	}
@@ -305,7 +306,7 @@ func runRecordUpdate(cctx *cli.Context) error {
 
 	validate := !cctx.Bool("no-validate")
 
-	resp, err := RepoPutRecord(ctx, xrpcc, &RepoPutRecord_Input{
+	resp, err := agnostic.RepoPutRecord(ctx, xrpcc, &agnostic.RepoPutRecord_Input{
 		Collection: nsid,
 		Repo:       xrpcc.Auth.Did,
 		Record:     recordVal,
diff --git a/cmd/goat/repogetRecord.go b/cmd/goat/repogetRecord.go
deleted file mode 100644
index e3b857cd..00000000
--- a/cmd/goat/repogetRecord.go
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copied from indigo:api/atproto/repolistRecords.go
-
-package main
-
-// schema: com.atproto.repo.getRecord
-
-import (
-	"context"
-	"encoding/json"
-
-	"github.com/bluesky-social/indigo/xrpc"
-)
-
-// RepoGetRecord_Output is the output of a com.atproto.repo.getRecord call.
-type RepoGetRecord_Output struct {
-	Cid *string `json:"cid,omitempty" cborgen:"cid,omitempty"`
-	Uri string  `json:"uri" cborgen:"uri"`
-	//  NOTE: changed from lex decoder to json.RawMessage
-	Value *json.RawMessage `json:"value" cborgen:"value"`
-}
-
-// RepoGetRecord calls the XRPC method "com.atproto.repo.getRecord".
-//
-// cid: The CID of the version of the record. If not specified, then return the most recent version.
-// collection: The NSID of the record collection.
-// repo: The handle or DID of the repo.
-// rkey: The Record Key.
-func RepoGetRecord(ctx context.Context, c *xrpc.Client, cid string, collection string, repo string, rkey string) (*RepoGetRecord_Output, error) {
-	var out RepoGetRecord_Output
-
-	params := map[string]interface{}{
-		"cid":        cid,
-		"collection": collection,
-		"repo":       repo,
-		"rkey":       rkey,
-	}
-	if err := c.Do(ctx, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil {
-		return nil, err
-	}
-
-	return &out, nil
-}
diff --git a/cmd/goat/repolistRecords.go b/cmd/goat/repolistRecords.go
deleted file mode 100644
index 3cfdd9b2..00000000
--- a/cmd/goat/repolistRecords.go
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copied from indigo:api/atproto/repolistRecords.go
-
-package main
-
-// schema: com.atproto.repo.listRecords
-
-import (
-	"context"
-	"encoding/json"
-
-	"github.com/bluesky-social/indigo/xrpc"
-)
-
-// RepoListRecords_Output is the output of a com.atproto.repo.listRecords call.
-type RepoListRecords_Output struct {
-	Cursor  *string                   `json:"cursor,omitempty" cborgen:"cursor,omitempty"`
-	Records []*RepoListRecords_Record `json:"records" cborgen:"records"`
-}
-
-// RepoListRecords_Record is a "record" in the com.atproto.repo.listRecords schema.
-type RepoListRecords_Record struct {
-	Cid string `json:"cid" cborgen:"cid"`
-	Uri string `json:"uri" cborgen:"uri"`
-	//  NOTE: changed from lex decoder to json.RawMessage
-	Value *json.RawMessage `json:"value" cborgen:"value"`
-}
-
-// RepoListRecords calls the XRPC method "com.atproto.repo.listRecords".
-//
-// collection: The NSID of the record type.
-// limit: The number of records to return.
-// repo: The handle or DID of the repo.
-// reverse: Flag to reverse the order of the returned records.
-// rkeyEnd: DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)
-// rkeyStart: DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)
-func RepoListRecords(ctx context.Context, c *xrpc.Client, collection string, cursor string, limit int64, repo string, reverse bool, rkeyEnd string, rkeyStart string) (*RepoListRecords_Output, error) {
-	var out RepoListRecords_Output
-
-	params := map[string]interface{}{
-		"collection": collection,
-		"cursor":     cursor,
-		"limit":      limit,
-		"repo":       repo,
-		"reverse":    reverse,
-		"rkeyEnd":    rkeyEnd,
-		"rkeyStart":  rkeyStart,
-	}
-	if err := c.Do(ctx, xrpc.Query, "", "com.atproto.repo.listRecords", params, nil, &out); err != nil {
-		return nil, err
-	}
-
-	return &out, nil
-}



Test patch:
diff --git a/atproto/syntax/path_test.go b/atproto/syntax/path_test.go
new file mode 100644
index 00000000..e55f94fa
--- /dev/null
+++ b/atproto/syntax/path_test.go
@@ -0,0 +1,41 @@
+package syntax
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRepoPath(t *testing.T) {
+	assert := assert.New(t)
+
+	testValid := [][]string{
+		{"app.bsky.feed.post/asdf", "app.bsky.feed.post", "asdf"},
+	}
+
+	testErr := []string{
+		"",
+		"/",
+		"/app.bsky.feed.post/asdf",
+		"/asdf",
+		"./app.bsky.feed.post",
+		"blob/asdf",
+		"app.bsky.feed.post/",
+		"app.bsky.feed.post/.",
+		"app.bsky.feed.post/!",
+	}
+
+	for _, parts := range testValid {
+		nsid, rkey, err := ParseRepoPath(parts[0])
+		assert.NoError(err)
+		assert.Equal(parts[1], nsid.String())
+		assert.Equal(parts[2], rkey.String())
+	}
+
+	for _, raw := range testErr {
+		nsid, rkey, err := ParseRepoPath(raw)
+		assert.Error(err)
+		assert.Equal("", nsid.String())
+		assert.Equal("", rkey.String())
+	}
+}