···2626 - `api/bsky`: generated types for `app.bsky` lexicon
2727 - `api/chat`: generated types for `chat.bsky` lexicon
2828 - `api/ozone`: generated types for `tools.ozone` lexicon
2929-- `atproto/crypto`: cryptographic helpers (signing, key generation and serialization)
2929+- `atproto/atcrypto`: cryptographic helpers (signing, key generation and serialization)
3030- `atproto/syntax`: string types and parsers for identifiers, datetimes, etc
3131- `atproto/identity`: DID and handle resolution
3232-- `atproto/data`: helpers for atproto data as JSON or CBOR with unknown schema
3232+- `atproto/atdata`: helpers for atproto data as JSON or CBOR with unknown schema
3333- `atproto/lexicon`: lexicon validation of generic data
3434- `atproto/repo`: repo and MST implementation
3535- `automod`: moderation and anti-spam rules engine
-1
Makefile
···16161717.PHONY: build
1818build: ## Build all executables
1919- go build ./cmd/goat
2019 go build ./cmd/gosky
2120 go build ./cmd/bigsky
2221 go build ./cmd/relay
+3-1
README.md
···2525| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2626| `api/atproto`: generated types for `com.atproto.*` Lexicons | [](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/api/atproto) |
2727| `api/bsky`: generated types for `app.bsky.*` Lexicons | [](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/api/bsky) |
2828-| `atproto/crypto`: crytographic signing and key serialization | [](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/atproto/crypto) |
2828+| `atproto/atcrypto`: crytographic signing and key serialization | [](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/atproto/atcrypto) |
2929| `atproto/identity`: DID and handle resolution | [](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/atproto/identity) |
3030| `atproto/syntax`: string types and parsers for identifiers | [](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/atproto/syntax) |
3131| `atproto/lexicon`: schema validation of data | [](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/atproto/lexicon) |
···103103- Apache License, Version 2.0, ([LICENSE-APACHE](https://github.com/bluesky-social/indigo/blob/main/LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
104104105105Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
106106+107107+Bluesky Social PBC has committed to a software patent non-aggression pledge. For details see [the original announcement](https://bsky.social/about/blog/10-01-2025-patent-pledge).
···12121313 _ "github.com/joho/godotenv/autoload"
14141515+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1516 "github.com/bluesky-social/indigo/atproto/auth/oauth"
1616- "github.com/bluesky-social/indigo/atproto/crypto"
1717 "github.com/bluesky-social/indigo/atproto/identity"
1818 "github.com/bluesky-social/indigo/atproto/syntax"
1919···101101102102 // If a client secret key is provided (as a multibase string), turn this in to a confidential client
103103 if cctx.String("client-secret-key") != "" && hostname != "" {
104104- priv, err := crypto.ParsePrivateMultibase(cctx.String("client-secret-key"))
104104+ priv, err := atcrypto.ParsePrivateMultibase(cctx.String("client-secret-key"))
105105 if err != nil {
106106 return err
107107 }
···294294295295func (s *Server) OAuthLogout(w http.ResponseWriter, r *http.Request) {
296296297297- // delete session from auth store
297297+ // revoke tokens and delete session from auth store
298298 did, sessionID := s.currentSessionDID(r)
299299 if did != nil {
300300- if err := s.OAuth.Store.DeleteSession(r.Context(), *did, sessionID); err != nil {
300300+ if err := s.OAuth.Logout(r.Context(), *did, sessionID); err != nil {
301301 slog.Error("failed to delete session", "did", did, "err", err)
302302 }
303303 }
+2-2
atproto/auth/oauth/doc.go
···122122123123The [ClientSession] will handle nonce updates and token refreshes, and persist the results in the [ClientAuthStore].
124124125125-To log out a user, delete their session from the [ClientAuthStore]:
125125+To log out a user, use the [ClientApp.Logout] helper method, which revokes their tokens (if supported by the AS) and deletes their session from the [ClientAuthStore]:
126126127127- if err := oauthApp.Store.DeleteSession(r.Context(), did, sessionID); err != nil {
127127+ if err := oauthApp.Logout(r.Context(), did, sessionID); err != nil {
128128 return err
129129 }
130130
+2-2
atproto/auth/oauth/jwt_signing.go
···44 "crypto"
55 "fmt"
6677- atcrypto "github.com/bluesky-social/indigo/atproto/crypto"
77+ "github.com/bluesky-social/indigo/atproto/atcrypto"
88 "github.com/golang-jwt/jwt/v5"
99)
1010···1515 supportedAlgs []string
1616)
17171818-// Implementation of jwt.SigningMethod for the `atproto/crypto` types.
1818+// Implementation of jwt.SigningMethod for the `atproto/atcrypto` types.
1919type signingMethodAtproto struct {
2020 alg string
2121 hash crypto.Hash
+5
atproto/auth/oauth/memstore.go
···7474 m.lk.Lock()
7575 defer m.lk.Unlock()
76767777+ if _, ok := m.requests[info.State]; ok {
7878+ // Should be unreachable, barring implementation bugs elsewhere
7979+ return fmt.Errorf("auth request already saved for state %s", info.State)
8080+ }
8181+7782 m.requests[info.State] = info
7883 return nil
7984}
+58-30
atproto/auth/oauth/oauth.go
···1111 "strings"
1212 "time"
13131414- "github.com/bluesky-social/indigo/atproto/crypto"
1414+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1515 "github.com/bluesky-social/indigo/atproto/identity"
1616 "github.com/bluesky-social/indigo/atproto/syntax"
1717···4343 UserAgent string
44444545 // For confidential clients, the private client assertion key. Note that while an interface is used here, only P-256 is allowed by the current specification.
4646- PrivateKey crypto.PrivateKey
4646+ PrivateKey atcrypto.PrivateKey
47474848 // ID for current client assertion key (should be provided if PrivateKey is)
4949 KeyID *string
···112112 return config.PrivateKey != nil && config.KeyID != nil
113113}
114114115115-func (config *ClientConfig) SetClientSecret(priv crypto.PrivateKey, keyID string) error {
115115+func (config *ClientConfig) SetClientSecret(priv atcrypto.PrivateKey, keyID string) error {
116116 switch priv.(type) {
117117- case *crypto.PrivateKeyP256:
117117+ case *atcrypto.PrivateKeyP256:
118118 // pass
119119- case *crypto.PrivateKeyK256:
119119+ case *atcrypto.PrivateKeyK256:
120120 return fmt.Errorf("only P-256 (ES256) private keys supported for atproto OAuth")
121121 default:
122122 return fmt.Errorf("unknown private key type: %T", priv)
···131131// If the client does not have any keys (eg, public client), returns an empty set.
132132func (config *ClientConfig) PublicJWKS() JWKS {
133133134134- jwks := JWKS{Keys: []crypto.JWK{}}
134134+ jwks := JWKS{Keys: []atcrypto.JWK{}}
135135136136 // public client with no keys
137137 if config.PrivateKey == nil || config.KeyID == nil {
···148148 }
149149 jwk.KeyID = config.KeyID
150150151151- jwks.Keys = []crypto.JWK{*jwk}
151151+ jwks.Keys = []atcrypto.JWK{*jwk}
152152 return jwks
153153}
154154···209209 }
210210211211 // TODO: refactor this in to ClientAuthStore layer?
212212- priv, err := crypto.ParsePrivateMultibase(sd.DPoPPrivateKeyMultibase)
212212+ priv, err := atcrypto.ParsePrivateMultibase(sd.DPoPPrivateKeyMultibase)
213213 if err != nil {
214214 return nil, err
215215 }
···264264// Creates a DPoP token (JWT) for use with an OAuth Auth Server (not to be used with Resource Server). The returned JWT is not bound to an Access Token (no 'ath'), and does not indicate an issuer ('iss').
265265//
266266// This is used during initial auth request (PAR), initial token request, and subsequent refresh token requests. Note that a full [ClientSession] is not available in several of these circumstances, so this is a stand-alone function.
267267-func NewAuthDPoP(httpMethod, url, dpopNonce string, privKey crypto.PrivateKey) (string, error) {
267267+func NewAuthDPoP(httpMethod, url, dpopNonce string, privKey atcrypto.PrivateKey) (string, error) {
268268269269 claims := dpopClaims{
270270 HTTPMethod: httpMethod,
···356356 dpopServerNonce := ""
357357358358 // create new key for the session
359359- dpopPrivKey, err := crypto.GeneratePrivateKeyP256()
359359+ dpopPrivKey, err := atcrypto.GeneratePrivateKeyP256()
360360 if err != nil {
361361 return nil, err
362362 }
···413413 }
414414415415 parInfo := AuthRequestData{
416416- State: state,
417417- AuthServerURL: authMeta.Issuer,
418418- Scopes: scopes,
419419- PKCEVerifier: pkceVerifier,
420420- RequestURI: parResp.RequestURI,
421421- AuthServerTokenEndpoint: authMeta.TokenEndpoint,
422422- DPoPAuthServerNonce: dpopServerNonce,
423423- DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(),
416416+ State: state,
417417+ AuthServerURL: authMeta.Issuer,
418418+ Scopes: scopes,
419419+ PKCEVerifier: pkceVerifier,
420420+ RequestURI: parResp.RequestURI,
421421+ AuthServerTokenEndpoint: authMeta.TokenEndpoint,
422422+ AuthServerRevocationEndpoint: authMeta.RevocationEndpoint,
423423+ DPoPAuthServerNonce: dpopServerNonce,
424424+ DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(),
424425 }
425426426427 return &parInfo, nil
···446447 body.ClientAssertion = &clientAssertion
447448 }
448449449449- dpopPrivKey, err := crypto.ParsePrivateMultibase(info.DPoPPrivateKeyMultibase)
450450+ dpopPrivKey, err := atcrypto.ParsePrivateMultibase(info.DPoPPrivateKeyMultibase)
450451 if err != nil {
451452 return nil, err
452453 }
···668669 }
669670670671 sessData := ClientSessionData{
671671- AccountDID: accountDID,
672672- SessionID: info.State,
673673- HostURL: hostURL,
674674- AuthServerURL: info.AuthServerURL,
675675- AuthServerTokenEndpoint: info.AuthServerTokenEndpoint,
676676- Scopes: strings.Split(tokenResp.Scope, " "),
677677- AccessToken: tokenResp.AccessToken,
678678- RefreshToken: tokenResp.RefreshToken,
679679- DPoPAuthServerNonce: info.DPoPAuthServerNonce,
680680- DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver
681681- DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase,
672672+ AccountDID: accountDID,
673673+ SessionID: info.State,
674674+ HostURL: hostURL,
675675+ AuthServerURL: info.AuthServerURL,
676676+ AuthServerTokenEndpoint: info.AuthServerTokenEndpoint,
677677+ AuthServerRevocationEndpoint: info.AuthServerRevocationEndpoint,
678678+ Scopes: strings.Split(tokenResp.Scope, " "),
679679+ AccessToken: tokenResp.AccessToken,
680680+ RefreshToken: tokenResp.RefreshToken,
681681+ DPoPAuthServerNonce: info.DPoPAuthServerNonce,
682682+ DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver
683683+ DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase,
682684 }
683685 if err := app.Store.SaveSession(ctx, sessData); err != nil {
684686 return nil, err
···689691 }
690692 return &sessData, nil
691693}
694694+695695+// High-level helper to delete a session, including revoking access/refresh tokens if supported by the AS
696696+func (app *ClientApp) Logout(ctx context.Context, did syntax.DID, sessionID string) error {
697697+ sess, err := app.ResumeSession(ctx, did, sessionID)
698698+ if err != nil {
699699+ return err
700700+ }
701701+702702+ // Tell the AS to revoke the tokens, if supported
703703+ if sess.Data.AuthServerRevocationEndpoint == "" {
704704+ slog.Info("AS does not support token revocation, skipping RevokeSession")
705705+ } else {
706706+ err = sess.RevokeSession(ctx)
707707+ if err != nil {
708708+ slog.Warn("error during session revocation", "err", err)
709709+ }
710710+ }
711711+712712+ // Delete from our own session store
713713+ err = app.Store.DeleteSession(ctx, did, sessionID)
714714+ if err != nil {
715715+ return err
716716+ }
717717+718718+ return nil
719719+}
+90-34
atproto/auth/oauth/session.go
···44 "bytes"
55 "context"
66 "encoding/json"
77+ "errors"
78 "fmt"
89 "log/slog"
910 "net/http"
···1213 "sync"
1314 "time"
14151515- "github.com/bluesky-social/indigo/atproto/client"
1616- "github.com/bluesky-social/indigo/atproto/crypto"
1616+ "github.com/bluesky-social/indigo/atproto/atclient"
1717+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1718 "github.com/bluesky-social/indigo/atproto/syntax"
18191920 "github.com/golang-jwt/jwt/v5"
···38393940 // Full token endpoint
4041 AuthServerTokenEndpoint string `json:"authserver_token_endpoint"`
4242+4343+ // Full revocation endpoint, if it exists
4444+ AuthServerRevocationEndpoint string `json:"authserver_revocation_endpoint,omitempty"`
41454246 // The set of scopes approved for this session (returned in the initial token request)
4347 Scopes []string `json:"scopes"`
···6064 // TODO: also persist access token creation time / expiration time? In context that token might not be an easily parsed JWT
6165}
62666363-// Implementation of [client.AuthMethod] for an OAuth session. Handles DPoP request token signing and nonce rotation, and token refresh requests. Optionally uses a callback to persist updated session data.
6767+// Implementation of [atclient.AuthMethod] for an OAuth session. Handles DPoP request token signing and nonce rotation, and token refresh requests. Optionally uses a callback to persist updated session data.
6468//
6569// A single ClientSession instance can be called concurrently: updates to session data (the 'Data' field) are protected with a RW mutex lock. Note that concurrent calls to distinct ClientSession instances for the same session could result in clobbered session data.
6670type ClientSession struct {
···69737074 Config *ClientConfig
7175 Data *ClientSessionData
7272- DPoPPrivateKey crypto.PrivateKey
7676+ DPoPPrivateKey atcrypto.PrivateKey
73777478 PersistSessionCallback PersistSessionCallback
7579···7781 lk sync.RWMutex
7882}
79838080-// Requests new tokens from auth server, and returns the new access token on success.
8181-//
8282-// Internally takes a lock on session data around the entire refresh process, including retries. Persists data using [PersistSessionCallback] if configured.
8383-func (sess *ClientSession) RefreshTokens(ctx context.Context) (string, error) {
8484- sess.lk.Lock()
8585- defer sess.lk.Unlock()
8686-8787- body := RefreshTokenRequest{
8888- ClientID: sess.Config.ClientID,
8989- GrantType: "refresh_token",
9090- RefreshToken: sess.Data.RefreshToken,
8484+// Helper method to handle DPoP retries and client assertions (if the client is confidential)
8585+// body object will be url-encoded (expected to be either RefreshTokenRequest or RevocationRequest)
8686+// expects sess.lk to be held by caller
8787+// if a non-nil *http.Response is returned, the caller is responsible for closing the response body
8888+func (sess *ClientSession) postToAuthServer(ctx context.Context, url string, body interface{}) (*http.Response, error) {
8989+ vals, err := query.Values(body)
9090+ if err != nil {
9191+ return nil, err
9192 }
9292- tokenURL := sess.Data.AuthServerTokenEndpoint
9393-9493 if sess.Config.IsConfidential() {
9594 clientAssertion, err := sess.Config.NewClientAssertion(sess.Data.AuthServerURL)
9695 if err != nil {
9797- return "", err
9696+ return nil, err
9897 }
9999- body.ClientAssertionType = &ClientAssertionJWTBearer
100100- body.ClientAssertion = &clientAssertion
101101- }
102102-103103- vals, err := query.Values(body)
104104- if err != nil {
105105- return "", err
9898+ vals.Set("client_assertion_type", ClientAssertionJWTBearer)
9999+ vals.Set("client_assertion", clientAssertion)
106100 }
107101 bodyBytes := []byte(vals.Encode())
108102109103 var resp *http.Response
110104 for range 2 {
111111- dpopJWT, err := NewAuthDPoP("POST", sess.Data.AuthServerTokenEndpoint, sess.Data.DPoPAuthServerNonce, sess.DPoPPrivateKey)
105105+ dpopJWT, err := NewAuthDPoP("POST", url, sess.Data.DPoPAuthServerNonce, sess.DPoPPrivateKey)
112106 if err != nil {
113113- return "", err
107107+ return nil, err
114108 }
115109116116- req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, bytes.NewBuffer(bodyBytes))
110110+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(bodyBytes))
117111 if err != nil {
118118- return "", err
112112+ return nil, err
119113 }
120114 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
121115 req.Header.Set("DPoP", dpopJWT)
122116123117 resp, err = sess.Client.Do(req)
124118 if err != nil {
125125- return "", err
119119+ return nil, err
126120 }
127121128122 // always check if a new DPoP nonce was provided, and proactively update session data (even if there was not an explicit error)
···140134 // already updated nonce value above; loop around and try again
141135 continue
142136 }
143143- return "", fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, reason)
137137+ return nil, fmt.Errorf("auth server request failed (HTTP %d): %s", resp.StatusCode, reason)
144138 }
145139146140 // otherwise process response (success or other error type)
147141 break
148142 }
149143144144+ return resp, nil
145145+}
146146+147147+// Requests new tokens from auth server, and returns the new access token on success.
148148+//
149149+// Internally takes a lock on session data around the entire refresh process, including retries. Persists data using [PersistSessionCallback] if configured.
150150+func (sess *ClientSession) RefreshTokens(ctx context.Context) (string, error) {
151151+ sess.lk.Lock()
152152+ defer sess.lk.Unlock()
153153+154154+ body := RefreshTokenRequest{
155155+ ClientID: sess.Config.ClientID,
156156+ GrantType: "refresh_token",
157157+ RefreshToken: sess.Data.RefreshToken,
158158+ }
159159+160160+ resp, err := sess.postToAuthServer(ctx, sess.Data.AuthServerTokenEndpoint, body)
161161+ if err != nil {
162162+ return "", fmt.Errorf("token refresh failed: %w", err)
163163+ }
164164+150165 defer resp.Body.Close()
151166 if resp.StatusCode != http.StatusOK {
152167 reason := parseAuthErrorReason(resp, "token-refresh")
···170185 }
171186172187 return sess.Data.AccessToken, nil
188188+}
189189+190190+// If supported by the AS, use the revocation endpoint to revoke both the access token and the refresh token.
191191+// This method always succeeds - any errors during revocation are logged but not returned.
192192+func (sess *ClientSession) RevokeSession(ctx context.Context) error {
193193+ sess.lk.Lock()
194194+ defer sess.lk.Unlock()
195195+196196+ if sess.Data.AuthServerRevocationEndpoint == "" {
197197+ return fmt.Errorf("AS does not support token revocation")
198198+ }
199199+200200+ resp, err1 := sess.postToAuthServer(ctx, sess.Data.AuthServerRevocationEndpoint, RevocationRequest{
201201+ ClientID: sess.Config.ClientID,
202202+ Token: sess.Data.AccessToken,
203203+ TokenTypeHint: "access_token",
204204+ })
205205+ if err1 != nil {
206206+ err1 = fmt.Errorf("failed revoking access token: %w", err1)
207207+ } else {
208208+ if resp.StatusCode != http.StatusOK {
209209+ err1 = fmt.Errorf("bad HTTP status while revoking access token (%d)", resp.StatusCode)
210210+ }
211211+ resp.Body.Close()
212212+ }
213213+214214+ resp, err2 := sess.postToAuthServer(ctx, sess.Data.AuthServerRevocationEndpoint, RevocationRequest{
215215+ ClientID: sess.Config.ClientID,
216216+ Token: sess.Data.RefreshToken,
217217+ TokenTypeHint: "refresh_token",
218218+ })
219219+ if err2 != nil {
220220+ err2 = fmt.Errorf("failed revoking refresh token: %w", err1)
221221+ } else {
222222+ if resp.StatusCode != 200 {
223223+ err2 = fmt.Errorf("bad HTTP status while revoking refresh token (%d)", resp.StatusCode)
224224+ }
225225+ resp.Body.Close()
226226+ }
227227+228228+ return errors.Join(err1, err2) // returns nil if both errors are nil
173229}
174230175231// Constructs and signs a DPoP JWT to include in request header to Host (aka Resource Server, aka PDS). These tokens are different from those used with Auth Server token endpoints (even if the PDS is filling both roles)
···341397 return nil, fmt.Errorf("OAuth client ran out of request retries")
342398}
343399344344-// Creates a new [client.APIClient] which wraps this session for auth.
345345-func (sess *ClientSession) APIClient() *client.APIClient {
346346- c := client.APIClient{
400400+// Creates a new [atclient.APIClient] which wraps this session for auth.
401401+func (sess *ClientSession) APIClient() *atclient.APIClient {
402402+ c := atclient.APIClient{
347403 Client: sess.Client,
348404 Host: sess.Data.HostURL,
349405 Auth: sess,
+5-1
atproto/auth/oauth/store.go
···1010//
1111// This interface supports multiple sessions for a single account (DID). This is helpful for traditional web app backends where a single user might log in and have concurrent sessions from multiple browsers/devices. For situations where multiple sessions are not required, implementations of this interface could ignore the `sessionID` parameters, though this could result in clobbering of active sessions.
1212//
1313-// For authorization-only (authn-only) applications, the `SaveSession()` method could be a no-op.
1313+// For authentication-only (authn-only) applications, the `SaveSession()` method could be a no-op.
1414//
1515// Implementations should generally allow for concurrent access.
1616+//
1717+// `SaveSession()` should be treated as an "upsert" operation (updating a previously saved session with matching did and sessionID, if present). `SaveAuthRequestInfo()` however is create-only.
1818+//
1919+// Implementations are responsible for garbage-collecting expired sessions and auth requests.
1620type ClientAuthStore interface {
1721 GetSession(ctx context.Context, did syntax.DID, sessionID string) (*ClientSessionData, error)
1822 SaveSession(ctx context.Context, sess ClientSessionData) error
+28-2
atproto/auth/oauth/types.go
···77 "slices"
88 "strings"
991010- "github.com/bluesky-social/indigo/atproto/crypto"
1010+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1111 "github.com/bluesky-social/indigo/atproto/syntax"
1212)
1313···1919)
20202121type JWKS struct {
2222- Keys []crypto.JWK `json:"keys"`
2222+ Keys []atcrypto.JWK `json:"keys"`
2323}
24242525// Expected response type from looking up OAuth Protected Resource information on a server (eg, a PDS instance)
···197197198198 // must be true
199199 ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"`
200200+201201+ // optional, used to explicitly revoke access/refresh tokens on logout, if present
202202+ RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
200203}
201204202205func (m *AuthServerMetadata) Validate(serverURL string) error {
···339342 // Full token endpoint URL
340343 AuthServerTokenEndpoint string `json:"authserver_token_endpoint"`
341344345345+ // Full revocation endpoint, if it exists
346346+ AuthServerRevocationEndpoint string `json:"authserver_revocation_endpoint,omitempty"`
347347+342348 // The secret token/nonce which a code challenge was generated from
343349 PKCEVerifier string `json:"pkce_verifier"`
344350···406412407413 // Refresh token, for doing additional token requests to the auth server.
408414 RefreshToken string `json:"refresh_token"`
415415+}
416416+417417+// The fields which are included in a token revocation request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON.
418418+//
419419+// Per https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
420420+type RevocationRequest struct {
421421+ // Client ID, aka client metadata URL
422422+ ClientID string `url:"client_id"`
423423+424424+ // The token to revoke
425425+ Token string `url:"token"`
426426+427427+ // Either "access_token" or "refresh_token"
428428+ TokenTypeHint string `url:"token_type_hint"`
429429+430430+ // For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
431431+ ClientAssertionType *string `url:"client_assertion_type"`
432432+433433+ // For confidential clients, the signed client assertion JWT
434434+ ClientAssertion *string `url:"client_assertion"`
409435}
410436411437// Returned by [ClientApp.ProcessCallback] if the AS signals an error in the redirect URL parameters, per rfc6749 section 4.1.2.1
···18181919This package tries to use minimal dependencies beyond the Go standard library, to make it easy to reference as a dependency. It does require the [github.com/bluesky-social/indigo/atproto/syntax] and [github.com/bluesky-social/indigo/atproto/identity] sibling packages. In particular, this package does not include any auth methods requiring JWTs, to avoid adding any specific JWT implementation as a dependency.
2020*/
2121-package client
2121+package atclient
···11-// Package crypto provides cryptographic keys and operations, as used in atproto (the protocol)
11+// Package atcrypto provides cryptographic keys and operations, as used in atproto (the protocol)
22//
33// This package attempts to abstract away the specific curves, compressions, signature variations, and other implementation details. The goal is to provide as few knobs and options as possible when working with this library. Use of cryptography in atproto is specified in https://atproto.com/specs/cryptography.
44//
···88// - K-256/secp256r1, internally implemented using https://gitlab.com/yawning/secp256k1-voi
99//
1010// "Low-S" signatures are enforced for both key types, both when creating signatures and during verification, as required by the atproto specification.
1111-package crypto
1111+package atcrypto
···11// Code generated by github.com/whyrusleeping/cbor-gen. DO NOT EDIT.
2233-package data
33+package atdata
4455import (
66 "fmt"
···264264 return err
265265 }
266266267267- // t.Ref (data.CIDLink) (struct)
267267+ // t.Ref (atdata.CIDLink) (struct)
268268 if len("ref") > 1000000 {
269269 return xerrors.Errorf("Value in field \"ref\" was too long")
270270 }
···387387 }
388388389389 switch string(nameBuf[:nameLen]) {
390390- // t.Ref (data.CIDLink) (struct)
390390+ // t.Ref (atdata.CIDLink) (struct)
391391 case "ref":
392392393393 {
+1-1
atproto/data/cidlink.go
atproto/atdata/cidlink.go
···11-package data
11+package atdata
2233import (
44 "encoding/json"
+1-1
atproto/data/const.go
atproto/atdata/const.go
···11-package data
11+package atdata
2233const (
44 // maximum size of any CBOR data, in any context, in atproto
+1-1
atproto/data/data.go
atproto/atdata/data.go
···11-package data
11+package atdata
2233import (
44 "encoding/json"
+2-2
atproto/data/doc.go
atproto/atdata/doc.go
···11/*
22-Package data supports schema-less serializaiton and deserialization of atproto data
22+Package atdata supports schema-less serializaiton and deserialization of atproto data
3344Some restrictions from the data model include:
55- string sizes
···15151616Has a helper for serializing generic data (map[string]interface{}) to CBOR, which handles converting JSON-style object types (like $link and $bytes) as needed. There is no "MarshalJSON" method; simply use the standard library's `encoding/json`.
1717*/
1818-package data
1818+package atdata
+1-1
atproto/data/extract.go
atproto/atdata/extract.go
···11-package data
11+package atdata
2233import (
44 "bytes"
···50505151// Inserts a schema loaded from a JSON file in to the catalog.
5252func (c *BaseCatalog) AddSchemaFile(sf SchemaFile) error {
5353- if sf.Lexicon != 1 {
5454- return fmt.Errorf("unsupported lexicon language version: %d", sf.Lexicon)
5353+5454+ if err := sf.CheckSchema(); err != nil {
5555+ return err
5556 }
5757+5658 base := sf.ID
5759 for frag, def := range sf.Defs {
5858- if len(frag) == 0 || strings.Contains(frag, "#") || strings.Contains(frag, ".") {
5959- // TODO: more validation here?
6060- return fmt.Errorf("schema name invalid: %s", frag)
6161- }
6260 name := base + "#" + frag
6361 if _, ok := c.schemas[name]; ok {
6462 return fmt.Errorf("catalog already contained a schema with name: %s", name)
6563 }
6666- // "A file can have at most one definition with one of the "primary" types. Primary types should always have the name main. It is possible for main to describe a non-primary type."
6767- switch s := def.Inner.(type) {
6868- case SchemaRecord, SchemaQuery, SchemaProcedure, SchemaSubscription, SchemaPermissionSet:
6969- if frag != "main" {
7070- return fmt.Errorf("record, query, procedure, and subscription types must be 'main', not: %s", frag)
7171- }
7272- case SchemaToken:
7373- // add fully-qualified name to token
7474- s.fullName = name
7575- def.Inner = s
7676- }
7777- def.SetBase(base)
7878- if err := def.CheckSchema(); err != nil {
7979- return err
8080- }
8164 s := Schema{
8265 ID: name,
8366 Def: def.Inner,
···9174func (c *BaseCatalog) addSchemaFromBytes(b []byte) error {
9275 var sf SchemaFile
9376 if err := json.Unmarshal(b, &sf); err != nil {
7777+ return err
7878+ }
7979+ if err := sf.FinishParse(); err != nil {
9480 return err
9581 }
9682 if err := c.AddSchemaFile(sf); err != nil {
···11+package lexicon
22+33+import (
44+ "fmt"
55+ "strings"
66+)
77+88+// Serialization helper type for top-level Lexicon schema JSON objects (files).
99+//
1010+// Note that the [FinishParse] method should always be called after unmarshalling a SchemaFile from JSON.
1111+type SchemaFile struct {
1212+ Type string `json:"$type,omitempty"` // com.atproto.lexicon.schema (if a record)
1313+ Lexicon int `json:"lexicon"` // must be 1
1414+ ID string `json:"id"`
1515+ Description *string `json:"description,omitempty"`
1616+ Defs map[string]SchemaDef `json:"defs"`
1717+}
1818+1919+// Helper method which should always be called after parsing a schema file (eg, from JSON).
2020+//
2121+// Does some very basic validation (eg, lexicon language version), and fills in
2222+// internal references (for example full name of tokens).
2323+func (sf *SchemaFile) FinishParse() error {
2424+ if sf.Lexicon != 1 {
2525+ return fmt.Errorf("unsupported lexicon language version: %d", sf.Lexicon)
2626+ }
2727+ base := sf.ID
2828+ for frag, def := range sf.Defs {
2929+ if len(frag) == 0 || strings.Contains(frag, "#") || strings.Contains(frag, ".") {
3030+ // TODO: more validation here?
3131+ return fmt.Errorf("schema name invalid: %s", frag)
3232+ }
3333+ name := base + "#" + frag
3434+ switch s := def.Inner.(type) {
3535+ case SchemaToken:
3636+ // add fully-qualified name to token
3737+ s.FullName = name
3838+ def.Inner = s
3939+ }
4040+ def.setBase(base)
4141+ sf.Defs[frag] = def
4242+ }
4343+ return nil
4444+}
4545+4646+// Calls [SchemaDef.CheckSchema] recursively over all defs
4747+func (sf *SchemaFile) CheckSchema() error {
4848+ if sf.Lexicon != 1 {
4949+ return fmt.Errorf("unsupported lexicon language version: %d", sf.Lexicon)
5050+ }
5151+5252+ for frag, def := range sf.Defs {
5353+ if len(frag) == 0 || strings.Contains(frag, "#") || strings.Contains(frag, ".") {
5454+ // TODO: more validation here?
5555+ return fmt.Errorf("schema name invalid: %s", frag)
5656+ }
5757+ // "A file can have at most one definition with one of the "primary" types. Primary types should always have the name main. It is possible for main to describe a non-primary type."
5858+ switch def.Inner.(type) {
5959+ case SchemaRecord, SchemaQuery, SchemaProcedure, SchemaSubscription, SchemaPermissionSet:
6060+ if frag != "main" {
6161+ return fmt.Errorf("record, query, procedure, and subscription types must be 'main', not: %s", frag)
6262+ }
6363+ }
6464+ if err := def.CheckSchema(); err != nil {
6565+ return err
6666+ }
6767+ }
6868+ return nil
6969+}
···11{
22 "lexicon": 1,
33 "id": "example.lexicon.minimal.query",
44- "revision": 1,
55- "description": "exercizes many lexicon features for the query type",
44+ "description": "exercises many lexicon features for the query type",
65 "defs": {
76 "main": {
87 "type": "query",
···11{
22 "lexicon": 1,
33 "id": "example.lexicon.procedure",
44- "description": "demonstrates lexicon features for the procedure type",
54 "defs": {
65 "main": {
76 "type": "procedure",
77+ "description": "demonstrates lexicon features for the procedure type",
88 "parameters": {
99 "type": "params",
1010 "properties": {
+1-2
atproto/lexicon/testdata/catalog/query.json
···11{
22 "lexicon": 1,
33 "id": "example.lexicon.query",
44- "revision": 1,
55- "description": "exercizes many lexicon features for the query type",
44+ "description": "exercises many lexicon features for the query type",
65 "defs": {
76 "main": {
87 "type": "query",
-1
atproto/lexicon/testdata/catalog/record.json
···11{
22 "lexicon": 1,
33 "id": "example.lexicon.record",
44- "revision": 1,
54 "description": "demonstrates lexicon features for the record type",
65 "defs": {
76 "main": {
···44 "bytes"
55 "fmt"
6677- "github.com/bluesky-social/indigo/atproto/crypto"
88- "github.com/bluesky-social/indigo/atproto/data"
77+ "github.com/bluesky-social/indigo/atproto/atcrypto"
88+ "github.com/bluesky-social/indigo/atproto/atdata"
99 "github.com/bluesky-social/indigo/atproto/syntax"
10101111 "github.com/ipfs/go-cid"
···4545 d := map[string]any{
4646 "did": c.DID,
4747 "version": c.Version,
4848- "prev": (*data.CIDLink)(c.Prev),
4949- "data": data.CIDLink(c.Data),
4848+ "prev": (*atdata.CIDLink)(c.Prev),
4949+ "data": atdata.CIDLink(c.Data),
5050 }
5151 if c.Sig != nil {
5252- d["sig"] = data.Bytes(c.Sig)
5252+ d["sig"] = atdata.Bytes(c.Sig)
5353 }
5454 if c.Rev != "" {
5555 d["rev"] = c.Rev
···8080}
81818282// Signs the commit, storing the signature in the `Sig` field
8383-func (c *Commit) Sign(privkey crypto.PrivateKey) error {
8383+func (c *Commit) Sign(privkey atcrypto.PrivateKey) error {
8484 b, err := c.UnsignedBytes()
8585 if err != nil {
8686 return err
···9494}
95959696// Verifies `Sig` field using the provided key. Returns `nil` if signature is valid.
9797-func (c *Commit) VerifySignature(pubkey crypto.PublicKey) error {
9797+func (c *Commit) VerifySignature(pubkey atcrypto.PublicKey) error {
9898 if c.Sig == nil {
9999 return fmt.Errorf("can not verify unsigned commit")
100100 }
+4-4
automod/engine/blobs.go
···77 "strings"
88 "time"
991010- "github.com/bluesky-social/indigo/atproto/data"
1010+ "github.com/bluesky-social/indigo/atproto/atdata"
1111 lexutil "github.com/bluesky-social/indigo/lex/util"
12121313 "github.com/carlmjohnson/versioninfo"
···2222 return []lexutil.LexBlob{}, nil
2323 }
24242525- rec, err := data.UnmarshalCBOR(c.RecordOp.RecordCBOR)
2525+ rec, err := atdata.UnmarshalCBOR(c.RecordOp.RecordCBOR)
2626 if err != nil {
2727 return nil, fmt.Errorf("parsing generic record CBOR: %v", err)
2828 }
2929- blobs := data.ExtractBlobs(rec)
2929+ blobs := atdata.ExtractBlobs(rec)
30303131- // convert from data.Blob to lexutil.LexBlob; plan is to merge these types eventually
3131+ // convert from atdata.Blob to lexutil.LexBlob; plan is to merge these types eventually
3232 var out []lexutil.LexBlob
3333 for _, b := range blobs {
3434 lb := lexutil.LexBlob{
···11`goat`: Go AT protocol CLI tool
22===============================
3344-**NOTE: this project is moving to a dedicated git repo at [bluesky-social/goat](https://github.com/bluesky-social/goat). This copy of the code is deprecated and will eventually be removed, though a notice will remain.**
55-66-77-This is a re-implementation of [adenosine-cli](https://gitlab.com/bnewbold/adenosine/-/tree/main/adenosine-cli?ref_type=heads) in golang.
88-99-1010-## Install
1111-1212-If you have the Go toolchain installed and configured correctly, you can directly build and install the tool for your local account:
1313-1414-```bash
1515-go install github.com/bluesky-social/goat@latest
1616-```
1717-1818-A more manual way to install is:
1919-2020-```bash
2121-git clone https://github.com/bluesky-social/goat
2222-go build .
2323-sudo cp goat /usr/local/bin
2424-```
2525-2626-The intention is to also provide a Homebrew "cask" and Debian/Ubuntu packages.
2727-2828-2929-## Usage
3030-3131-`goat` is relatively self-documenting via help pages:
3232-3333-```bash
3434-goat --help
3535-goat bsky -h
3636-goat help bsky
3737-# etc
3838-```
3939-4040-Most commands use public APIs are don't require authentication. Some commands, like creating records, require an atproto account. You can log in using an "app password" with `goat account login -u <handle> -p <app-password>`.
4141-4242-WARNING: `goat` will store both the app password and authentication tokens in the current users home directory, in cleartext. `goat logout` will wipe the file. Intention is to eventually support configuration via environment variables to keep sensitive state in a password manager or otherwise not-cleartext-on-disk.
4343-4444-Some commands output JSON, and you can use tools like `jq` to process them.
4545-4646-## Examples
4747-4848-Resolve an account's identity in the network:
4949-5050-```bash
5151-$ goat resolve wyden.senate.gov
5252-{
5353- "id": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc",
5454- "alsoKnownAs": [
5555- "at://wyden.senate.gov"
5656- ],
5757- "verificationMethod": [
5858- {
5959- "id": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc#atproto",
6060- "type": "Multikey",
6161- "controller": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc",
6262- "publicKeyMultibase": "zQ3shuMW7q4KBdsFcdvebGi2EVv8KcqS24tF9Pg7Wh5NLB2NM"
6363- }
6464- ],
6565- "service": [
6666- {
6767- "id": "#atproto_pds",
6868- "type": "AtprotoPersonalDataServer",
6969- "serviceEndpoint": "https://shimeji.us-east.host.bsky.network"
7070- }
7171- ]
7272-}
7373-```
7474-7575-List record collection types for an account:
7676-7777-```bash
7878-$ goat ls -c dril.bsky.social
7979-app.bsky.actor.profile
8080-app.bsky.feed.post
8181-app.bsky.feed.repost
8282-app.bsky.graph.follow
8383-chat.bsky.actor.declaration
8484-```
8585-8686-Fetch a record from the network as JSON:
8787-8888-```bash
8989-$ goat get at://dril.bsky.social/app.bsky.feed.post/3kkreaz3amd27
9090-{
9191- "$type": "app.bsky.feed.post",
9292- "createdAt": "2024-02-06T18:15:19.802Z",
9393- "langs": [
9494- "en"
9595- ],
9696- "text": "I do not Fucking recall them asking the blue sky elders permission to open registration to commoners ."
9797-}
9898-```
9999-100100-Make a public snapshot of your account:
101101-102102-```bash
103103-$ goat repo export jay.bsky.team
104104-downloading from https://morel.us-east.host.bsky.network to: jay.bsky.team.20240811183155.car
105105-106106-$ downloading blobs to: jay.bsky.team_blobs
107107-jay.bsky.team_blobs/bafkreia2x4faux5y7v7v54yl5ebkbaek7z7nhmsd4cooubz3yj4zox34cq downloaded
108108-jay.bsky.team_blobs/bafkreia3qgbww7odprmysd6jcyxoh5sczkwoxinnmzpsp73gs623fqfm3a downloaded
109109-jay.bsky.team_blobs/bafkreia3rgnywdrysy65vid42ulyno2cybxhxrn3ragm7cw3smmsxzvbs4 downloaded
110110-[...]
111111-```
112112-113113-Show PLC history for a single account, or make a snapshot of all PLC records (this takes a while), or monitor new ops:
114114-115115-```bash
116116-$ goat plc history atproto.com
117117-[...]
118118-119119-$ goat plc dump | pv -l | gzip > plc_snapshot.json.gz
120120-[...]
121121-122122-$ goat plc dump --cursor now --tail
123123-[...]
124124-```
125125-126126-Verify syntax and generate TIDs:
127127-128128-```bash
129129-$ goat syntax handle check xn--fiqa61au8b7zsevnm8ak20mc4a87e.xn--fiqs8s
130130-valid
131131-132132-$ goat syntax rkey check dHJ1ZQ==
133133-error: recordkey syntax didn't validate via regex
134134-135135-$ goat syntax tid inspect 3kzifvcppte22
136136-Timestamp (UTC): 2024-08-12T02:08:03.29Z
137137-Timestamp (Local): 2024-08-11T19:08:03-07:00
138138-ClockID: 0
139139-uint64: 0x187dcbda2b5ca800
140140-```
141141-142142-The `firehose` commands subscribes to the repo commit stream from a Relay. The default stream outputs event metadata, but doesn't include record blocks (bytes). The `--ops` variant will unpack records and output one line per record operation (instead of one line per commit event), and includes the record values themselves. Some example invocations:
143143-144144-```bash
145145-# possible handle updates
146146-$ goat firehose --account-events | jq .payload.handle
147147-[...]
148148-149149-# text of posts (empty lines for post-deletions)
150150-$ goat firehose - app.bsky.feed.post --ops | jq .record.text
151151-[...]
152152-153153-# sample ratio of languages in current posts
154154-$ goat firehose --ops -c app.bsky.feed.post | head -n100 | jq .record.langs[0] -c | sort | uniq -c | sort -nr
155155- 51 "en"
156156- 33 "ja"
157157- 7 null
158158- 3 "pt"
159159- 2 "ko"
160160- 1 "th"
161161- 1 "id"
162162- 1 "es"
163163- 1 "am"
164164-```
165165-166166-A minimal bsky posting interface, requires account login:
167167-168168-```bash
169169-$ goat bsky post "hello from goat"
170170-```
44+**NOTE: this project has been moved to a dedicated git repo at [bluesky-social/goat](https://github.com/bluesky-social/goat)**
-521
cmd/goat/account.go
···11-package main
22-33-import (
44- "context"
55- "encoding/json"
66- "fmt"
77- "strings"
88- "time"
99-1010- comatproto "github.com/bluesky-social/indigo/api/atproto"
1111- "github.com/bluesky-social/indigo/atproto/auth"
1212- "github.com/bluesky-social/indigo/atproto/crypto"
1313- "github.com/bluesky-social/indigo/atproto/syntax"
1414- "github.com/bluesky-social/indigo/xrpc"
1515-1616- "github.com/urfave/cli/v2"
1717-)
1818-1919-var cmdAccount = &cli.Command{
2020- Name: "account",
2121- Usage: "sub-commands for auth and account management",
2222- Flags: []cli.Flag{},
2323- Subcommands: []*cli.Command{
2424- &cli.Command{
2525- Name: "login",
2626- Usage: "create session with PDS instance",
2727- Flags: []cli.Flag{
2828- &cli.StringFlag{
2929- Name: "username",
3030- Aliases: []string{"u"},
3131- Required: true,
3232- Usage: "account identifier (handle or DID)",
3333- EnvVars: []string{"ATP_AUTH_USERNAME"},
3434- },
3535- &cli.StringFlag{
3636- Name: "app-password",
3737- Aliases: []string{"p"},
3838- Required: true,
3939- Usage: "password (app password recommended)",
4040- EnvVars: []string{"ATP_AUTH_PASSWORD"},
4141- },
4242- &cli.StringFlag{
4343- Name: "auth-factor-token",
4444- Usage: "token required if password is used and 2fa is required",
4545- EnvVars: []string{"ATP_AUTH_FACTOR_TOKEN"},
4646- },
4747- &cli.StringFlag{
4848- Name: "pds-host",
4949- Usage: "URL of the PDS to create account on (overrides DID doc)",
5050- EnvVars: []string{"ATP_PDS_HOST"},
5151- },
5252- },
5353- Action: runAccountLogin,
5454- },
5555- &cli.Command{
5656- Name: "logout",
5757- Usage: "delete any current session",
5858- Action: runAccountLogout,
5959- },
6060- &cli.Command{
6161- Name: "activate",
6262- Usage: "(re)activate current account",
6363- Action: runAccountActivate,
6464- },
6565- &cli.Command{
6666- Name: "deactivate",
6767- Usage: "deactivate current account",
6868- Action: runAccountDeactivate,
6969- },
7070- &cli.Command{
7171- Name: "lookup",
7272- Usage: "show basic account hosting status for any account",
7373- ArgsUsage: `<at-identifier>`,
7474- Action: runAccountLookup,
7575- },
7676- &cli.Command{
7777- Name: "update-handle",
7878- Usage: "change handle for current account",
7979- ArgsUsage: `<handle>`,
8080- Action: runAccountUpdateHandle,
8181- },
8282- &cli.Command{
8383- Name: "status",
8484- Usage: "show current account status at PDS",
8585- Action: runAccountStatus,
8686- },
8787- &cli.Command{
8888- Name: "missing-blobs",
8989- Usage: "list any missing blobs for current account",
9090- Action: runAccountMissingBlobs,
9191- },
9292- &cli.Command{
9393- Name: "service-auth",
9494- Usage: "ask the PDS to create a service auth token",
9595- Flags: []cli.Flag{
9696- &cli.StringFlag{
9797- Name: "endpoint",
9898- Aliases: []string{"lxm"},
9999- Usage: "restrict token to API endpoint (NSID, optional)",
100100- },
101101- &cli.StringFlag{
102102- Name: "audience",
103103- Aliases: []string{"aud"},
104104- Required: true,
105105- Usage: "DID of service that will receive and validate token",
106106- },
107107- &cli.IntFlag{
108108- Name: "duration-sec",
109109- Value: 60,
110110- Usage: "validity time window of token (seconds)",
111111- },
112112- },
113113- Action: runAccountServiceAuth,
114114- },
115115- &cli.Command{
116116- Name: "service-auth-offline",
117117- Usage: "create service auth token via locally-held signing key",
118118- Flags: []cli.Flag{
119119- &cli.StringFlag{
120120- Name: "atproto-signing-key",
121121- Required: true,
122122- Usage: "private key used to sign the token (multibase syntax)",
123123- EnvVars: []string{"ATPROTO_SIGNING_KEY"},
124124- },
125125- &cli.StringFlag{
126126- Name: "iss",
127127- Required: true,
128128- Usage: "the DID of the account issuing the token",
129129- },
130130- &cli.StringFlag{
131131- Name: "endpoint",
132132- Aliases: []string{"lxm"},
133133- Usage: "restrict token to API endpoint (NSID, optional)",
134134- },
135135- &cli.StringFlag{
136136- Name: "audience",
137137- Aliases: []string{"aud"},
138138- Required: true,
139139- Usage: "DID of service that will receive and validate token",
140140- },
141141- &cli.IntFlag{
142142- Name: "duration-sec",
143143- Value: 60,
144144- Usage: "validity time window of token (seconds)",
145145- },
146146- },
147147- Action: runAccountServiceAuthOffline,
148148- },
149149- &cli.Command{
150150- Name: "create",
151151- Usage: "create a new account on the indicated PDS host",
152152- Flags: []cli.Flag{
153153- &cli.StringFlag{
154154- Name: "pds-host",
155155- Usage: "URL of the PDS to create account on",
156156- Required: true,
157157- EnvVars: []string{"ATP_PDS_HOST"},
158158- },
159159- &cli.StringFlag{
160160- Name: "handle",
161161- Usage: "handle for new account",
162162- Required: true,
163163- EnvVars: []string{"ATP_AUTH_HANDLE"},
164164- },
165165- &cli.StringFlag{
166166- Name: "password",
167167- Usage: "initial account password",
168168- Required: true,
169169- EnvVars: []string{"ATP_AUTH_PASSWORD"},
170170- },
171171- &cli.StringFlag{
172172- Name: "invite-code",
173173- Usage: "invite code for account signup",
174174- },
175175- &cli.StringFlag{
176176- Name: "email",
177177- Usage: "email address for new account",
178178- },
179179- &cli.StringFlag{
180180- Name: "existing-did",
181181- Usage: "an existing DID to use (eg, non-PLC DID, or migration)",
182182- },
183183- &cli.StringFlag{
184184- Name: "recovery-key",
185185- Usage: "public cryptographic key (did:key) to add as PLC recovery",
186186- },
187187- &cli.StringFlag{
188188- Name: "service-auth",
189189- Usage: "service auth token (for account migration)",
190190- },
191191- },
192192- Action: runAccountCreate,
193193- },
194194- cmdAccountMigrate,
195195- cmdAccountPlc,
196196- },
197197-}
198198-199199-func runAccountLogin(cctx *cli.Context) error {
200200- ctx := context.Background()
201201-202202- username, err := syntax.ParseAtIdentifier(cctx.String("username"))
203203- if err != nil {
204204- return err
205205- }
206206-207207- _, err = refreshAuthSession(ctx, *username, cctx.String("app-password"), cctx.String("pds-host"), cctx.String("auth-factor-token"))
208208- return err
209209-}
210210-211211-func runAccountLogout(cctx *cli.Context) error {
212212- return wipeAuthSession()
213213-}
214214-215215-func runAccountLookup(cctx *cli.Context) error {
216216- ctx := context.Background()
217217- username := cctx.Args().First()
218218- if username == "" {
219219- return fmt.Errorf("need to provide username as an argument")
220220- }
221221- ident, err := resolveIdent(ctx, username)
222222- if err != nil {
223223- return err
224224- }
225225-226226- // create a new API client to connect to the account's PDS
227227- xrpcc := xrpc.Client{
228228- Host: ident.PDSEndpoint(),
229229- UserAgent: userAgent(),
230230- }
231231- if xrpcc.Host == "" {
232232- return fmt.Errorf("no PDS endpoint for identity")
233233- }
234234-235235- status, err := comatproto.SyncGetRepoStatus(ctx, &xrpcc, ident.DID.String())
236236- if err != nil {
237237- return err
238238- }
239239-240240- fmt.Printf("DID: %s\n", status.Did)
241241- fmt.Printf("Active: %v\n", status.Active)
242242- if status.Status != nil {
243243- fmt.Printf("Status: %s\n", *status.Status)
244244- }
245245- if status.Rev != nil {
246246- fmt.Printf("Repo Rev: %s\n", *status.Rev)
247247- }
248248- return nil
249249-}
250250-251251-func runAccountStatus(cctx *cli.Context) error {
252252- ctx := context.Background()
253253-254254- client, err := loadAuthClient(ctx)
255255- if err == ErrNoAuthSession {
256256- return fmt.Errorf("auth required, but not logged in")
257257- } else if err != nil {
258258- return err
259259- }
260260-261261- status, err := comatproto.ServerCheckAccountStatus(ctx, client)
262262- if err != nil {
263263- return fmt.Errorf("failed checking account status: %w", err)
264264- }
265265-266266- b, err := json.MarshalIndent(status, "", " ")
267267- if err != nil {
268268- return err
269269- }
270270- fmt.Printf("DID: %s\n", client.Auth.Did)
271271- fmt.Printf("Host: %s\n", client.Host)
272272- fmt.Println(string(b))
273273-274274- return nil
275275-}
276276-277277-func runAccountMissingBlobs(cctx *cli.Context) error {
278278- ctx := context.Background()
279279-280280- client, err := loadAuthClient(ctx)
281281- if err == ErrNoAuthSession {
282282- return fmt.Errorf("auth required, but not logged in")
283283- } else if err != nil {
284284- return err
285285- }
286286-287287- cursor := ""
288288- for {
289289- resp, err := comatproto.RepoListMissingBlobs(ctx, client, cursor, 500)
290290- if err != nil {
291291- return err
292292- }
293293- for _, missing := range resp.Blobs {
294294- fmt.Printf("%s\t%s\n", missing.Cid, missing.RecordUri)
295295- }
296296- if resp.Cursor != nil && *resp.Cursor != "" {
297297- cursor = *resp.Cursor
298298- } else {
299299- break
300300- }
301301- }
302302- return nil
303303-}
304304-305305-func runAccountActivate(cctx *cli.Context) error {
306306- ctx := context.Background()
307307-308308- client, err := loadAuthClient(ctx)
309309- if err == ErrNoAuthSession {
310310- return fmt.Errorf("auth required, but not logged in")
311311- } else if err != nil {
312312- return err
313313- }
314314-315315- err = comatproto.ServerActivateAccount(ctx, client)
316316- if err != nil {
317317- return fmt.Errorf("failed activating account: %w", err)
318318- }
319319-320320- return nil
321321-}
322322-323323-func runAccountDeactivate(cctx *cli.Context) error {
324324- ctx := context.Background()
325325-326326- client, err := loadAuthClient(ctx)
327327- if err == ErrNoAuthSession {
328328- return fmt.Errorf("auth required, but not logged in")
329329- } else if err != nil {
330330- return err
331331- }
332332-333333- err = comatproto.ServerDeactivateAccount(ctx, client, &comatproto.ServerDeactivateAccount_Input{})
334334- if err != nil {
335335- return fmt.Errorf("failed deactivating account: %w", err)
336336- }
337337-338338- return nil
339339-}
340340-341341-func runAccountUpdateHandle(cctx *cli.Context) error {
342342- ctx := context.Background()
343343-344344- raw := cctx.Args().First()
345345- if raw == "" {
346346- return fmt.Errorf("need to provide new handle as argument")
347347- }
348348- handle, err := syntax.ParseHandle(raw)
349349- if err != nil {
350350- return err
351351- }
352352-353353- client, err := loadAuthClient(ctx)
354354- if err == ErrNoAuthSession {
355355- return fmt.Errorf("auth required, but not logged in")
356356- } else if err != nil {
357357- return err
358358- }
359359-360360- err = comatproto.IdentityUpdateHandle(ctx, client, &comatproto.IdentityUpdateHandle_Input{
361361- Handle: handle.String(),
362362- })
363363- if err != nil {
364364- return fmt.Errorf("failed updating handle: %w", err)
365365- }
366366-367367- return nil
368368-}
369369-370370-func runAccountServiceAuth(cctx *cli.Context) error {
371371- ctx := context.Background()
372372-373373- client, err := loadAuthClient(ctx)
374374- if err == ErrNoAuthSession {
375375- return fmt.Errorf("auth required, but not logged in")
376376- } else if err != nil {
377377- return err
378378- }
379379-380380- lxm := cctx.String("endpoint")
381381- if lxm != "" {
382382- _, err := syntax.ParseNSID(lxm)
383383- if err != nil {
384384- return fmt.Errorf("lxm argument must be a valid NSID: %w", err)
385385- }
386386- }
387387-388388- aud := cctx.String("audience")
389389- // TODO: can aud DID have a fragment?
390390- _, err = syntax.ParseDID(aud)
391391- if err != nil {
392392- return fmt.Errorf("aud argument must be a valid DID: %w", err)
393393- }
394394-395395- durSec := cctx.Int("duration-sec")
396396- expTimestamp := time.Now().Unix() + int64(durSec)
397397-398398- resp, err := comatproto.ServerGetServiceAuth(ctx, client, aud, expTimestamp, lxm)
399399- if err != nil {
400400- return fmt.Errorf("failed updating handle: %w", err)
401401- }
402402-403403- fmt.Println(resp.Token)
404404-405405- return nil
406406-}
407407-408408-func runAccountServiceAuthOffline(cctx *cli.Context) error {
409409- privStr := cctx.String("atproto-signing-key")
410410- if privStr == "" {
411411- return fmt.Errorf("private key must be provided")
412412- }
413413- privkey, err := crypto.ParsePrivateMultibase(privStr)
414414- if err != nil {
415415- return fmt.Errorf("failed parsing private key: %w", err)
416416- }
417417-418418- issString := cctx.String("iss")
419419- // TODO: support fragment identifiers
420420- iss, err := syntax.ParseDID(issString)
421421- if err != nil {
422422- return fmt.Errorf("iss argument must be a valid DID: %w", err)
423423- }
424424-425425- lxmString := cctx.String("endpoint")
426426- var lxm *syntax.NSID = nil
427427- if lxmString != "" {
428428- lxmTmp, err := syntax.ParseNSID(lxmString)
429429- if err != nil {
430430- return fmt.Errorf("lxm argument must be a valid NSID: %w", err)
431431- }
432432- lxm = &lxmTmp
433433- }
434434-435435- aud := cctx.String("audience")
436436- // TODO: can aud DID have a fragment?
437437- _, err = syntax.ParseDID(aud)
438438- if err != nil {
439439- return fmt.Errorf("aud argument must be a valid DID: %w", err)
440440- }
441441-442442- durSec := cctx.Int("duration-sec")
443443- duration := time.Duration(durSec * int(time.Second))
444444-445445- token, err := auth.SignServiceAuth(iss, aud, duration, lxm, privkey)
446446- if err != nil {
447447- return fmt.Errorf("failed signing token: %w", err)
448448- }
449449-450450- fmt.Println(token)
451451-452452- return nil
453453-}
454454-455455-func runAccountCreate(cctx *cli.Context) error {
456456- ctx := context.Background()
457457-458458- // validate args
459459- pdsHost := cctx.String("pds-host")
460460- if !strings.Contains(pdsHost, "://") {
461461- return fmt.Errorf("PDS host is not a url: %s", pdsHost)
462462- }
463463- handle := cctx.String("handle")
464464- _, err := syntax.ParseHandle(handle)
465465- if err != nil {
466466- return err
467467- }
468468- password := cctx.String("password")
469469- params := &comatproto.ServerCreateAccount_Input{
470470- Handle: handle,
471471- Password: &password,
472472- }
473473- raw := cctx.String("existing-did")
474474- if raw != "" {
475475- _, err := syntax.ParseDID(raw)
476476- if err != nil {
477477- return err
478478- }
479479- s := raw
480480- params.Did = &s
481481- }
482482- raw = cctx.String("email")
483483- if raw != "" {
484484- s := raw
485485- params.Email = &s
486486- }
487487- raw = cctx.String("invite-code")
488488- if raw != "" {
489489- s := raw
490490- params.InviteCode = &s
491491- }
492492- raw = cctx.String("recovery-key")
493493- if raw != "" {
494494- s := raw
495495- params.RecoveryKey = &s
496496- }
497497-498498- // create a new API client to connect to the account's PDS
499499- xrpcc := xrpc.Client{
500500- Host: pdsHost,
501501- UserAgent: userAgent(),
502502- }
503503-504504- raw = cctx.String("service-auth")
505505- if raw != "" && params.Did != nil {
506506- xrpcc.Auth = &xrpc.AuthInfo{
507507- Did: *params.Did,
508508- AccessJwt: raw,
509509- }
510510- }
511511-512512- resp, err := comatproto.ServerCreateAccount(ctx, &xrpcc, params)
513513- if err != nil {
514514- return fmt.Errorf("failed to create account: %w", err)
515515- }
516516-517517- fmt.Println("Success!")
518518- fmt.Printf("DID: %s\n", resp.Did)
519519- fmt.Printf("Handle: %s\n", resp.Handle)
520520- return nil
521521-}
-262
cmd/goat/account_migrate.go
···11-package main
22-33-import (
44- "bytes"
55- "context"
66- "encoding/json"
77- "fmt"
88- "log/slog"
99- "strings"
1010- "time"
1111-1212- "github.com/bluesky-social/indigo/api/agnostic"
1313- comatproto "github.com/bluesky-social/indigo/api/atproto"
1414- "github.com/bluesky-social/indigo/atproto/syntax"
1515- "github.com/bluesky-social/indigo/xrpc"
1616-1717- "github.com/urfave/cli/v2"
1818-)
1919-2020-var cmdAccountMigrate = &cli.Command{
2121- Name: "migrate",
2222- Usage: "move account to a new PDS. requires full auth.",
2323- Flags: []cli.Flag{
2424- &cli.StringFlag{
2525- Name: "pds-host",
2626- Usage: "URL of the new PDS to create account on",
2727- Required: true,
2828- EnvVars: []string{"ATP_PDS_HOST"},
2929- },
3030- &cli.StringFlag{
3131- Name: "new-handle",
3232- Required: true,
3333- Usage: "handle on new PDS",
3434- EnvVars: []string{"NEW_ACCOUNT_HANDLE"},
3535- },
3636- &cli.StringFlag{
3737- Name: "new-password",
3838- Required: true,
3939- Usage: "password on new PDS",
4040- EnvVars: []string{"NEW_ACCOUNT_PASSWORD"},
4141- },
4242- &cli.StringFlag{
4343- Name: "plc-token",
4444- Required: true,
4545- Usage: "token from old PDS authorizing token signature",
4646- EnvVars: []string{"PLC_SIGN_TOKEN"},
4747- },
4848- &cli.StringFlag{
4949- Name: "invite-code",
5050- Usage: "invite code for account signup",
5151- },
5252- &cli.StringFlag{
5353- Name: "new-email",
5454- Usage: "email address for new account",
5555- },
5656- },
5757- Action: runAccountMigrate,
5858-}
5959-6060-func runAccountMigrate(cctx *cli.Context) error {
6161- // NOTE: this could check rev / commit before and after and ensure last-minute content additions get lost
6262- ctx := context.Background()
6363-6464- oldClient, err := loadAuthClient(ctx)
6565- if err == ErrNoAuthSession {
6666- return fmt.Errorf("auth required, but not logged in")
6767- } else if err != nil {
6868- return err
6969- }
7070- did := oldClient.Auth.Did
7171-7272- newHostURL := cctx.String("pds-host")
7373- if !strings.Contains(newHostURL, "://") {
7474- return fmt.Errorf("PDS host is not a url: %s", newHostURL)
7575- }
7676- newHandle := cctx.String("new-handle")
7777- _, err = syntax.ParseHandle(newHandle)
7878- if err != nil {
7979- return err
8080- }
8181- newPassword := cctx.String("new-password")
8282- plcToken := cctx.String("plc-token")
8383- inviteCode := cctx.String("invite-code")
8484- newEmail := cctx.String("new-email")
8585-8686- newClient := xrpc.Client{
8787- Host: newHostURL,
8888- UserAgent: userAgent(),
8989- }
9090-9191- // connect to new host to discover service DID
9292- newHostDesc, err := comatproto.ServerDescribeServer(ctx, &newClient)
9393- if err != nil {
9494- return fmt.Errorf("failed connecting to new host: %w", err)
9595- }
9696- newHostDID, err := syntax.ParseDID(newHostDesc.Did)
9797- if err != nil {
9898- return err
9999- }
100100- slog.Info("new host", "serviceDID", newHostDID, "url", newHostURL)
101101-102102- // 1. Create New Account
103103- slog.Info("creating account on new host", "handle", newHandle, "host", newHostURL)
104104-105105- // get service auth token from old host
106106- // args: (ctx, client, aud string, exp int64, lxm string)
107107- expTimestamp := time.Now().Unix() + 60
108108- createAuthResp, err := comatproto.ServerGetServiceAuth(ctx, oldClient, newHostDID.String(), expTimestamp, "com.atproto.server.createAccount")
109109- if err != nil {
110110- return fmt.Errorf("failed getting service auth token from old host: %w", err)
111111- }
112112-113113- // then create the new account
114114- createParams := comatproto.ServerCreateAccount_Input{
115115- Did: &did,
116116- Handle: newHandle,
117117- Password: &newPassword,
118118- }
119119- if newEmail != "" {
120120- createParams.Email = &newEmail
121121- }
122122- if inviteCode != "" {
123123- createParams.InviteCode = &inviteCode
124124- }
125125-126126- // use service auth for access token, temporarily
127127- newClient.Auth = &xrpc.AuthInfo{
128128- Did: did,
129129- Handle: newHandle,
130130- AccessJwt: createAuthResp.Token,
131131- RefreshJwt: createAuthResp.Token,
132132- }
133133- createAccountResp, err := comatproto.ServerCreateAccount(ctx, &newClient, &createParams)
134134- if err != nil {
135135- return fmt.Errorf("failed creating new account: %w", err)
136136- }
137137-138138- if createAccountResp.Did != did {
139139- return fmt.Errorf("new account DID not a match: %s != %s", createAccountResp.Did, did)
140140- }
141141- newClient.Auth.AccessJwt = createAccountResp.AccessJwt
142142- newClient.Auth.RefreshJwt = createAccountResp.RefreshJwt
143143-144144- // login client on the new host
145145- sess, err := comatproto.ServerCreateSession(ctx, &newClient, &comatproto.ServerCreateSession_Input{
146146- Identifier: did,
147147- Password: newPassword,
148148- })
149149- if err != nil {
150150- return fmt.Errorf("failed login to newly created account on new host: %w", err)
151151- }
152152- newClient.Auth = &xrpc.AuthInfo{
153153- Did: did,
154154- AccessJwt: sess.AccessJwt,
155155- RefreshJwt: sess.RefreshJwt,
156156- }
157157-158158- // 2. Migrate Data
159159- slog.Info("migrating repo")
160160- repoBytes, err := comatproto.SyncGetRepo(ctx, oldClient, did, "")
161161- if err != nil {
162162- return fmt.Errorf("failed exporting repo: %w", err)
163163- }
164164- err = comatproto.RepoImportRepo(ctx, &newClient, bytes.NewReader(repoBytes))
165165- if err != nil {
166166- return fmt.Errorf("failed importing repo: %w", err)
167167- }
168168-169169- slog.Info("migrating preferences")
170170- // TODO: service proxy header for AppView?
171171- prefResp, err := agnostic.ActorGetPreferences(ctx, oldClient)
172172- if err != nil {
173173- return fmt.Errorf("failed fetching old preferences: %w", err)
174174- }
175175- err = agnostic.ActorPutPreferences(ctx, &newClient, &agnostic.ActorPutPreferences_Input{
176176- Preferences: prefResp.Preferences,
177177- })
178178- if err != nil {
179179- return fmt.Errorf("failed importing preferences: %w", err)
180180- }
181181-182182- slog.Info("migrating blobs")
183183- blobCursor := ""
184184- for {
185185- listResp, err := comatproto.SyncListBlobs(ctx, oldClient, blobCursor, did, 100, "")
186186- if err != nil {
187187- return fmt.Errorf("failed listing blobs: %w", err)
188188- }
189189- for _, blobCID := range listResp.Cids {
190190- blobBytes, err := comatproto.SyncGetBlob(ctx, oldClient, blobCID, did)
191191- if err != nil {
192192- slog.Warn("failed downloading blob", "cid", blobCID, "err", err)
193193- continue
194194- }
195195- _, err = comatproto.RepoUploadBlob(ctx, &newClient, bytes.NewReader(blobBytes))
196196- if err != nil {
197197- slog.Warn("failed uploading blob", "cid", blobCID, "err", err, "size", len(blobBytes))
198198- }
199199- slog.Info("transferred blob", "cid", blobCID, "size", len(blobBytes))
200200- }
201201- if listResp.Cursor == nil || *listResp.Cursor == "" {
202202- break
203203- }
204204- blobCursor = *listResp.Cursor
205205- }
206206-207207- // display migration status
208208- // NOTE: this could check between the old PDS and new PDS, polling in a loop showing progress until all records have been indexed
209209- statusResp, err := comatproto.ServerCheckAccountStatus(ctx, &newClient)
210210- if err != nil {
211211- return fmt.Errorf("failed checking account status: %w", err)
212212- }
213213- slog.Info("account migration status", "status", statusResp)
214214-215215- // 3. Migrate Identity
216216- // NOTE: to work with did:web or non-PDS-managed did:plc, need to do manual migraiton process
217217- slog.Info("updating identity to new host")
218218-219219- credsResp, err := agnostic.IdentityGetRecommendedDidCredentials(ctx, &newClient)
220220- if err != nil {
221221- return fmt.Errorf("failed fetching new credentials: %w", err)
222222- }
223223- credsBytes, err := json.Marshal(credsResp)
224224- if err != nil {
225225- return nil
226226- }
227227-228228- var unsignedOp agnostic.IdentitySignPlcOperation_Input
229229- if err = json.Unmarshal(credsBytes, &unsignedOp); err != nil {
230230- return fmt.Errorf("failed parsing PLC op: %w", err)
231231- }
232232- unsignedOp.Token = &plcToken
233233-234234- // 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.
235235-236236- signedPlcOpResp, err := agnostic.IdentitySignPlcOperation(ctx, oldClient, &unsignedOp)
237237- if err != nil {
238238- return fmt.Errorf("failed requesting PLC operation signature: %w", err)
239239- }
240240-241241- err = agnostic.IdentitySubmitPlcOperation(ctx, &newClient, &agnostic.IdentitySubmitPlcOperation_Input{
242242- Operation: signedPlcOpResp.Operation,
243243- })
244244- if err != nil {
245245- return fmt.Errorf("failed submitting PLC operation: %w", err)
246246- }
247247-248248- // 4. Finalize Migration
249249- slog.Info("activating new account")
250250-251251- err = comatproto.ServerActivateAccount(ctx, &newClient)
252252- if err != nil {
253253- return fmt.Errorf("failed activating new host: %w", err)
254254- }
255255- err = comatproto.ServerDeactivateAccount(ctx, oldClient, &comatproto.ServerDeactivateAccount_Input{})
256256- if err != nil {
257257- return fmt.Errorf("failed deactivating old host: %w", err)
258258- }
259259-260260- slog.Info("account migration completed")
261261- return nil
262262-}
-328
cmd/goat/account_plc.go
···11-package main
22-33-import (
44- "context"
55- "encoding/json"
66- "fmt"
77- "os"
88- "slices"
99-1010- "github.com/bluesky-social/indigo/api/agnostic"
1111- comatproto "github.com/bluesky-social/indigo/api/atproto"
1212- "github.com/bluesky-social/indigo/atproto/crypto"
1313- "github.com/bluesky-social/indigo/atproto/syntax"
1414- "github.com/did-method-plc/go-didplc"
1515-1616- "github.com/urfave/cli/v2"
1717-)
1818-1919-var cmdAccountPlc = &cli.Command{
2020- Name: "plc",
2121- Usage: "sub-commands for managing PLC DID via PDS host",
2222- Flags: []cli.Flag{
2323- &cli.StringFlag{
2424- Name: "plc-host",
2525- Usage: "method, hostname, and port of PLC registry",
2626- Value: "https://plc.directory",
2727- EnvVars: []string{"ATP_PLC_HOST"},
2828- },
2929- },
3030- Subcommands: []*cli.Command{
3131- &cli.Command{
3232- Name: "recommended",
3333- Usage: "list recommended DID fields for current account",
3434- Action: runAccountPlcRecommended,
3535- },
3636- &cli.Command{
3737- Name: "request-token",
3838- Usage: "request a 2FA token (by email) for signing op",
3939- Action: runAccountPlcRequestToken,
4040- },
4141- &cli.Command{
4242- Name: "sign",
4343- Usage: "sign a PLC operation",
4444- ArgsUsage: `<json-file>`,
4545- Action: runAccountPlcSign,
4646- Flags: []cli.Flag{
4747- &cli.StringFlag{
4848- Name: "token",
4949- Usage: "2FA token for PLC operation signing request",
5050- },
5151- },
5252- },
5353- &cli.Command{
5454- Name: "submit",
5555- Usage: "submit a PLC operation (via PDS)",
5656- ArgsUsage: `<json-file>`,
5757- Action: runAccountPlcSubmit,
5858- },
5959- &cli.Command{
6060- Name: "current",
6161- Usage: "print current PLC data for account (fetched from directory)",
6262- Action: runAccountPlcCurrent,
6363- },
6464- &cli.Command{
6565- Name: "add-rotation-key",
6666- Usage: "add a new rotation key to PLC identity (via PDS)",
6767- ArgsUsage: `<pubkey>`,
6868- Action: runAccountPlcAddRotationKey,
6969- Flags: []cli.Flag{
7070- &cli.StringFlag{
7171- Name: "token",
7272- Usage: "2FA token for PLC operation signing request",
7373- },
7474- &cli.BoolFlag{
7575- Name: "first",
7676- Usage: "inserts key at the top of key list (highest priority)",
7777- },
7878- },
7979- },
8080- },
8181-}
8282-8383-func runAccountPlcRecommended(cctx *cli.Context) error {
8484- ctx := context.Background()
8585-8686- xrpcc, err := loadAuthClient(ctx)
8787- if err == ErrNoAuthSession {
8888- return fmt.Errorf("auth required, but not logged in")
8989- } else if err != nil {
9090- return err
9191- }
9292-9393- resp, err := agnostic.IdentityGetRecommendedDidCredentials(ctx, xrpcc)
9494- if err != nil {
9595- return err
9696- }
9797-9898- b, err := json.MarshalIndent(resp, "", " ")
9999- if err != nil {
100100- return err
101101- }
102102-103103- fmt.Println(string(b))
104104- return nil
105105-}
106106-107107-func runAccountPlcRequestToken(cctx *cli.Context) error {
108108- ctx := context.Background()
109109-110110- xrpcc, err := loadAuthClient(ctx)
111111- if err == ErrNoAuthSession {
112112- return fmt.Errorf("auth required, but not logged in")
113113- } else if err != nil {
114114- return err
115115- }
116116-117117- err = comatproto.IdentityRequestPlcOperationSignature(ctx, xrpcc)
118118- if err != nil {
119119- return err
120120- }
121121-122122- fmt.Println("Success; check email for token.")
123123- return nil
124124-}
125125-126126-func runAccountPlcSign(cctx *cli.Context) error {
127127- ctx := context.Background()
128128-129129- opPath := cctx.Args().First()
130130- if opPath == "" {
131131- return fmt.Errorf("need to provide JSON file path as an argument")
132132- }
133133-134134- xrpcc, err := loadAuthClient(ctx)
135135- if err == ErrNoAuthSession {
136136- return fmt.Errorf("auth required, but not logged in")
137137- } else if err != nil {
138138- return err
139139- }
140140-141141- fileBytes, err := os.ReadFile(opPath)
142142- if err != nil {
143143- return err
144144- }
145145-146146- var body agnostic.IdentitySignPlcOperation_Input
147147- if err = json.Unmarshal(fileBytes, &body); err != nil {
148148- return fmt.Errorf("failed decoding PLC op JSON: %w", err)
149149- }
150150-151151- token := cctx.String("token")
152152- if token != "" {
153153- body.Token = &token
154154- }
155155-156156- resp, err := agnostic.IdentitySignPlcOperation(ctx, xrpcc, &body)
157157- if err != nil {
158158- return err
159159- }
160160-161161- b, err := json.MarshalIndent(resp.Operation, "", " ")
162162- if err != nil {
163163- return err
164164- }
165165-166166- fmt.Println(string(b))
167167- return nil
168168-}
169169-170170-func runAccountPlcSubmit(cctx *cli.Context) error {
171171- ctx := context.Background()
172172-173173- opPath := cctx.Args().First()
174174- if opPath == "" {
175175- return fmt.Errorf("need to provide JSON file path as an argument")
176176- }
177177-178178- xrpcc, err := loadAuthClient(ctx)
179179- if err == ErrNoAuthSession {
180180- return fmt.Errorf("auth required, but not logged in")
181181- } else if err != nil {
182182- return err
183183- }
184184-185185- fileBytes, err := os.ReadFile(opPath)
186186- if err != nil {
187187- return err
188188- }
189189-190190- var opEnum didplc.OpEnum
191191- if err = json.Unmarshal(fileBytes, &opEnum); err != nil {
192192- return fmt.Errorf("failed decoding PLC op JSON: %w", err)
193193- }
194194- op := opEnum.AsOperation()
195195-196196- if op.IsGenesis() {
197197- return fmt.Errorf("can't submit a genesis operation via PDS (HINT: Make sure the prev field is set, or try `goat plc submit --genesis`)")
198198- }
199199-200200- if !op.IsSigned() {
201201- return fmt.Errorf("operation must be signed (HINT: try `goat account plc sign`)")
202202- }
203203-204204- // convert it back to JSON for submission
205205- opEncoded, err := json.Marshal(op)
206206- if err != nil {
207207- return err
208208- }
209209- rawMsg := json.RawMessage(opEncoded)
210210- err = agnostic.IdentitySubmitPlcOperation(ctx, xrpcc, &agnostic.IdentitySubmitPlcOperation_Input{
211211- Operation: &rawMsg,
212212- })
213213-214214- if err != nil {
215215- return fmt.Errorf("failed submitting PLC op via PDS: %w", err)
216216- }
217217-218218- return nil
219219-}
220220-221221-func runAccountPlcCurrent(cctx *cli.Context) error {
222222- ctx := context.Background()
223223-224224- xrpcc, err := loadAuthClient(ctx)
225225- if err == ErrNoAuthSession || xrpcc.Auth == nil {
226226- return fmt.Errorf("auth required, but not logged in")
227227- } else if err != nil {
228228- return err
229229- }
230230-231231- did, err := syntax.ParseDID(xrpcc.Auth.Did)
232232- if err != nil {
233233- return err
234234- }
235235-236236- plcData, err := fetchPLCData(ctx, cctx.String("plc-host"), did)
237237- if err != nil {
238238- return err
239239- }
240240-241241- b, err := json.MarshalIndent(plcData, "", " ")
242242- if err != nil {
243243- return err
244244- }
245245- fmt.Println(string(b))
246246- return nil
247247-}
248248-249249-func runAccountPlcAddRotationKey(cctx *cli.Context) error {
250250- ctx := context.Background()
251251-252252- newKeyStr := cctx.Args().First()
253253- if newKeyStr == "" {
254254- return fmt.Errorf("need to provide public key argument (as did:key)")
255255- }
256256-257257- // check that it is a valid pubkey
258258- _, err := crypto.ParsePublicDIDKey(newKeyStr)
259259- if err != nil {
260260- return err
261261- }
262262-263263- xrpcc, err := loadAuthClient(ctx)
264264- if err == ErrNoAuthSession {
265265- return fmt.Errorf("auth required, but not logged in")
266266- } else if err != nil {
267267- return err
268268- }
269269-270270- did, err := syntax.ParseDID(xrpcc.Auth.Did)
271271- if err != nil {
272272- return err
273273- }
274274-275275- // 1. fetch current PLC op: plc.directory/{did}/data
276276- plcData, err := fetchPLCData(ctx, cctx.String("plc-host"), did)
277277- if err != nil {
278278- return err
279279- }
280280-281281- if len(plcData.RotationKeys) >= 5 {
282282- fmt.Println("WARNGING: already have 5 rotation keys, which is the maximum")
283283- }
284284-285285- for _, k := range plcData.RotationKeys {
286286- if k == newKeyStr {
287287- return fmt.Errorf("key already registered as a rotation key")
288288- }
289289- }
290290-291291- // 2. update data
292292- if cctx.Bool("first") {
293293- plcData.RotationKeys = slices.Insert(plcData.RotationKeys, 0, newKeyStr)
294294- } else {
295295- plcData.RotationKeys = append(plcData.RotationKeys, newKeyStr)
296296- }
297297-298298- // 3. get data signed (using token)
299299- opBytes, err := json.Marshal(&plcData)
300300- if err != nil {
301301- return err
302302- }
303303- var body agnostic.IdentitySignPlcOperation_Input
304304- if err = json.Unmarshal(opBytes, &body); err != nil {
305305- return fmt.Errorf("failed decoding PLC op JSON: %w", err)
306306- }
307307-308308- token := cctx.String("token")
309309- if token != "" {
310310- body.Token = &token
311311- }
312312-313313- resp, err := agnostic.IdentitySignPlcOperation(ctx, xrpcc, &body)
314314- if err != nil {
315315- return err
316316- }
317317-318318- // 4. submit signed op
319319- err = agnostic.IdentitySubmitPlcOperation(ctx, xrpcc, &agnostic.IdentitySubmitPlcOperation_Input{
320320- Operation: resp.Operation,
321321- })
322322- if err != nil {
323323- return fmt.Errorf("failed submitting PLC op via PDS: %w", err)
324324- }
325325-326326- fmt.Println("Success!")
327327- return nil
328328-}
···66666767 http post :2470/admin/pds/requestCrawl -a admin:dummy hostname=pds.example.com
68686969-The `goat` command line tool (also part of the indigo git repository) includes helpers for administering, inspecting, and debugging relays:
6969+The `goat` command line tool includes helpers for administering, inspecting, and debugging relays:
70707171 RELAY_HOST=http://localhost:2470 goat firehose --verify-mst
7272 RELAY_HOST=http://localhost:2470 goat relay admin host list