diff --git a/go.mod b/go.mod index c2017e0b..9185904a 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 go.lumeweb.com/configmanager v0.3.30 go.lumeweb.com/ipfs-content v0.1.18 - go.lumeweb.com/ipfs-sdk v0.1.95 + go.lumeweb.com/ipfs-sdk v0.1.97 go.lumeweb.com/ipfs-sdk/dnsname v0.1.64 go.lumeweb.com/oauth v0.1.6 go.lumeweb.com/portal-sdk v0.1.72 diff --git a/go.sum b/go.sum index 7a0d9453..fefec05e 100644 --- a/go.sum +++ b/go.sum @@ -737,6 +737,10 @@ go.lumeweb.com/ipfs-content v0.1.18 h1:Lq3/A+2kZcS4RK+bpdk+m4iJ95ZgpEa4DKhiH6bQW go.lumeweb.com/ipfs-content v0.1.18/go.mod h1:idWCsfndMDCsE5LtU0ZBNaFTBAaiuIe50dce0SDJLxw= go.lumeweb.com/ipfs-sdk v0.1.95 h1:4y3Oj6aTKLTWSzpdjizrQ7wJc/iXhza9MVuEhxbblyo= go.lumeweb.com/ipfs-sdk v0.1.95/go.mod h1:+V8YkslX65rPa83BbNYLGnhK24i75wqFtXKyltJ6jV4= +go.lumeweb.com/ipfs-sdk v0.1.96 h1:ciXYrRYtTe2cKs+8cXTQ63GRCQVoF4AqWBWriymybkQ= +go.lumeweb.com/ipfs-sdk v0.1.96/go.mod h1:oIMMi/yGovDqd8fAsfBYkoLV+GXcN0hNVmQXQv0ngDA= +go.lumeweb.com/ipfs-sdk v0.1.97 h1:JV35ex2g5zcoN4uwKGaPIVtiiupli0zVfYnh7eR9OYw= +go.lumeweb.com/ipfs-sdk v0.1.97/go.mod h1:VE85lcKSVHWqRHbKPfyWoMKv7aKwdhXBrRODGOIfoxQ= go.lumeweb.com/ipfs-sdk/dnsname v0.1.64 h1:RTBD+zoXHOYYKreNRoQhyLLKKWp/VgYfr7qojCzKjww= go.lumeweb.com/ipfs-sdk/dnsname v0.1.64/go.mod h1:1++6EWMiG/BC5UQjlIobId2YcWDVHjHEhdsEzeISKpQ= go.lumeweb.com/oauth v0.1.6 h1:6c6LrXxMwx5klbq3OshzXjkGwvYVjAQhjX2FxCAgqqg= diff --git a/internal/catalogops/websites_domains.go b/internal/catalogops/websites_domains.go index c2370a59..6a95b69d 100644 --- a/internal/catalogops/websites_domains.go +++ b/internal/catalogops/websites_domains.go @@ -213,11 +213,21 @@ func websitesDomainsRemove(d WebsitesDeps) catalog.Operation { }) } +const ( + // OpWebsitesDomainsVerify is the canonical name of the verify operation. + // Frontend wiring compares against it to switch rendering (e.g. the verify + // outcome renderer), so it lives here next to the definition. + OpWebsitesDomainsVerify = "websites_domains_verify" + // OpWebsitesDomainsDNSRequirements is the canonical name of the + // dns-requirements operation (same need). + OpWebsitesDomainsDNSRequirements = "websites_domains_dns_requirements" +) + // websitesDomainsVerify is the `websites domains verify` operation. Returns // *ipfs.DomainResponse. func websitesDomainsVerify(d WebsitesDeps) catalog.Operation { return catalog.NewOperation(catalog.OperationSpec{ - Name: "websites_domains_verify", + Name: OpWebsitesDomainsVerify, Title: "Verify a domain binding", Summary: "Verify a domain's DNS delegation", Description: "Verify that a bound domain's DNS delegation is correctly configured. The domain argument can be the domain name or its numeric binding ID; the owning website is resolved automatically. Returns the domain's status and delegation after verification.", @@ -255,7 +265,7 @@ func websitesDomainsVerify(d WebsitesDeps) catalog.Operation { // operation. Returns *ipfs.DomainResponse. func websitesDomainsDNSRequirements(d WebsitesDeps) catalog.Operation { return catalog.NewOperation(catalog.OperationSpec{ - Name: "websites_domains_dns_requirements", + Name: OpWebsitesDomainsDNSRequirements, Title: "DNS requirements for a domain", Summary: "Show DNS records needed to complete domain delegation", Description: "Show the DNS records a user must publish to complete delegation for a bound domain. For HNS namespaces this is the delegation bundle (parent NS/GLUE/DS and authoritative NS/TLSA). The domain argument can be the domain name or its numeric binding ID; the owning website is resolved automatically.", @@ -283,12 +293,30 @@ func websitesDomainsDNSRequirements(d WebsitesDeps) catalog.Operation { if err != nil { return nil, err } - // *ipfs.DomainResponse - return svc.GetDomainDNSRequirements(ctx, websiteID, domainID) + domain, err := svc.GetDomainDNSRequirements(ctx, websiteID, domainID) + if err != nil { + return nil, err + } + // On-chain bindings no longer carry a delegation bundle: the + // owning website is attached so frontends can derive the + // authoritative records (the _dnslink TXT) from its target. + // Best effort — a fetch failure only loses the derived rows. + website, _ := svc.Get(ctx, websiteID) + return &DomainDNSRequirements{Domain: domain, Website: website}, nil }), }) } +// DomainDNSRequirements pairs the dns-requirements response with the owning +// website record. On-chain bindings no longer carry a delegation bundle, so +// frontends derive the authoritative record set (the _dnslink TXT) from the +// website's target. CLI --json prints only the domain response, keeping the +// historical shape; the website is presentation data. +type DomainDNSRequirements struct { + Domain *ipfs.DomainResponse + Website *ipfs.WebsiteItem +} + // websitesDomainsDANERepublish is the `websites domains dane republish` // operation. Returns *ipfs.DomainDANERepublishResponse. func websitesDomainsDANERepublish(d WebsitesDeps) catalog.Operation { diff --git a/internal/catalogops/websites_domains_test.go b/internal/catalogops/websites_domains_test.go index 2123c52f..2dcaf4c2 100644 --- a/internal/catalogops/websites_domains_test.go +++ b/internal/catalogops/websites_domains_test.go @@ -20,6 +20,7 @@ type domainsService struct { authErr error listFn func(ctx context.Context) ([]ipfs.WebsiteItem, error) + getFn func(ctx context.Context, websiteID string) (*ipfs.WebsiteItem, error) listDomainsFn func(ctx context.Context, websiteID string) ([]ipfs.DomainResponse, error) bindDomainFn func(ctx context.Context, websiteID string, req ipfs.DomainRequest) (*ipfs.DomainResponse, error) unbindDomainFn func(ctx context.Context, websiteID string, domainID string) error @@ -67,6 +68,13 @@ func (f *domainsService) VerifyDomain(ctx context.Context, websiteID string, dom return nil, nil } +func (f *domainsService) Get(ctx context.Context, websiteID string) (*ipfs.WebsiteItem, error) { + if f.getFn != nil { + return f.getFn(ctx, websiteID) + } + return nil, nil +} + func (f *domainsService) GetDomainDNSRequirements(ctx context.Context, websiteID string, domainID string) (*ipfs.DomainResponse, error) { if f.dnsRequirementsFn != nil { return f.dnsRequirementsFn(ctx, websiteID, domainID) @@ -141,6 +149,38 @@ func TestWebsitesDomainsListResolvesWebsite(t *testing.T) { } } +func TestWebsitesDomainsDNSRequirementsAttachesWebsite(t *testing.T) { + // On-chain bindings no longer carry a delegation bundle; the handler must + // attach the owning website so frontends can derive the authoritative + // records (the _dnslink TXT) from its target. + fake := singleWebsiteFixture() + fake.dnsRequirementsFn = func(_ context.Context, websiteID string, domainID string) (*ipfs.DomainResponse, error) { + return &ipfs.DomainResponse{ + Id: 3, Domain: "example.test", Namespace: ipfs.DomainNamespaceHNS, + Status: new(ipfs.DomainResponseStatusOnchainManaged), + }, nil + } + fake.getFn = func(_ context.Context, websiteID string) (*ipfs.WebsiteItem, error) { + return &ipfs.WebsiteItem{Id: 7, Domain: "example.test", TargetType: "ipfs", TargetHash: "QmFoo"}, nil + } + + op := websitesDomainsDNSRequirements(domainsDeps(t, fake)) + res, err := op.Handler().Execute(context.Background(), map[string]any{"domain": "example.test"}) + if err != nil { + t.Fatalf("dns-requirements handler: %v", err) + } + result, ok := res.(*DomainDNSRequirements) + if !ok { + t.Fatalf("result type = %T, want *DomainDNSRequirements", res) + } + if result.Domain == nil || result.Domain.Id != 3 { + t.Fatalf("unexpected domain response: %+v", result.Domain) + } + if result.Website == nil || result.Website.TargetHash != "QmFoo" { + t.Fatalf("website not attached: %+v", result.Website) + } +} + func TestWebsitesDomainsListRequiresWebsite(t *testing.T) { fake := singleWebsiteFixture() op := websitesDomainsList(domainsDeps(t, fake)) @@ -221,8 +261,8 @@ func TestWebsitesDomainsDANERepublishTypedResult(t *testing.T) { if websiteID != "7" || domainID != "3" { t.Fatalf("RepublishDANE(%q, %q), want (\"7\", \"3\")", websiteID, domainID) } - tlsa := "_443._tcp.example.test. 60 IN TLSA 3 1 1 abc123" - return &ipfs.DomainDANERepublishResponse{Id: 3, Domain: "example.test", TlsaRecord: &tlsa}, nil + rdata := "3 1 1 abc123" + return &ipfs.DomainDANERepublishResponse{Id: 3, Domain: "example.test", PublishedToManagedZone: true, TlsaRdata: &rdata}, nil } op := websitesDomainsDANERepublish(domainsDeps(t, fake)) diff --git a/internal/cli/catalog_websites_wiring.go b/internal/cli/catalog_websites_wiring.go index 9511a196..3599a8d7 100644 --- a/internal/cli/catalog_websites_wiring.go +++ b/internal/cli/catalog_websites_wiring.go @@ -350,7 +350,7 @@ func websitesActionAdapter(op catalog.Operation) cli.ActionFunc { // human (non-JSON) output — in --json mode the error document stays machine // clean, and it no-ops for every non-verify operation. func renderVerifyGuidance(output Output, op catalog.Operation, err error) { - if op.Name() == "websites_domains_verify" && !output.IsJSON() { + if op.Name() == catalogops.OpWebsitesDomainsVerify && !output.IsJSON() { renderDNSSelfServiceGuidance(output, err) } } @@ -380,7 +380,13 @@ func renderWebsitesResult(_ context.Context, c *cli.Command, op catalog.Operatio // Slices/maps are excluded — a nil slice legitimately means an empty // result set (e.g. `websites domains list` with no domains) and is handled // by the renderer's empty-state branches. + // A verify returning (nil, nil) is meaningful: DNS is not resolvable yet, + // so render the not-verified outcome rather than bailing out. if result != nil && isNilPointerResult(result) { + if op.Name() == catalogops.OpWebsitesDomainsVerify { + renderDomainVerifyResult(output, nil) + return nil + } return fmt.Errorf("%s returned no result", op.Name()) } @@ -421,6 +427,7 @@ func renderWebsitesResult(_ context.Context, c *cli.Command, op catalog.Operatio {"Message", r.Message}, }, }) + renderValidationChecks(output, r.Checks) return nil case *ipfs.WebsiteConfigResponse: @@ -450,6 +457,20 @@ func renderWebsitesResult(_ context.Context, c *cli.Command, op catalog.Operatio output.Printfln("Website deleted successfully") return nil + case *catalogops.DomainDNSRequirements: + // websites domains dns-requirements: the domain response plus the + // owning website, from which the renderer derives the authoritative + // records the backend no longer returns for on-chain bindings. + // --json keeps the historical domain-response shape. + if r.Domain == nil { + return fmt.Errorf("no result returned for %s", op.Name()) + } + if output.IsJSON() { + return output.PrintJSON(r.Domain) + } + renderDomainDelegation(output, r.Domain, r.Domain.DnsHostingEnabled, r.Website) + return nil + case []ipfs.DomainResponse: // websites domains list: a website's domain bindings. if output.IsJSON() { @@ -491,8 +512,8 @@ func renderWebsitesResult(_ context.Context, c *cli.Command, op catalog.Operatio if output.IsJSON() { return output.PrintJSON(r) } - if op.Name() == "websites_domains_dns_requirements" { - renderDomainDelegation(output, r, r.DnsHostingEnabled) + if op.Name() == catalogops.OpWebsitesDomainsVerify { + renderDomainVerifyResult(output, r) return nil } renderDomainResponse(output, r) @@ -609,20 +630,21 @@ func renderDomainDANEResponse(output Output, r *ipfs.DomainDANERepublishResponse if r.OwnerName != nil { ownerName = *r.OwnerName } - tlsaRecord := "" - if r.TlsaRecord != nil { - tlsaRecord = *r.TlsaRecord - } - output.PrintFields(FieldGroup{ - Fields: []Field{ - {"ID", fmt.Sprintf("%d", r.Id)}, - {"Domain", r.Domain}, - {"Namespace", string(r.Namespace)}, - {"Status", status}, - {"Owner Name", ownerName}, - {"TLSA Record", tlsaRecord}, - }, - }) + fields := []Field{ + {"ID", fmt.Sprintf("%d", r.Id)}, + {"Domain", r.Domain}, + {"Namespace", string(r.Namespace)}, + {"Status", status}, + {"Owner Name", ownerName}, + // published_to_managed_zone is a required field and always echoed; a + // false value means the republished TLSA is NOT live in the managed + // zone yet, so the user must not treat the command as a success. + {"TLSA Published", fmt.Sprintf("%t", r.PublishedToManagedZone)}, + } + if r.TlsaRdata != nil && *r.TlsaRdata != "" { + fields = append(fields, Field{"TLSA Record", *r.TlsaRdata}) + } + output.PrintFields(FieldGroup{Fields: fields}) } // renderWebsiteItemHuman renders the fields of a single website (used by get, diff --git a/internal/cli/catalog_websites_wiring_test.go b/internal/cli/catalog_websites_wiring_test.go index 003353f9..c89fbb5d 100644 --- a/internal/cli/catalog_websites_wiring_test.go +++ b/internal/cli/catalog_websites_wiring_test.go @@ -121,7 +121,7 @@ func TestWebsitesCRUDPositionalMapping(t *testing.T) { } func TestRenderWebsitesResultRejectsTypedNil(t *testing.T) { - op := catalog.NewOperation(catalog.OperationSpec{Name: "websites_domains_verify"}) + op := catalog.NewOperation(catalog.OperationSpec{Name: "websites_get"}) // A handler that returns (nil, nil) surfaces as a typed nil *ipfs.DomainResponse. var typedNil *ipfs.DomainResponse @@ -131,6 +131,23 @@ func TestRenderWebsitesResultRejectsTypedNil(t *testing.T) { } } +func TestRenderWebsitesResultVerifyTypedNilRendersNotVerified(t *testing.T) { + // For verify, a typed nil (the handler returned (nil, nil)) means the + // domain's DNS could not be resolved yet — the not-verified outcome, not + // an error. + op := catalog.NewOperation(catalog.OperationSpec{Name: catalogops.OpWebsitesDomainsVerify}) + var typedNil *ipfs.DomainResponse + var buf bytes.Buffer + cmd := &cli.Command{} + cmd.Writer = &buf + if err := renderWebsitesResult(context.Background(), cmd, op, typedNil); err != nil { + t.Fatalf("verify typed-nil should render the not-verified outcome, got err: %v", err) + } + if !strings.Contains(buf.String(), "⏳ not verified yet") { + t.Errorf("expected the not-verified outcome on stdout, got: %q", buf.String()) + } +} + func TestIsNilPointerResultTypedNil(t *testing.T) { var p *int var s []string diff --git a/internal/cli/dns_test.go b/internal/cli/dns_test.go index b8e6c609..027355f3 100644 --- a/internal/cli/dns_test.go +++ b/internal/cli/dns_test.go @@ -135,6 +135,12 @@ func TestKeepWholeValue(t *testing.T) { {"full table A content wraps", []string{"r1", "www", "A", "1.2.3.4", "300", ""}, 3, false}, {"full table name col wraps", []string{"r1", "www", "A", "1.2.3.4", "300", ""}, 1, false}, {"full table ttl wraps", []string{"r1", "www", "A", "1.2.3.4", "300", ""}, 4, false}, + // On-chain TLSA layout [NAME, TYPE, VALUE] + {"onchain tlsa value kept whole", []string{"_443._tcp.example.com", "TLSA", "3 1 1 0a9e..."}, 2, true}, + {"onchain dnslink value kept whole", []string{"_dnslink.example.com", "TXT", "dnslink=/ipfs/bafy..."}, 2, true}, + {"onchain non-record value wraps", []string{"example.com", "A", "1.2.3.4"}, 2, false}, + {"onchain name col wraps", []string{"_443._tcp.example.com", "TLSA", "3 1 1 0a9e..."}, 0, false}, + {"onchain type col wraps", []string{"_443._tcp.example.com", "TLSA", "3 1 1 0a9e..."}, 1, false}, } for _, tt := range tests { diff --git a/internal/cli/output.go b/internal/cli/output.go index de2f9741..c9a072dd 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -344,6 +344,16 @@ func keepWholeValue(row []string, j int) bool { if len(row) == 2 { return (row[0] == "DS" || row[0] == "TLSA") && j == 1 } + // On-chain TLSA table: [NAME, TYPE, VALUE] — the digest is the same + // copyable opaque value as above, just with the owner name (the + // _443._tcp. record location) promoted to its own column. + if len(row) == 3 && j == 2 { + switch row[1] { + case "TLSA", "DS": + return true + } + return strings.Contains(row[0], "_443._tcp") || strings.Contains(row[0], "_dnslink") + } // Full DNS record table: [ID, NAME, TYPE, CONTENT, TTL, STATUS] if len(row) >= 6 && j == 3 { switch row[2] { diff --git a/internal/cli/websites_domains.go b/internal/cli/websites_domains.go index 93b83b8a..447161e4 100644 --- a/internal/cli/websites_domains.go +++ b/internal/cli/websites_domains.go @@ -11,13 +11,59 @@ import ( // websites_domains_wizard.go). This file retains the domain rendering helper // shared by the domains feature. +// renderDomainVerifyResult turns a `websites domains verify` response into an +// outcome the user can act on, instead of the generic binding field table. +// valid mirrors the wizard's notion of a fully validated binding (active, or +// on-chain managed via the namespace TXT token). A nil response means the +// backend could not resolve the domain's DNS yet — still not verified. +func renderDomainVerifyResult(output Output, r *ipfs.DomainResponse) { + if r == nil { + output.Printfln("⏳ not verified yet") + output.Printfln(" The domain's DNS could not be resolved, so validation didn't run.") + output.Printfln(" DNS can take a while to propagate. Re-check with:") + output.Printfln(" pinner websites domains verify ") + return + } + + status := "" + if r.Status != nil { + status = string(*r.Status) + } + + if domainStatusIsValid(statusOf(r)) { + output.Printfln("✅ %s verified", r.Domain) + output.Printfln(" Status: %s", status) + output.Printfln(" Your site will be served at https://%s", r.Domain) + if statusOnchainManaged(r) { + // Verification only proves ownership through the on-chain TXT + // token — it does not confirm the TLSA was published, and the + // site won't load over HTTPS without it. + output.Printfln(" Make sure the TLSA record is published so the site loads") + output.Printfln(" over HTTPS:") + output.Printfln(" pinner websites domains dns-requirements %s", r.Domain) + } + renderValidationChecks(output, r.Checks) + return + } + + output.Printfln("⏳ %s is not verified yet", r.Domain) + output.Printfln(" Status: %s", status) + output.Printfln(" DNS can take a while to propagate. Re-check with:") + output.Printfln(" pinner websites domains verify %s", r.Domain) + output.Printfln(" Check the records the domain needs:") + output.Printfln(" pinner websites domains dns-requirements %s", r.Domain) + renderValidationChecks(output, r.Checks) +} + // renderDomainDelegation prints the DNS delegation bundle the server computes // for a domain. Rendering is driver-based: the namespace selects a // context-specific driver (HNS, ICANN, ...) with a neutral generic fallback, // matching the server's per-namespace DomainProvider design. managed indicates // whether Pinner manages the domain's DNS, so drivers can omit authoritative -// records the user does not need to configure. -func renderDomainDelegation(output Output, result *ipfs.DomainResponse, managed bool) { +// records the user does not need to configure. website is the owning website +// when the caller has it; on-chain drivers use its target to derive the +// authoritative records the backend no longer returns. +func renderDomainDelegation(output Output, result *ipfs.DomainResponse, managed bool, website *ipfs.WebsiteItem) { output.Printfln("DNS requirements for %s", result.Domain) status := "" @@ -43,5 +89,10 @@ func renderDomainDelegation(output Output, result *ipfs.DomainResponse, managed // meaningful per-namespace (e.g. an HNS on-chain managed binding // serves its DNS from an external contract and has no records to publish), // so the driver owns the explanation instead of a generic miss here. - defaultDelegationDriver.Render(output, result, managed) + defaultDelegationDriver.Render(output, result, managed, website) + + // The backend computes per-record checks (validation token, dnslink, + // TLSA...) on every requirements response; they are the record-level truth + // the user needs to act on, so they render regardless of namespace. + renderValidationChecks(output, result.Checks) } diff --git a/internal/cli/websites_domains_delegation.go b/internal/cli/websites_domains_delegation.go index 16477fd3..bf2e677e 100644 --- a/internal/cli/websites_domains_delegation.go +++ b/internal/cli/websites_domains_delegation.go @@ -12,7 +12,9 @@ type delegationDriver interface { // Render prints the delegation bundle for a domain response. managed // indicates whether Pinner manages the domain's DNS (authoritative side // served by Pinner), which drivers use to decide which records to show. - Render(output Output, result *ipfs.DomainResponse, managed bool) + // website is the owning website when resolvable; drivers that derive + // records from it (on-chain) render them when present. + Render(output Output, result *ipfs.DomainResponse, managed bool, website *ipfs.WebsiteItem) } // delegationRegistry resolves the driver responsible for a namespace and holds @@ -33,7 +35,7 @@ func newDelegationRegistry(fallback delegationDriver, drivers map[ipfs.DomainNam // Render routes a domain response to the driver registered for its namespace, // falling back to the generic driver for unrecognized namespaces. -func (r *delegationRegistry) Render(output Output, result *ipfs.DomainResponse, managed bool) { +func (r *delegationRegistry) Render(output Output, result *ipfs.DomainResponse, managed bool, website *ipfs.WebsiteItem) { if r == nil { return } @@ -41,7 +43,7 @@ func (r *delegationRegistry) Render(output Output, result *ipfs.DomainResponse, if !ok { driver = r.fallback } - driver.Render(output, result, managed) + driver.Render(output, result, managed, website) } // defaultDelegationDriver is the registry wired to the built-in drivers. diff --git a/internal/cli/websites_domains_delegation_common.go b/internal/cli/websites_domains_delegation_common.go index 6c022a61..4e2f04b5 100644 --- a/internal/cli/websites_domains_delegation_common.go +++ b/internal/cli/websites_domains_delegation_common.go @@ -31,6 +31,151 @@ func renderDelegationNameservers(output Output, d *ipfs.DNSDelegation) { output.PrintList(*d.Nameservers) } +// renderValidationChecks turns the per-record checks the backend computes for +// a domain or website (validation token, dnslink, TLSA, delegation...) into +// an actionable to-do list: a short summary of how many are fine, then the +// ones needing attention with the exact record value to publish. Passing +// checks are summarized rather than listed — a wall of green rows does not +// help the user act; the missing/incorrect records do. +func renderValidationChecks(output Output, checks *[]ipfs.ValidationCheck) { + if checks == nil || len(*checks) == 0 { + return + } + var failing []ipfs.ValidationCheck + passing := 0 + for _, c := range *checks { + if c.Ok { + passing++ + continue + } + failing = append(failing, c) + } + switch { + case len(failing) == 0: + output.Printfln("") + output.Printfln("All %d record checks passed.", len(*checks)) + return + case passing > 0: + output.Printfln("") + output.Printfln("%d of %d record checks passed. Fix the %d below:", passing, len(*checks), len(failing)) + default: + output.Printfln("") + output.Printfln("%d records need attention:", len(failing)) + } + // Deliberately NOT a table: expected record values (dnslink, TLSA...) can + // exceed the table wrap width, and a hard-wrapped value is neither + // copyable nor honest. The expected value is printed on its own line, + // verbatim, so it can be copied straight into the user's DNS. + for _, c := range failing { + output.Printfln(" • %s", c.Name) + if c.Message != nil && *c.Message != "" { + output.Printfln(" %s", *c.Message) + } + if c.Expected != nil && *c.Expected != "" { + output.Printfln(" Publish this record:") + output.Printfln(" %s", *c.Expected) + } + if c.Found != nil && *c.Found != "" { + output.Printfln(" Found instead: %s", *c.Found) + } + } +} + +// tlsaRecordType is the DNS resource-record type of the DANE TLSA record. +// The SDK models delegation record types as plain strings, so the constant +// lives here next to the only logic that filters on it. +const tlsaRecordType = "TLSA" + +// tlsaRecords collects the TLSA records the user must publish on-chain, from +// the delegation bundle (authoritative group first). +func tlsaRecords(d *ipfs.DNSDelegation) []ipfs.DNSDelegationRecord { + if d == nil { + return nil + } + var out []ipfs.DNSDelegationRecord + seen := map[string]bool{} + add := func(records *[]ipfs.DNSDelegationRecord) { + if records == nil { + return + } + for _, r := range *records { + if r.Type != tlsaRecordType { + continue + } + value := "" + if r.Value != nil { + value = *r.Value + } + if seen[value] { + continue + } + seen[value] = true + out = append(out, r) + } + } + add(d.AuthoritativeRecords) + add(d.ParentRecords) + return out +} + +// tlsaOwnerName is the record name a DANE TLSA record for HTTPS lives under: +// the TCP port 443 service on the domain ("_443._tcp."). Passing the +// bare rdata alone leaves the user to guess this; TLSA is never published at +// the domain apex. +func tlsaOwnerName(result *ipfs.DomainResponse) string { + if result != nil && result.OwnerName != nil && *result.OwnerName != "" { + return *result.OwnerName + } + if result != nil { + return "_443._tcp." + result.Domain + } + return "_443._tcp." +} + +// renderOnchainTLSA renders the TLSA record the user must publish alongside +// their on-chain records — browsers use it to verify the gateway's HTTPS +// certificate for on-chain names, so without it the site won't load over +// HTTPS. The record comes from the delegation bundle's TLSA entries, falling +// back to the response's owner_name/tlsa_rdata fields (schema v0.1.96+). The +// owner name (where the record goes) is always shown: bare rdata like +// "3 1 1 " is not publishable without knowing it belongs at +// _443._tcp.. +func renderOnchainTLSA(output Output, result *ipfs.DomainResponse, d *ipfs.DNSDelegation) { + records := tlsaRecords(d) + // The bundle frequently comes back nil on on-chain Managed bindings, so + // the response-level tlsa_rdata (schema v0.1.96) is the usual source here. + if len(records) == 0 && result != nil && result.TlsaRdata != nil && *result.TlsaRdata != "" { + records = []ipfs.DNSDelegationRecord{{Type: tlsaRecordType, Value: result.TlsaRdata}} + } + if len(records) == 0 { + // TODO: backend - return tlsa_rdata on on-chain bindings so the + // onboarding story is complete. Until then, point the user at the + // DANE republish command instead of leaving a silent gap. + output.Printfln("") + output.Printfln("This domain also needs a TLSA record published (it lets your") + output.Printfln("site load over HTTPS). If it is missing, you can regenerate it") + output.Printfln("with:") + output.Printfln(" pinner websites domains dane republish ") + return + } + owner := tlsaOwnerName(result) + output.Printfln("") + output.Printfln("TLSA — publish this record at %s (the TCP port 443 service", owner) + output.Printfln("of your domain) so your site loads over HTTPS:") + // NAME/TYPE/VALUE so the owner name (_443._tcp.) is part of the + // copyable record itself; keepWholeValue treats the digest in the VALUE + // column of this layout as whole-token so it is never hard-wrapped. + rows := make([][]string, 0, len(records)) + for _, r := range records { + value := "" + if r.Value != nil { + value = *r.Value + } + rows = append(rows, []string{owner, tlsaRecordType, value}) + } + output.PrintTable([]string{"NAME", "TYPE", "VALUE"}, rows) +} + // printDelegationRecords renders a group of DNS records as a TYPE/VALUE table. func printDelegationRecords(output Output, title string, records *[]ipfs.DNSDelegationRecord) { if records == nil || len(*records) == 0 { diff --git a/internal/cli/websites_domains_delegation_generic.go b/internal/cli/websites_domains_delegation_generic.go index eff83576..1033c38a 100644 --- a/internal/cli/websites_domains_delegation_generic.go +++ b/internal/cli/websites_domains_delegation_generic.go @@ -9,7 +9,7 @@ import ( // namespace-specific copy. type genericDelegationDriver struct{} -func (g *genericDelegationDriver) Render(output Output, result *ipfs.DomainResponse, managed bool) { +func (g *genericDelegationDriver) Render(output Output, result *ipfs.DomainResponse, managed bool, website *ipfs.WebsiteItem) { d := result.Delegation if d == nil { // A namespace without a dedicated driver has no on-chain concept to diff --git a/internal/cli/websites_domains_delegation_hns.go b/internal/cli/websites_domains_delegation_hns.go index 734198b6..e13fa180 100644 --- a/internal/cli/websites_domains_delegation_hns.go +++ b/internal/cli/websites_domains_delegation_hns.go @@ -14,21 +14,30 @@ import ( // records are therefore shown only in the last case. type hnsDelegationDriver struct{} -func (h *hnsDelegationDriver) Render(output Output, result *ipfs.DomainResponse, managed bool) { +func (h *hnsDelegationDriver) Render(output Output, result *ipfs.DomainResponse, managed bool, website *ipfs.WebsiteItem) { d := result.Delegation - // On-chain managed or otherwise delegation-less HNS binding: the name's - // DNS is served by an external contract (its NS record points at one), so - // the portal provisions no zone, DNSSEC, or delegation bundle — ownership - // is proven via a TXT token resolved through the HNS-aware resolver. There - // are no parent/authoritative records for the user to publish; publishing - // Pinner's delegation records would be wrong here. + // On-chain managed or otherwise delegation-less HNS binding: the domain is + // held on-chain and its DNS records are set on-chain, outside any + // Pinner-managed zone. The crucial record to surface here is the TLSA — + // without it published on-chain the site won't load over HTTPS. if statusOnchainManaged(result) { output.Printfln("") - output.Printfln("%s is on-chain managed: its DNS is served by an external", result.Domain) - output.Printfln("contract on the Handshake chain, not by a Pinner-managed zone.") - output.Printfln("No delegation records must be published — ownership is verified via a") - output.Printfln("TXT token through the HNS resolver. Manage the zone in your HNS wallet.") + output.Printfln("%s is on-chain managed: this domain is held on-chain, so its", result.Domain) + output.Printfln("DNS records are set on-chain rather than in a Pinner-managed zone.") + output.Printfln("Set up the domain's on-chain DNS wherever you manage it.") + // The backend no longer returns an authoritative record set for + // on-chain bindings; the record the site needs is derivable from the + // owning website's target. + if website != nil && website.TargetHash != "" { + rows := [][]string{ + {"_dnslink." + result.Domain, "TXT", "dnslink=/" + website.TargetType + "/" + website.TargetHash}, + } + output.Printfln("") + output.Printfln("records to publish on-chain (they wire the name to the site's content):") + output.PrintTable([]string{"NAME", "TYPE", "VALUE"}, rows) + } + renderOnchainTLSA(output, result, d) return } diff --git a/internal/cli/websites_domains_delegation_icann.go b/internal/cli/websites_domains_delegation_icann.go index a9310fad..c910399c 100644 --- a/internal/cli/websites_domains_delegation_icann.go +++ b/internal/cli/websites_domains_delegation_icann.go @@ -10,7 +10,7 @@ import ( // the parent (registrar) records are shown. type icannDelegationDriver struct{} -func (i *icannDelegationDriver) Render(output Output, result *ipfs.DomainResponse, managed bool) { +func (i *icannDelegationDriver) Render(output Output, result *ipfs.DomainResponse, managed bool, website *ipfs.WebsiteItem) { d := result.Delegation if d == nil { // An ICANN binding with no bundle means Pinner holds nothing for this diff --git a/internal/cli/websites_domains_test.go b/internal/cli/websites_domains_test.go index 1db40bfd..2837457e 100644 --- a/internal/cli/websites_domains_test.go +++ b/internal/cli/websites_domains_test.go @@ -14,7 +14,7 @@ func TestRenderDomainDelegation(t *testing.T) { output := newTestOutput() renderDomainDelegation(output, &ipfs.DomainResponse{ Id: 1, Domain: "mydomain.hns", Namespace: ipfs.DomainNamespaceHNS, Status: new(ipfs.DomainResponseStatusActive), - }, false) + }, false, nil) // exercises the nil-delegation branch without asserting exact text }) @@ -32,7 +32,7 @@ func TestRenderDomainDelegation(t *testing.T) { {Type: "TLSA", Value: new("_443._tcp.mydomain. 3 1 1 ")}, }, }, - }, false) + }, false, nil) // exercises the non-nil typed-helper path }) @@ -51,7 +51,7 @@ func TestRenderDomainDelegation(t *testing.T) { {Type: "NS", Value: new("hns-626f7578e5.rec.ns1.lumeweb")}, }, }, - }, false) + }, false, nil) out := buf.String() // In inline mode the authoritative side is served via Pinner's // synthetic nameservers; it is not user-configured, so it is omitted. @@ -75,7 +75,7 @@ func TestRenderDomainDelegation(t *testing.T) { {Type: "NS", Value: new("hns-626f7578e5.rec.ns1.lumeweb")}, }, }, - }, true) + }, true, nil) out := buf.String() assert.Contains(t, out, "synthetic nameservers") assert.NotContains(t, out, "Authoritative records") @@ -88,7 +88,7 @@ func TestRenderDomainDelegation(t *testing.T) { Delegation: &ipfs.DNSDelegation{ Nameservers: &[]string{"ns1.example.com", "ns2.example.com"}, }, - }, false) + }, false, nil) // exercises the icann driver path (registrar wording, nameservers list) }) @@ -105,7 +105,7 @@ func TestRenderDomainDelegation(t *testing.T) { {Type: "TLSA", Value: new("_443._tcp.mydomain.eth. 3 1 1 ")}, }, }, - }, false) + }, false, nil) // exercises the generic fallback path for an unrecognized namespace }) @@ -127,7 +127,7 @@ func TestRenderDomainDelegation(t *testing.T) { {Type: "DS", Value: new(dsValue)}, }, }, - }, false) + }, false, nil) out := buf.String() // The DS record is communicated once, as a parent record in the // parent-records table, not re-decoded into a redundant block. @@ -161,7 +161,7 @@ func TestRenderDomainDelegation(t *testing.T) { {Type: "NS", Value: new("nsx.pinner.xyz")}, }, }, - }, true) + }, true, nil) out := buf.String() // Pinner manages DNS, so only the parent records (for the HNS wallet) // are shown; the authoritative side is handled for the user. @@ -189,7 +189,7 @@ func TestRenderDomainDelegation(t *testing.T) { {Type: "TLSA", Value: new("_443._tcp.mydomain.com. 3 1 1 ")}, }, }, - }, true) + }, true, nil) out := buf.String() assert.Contains(t, out, "Point your registrar's nameservers") assert.Contains(t, out, "Pinner manages your DNS") @@ -197,27 +197,190 @@ func TestRenderDomainDelegation(t *testing.T) { assert.NotContains(t, out, "TLSA") }) - t.Run("onchain managed hns explains the contract-served DNS and publishes nothing", func(t *testing.T) { + t.Run("onchain managed hns explains the on-chain DNS and surfaces the TLSA", func(t *testing.T) { var buf bytes.Buffer output := NewOutputFormatter(false, false, false, false) output.SetWriter(&buf) - // On-chain managed: the HNS name's DNS is served by an external - // contract, so the backend returns status onchain_managed with NO - // delegation bundle. + // On-chain managed: the domain is held on-chain, so the backend + // returns status onchain_managed with delegation carrying the TLSA + // record the user must publish on-chain. renderDomainDelegation(output, &ipfs.DomainResponse{ Id: 1, Domain: "mydomain", Namespace: ipfs.DomainNamespaceHNS, Status: new(ipfs.DomainResponseStatusOnchainManaged), - }, false) + Delegation: &ipfs.DNSDelegation{ + AuthoritativeRecords: &[]ipfs.DNSDelegationRecord{ + {Type: "TLSA", Value: new("_443._tcp.mydomain.hns. 60 IN TLSA 3 1 1 abcdef")}, + }, + }, + }, false, nil) out := buf.String() - // The driver must not crash on a nil Delegation and must explain the - // on-chain hosting shape instead of rendering a record table. + // The driver must not crash and must explain the on-chain hosting + // shape in user-friendly terms, and the TLSA must reach the user — + // without it the site won't load over HTTPS. assert.Contains(t, out, "on-chain managed") - assert.Contains(t, out, "external") - assert.Contains(t, out, "TXT token") - // No record tables and no delegation publishing instructions: none of - // it applies to a contract-served name. + assert.Contains(t, out, "held on-chain") + assert.Contains(t, out, "TLSA") + assert.Contains(t, out, "_443._tcp.mydomain.hns. 60 IN TLSA 3 1 1 abcdef") + // No portal delegation publishing: none of it applies here. assert.NotContains(t, out, "Parent records") assert.NotContains(t, out, "Authoritative records") - assert.NotContains(t, out, "Publish the records") + }) + + t.Run("dns-requirements renders the backend record checks incl. dnslink", func(t *testing.T) { + var buf bytes.Buffer + output := NewOutputFormatter(false, false, false, false) + output.SetWriter(&buf) + // The checks slice is the record-level truth for a managed HNS + // binding: the dnslink value the authoritative side needs, the + // validation token, the TLSA. It must reach the user, not be dropped. + renderDomainDelegation(output, &ipfs.DomainResponse{ + Id: 1, Domain: "mydomain.hns", Namespace: ipfs.DomainNamespaceHNS, Status: new(ipfs.DomainResponseStatusWaitingDelegation), + Delegation: &ipfs.DNSDelegation{ + ParentRecords: &[]ipfs.DNSDelegationRecord{ + {Type: "NS", Value: new("ns1.lumeweb")}, + {Type: "DS", Value: new("11068 13 2 c7af")}, + }, + }, + Checks: &[]ipfs.ValidationCheck{ + {Name: "dnslink record", Ok: false, Expected: new("_dnslink.mydomain.hns. 60 IN TXT \"dnslink=/ipfs/QmXyz\"")}, + {Name: "validation token", Ok: true}, + }, + }, true, nil) + out := buf.String() + assert.Contains(t, out, "Parent records (publish in your HNS wallet)") + assert.Contains(t, out, "11068 13 2 c7af") + // The dnslink record the site needs is surfaced as an actionable + // to-do with the exact value to publish — passing checks are only + // summarized, not row-walled. + assert.Contains(t, out, "1 of 2 record checks passed. Fix the 1 below:") + assert.Contains(t, out, "dnslink record") + assert.Contains(t, out, "Publish this record:") + assert.Contains(t, out, `_dnslink.mydomain.hns. 60 IN TXT "dnslink=/ipfs/QmXyz"`) + }) + + t.Run("verify renders failing record checks next to the not-verified outcome", func(t *testing.T) { + var buf bytes.Buffer + output := NewOutputFormatter(false, false, false, false) + output.SetWriter(&buf) + renderDomainVerifyResult(output, &ipfs.DomainResponse{ + Id: 1, Domain: "pending.com", Namespace: ipfs.DomainNamespaceICANN, Status: new(ipfs.DomainResponseStatusWaitingDelegation), + Checks: &[]ipfs.ValidationCheck{ + {Name: "dnslink record", Ok: false, Message: new("record not found"), Expected: new("dnslink=/ipfs/Qm")}, + }, + }) + out := buf.String() + assert.Contains(t, out, "⏳ pending.com is not verified yet") + assert.Contains(t, out, "1 records need attention:") + // Expected record values must render contiguous (never hard-wrapped) + // so they stay copy-into-DNS safe. + assert.Contains(t, out, "Publish this record:") + assert.Contains(t, out, "dnslink=/ipfs/Qm") + assert.Contains(t, out, "record not found") + }) + + t.Run("onchain managed hns falls back to the response's tlsa_rdata", func(t *testing.T) { + var buf bytes.Buffer + output := NewOutputFormatter(false, false, false, false) + output.SetWriter(&buf) + // Schema v0.1.96 carries tlsa_rdata directly on the response; the + // fallback must use it when the bundle has no TLSA record. + rdata := "3 1 1 abcdef" + renderDomainDelegation(output, &ipfs.DomainResponse{ + Id: 1, Domain: "mydomain", Namespace: ipfs.DomainNamespaceHNS, Status: new(ipfs.DomainResponseStatusOnchainManaged), + TlsaRdata: &rdata, + }, false, nil) + out := buf.String() + assert.Contains(t, out, "TLSA") + assert.Contains(t, out, "3 1 1 abcdef") + assert.NotContains(t, out, "dane republish") + }) + + t.Run("onchain managed hns derives the dnslink row from the owning website", func(t *testing.T) { + var buf bytes.Buffer + output := NewOutputFormatter(false, false, false, false) + output.SetWriter(&buf) + // The backend no longer returns an authoritative record set for + // on-chain bindings; the CLI derives the _dnslink TXT from the + // website's target. + rdata := "3 1 1 abcdef" + renderDomainDelegation(output, &ipfs.DomainResponse{ + Id: 1, + Domain: "mydomain.hns", + Namespace: ipfs.DomainNamespaceHNS, + Status: new(ipfs.DomainResponseStatusOnchainManaged), + TlsaRdata: &rdata, + }, false, &ipfs.WebsiteItem{Id: 7, Domain: "mydomain.hns", TargetType: "ipfs", TargetHash: "QmFoo"}) + out := buf.String() + assert.Contains(t, out, "_dnslink.mydomain.hns") + assert.Contains(t, out, "TXT") + assert.Contains(t, out, "dnslink=/ipfs/QmFoo") + // The server-served TLSA rides in the same checklist. + assert.Contains(t, out, "3 1 1 abcdef") + }) + + t.Run("onchain managed hns without a TLSA still tells the user one is needed", func(t *testing.T) { + var buf bytes.Buffer + output := NewOutputFormatter(false, false, false, false) + output.SetWriter(&buf) + // The backend does not always include the TLSA on the response yet; + // the gap must not pass silently. + renderDomainDelegation(output, &ipfs.DomainResponse{ + Id: 1, Domain: "mydomain", Namespace: ipfs.DomainNamespaceHNS, Status: new(ipfs.DomainResponseStatusOnchainManaged), + }, false, nil) + out := buf.String() + assert.Contains(t, out, "on-chain managed") + assert.Contains(t, out, "TLSA record") + assert.Contains(t, out, "dane republish") + assert.NotContains(t, out, "Parent records") + assert.NotContains(t, out, "Authoritative records") + }) + + t.Run("verify result shows a verified outcome with next steps", func(t *testing.T) { + var buf bytes.Buffer + output := NewOutputFormatter(false, false, false, false) + output.SetWriter(&buf) + renderDomainVerifyResult(output, &ipfs.DomainResponse{ + Id: 1, Domain: "mydomain.com", Namespace: ipfs.DomainNamespaceICANN, Status: new(ipfs.DomainResponseStatusActive), + }) + out := buf.String() + assert.Contains(t, out, "✅ mydomain.com verified") + assert.Contains(t, out, "https://mydomain.com") + // The records already check out on a verified domain, so the success + // path must not tell the user to go fix DNS records. + assert.NotContains(t, out, "dns-requirements") + }) + + t.Run("verify result for on-chain managed names points at the TLSA", func(t *testing.T) { + var buf bytes.Buffer + output := NewOutputFormatter(false, false, false, false) + output.SetWriter(&buf) + renderDomainVerifyResult(output, &ipfs.DomainResponse{ + Id: 1, Domain: "mydomain", Namespace: ipfs.DomainNamespaceHNS, Status: new(ipfs.DomainResponseStatusOnchainManaged), + }) + out := buf.String() + assert.Contains(t, out, "✅ mydomain verified") + assert.Contains(t, out, "TLSA") + assert.Contains(t, out, "dns-requirements mydomain") + }) + + t.Run("verify result for a pending binding says not verified with retry guidance", func(t *testing.T) { + var buf bytes.Buffer + output := NewOutputFormatter(false, false, false, false) + output.SetWriter(&buf) + renderDomainVerifyResult(output, &ipfs.DomainResponse{ + Id: 1, Domain: "pending.com", Namespace: ipfs.DomainNamespaceICANN, Status: new(ipfs.DomainResponseStatusWaitingDelegation), + }) + out := buf.String() + assert.Contains(t, out, "⏳ pending.com is not verified yet") + assert.Contains(t, out, "verify pending.com") + }) + + t.Run("verify result for a nil response renders the resolution-missing guidance", func(t *testing.T) { + var buf bytes.Buffer + output := NewOutputFormatter(false, false, false, false) + output.SetWriter(&buf) + renderDomainVerifyResult(output, nil) + out := buf.String() + assert.Contains(t, out, "⏳ not verified yet") }) t.Run("self-managed hns shows authoritative records", func(t *testing.T) { @@ -235,7 +398,7 @@ func TestRenderDomainDelegation(t *testing.T) { {Type: "NS", Value: new("ns.eigen.lumeweb")}, }, }, - }, false) + }, false, nil) out := buf.String() assert.Contains(t, out, "point your own DNS server") assert.Contains(t, out, "Authoritative records (configure on your DNS server)") diff --git a/internal/cli/websites_domains_wizard.go b/internal/cli/websites_domains_wizard.go index fe9a940d..37f3b44b 100644 --- a/internal/cli/websites_domains_wizard.go +++ b/internal/cli/websites_domains_wizard.go @@ -179,18 +179,30 @@ func (w *DomainAddWizard) executeDelegationSetup(ctx context.Context) error { return nil } + // A nil Delegation is still worth routing through the renderer: on-chain + // managed bindings carry none, and the on-chain branch renders the + // derived records (dnslink, TLSA) instead of a delegation bundle. Only a + // nil overall response is "nothing available". + if delegResult == nil { + w.output.Printfln("No delegation records are available for %s.", result.Domain) + return nil + } + // Whether Pinner manages this website's DNS, derived from the cached // website list fetched during the selection step. managed := false wID := w.WebsiteID() + var website *ipfs.WebsiteItem for _, ws := range w.websites { if fmt.Sprintf("%d", ws.Id) == wID { managed = ws.DnsHostingEnabled + wsCopy := ws + website = &wsCopy break } } - renderDomainDelegation(w.output, delegResult, managed) + renderDomainDelegation(w.output, delegResult, managed, website) return nil } diff --git a/internal/mcp/guide_fragments.go b/internal/mcp/guide_fragments.go index 1910b7e0..f2f98d13 100644 --- a/internal/mcp/guide_fragments.go +++ b/internal/mcp/guide_fragments.go @@ -55,7 +55,7 @@ var hnsNamespaceClause = toolforge.Static("For a Handshake (alt-root) name such // HNS bind outcomes in the same domain: status onchain_managed (no // records to publish) versus the normal delegation flow. var hnsOnchainClause = toolforge.Static("Read the created/bound domain's status next:").Sentences( - "On status onchain_managed the HNS name's DNS is served by an external contract on the Handshake chain, so there are NO delegation records to publish and pinner://websites//dns-requirements returns no parent/authoritative records — the site works and ownership is verified via a TXT token through the HNS resolver. Do not call websites_domains_convert_onchain (it is already on-chain managed) and do not wait for delegation.", + "On status onchain_managed the domain is held on-chain, so its DNS records are set on-chain (no Pinner delegation to publish) and pinner://websites//dns-requirements returns no parent records — the TLSA record there is what makes the site load over HTTPS, so make sure it is surfaced to the user. Do not call websites_domains_convert_onchain (it is already on-chain managed) and do not wait for delegation.", "On any other status (records_generated, waiting_delegation), read pinner://websites//dns-requirements for the HNS delegation bundle and publish the parent NS/DS/GLUE records on-chain in the HNS wallet; with managed DNS the authoritative side is handled for you. To migrate a portal-managed HNS name whose DNS now lives in an external contract, call websites_domains_convert_onchain — it is one-way and destructive (deletes Pinner's managed zone/DNSSEC), so it requires confirm=true.", ) diff --git a/internal/mcp/prompttemplates/website_onboarding.tmpl b/internal/mcp/prompttemplates/website_onboarding.tmpl index f0cfde7d..f2e245aa 100644 --- a/internal/mcp/prompttemplates/website_onboarding.tmpl +++ b/internal/mcp/prompttemplates/website_onboarding.tmpl @@ -97,9 +97,11 @@ Determine how the user wants to address the website: If it is a Handshake (alt-root) name (e.g. acme/), also pass {"namespace": "hns"}; otherwise omit namespace (defaults to icann). After create, check the created domain's status first (websites_domains_list): - status onchain_managed means the HNS name's DNS is served by an external - contract on the Handshake chain — there are NO delegation records to publish - and the site needs no DNS setup. Any other status: read + status onchain_managed means the domain is held on-chain, so its DNS + records are set on-chain — there are NO delegation records to publish; + read pinner://websites//dns-requirements and make sure the TLSA + record reaches the user (it is what makes the site load over HTTPS). + Status anything else: read pinner://websites//dns-requirements — for HNS it renders the records to publish on-chain in the HNS wallet (parent NS/DS/GLUE). {{end}} diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 095b9e45..f3bb8878 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -449,7 +449,7 @@ func buildDNSRequirements(website *ipfs.WebsiteItem) DNSRequirements { // level records against a domain the per-domain flow owns. reqs.Notes = append(reqs.Notes, "For a custom domain, the per-domain delegation bundle (and its namespace) is authoritative: call websites_domains_list to see the binding's status and websites_domains_dns_requirements for its records.", - "A Handshake (HNS) binding with status onchain_managed (DNS served by an external contract on the Handshake chain) requires NO records to be published — do not add website-level records for it.", + "A binding with status onchain_managed is held on-chain and its DNS records are set on-chain — no Pinner delegation to publish. Its TLSA record (surface it for the user) is what makes the site load over HTTPS.", ) if website.DnsHostingEnabled { diff --git a/internal/mcptest/ipfs/server.gen.go b/internal/mcptest/ipfs/server.gen.go index c2c8401e..3974d331 100644 --- a/internal/mcptest/ipfs/server.gen.go +++ b/internal/mcptest/ipfs/server.gen.go @@ -296,17 +296,17 @@ type DNSDelegationRecord struct { // DomainDANERepublishResponse defines model for DomainDANERepublishResponse. type DomainDANERepublishResponse struct { - Delegation *DNSDelegation `json:"delegation,omitempty"` - Domain string `json:"domain"` - GatewayHost *string `json:"gateway_host,omitempty"` - Id int `json:"id"` - Namespace DomainDANERepublishResponseNamespace `json:"namespace"` - OwnerName *string `json:"owner_name,omitempty"` - Ssl *SSLStatusInfo `json:"ssl,omitempty"` - Status *DomainDANERepublishResponseStatus `json:"status,omitempty"` - TlsaRdata *string `json:"tlsa_rdata,omitempty"` - TlsaRecord *string `json:"tlsa_record,omitempty"` - ZoneName *string `json:"zone_name,omitempty"` + Delegation *DNSDelegation `json:"delegation,omitempty"` + Domain string `json:"domain"` + GatewayHost *string `json:"gateway_host,omitempty"` + Id int `json:"id"` + Namespace DomainDANERepublishResponseNamespace `json:"namespace"` + OwnerName *string `json:"owner_name,omitempty"` + PublishedToManagedZone bool `json:"published_to_managed_zone"` + Ssl *SSLStatusInfo `json:"ssl,omitempty"` + Status *DomainDANERepublishResponseStatus `json:"status,omitempty"` + TlsaRdata *string `json:"tlsa_rdata,omitempty"` + ZoneName *string `json:"zone_name,omitempty"` } // DomainDANERepublishResponseNamespace defines model for DomainDANERepublishResponse.Namespace. @@ -335,14 +335,17 @@ type DomainRequest struct { // DomainResponse defines model for DomainResponse. type DomainResponse struct { + Checks *[]ValidationCheck `json:"checks,omitempty"` Delegation *DNSDelegation `json:"delegation,omitempty"` DnsHostingEnabled bool `json:"dns_hosting_enabled"` Domain string `json:"domain"` GatewayHost *string `json:"gateway_host,omitempty"` Id int `json:"id"` Namespace DomainResponseNamespace `json:"namespace"` + OwnerName *string `json:"owner_name,omitempty"` Ssl *SSLStatusInfo `json:"ssl,omitempty"` Status *DomainResponseStatus `json:"status,omitempty"` + TlsaRdata *string `json:"tlsa_rdata,omitempty"` ZoneName *string `json:"zone_name,omitempty"` } @@ -634,6 +637,15 @@ type UploadResultResponse struct { // UploadResultResponseStatus defines model for UploadResultResponse.Status. type UploadResultResponseStatus string +// ValidationCheck defines model for ValidationCheck. +type ValidationCheck struct { + Expected *string `json:"expected,omitempty"` + Found *string `json:"found,omitempty"` + Message *string `json:"message,omitempty"` + Name string `json:"name"` + Ok bool `json:"ok"` +} + // ValidationResponse defines model for ValidationResponse. type ValidationResponse struct { CheckedAt time.Time `json:"checked_at"` @@ -745,11 +757,12 @@ type WebsiteUpdateRequest struct { // WebsiteValidateResponse defines model for WebsiteValidateResponse. type WebsiteValidateResponse struct { - Domain string `json:"domain"` - Id int `json:"id"` - Message string `json:"message"` - Reason string `json:"reason"` - Valid bool `json:"valid"` + Checks *[]ValidationCheck `json:"checks,omitempty"` + Domain string `json:"domain"` + Id int `json:"id"` + Message string `json:"message"` + Reason string `json:"reason"` + Valid bool `json:"valid"` } // ZoneListResponse defines model for ZoneListResponse. diff --git a/internal/mcptest/ipfs/websites.go b/internal/mcptest/ipfs/websites.go index 6db22d6e..ba4ba993 100644 --- a/internal/mcptest/ipfs/websites.go +++ b/internal/mcptest/ipfs/websites.go @@ -535,20 +535,23 @@ func (d *websiteDomain) toResponse() DomainResponse { } func (d *websiteDomain) toRepublishResponse() DomainDANERepublishResponse { + // The 0.1.96 wire contract carries the owners-style owner name + // ("_443._tcp.") plus the bare TLSA rdata, and always echoes + // whether the record actually landed in the managed zone. tlsa := "_443._tcp." + d.Domain rdata := "3 1 1 ab12cd34ef56" return DomainDANERepublishResponse{ - Delegation: d.Delegation, - Domain: d.Domain, - GatewayHost: d.GatewayHost, - Id: d.Id, - Namespace: DomainDANERepublishResponseNamespace(d.Namespace), - OwnerName: d.OwnerName, - Ssl: d.Ssl, - Status: domainDANERepublishStatusPtr(d.Status), - TlsaRdata: &rdata, - TlsaRecord: &tlsa, - ZoneName: d.ZoneName, + Delegation: d.Delegation, + Domain: d.Domain, + GatewayHost: d.GatewayHost, + Id: d.Id, + Namespace: DomainDANERepublishResponseNamespace(d.Namespace), + OwnerName: &tlsa, + PublishedToManagedZone: true, + Ssl: d.Ssl, + Status: domainDANERepublishStatusPtr(d.Status), + TlsaRdata: &rdata, + ZoneName: d.ZoneName, } } diff --git a/internal/mcptest/ipfs/websites_test.go b/internal/mcptest/ipfs/websites_test.go index 3fac52c2..805c5f21 100644 --- a/internal/mcptest/ipfs/websites_test.go +++ b/internal/mcptest/ipfs/websites_test.go @@ -327,8 +327,11 @@ func TestWebsitesDomainsFlow(t *testing.T) { if err := json.Unmarshal(b, &dan); err != nil { t.Fatal(err) } - if dan.TlsaRecord == nil || *dan.TlsaRecord != "_443._tcp.www.seed.example.com" { - t.Fatalf("dane-republish should return tlsa record, got %+v", dan.TlsaRecord) + if dan.TlsaRdata == nil || *dan.TlsaRdata != "3 1 1 ab12cd34ef56" { + t.Fatalf("dane-republish should return tlsa rdata, got %+v", dan.TlsaRdata) + } + if !dan.PublishedToManagedZone { + t.Fatal("dane-republish should report published_to_managed_zone=true") } // patch (update) the secondary domain's dns_hosting_enabled diff --git a/internal/mcptest/specs/ipfs.yaml b/internal/mcptest/specs/ipfs.yaml index c370cd97..dba34d8d 100644 --- a/internal/mcptest/specs/ipfs.yaml +++ b/internal/mcptest/specs/ipfs.yaml @@ -207,6 +207,8 @@ components: type: string owner_name: type: string + published_to_managed_zone: + type: boolean ssl: $ref: '#/components/schemas/SSLStatusInfo' status: @@ -221,14 +223,13 @@ components: type: string tlsa_rdata: type: string - tlsa_record: - type: string zone_name: type: string required: - id - domain - namespace + - published_to_managed_zone type: object DomainListResponse: additionalProperties: false @@ -269,6 +270,10 @@ components: DomainResponse: additionalProperties: false properties: + checks: + items: + $ref: '#/components/schemas/ValidationCheck' + type: array delegation: $ref: '#/components/schemas/DNSDelegation' dns_hosting_enabled: @@ -284,6 +289,8 @@ components: - icann - hns type: string + owner_name: + type: string ssl: $ref: '#/components/schemas/SSLStatusInfo' status: @@ -296,6 +303,8 @@ components: - error - onchain_managed type: string + tlsa_rdata: + type: string zone_name: type: string required: @@ -889,6 +898,23 @@ components: required: - status type: object + ValidationCheck: + additionalProperties: false + properties: + expected: + type: string + found: + type: string + message: + type: string + name: + type: string + ok: + type: boolean + required: + - name + - ok + type: object ValidationResponse: additionalProperties: false properties: @@ -1132,6 +1158,10 @@ components: WebsiteValidateResponse: additionalProperties: false properties: + checks: + items: + $ref: '#/components/schemas/ValidationCheck' + type: array domain: type: string id: