diff --git a/docs/configuration.md b/docs/configuration.md index 9c9a60f..3985342 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,7 +3,7 @@ `tg` resolves configuration values from the following sources, in increasing precedence (later sources override earlier ones): -1. **Defaults** — `appview` is `https://bobbin.klbr.net`; `knot` is `knot1.tangled.sh`; `ssh-port` is `22` +1. **Defaults** — `appview` is `https://bobbin.klbr.net`; `knot` is unset to permit automatic verified Knot discovery; `ssh-port` is `22` 2. **Config file** — `$XDG_CONFIG_HOME/tg/config.toml` (or `~/.config/tg/config.toml`) 3. **Environment variables** — prefixed `TG_` (e.g. `TG_APPVIEW`) 4. **Command-line flags** — e.g. `--appview` @@ -45,3 +45,17 @@ Keys containing `.` or `-` map to `TG_`-prefixed underscore-separated names | `--config` | Path to config file | | `--appview` | Appview host URL (overrides config file and `TG_APPVIEW`) | | `--account` | Account handle or DID for this command | + +## Automatic verified Knot discovery + +When you don't specify a Knot with `--knot`, repo creation reads up to 10 Knot +registrations from your PDS and verifies each registration. Select a Knot +explicitly if you have more than 10 registrations. Exactly one verified Knot is +selected automatically. If no registrations exist, it falls back to +`knot1.tangled.sh`. One registration that can't be verified errors. If `tg` +successfully verifies multiple Knots, it errors and lists the candidates so you +can specify one with `--knot` or set it in the config file. + +Set `knot` in your config, `TG_KNOT` in your environment, or pass `--knot` to +select a host explicitly and bypass automatic discovery. The flag overrides +the environment, which overrides the config file. diff --git a/internal/app/app.go b/internal/app/app.go index dbb83fd..db68f09 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,13 +17,14 @@ import ( ) type Service struct { - resolver identityResolver - appview appviewClient - sessions sessionProvider - auth *atproto.AuthManager - git gitClient - knot knotClientFactory - httpClient *http.Client + resolver identityResolver + appview appviewClient + sessions sessionProvider + auth *atproto.AuthManager + git gitClient + knot knotClientFactory + knotOwnershipVerifier knotOwnershipVerifier + httpClient *http.Client } // DefaultKnot is used when repository creation does not specify a knot. @@ -51,11 +52,12 @@ func NewWithStreams(appviewHost string, stdout, stderr io.Writer) *Service { Client: &atclient.APIClient{Client: httpClient, Host: appviewHost}, Logger: slog.Default(), }, - sessions: productionSessions{auth: auth, resolver: resolver, httpClient: httpClient}, - auth: auth, - git: gitutil.NewClient(stdout, stderr), - knot: productionKnotFactory{httpClient: httpClient}, - httpClient: httpClient, + sessions: productionSessions{auth: auth, resolver: resolver, httpClient: httpClient}, + auth: auth, + git: gitutil.NewClient(stdout, stderr), + knot: productionKnotFactory{httpClient: httpClient}, + knotOwnershipVerifier: newHTTPKnotOwnershipVerifier(), + httpClient: httpClient, } } diff --git a/internal/app/dependencies.go b/internal/app/dependencies.go index 72081cf..d4e4fde 100644 --- a/internal/app/dependencies.go +++ b/internal/app/dependencies.go @@ -32,6 +32,7 @@ type pdsClient interface { DeleteRecord(context.Context, atproto.DeleteRecordInput) error UploadBlob(context.Context, []byte, string) (*atproto.Blob, error) GetRecord(context.Context, string, string, string) (*atproto.GetRecordOutput, error) + ListRecords(context.Context, string, string, atproto.ListRecordsOpts) (*atproto.ListRecordsOutput, error) ListAllRecords(context.Context, string, string, atproto.ListRecordsOpts) ([]atproto.RecordItem, error) GetServiceAuth(context.Context, string, string) (string, error) } @@ -63,6 +64,10 @@ type knotClientFactory interface { New(string, string) knotClient } +type knotOwnershipVerifier interface { + Verify(ctx context.Context, host, expectedOwnerDID string) error +} + type productionSessions struct { auth *atproto.AuthManager resolver identityResolver @@ -115,8 +120,10 @@ func isNotAuthenticated(err error) bool { return errors.Is(err, ErrNotAuthenticated) || errors.Is(err, atproto.ErrNotAuthenticated) } -var _ identityResolver = (*atproto.Resolver)(nil) -var _ appviewClient = (*tangled.Tangled)(nil) -var _ gitClient = (*gitutil.Client)(nil) -var _ pdsClient = (*atproto.ATProto)(nil) -var _ knotClient = (*knot.Client)(nil) +var ( + _ identityResolver = (*atproto.Resolver)(nil) + _ appviewClient = (*tangled.Tangled)(nil) + _ gitClient = (*gitutil.Client)(nil) + _ pdsClient = (*atproto.ATProto)(nil) + _ knotClient = (*knot.Client)(nil) +) diff --git a/internal/app/knot_verification.go b/internal/app/knot_verification.go new file mode 100644 index 0000000..e6dd43c --- /dev/null +++ b/internal/app/knot_verification.go @@ -0,0 +1,68 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +const ( + knotVerificationTimeout = 10 * time.Second + knotOwnerResponseMax = 64 << 10 +) + +type httpKnotOwnershipVerifier struct { + client *http.Client +} + +func newHTTPKnotOwnershipVerifier() *httpKnotOwnershipVerifier { + return &httpKnotOwnershipVerifier{ + client: &http.Client{ + Timeout: knotVerificationTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +func (v *httpKnotOwnershipVerifier) Verify(ctx context.Context, host, expectedOwnerDID string) (err error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+host+"/xrpc/sh.tangled.owner", nil) + if err != nil { + return fmt.Errorf("create owner request: %w", err) + } + response, err := v.client.Do(request) + if err != nil { + return fmt.Errorf("fetch Knot owner: %w", err) + } + defer func() { + err = errors.Join(err, response.Body.Close()) + }() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("fetch Knot owner: unexpected HTTP status %d %s", response.StatusCode, http.StatusText(response.StatusCode)) + } + body, err := io.ReadAll(io.LimitReader(response.Body, knotOwnerResponseMax+1)) + if err != nil { + return fmt.Errorf("read Knot owner: %w", err) + } + if len(body) > knotOwnerResponseMax { + return fmt.Errorf("read Knot owner: response exceeds %d bytes", knotOwnerResponseMax) + } + var output struct { + Owner string `json:"owner"` + } + if err := json.Unmarshal(body, &output); err != nil { + return fmt.Errorf("decode Knot owner: %w", err) + } + if output.Owner == "" { + return fmt.Errorf("decode Knot owner: response omitted owner DID") + } + if output.Owner != expectedOwnerDID { + return fmt.Errorf("knot owner mismatch: got %q, want %q", output.Owner, expectedOwnerDID) + } + return nil +} diff --git a/internal/app/knot_verification_test.go b/internal/app/knot_verification_test.go new file mode 100644 index 0000000..a2a446b --- /dev/null +++ b/internal/app/knot_verification_test.go @@ -0,0 +1,100 @@ +package app + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const testKnotOwnerDID = "did:plc:owner" + +func TestKnotOwnerVerification(t *testing.T) { + tests := []struct { + name string + status int + body string + wantErr string + }{ + {name: "matching owner", status: http.StatusOK, body: `{"owner":"did:plc:owner"}`}, + {name: "different owner", status: http.StatusOK, body: `{"owner":"did:plc:other"}`, wantErr: "owner mismatch"}, + {name: "missing owner", status: http.StatusOK, body: `{}`, wantErr: "omitted owner DID"}, + {name: "malformed response", status: http.StatusOK, body: `{`, wantErr: "decode Knot owner"}, + {name: "server error", status: http.StatusBadGateway, body: `{}`, wantErr: "502 Bad Gateway"}, + {name: "oversized response", status: http.StatusOK, body: strings.Repeat("x", knotOwnerResponseMax+1), wantErr: "response exceeds"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + transport := knotOwnerRoundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.String() != "https://knot.example/xrpc/sh.tangled.owner" { + t.Fatalf("owner request URL = %q", request.URL) + } + if request.Method != http.MethodGet { + t.Fatalf("owner request method = %q, want GET", request.Method) + } + if authorization := request.Header.Get("Authorization"); authorization != "" { + t.Fatalf("owner request Authorization = %q, want none", authorization) + } + return &http.Response{ + StatusCode: tt.status, + Status: http.StatusText(tt.status), + Body: io.NopCloser(strings.NewReader(tt.body)), + Header: make(http.Header), + }, nil + }) + verifier := &httpKnotOwnershipVerifier{client: &http.Client{Transport: transport}} + + err := verifier.Verify(context.Background(), "knot.example", testKnotOwnerDID) + if tt.wantErr == "" && err != nil { + t.Fatalf("Verify() error = %v", err) + } + if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { + t.Fatalf("Verify() error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestKnotOwnerVerificationRejectsRedirects(t *testing.T) { + verifier := newHTTPKnotOwnershipVerifier() + tests := []string{ + "https://other.example/xrpc/sh.tangled.owner", + "http://knot.example/xrpc/sh.tangled.owner", + } + for _, target := range tests { + request, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + t.Fatalf("create redirect request: %v", err) + } + if err := verifier.client.CheckRedirect(request, nil); !errors.Is(err, http.ErrUseLastResponse) { + t.Errorf("CheckRedirect(%q) error = %v, want ErrUseLastResponse", target, err) + } + } +} + +func TestKnotOwnerVerificationAllowsTrustedLocalEndpoint(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/xrpc/sh.tangled.owner" { + t.Errorf("owner request path = %q", request.URL.Path) + } + if _, err := io.WriteString(response, `{"owner":"did:plc:owner"}`); err != nil { + t.Errorf("write owner response: %v", err) + } + })) + t.Cleanup(server.Close) + verifier := &httpKnotOwnershipVerifier{client: server.Client()} + + err := verifier.Verify(context.Background(), strings.TrimPrefix(server.URL, "https://"), testKnotOwnerDID) + if err != nil { + t.Fatalf("Verify() error = %v", err) + } +} + +type knotOwnerRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f knotOwnerRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} diff --git a/internal/app/repos.go b/internal/app/repos.go index 8d70d26..523db2d 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "strings" + "sync" "time" "github.com/alyraffauf/tg/atproto" @@ -16,6 +17,16 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" ) +const ( + knotCollection = "sh.tangled.knot" + maxKnotRegistrations = 10 +) + +type knotRegistration struct { + LexiconTypeID string `json:"$type"` + CreatedAt string `json:"createdAt"` +} + // ViewRepo fetches a single repository record. func (s *Service) ViewRepo(ctx context.Context, t Target) (*RepoItem, error) { tangledRepo, err := s.resolveRepo(ctx, t) @@ -127,21 +138,23 @@ func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCrea if (in.Clone || in.PushPath != "") && (in.SSHPort < 1 || in.SSHPort > 65535) { return nil, fmt.Errorf("SSH port must be between 1 and 65535") } - knotHost, err := parseKnotHostname(in.KnotHost) - if err != nil { - return nil, err + if in.KnotHost != "" { + knotHost, err := parseKnotHostname(in.KnotHost) + if err != nil { + return nil, err + } + in.KnotHost = knotHost } - in.KnotHost = knotHost - uri, handle, err := s.provisionRepo(ctx, ProvisionRepoInput{ + uri, handle, selectedKnot, warnings, err := s.provisionRepo(ctx, ProvisionRepoInput{ KnotHost: in.KnotHost, Name: in.Name, Description: in.Description, }) if err != nil { return nil, err } - result := &RepoCreateResult{Handle: handle, Name: in.Name, URI: uri, Knot: in.KnotHost} + result := &RepoCreateResult{Handle: handle, Name: in.Name, URI: uri, Knot: selectedKnot, Warnings: warnings} if in.Clone { if _, err := s.CloneRepo(ctx, CloneRepoInput{ - KnotHost: in.KnotHost, SSHPort: in.SSHPort, + KnotHost: selectedKnot, SSHPort: in.SSHPort, Handle: handle, Repo: in.Name, Destination: in.Name, }); err != nil { return nil, fmt.Errorf("clone new repository: %w", err) @@ -152,7 +165,7 @@ func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCrea return result, nil } pushResult, err := s.pushNewRepo(ctx, PushNewRepoInput{ - KnotHost: in.KnotHost, SSHPort: in.SSHPort, RepoURI: uri, Dir: in.PushPath, + KnotHost: selectedKnot, SSHPort: in.SSHPort, RepoURI: uri, Dir: in.PushPath, Handle: handle, Repo: in.Name, RemoteName: in.RemoteName, }) if pushResult.defaultBranchWarning != nil { @@ -166,25 +179,29 @@ func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCrea return result, nil } -func (s *Service) provisionRepo(ctx context.Context, in ProvisionRepoInput) (uri, handle string, err error) { +func (s *Service) provisionRepo(ctx context.Context, in ProvisionRepoInput) (uri, handle, knotHost string, warnings []string, err error) { atClient, did, err := s.authenticatedPDS(ctx) if err != nil { - return "", "", err + return "", "", "", nil, err } - token, err := atClient.GetServiceAuth(ctx, "did:web:"+in.KnotHost, "sh.tangled.repo.create") + knotHost, warnings, err = s.selectCreationKnot(ctx, atClient, did, in.KnotHost) if err != nil { - return "", "", err + return "", "", "", nil, err } - repoDid, err := s.knot.New(in.KnotHost, token).CreateRepo(ctx, knot.CreateRepoInput{ + token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.create") + if err != nil { + return "", "", "", nil, err + } + repoDid, err := s.knot.New(knotHost, token).CreateRepo(ctx, knot.CreateRepoInput{ Name: in.Name, Rkey: in.Name, }) if err != nil { - return "", "", err + return "", "", "", nil, err } record := tangledlex.Repo{ LexiconTypeID: repoCollection, - Knot: in.KnotHost, + Knot: knotHost, CreatedAt: time.Now().UTC().Format(time.RFC3339), RepoDid: optionalString(repoDid), } @@ -198,9 +215,95 @@ func (s *Service) provisionRepo(ctx context.Context, in ProvisionRepoInput) (uri Record: record, }) if err != nil { - return "", "", err + return "", "", "", nil, err + } + return uri, s.ownerHandle(ctx, did), knotHost, warnings, nil +} + +func (s *Service) selectCreationKnot(ctx context.Context, atClient pdsClient, did, configured string) (string, []string, error) { + if configured != "" { + return configured, nil, nil + } + page, err := atClient.ListRecords(ctx, did, knotCollection, atproto.ListRecordsOpts{Limit: maxKnotRegistrations + 1}) + if err != nil { + return "", nil, fmt.Errorf("discover verified Knots: %w", err) + } + records := page.Records + if len(records) == 0 { + return DefaultKnot, nil, nil + } + if len(records) > maxKnotRegistrations { + return "", nil, fmt.Errorf("found more than %d Knot registrations; select one with --knot or set it in the config file", maxKnotRegistrations) + } + hosts := make([]string, 0, len(records)) + warnings := make([]string, 0, len(records)) + seen := make(map[string]bool, len(records)) + for _, record := range records { + uri, err := syntax.ParseATURI(record.URI) + if err != nil || uri.Authority().String() != did || uri.Collection().String() != knotCollection || uri.RecordKey().String() == "" { + warnings = append(warnings, fmt.Sprintf("ignored invalid Knot registration URI %q", record.URI)) + continue + } + if err := validateKnotRegistration(record.Value); err != nil { + warnings = append(warnings, fmt.Sprintf("ignored invalid Knot registration %q: %v", record.URI, err)) + continue + } + host, err := parseKnotHostname(uri.RecordKey().String()) + if err != nil { + warnings = append(warnings, fmt.Sprintf("ignored Knot registration %q: %v", record.URI, err)) + continue + } + if seen[host] { + warnings = append(warnings, fmt.Sprintf("ignored duplicate Knot registration for %s", host)) + continue + } + seen[host] = true + hosts = append(hosts, host) + } + verificationErrors := make([]error, len(hosts)) + var verificationGroup sync.WaitGroup + for index, host := range hosts { + verificationGroup.Add(1) + go func() { + defer verificationGroup.Done() + verificationErrors[index] = s.knotOwnershipVerifier.Verify(ctx, host, did) + }() + } + verificationGroup.Wait() + verified := make([]string, 0, len(hosts)) + for index, host := range hosts { + if err := verificationErrors[index]; err != nil { + warnings = append(warnings, fmt.Sprintf("could not verify Knot registration for %s: %v", host, err)) + continue + } + verified = append(verified, host) + } + if len(verified) == 0 { + return "", nil, fmt.Errorf("no Knot registrations could be verified: %s", strings.Join(warnings, "; ")) + } + if len(verified) > 1 { + sort.Strings(verified) + return "", nil, fmt.Errorf("multiple verified Knots found (%s); select one with --knot or set it in the config file", strings.Join(verified, ", ")) + } + return verified[0], warnings, nil +} + +func validateKnotRegistration(value any) error { + data, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("encode record: %w", err) } - return uri, s.ownerHandle(ctx, did), nil + var registration knotRegistration + if err := json.Unmarshal(data, ®istration); err != nil { + return fmt.Errorf("decode record: %w", err) + } + if registration.LexiconTypeID != knotCollection { + return fmt.Errorf("$type must be %q", knotCollection) + } + if _, err := syntax.ParseDatetime(registration.CreatedAt); err != nil { + return fmt.Errorf("invalid createdAt: %w", err) + } + return nil } func parseKnotHostname(raw string) (string, error) { diff --git a/internal/app/repos_create_test.go b/internal/app/repos_create_test.go index cdd553b..f0333ae 100644 --- a/internal/app/repos_create_test.go +++ b/internal/app/repos_create_test.go @@ -2,8 +2,11 @@ package app import ( "context" + "errors" "strings" "testing" + + "github.com/alyraffauf/tg/atproto" ) func TestCreateRepoValidatesConnectionSettingsBeforeProvisioning(t *testing.T) { @@ -78,3 +81,188 @@ func TestCreateRepoValidatesConnectionSettingsBeforeProvisioning(t *testing.T) { }) } } + +func TestCreateRepoSelectsKnot(t *testing.T) { + listFailure := errors.New("PDS unavailable") + tests := []struct { + name string + configuredKnot string + records []atproto.RecordItem + listErr error + verifyErrors map[string]error + wantKnot string + wantErr string + wantWarning string + wantVerifies int + wantListCalls int + wantCreates int + clone bool + push bool + }{ + { + name: "configured Knot bypasses discovery", configuredKnot: "configured.example", + listErr: listFailure, wantKnot: "configured.example", wantCreates: 1, + }, + { + name: "one verified Knot", records: []atproto.RecordItem{ + validKnotRegistration("verified.example"), + }, wantKnot: "verified.example", wantVerifies: 1, wantListCalls: 1, wantCreates: 1, push: true, + }, + { + name: "verified Knot is canonicalized", records: []atproto.RecordItem{ + validKnotRegistration("VERIFIED.EXAMPLE"), + }, wantKnot: "verified.example", wantVerifies: 1, wantListCalls: 1, wantCreates: 1, push: true, + }, + { + name: "automatically selected Knot is used for clone", records: []atproto.RecordItem{ + validKnotRegistration("verified.example"), + }, wantKnot: "verified.example", wantVerifies: 1, wantListCalls: 1, wantCreates: 1, clone: true, + }, + {name: "no verified Knots", wantKnot: DefaultKnot, wantListCalls: 1, wantCreates: 1}, + { + name: "multiple verified Knots", records: []atproto.RecordItem{ + validKnotRegistration("two.example"), + validKnotRegistration("one.example"), + }, wantErr: "multiple verified Knots found (one.example, two.example); select one with --knot or set it in the config file", wantVerifies: 2, wantListCalls: 1, + }, + { + name: "verified Knot is selected with warning for stale registration", records: []atproto.RecordItem{ + validKnotRegistration("verified.example"), + validKnotRegistration("stale.example"), + }, verifyErrors: map[string]error{"stale.example": errors.New("owner mismatch")}, + wantKnot: "verified.example", wantWarning: "stale.example: owner mismatch", wantVerifies: 2, wantListCalls: 1, wantCreates: 1, + }, + { + name: "unverified registration does not fall back", records: []atproto.RecordItem{ + validKnotRegistration("stale.example"), + }, verifyErrors: map[string]error{"stale.example": errors.New("owner mismatch")}, + wantErr: "no Knot registrations could be verified", wantVerifies: 1, wantListCalls: 1, + }, + { + name: "duplicate normalized registration is ignored", records: []atproto.RecordItem{ + validKnotRegistration("verified.example"), + validKnotRegistration("VERIFIED.EXAMPLE"), + }, wantKnot: "verified.example", wantWarning: "duplicate Knot registration", wantVerifies: 1, wantListCalls: 1, wantCreates: 1, + }, + { + name: "malformed record URI", records: []atproto.RecordItem{{URI: "not-an-at-uri", Value: validKnotRegistrationValue()}}, + wantErr: "ignored invalid Knot registration URI", wantListCalls: 1, + }, + { + name: "malformed record value", records: []atproto.RecordItem{ + {URI: "at://did:plc:owner/sh.tangled.knot/invalid.example", Value: map[string]any{"$type": knotCollection}}, + }, wantErr: "invalid createdAt", wantListCalls: 1, + }, + { + name: "verified Knot is selected with warning for malformed record", records: []atproto.RecordItem{ + validKnotRegistration("verified.example"), + {URI: "at://did:plc:owner/sh.tangled.knot/invalid.example", Value: map[string]any{"$type": "wrong.type", "createdAt": "2026-01-01T00:00:00Z"}}, + }, wantKnot: "verified.example", wantWarning: "$type must be", wantVerifies: 1, wantListCalls: 1, wantCreates: 1, + }, + { + name: "record URI with port", records: []atproto.RecordItem{ + {URI: "at://did:plc:owner/sh.tangled.knot/host:2222", Value: validKnotRegistrationValue()}, + }, wantErr: "invalid Knot hostname", wantListCalls: 1, + }, + { + name: "record URI with invalid DNS hostname", records: []atproto.RecordItem{ + {URI: "at://did:plc:owner/sh.tangled.knot/...", Value: validKnotRegistrationValue()}, + }, wantErr: "invalid Knot hostname", wantListCalls: 1, + }, + {name: "list error", listErr: listFailure, wantErr: listFailure.Error(), wantListCalls: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pds := &testPDS{records: tt.records, listErr: tt.listErr} + knotClient := &testKnot{} + git := &testGit{} + service := testService(pds, git, knotClient) + verifier := &testKnotOwnershipVerifier{errors: tt.verifyErrors} + service.knotOwnershipVerifier = verifier + knotFactory := &testKnotFactory{client: knotClient} + service.knot = knotFactory + pushPath := "" + if tt.push { + pushPath = "." + } + + result, err := service.CreateRepo(context.Background(), CreateRepoInput{ + KnotHost: tt.configuredKnot, SSHPort: 22, Name: "example", Clone: tt.clone, PushPath: pushPath, + }) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("CreateRepo() error = %v, want containing %q", err, tt.wantErr) + } + } else { + if err != nil { + t.Fatalf("CreateRepo() error = %v", err) + } + if result.Knot != tt.wantKnot { + t.Fatalf("CreateRepo() Knot = %q, want %q", result.Knot, tt.wantKnot) + } + if tt.wantWarning != "" && (len(result.Warnings) == 0 || !strings.Contains(strings.Join(result.Warnings, "; "), tt.wantWarning)) { + t.Fatalf("CreateRepo() warnings = %v, want containing %q", result.Warnings, tt.wantWarning) + } + } + if pds.listCalls != tt.wantListCalls { + t.Fatalf("list calls = %d, want %d", pds.listCalls, tt.wantListCalls) + } + if knotClient.createCalls != tt.wantCreates { + t.Fatalf("provision calls = %d, want %d", knotClient.createCalls, tt.wantCreates) + } + if len(verifier.hosts) != tt.wantVerifies { + t.Fatalf("verification calls = %v, want %d", verifier.hosts, tt.wantVerifies) + } + if tt.wantErr != "" && (pds.serviceAuthCalls != 0 || len(pds.puts) != 0) { + t.Fatalf("service auth/put calls = %d/%d, want no mutation side effects", pds.serviceAuthCalls, len(pds.puts)) + } + if tt.push && (len(git.pushes) != 1 || git.pushes[0].KnotHost != tt.wantKnot) { + t.Fatalf("pushes = %+v, want one push to %q", git.pushes, tt.wantKnot) + } + if tt.clone && (len(git.clones) != 1 || git.clones[0].KnotHost != tt.wantKnot) { + t.Fatalf("clones = %+v, want one clone from %q", git.clones, tt.wantKnot) + } + if tt.wantErr == "" { + if len(knotFactory.hosts) == 0 || knotFactory.hosts[0] != tt.wantKnot { + t.Fatalf("Knot client hosts = %v, want %q", knotFactory.hosts, tt.wantKnot) + } + if len(pds.serviceAuthAudiences) == 0 || pds.serviceAuthAudiences[0] != "did:web:"+tt.wantKnot { + t.Fatalf("service auth audiences = %v, want did:web:%s", pds.serviceAuthAudiences, tt.wantKnot) + } + } + }) + } +} + +func TestCreateRepoBoundsAutomaticKnotVerification(t *testing.T) { + pds := &testPDS{records: make([]atproto.RecordItem, maxKnotRegistrations+1)} + service := testService(pds, &testGit{}, &testKnot{}) + verifier := &testKnotOwnershipVerifier{} + service.knotOwnershipVerifier = verifier + + _, err := service.CreateRepo(context.Background(), CreateRepoInput{Name: "example"}) + if err == nil || !strings.Contains(err.Error(), "found more than 10 Knot registrations") { + t.Fatalf("CreateRepo() error = %v, want bounded-verification error", err) + } + if len(pds.listOptions) != 1 || pds.listOptions[0].Limit != maxKnotRegistrations+1 { + t.Fatalf("list options = %+v, want one bounded request", pds.listOptions) + } + if len(verifier.hosts) != 0 || pds.serviceAuthCalls != 0 || len(pds.puts) != 0 { + t.Fatalf("verification/auth/put side effects = %d/%d/%d, want none", len(verifier.hosts), pds.serviceAuthCalls, len(pds.puts)) + } +} + +func validKnotRegistration(host string) atproto.RecordItem { + return atproto.RecordItem{ + URI: "at://did:plc:owner/sh.tangled.knot/" + host, + Value: validKnotRegistrationValue(), + } +} + +func validKnotRegistrationValue() map[string]any { + return map[string]any{ + "$type": knotCollection, + "createdAt": "2026-01-01T00:00:00Z", + } +} diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 4b8d123..222b628 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "github.com/alyraffauf/tg/atproto" @@ -214,10 +215,11 @@ func testService(pds *testPDS, git *testGit, knotClient *testKnot) *Service { Handle: syntax.Handle("owner.test"), }} return &Service{ - resolver: resolver, - sessions: testSessions{pds: pds}, - git: git, - knot: &testKnotFactory{client: knotClient}, + resolver: resolver, + sessions: testSessions{pds: pds}, + git: git, + knot: &testKnotFactory{client: knotClient}, + knotOwnershipVerifier: &testKnotOwnershipVerifier{}, } } @@ -265,7 +267,11 @@ type testPDS struct { puts []atproto.PutRecordInput deletes []atproto.DeleteRecordInput record *atproto.GetRecordOutput + records []atproto.RecordItem putErr error + listErr error + listCalls int + listOptions []atproto.ListRecordsOpts serviceAuthCalls int serviceAuthAudiences []string } @@ -296,8 +302,27 @@ func (p *testPDS) GetRecord(context.Context, string, string, string) (*atproto.G return p.record, nil } +func (p *testPDS) ListRecords(_ context.Context, _, _ string, opts atproto.ListRecordsOpts) (*atproto.ListRecordsOutput, error) { + p.listCalls++ + p.listOptions = append(p.listOptions, opts) + if p.listErr != nil { + return nil, p.listErr + } + records := p.records + var cursor *string + if opts.Limit > 0 && int64(len(records)) > opts.Limit { + records = records[:opts.Limit] + } + if len(records) > 0 { + lastRecordKey := "last-record" + cursor = &lastRecordKey + } + return &atproto.ListRecordsOutput{Records: records, Cursor: cursor}, nil +} + func (p *testPDS) ListAllRecords(context.Context, string, string, atproto.ListRecordsOpts) ([]atproto.RecordItem, error) { - return nil, errors.New("not implemented") + p.listCalls++ + return p.records, p.listErr } func (p *testPDS) GetServiceAuth(_ context.Context, audience, _ string) (string, error) { @@ -345,6 +370,19 @@ func (f *testKnotFactory) New(host string, _ string) knotClient { return f.client } +type testKnotOwnershipVerifier struct { + errors map[string]error + hosts []string + mu sync.Mutex +} + +func (v *testKnotOwnershipVerifier) Verify(_ context.Context, host, _ string) error { + v.mu.Lock() + defer v.mu.Unlock() + v.hosts = append(v.hosts, host) + return v.errors[host] +} + type testKnot struct { setDefaultBranchErr error deleteErr error diff --git a/internal/cli/config.go b/internal/cli/config.go index 5e084a4..d4db2e9 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -8,7 +8,6 @@ import ( "path/filepath" "strings" - "github.com/alyraffauf/tg/internal/app" "github.com/spf13/viper" ) @@ -58,7 +57,7 @@ func loadConfig(flags flagSettings, errorWriter io.Writer) settings { config.AutomaticEnv() config.SetDefault("appview", defaultAppview) config.SetDefault("account", "") - config.SetDefault("knot", app.DefaultKnot) + config.SetDefault("knot", "") config.SetDefault("ssh-port", "22") if err := config.ReadInConfig(); err != nil { diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 6260f47..8c98044 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -48,7 +48,7 @@ func TestLoadConfigKnotPrecedence(t *testing.T) { env string wantKnot string }{ - {name: "hosted Knot default", wantKnot: "knot1.tangled.sh"}, + {name: "unset enables discovery", wantKnot: ""}, {name: "config", config: "config.example", wantKnot: "config.example"}, {name: "environment over config", config: "config.example", env: "env.example", wantKnot: "env.example"}, } diff --git a/internal/cli/repo_create.go b/internal/cli/repo_create.go index b02bc28..a119189 100644 --- a/internal/cli/repo_create.go +++ b/internal/cli/repo_create.go @@ -17,7 +17,16 @@ func newRepoCreateCommand(service *app.Service, defaultKnot, defaultSSHPort stri Short: "Create a repository on Tangled", Long: `Create a repository on Tangled. -The repository is provisioned on the selected Knot and a +The repository is provisioned on the Knot selected by --knot, TG_KNOT, or tg +configuration. When none is selected, tg reads your sh.tangled.knot +registrations from your PDS and verifies each Knot's sh.tangled.owner response +against your DID. At most 10 registrations are considered; select a Knot +explicitly when you have more. One verified Knot is selected automatically. +If no registrations exist, tg falls back to ` + app.DefaultKnot + `. Registrations +with no successful verification stop creation. Failed registrations produce +warnings when another registration succeeds, and multiple verified Knots +require an explicit selection. Discovered hosts are contacted through your +machine's normal DNS and HTTPS networking; private hosts are allowed. A sh.tangled.repo record is written to your PDS. The repository name is used as the record key, matching the current Tangled schema. @@ -34,13 +43,8 @@ Requires authentication (run "tg auth login" first).`, return fmt.Errorf("invalid SSH port %q: %w", sshPort, err) } - selectedKnot := knotHost - if selectedKnot == "" { - selectedKnot = app.DefaultKnot - } - result, err := service.CreateRepo(ctx, app.CreateRepoInput{ - KnotHost: selectedKnot, SSHPort: parsedSSHPort, Name: args[0], Description: description, + KnotHost: knotHost, SSHPort: parsedSSHPort, Name: args[0], Description: description, Clone: clone, PushPath: pushPath, RemoteName: remote, }) if err != nil { @@ -50,7 +54,7 @@ Requires authentication (run "tg auth login" first).`, }, } command.Flags().StringVar(&description, "description", "", "Repository description") - command.Flags().StringVar(&knotHost, "knot", defaultKnot, "Knot host to provision and optionally push to (overrides config file and TG_KNOT)") + command.Flags().StringVar(&knotHost, "knot", defaultKnot, "Knot host to provision and optionally push to (overrides TG_KNOT, config, and automatic discovery)") command.Flags().StringVar(&sshPort, "ssh-port", defaultSSHPort, "SSH port for cloning from or pushing to the selected Knot (overrides config file and TG_SSH_PORT)") command.Flags().BoolVar(&clone, "clone", false, "Clone the new repository into the current directory") command.Flags().StringVar(&pushPath, "push", "", "Push an existing local repository at this path to the new remote (e.g. .)") diff --git a/internal/cli/root.go b/internal/cli/root.go index 0f5a02f..4a73189 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -20,7 +20,7 @@ const ( ) func NewRoot(service *app.Service) *cobra.Command { - return newRoot(service, app.DefaultKnot, "22") + return newRoot(service, "", "22") } func newRoot(service *app.Service, defaultKnot, defaultSSHPort string) *cobra.Command { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 8c85a88..0cf14bf 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -94,7 +94,7 @@ func TestRepoCreateKnotFlag(t *testing.T) { if flag == nil { t.Fatal("repo create has no knot flag") } - if flag.DefValue != "configured.example" || flag.Usage != "Knot host to provision and optionally push to (overrides config file and TG_KNOT)" { + if flag.DefValue != "configured.example" || flag.Usage != "Knot host to provision and optionally push to (overrides TG_KNOT, config, and automatic discovery)" { t.Fatalf("knot flag = %+v", flag) } if err := create.Flags().Set("knot", "flag.example"); err != nil {