From 6601f7601ef376ca04b053db988073a11b4c9f53 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 24 Jul 2026 18:38:11 -0600 Subject: [PATCH] app: discover verified knot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do some fancy stuff because we can 👀 When no knot is explicitly configured, select the sole registration whose ownership endpoint confirms the authenticated user's DID. Fall back to the hosted knot only when no registrations exist, and reject unverifiable or ambiguous registrations so we don't silently create on the wrong server. --- docs/configuration.md | 16 ++- internal/app/app.go | 26 ++-- internal/app/dependencies.go | 17 ++- internal/app/knot_verification.go | 68 +++++++++ internal/app/knot_verification_test.go | 100 +++++++++++++ internal/app/repos.go | 137 +++++++++++++++--- internal/app/repos_create_test.go | 188 +++++++++++++++++++++++++ internal/app/service_test.go | 48 ++++++- internal/cli/config.go | 3 +- internal/cli/config_test.go | 2 +- internal/cli/repo_create.go | 20 +-- internal/cli/root.go | 2 +- internal/cli/root_test.go | 2 +- 13 files changed, 576 insertions(+), 53 deletions(-) create mode 100644 internal/app/knot_verification.go create mode 100644 internal/app/knot_verification_test.go 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 { -- 2.51.2