From 6ecb8407421a66b52b8e5f6586ca2f71fe990bb3 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sat, 5 Sep 2026 16:50:24 +0000 Subject: [PATCH 1/7] fix(cli): surface TLSA and fix domain verify output - websites domains dns-requirements for on-chain managed domains now states the domain is held on-chain and puts the TLSA record in front of the user; without one in the bundle it points at dane republish - websites domains verify human output becomes an outcome: verified confirmation pointing at the served site, or explicit not-verified with retry guidance and the dns-requirements command (also when the backend can't resolve the domain) - add OpWebsitesDomainsVerify/DNSRequirements constants in catalogops and use them in the websites wiring --- internal/catalogops/websites_domains.go | 14 ++- internal/cli/catalog_websites_wiring.go | 14 ++- internal/cli/catalog_websites_wiring_test.go | 19 +++- internal/cli/websites_domains.go | 42 +++++++++ .../cli/websites_domains_delegation_common.go | 70 ++++++++++++++ .../cli/websites_domains_delegation_hns.go | 18 ++-- internal/cli/websites_domains_test.go | 93 ++++++++++++++++--- internal/mcp/guide_fragments.go | 2 +- .../prompttemplates/website_onboarding.tmpl | 8 +- internal/mcp/resources.go | 2 +- 10 files changed, 251 insertions(+), 31 deletions(-) diff --git a/internal/catalogops/websites_domains.go b/internal/catalogops/websites_domains.go index c2370a59..b11df279 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.", diff --git a/internal/cli/catalog_websites_wiring.go b/internal/cli/catalog_websites_wiring.go index 9511a196..fc60c914 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()) } @@ -491,10 +497,14 @@ func renderWebsitesResult(_ context.Context, c *cli.Command, op catalog.Operatio if output.IsJSON() { return output.PrintJSON(r) } - if op.Name() == "websites_domains_dns_requirements" { + if op.Name() == catalogops.OpWebsitesDomainsDNSRequirements { renderDomainDelegation(output, r, r.DnsHostingEnabled) return nil } + if op.Name() == catalogops.OpWebsitesDomainsVerify { + renderDomainVerifyResult(output, r) + return nil + } renderDomainResponse(output, r) return nil 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/websites_domains.go b/internal/cli/websites_domains.go index 93b83b8a..a338a766 100644 --- a/internal/cli/websites_domains.go +++ b/internal/cli/websites_domains.go @@ -11,6 +11,48 @@ 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) + } + 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) +} + // 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, diff --git a/internal/cli/websites_domains_delegation_common.go b/internal/cli/websites_domains_delegation_common.go index 6c022a61..10bfc30e 100644 --- a/internal/cli/websites_domains_delegation_common.go +++ b/internal/cli/websites_domains_delegation_common.go @@ -31,6 +31,76 @@ func renderDelegationNameservers(output Output, d *ipfs.DNSDelegation) { output.PrintList(*d.Nameservers) } +// 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 +} + +// 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 TLSA only exists on the response when the backend supplies it; +// TLSA-bearing groups are rendered wherever they appear, but on-chain +// domains get it called out explicitly so it is never missed. +func renderOnchainTLSA(output Output, d *ipfs.DNSDelegation) { + records := tlsaRecords(d) + if len(records) == 0 { + // TODO: backend - return the TLSA record 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 + } + output.Printfln("") + output.Printfln("TLSA — publish this alongside your on-chain records so your site") + output.Printfln("loads over HTTPS:") + rows := make([][]string, 0, len(records)) + for _, r := range records { + value := "" + if r.Value != nil { + value = *r.Value + } + rows = append(rows, []string{tlsaRecordType, value}) + } + output.PrintTable([]string{"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_hns.go b/internal/cli/websites_domains_delegation_hns.go index 734198b6..78658842 100644 --- a/internal/cli/websites_domains_delegation_hns.go +++ b/internal/cli/websites_domains_delegation_hns.go @@ -17,18 +17,16 @@ type hnsDelegationDriver struct{} func (h *hnsDelegationDriver) Render(output Output, result *ipfs.DomainResponse, managed bool) { 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.") + renderOnchainTLSA(output, d) return } diff --git a/internal/cli/websites_domains_test.go b/internal/cli/websites_domains_test.go index 1db40bfd..475c6702 100644 --- a/internal/cli/websites_domains_test.go +++ b/internal/cli/websites_domains_test.go @@ -197,27 +197,98 @@ 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), + Delegation: &ipfs.DNSDelegation{ + AuthoritativeRecords: &[]ipfs.DNSDelegationRecord{ + {Type: "TLSA", Value: new("_443._tcp.mydomain.hns. 60 IN TLSA 3 1 1 abcdef")}, + }, + }, }, false) 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("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) + 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) { 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 { From bdaf08ab4beb762f059620ae3af37a8925912e16 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sat, 5 Sep 2026 17:59:16 +0000 Subject: [PATCH 2/7] feat(cli): use tlsa_rdata from ipfs-sdk v0.1.96 - bump go.lumeweb.com/ipfs-sdk to v0.1.96 (swagger sync: tlsa_record -> tlsa_rdata, adds published_to_managed_zone + owner_name) - renderOnchainTLSA falls back to the response's tlsa_rdata when the delegation bundle carries no TLSA record, removing the backend TODO gap for on-chain bindings - dane republish output reports TLSA Published (published_to_managed_zone) - resync mcptest spec/generators to the synced swagger --- go.mod | 2 +- go.sum | 2 ++ internal/catalogops/websites_domains_test.go | 4 +-- internal/cli/catalog_websites_wiring.go | 29 ++++++++++--------- .../cli/websites_domains_delegation_common.go | 16 ++++++---- .../cli/websites_domains_delegation_hns.go | 2 +- internal/cli/websites_domains_test.go | 17 +++++++++++ internal/mcptest/ipfs/server.gen.go | 24 ++++++++------- internal/mcptest/ipfs/websites.go | 25 +++++++++------- internal/mcptest/ipfs/websites_test.go | 7 +++-- internal/mcptest/specs/ipfs.yaml | 9 ++++-- 11 files changed, 88 insertions(+), 49 deletions(-) diff --git a/go.mod b/go.mod index c2017e0b..e64a3718 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.96 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..19711ba4 100644 --- a/go.sum +++ b/go.sum @@ -737,6 +737,8 @@ 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/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_test.go b/internal/catalogops/websites_domains_test.go index 2123c52f..48c22701 100644 --- a/internal/catalogops/websites_domains_test.go +++ b/internal/catalogops/websites_domains_test.go @@ -221,8 +221,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 fc60c914..6b519ac6 100644 --- a/internal/cli/catalog_websites_wiring.go +++ b/internal/cli/catalog_websites_wiring.go @@ -619,20 +619,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/websites_domains_delegation_common.go b/internal/cli/websites_domains_delegation_common.go index 10bfc30e..4d856642 100644 --- a/internal/cli/websites_domains_delegation_common.go +++ b/internal/cli/websites_domains_delegation_common.go @@ -71,13 +71,19 @@ func tlsaRecords(d *ipfs.DNSDelegation) []ipfs.DNSDelegationRecord { // 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 TLSA only exists on the response when the backend supplies it; -// TLSA-bearing groups are rendered wherever they appear, but on-chain -// domains get it called out explicitly so it is never missed. -func renderOnchainTLSA(output Output, d *ipfs.DNSDelegation) { +// HTTPS. The record comes from the delegation bundle's TLSA entries, falling +// back to the response's tlsa_rdata field (schema v0.1.96). TLSA-bearing +// groups are rendered wherever they appear; on-chain domains get the record +// called out explicitly so it is never missed. +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 the TLSA record on on-chain bindings so the + // 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("") diff --git a/internal/cli/websites_domains_delegation_hns.go b/internal/cli/websites_domains_delegation_hns.go index 78658842..be6f6d2c 100644 --- a/internal/cli/websites_domains_delegation_hns.go +++ b/internal/cli/websites_domains_delegation_hns.go @@ -26,7 +26,7 @@ func (h *hnsDelegationDriver) Render(output Output, result *ipfs.DomainResponse, 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.") - renderOnchainTLSA(output, d) + renderOnchainTLSA(output, result, d) return } diff --git a/internal/cli/websites_domains_test.go b/internal/cli/websites_domains_test.go index 475c6702..8ae8e8af 100644 --- a/internal/cli/websites_domains_test.go +++ b/internal/cli/websites_domains_test.go @@ -225,6 +225,23 @@ func TestRenderDomainDelegation(t *testing.T) { assert.NotContains(t, out, "Authoritative records") }) + 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) + 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 without a TLSA still tells the user one is needed", func(t *testing.T) { var buf bytes.Buffer output := NewOutputFormatter(false, false, false, false) diff --git a/internal/mcptest/ipfs/server.gen.go b/internal/mcptest/ipfs/server.gen.go index c2c8401e..f3f16d39 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. @@ -341,8 +341,10 @@ type DomainResponse struct { 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"` } 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..520f502b 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 @@ -284,6 +285,8 @@ components: - icann - hns type: string + owner_name: + type: string ssl: $ref: '#/components/schemas/SSLStatusInfo' status: @@ -296,6 +299,8 @@ components: - error - onchain_managed type: string + tlsa_rdata: + type: string zone_name: type: string required: From 78998207f9461d3d0efdff8c019f8d19f21aaf17 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sun, 6 Sep 2026 05:57:48 +0000 Subject: [PATCH 3/7] feat(cli): render domain record checks from sdk v0.1.97 - bump go.lumeweb.com/ipfs-sdk to v0.1.97 (adds ValidationCheck on DomainResponse and WebsiteValidateResponse) and resync the mcptest spec/generators to the new swagger - dns-requirements, verify, and websites validate now turn the backend per-record checks (validation token, dnslink, TLSA, delegation) into an actionable to-do list: a passing/failing summary plus, for each failing record, a verbatim copyable expected value - the dnslink record a managed HNS site needs is no longer missing from the output - check values are rendered as plain lines, never a table, so long record values are not hard-wrapped and stay copyable --- go.mod | 2 +- go.sum | 2 + internal/cli/catalog_websites_wiring.go | 1 + internal/cli/websites_domains.go | 7 +++ .../cli/websites_domains_delegation_common.go | 50 ++++++++++++++++++ internal/cli/websites_domains_test.go | 52 +++++++++++++++++++ internal/mcptest/ipfs/server.gen.go | 21 ++++++-- internal/mcptest/specs/ipfs.yaml | 25 +++++++++ 8 files changed, 154 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index e64a3718..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.96 + 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 19711ba4..fefec05e 100644 --- a/go.sum +++ b/go.sum @@ -739,6 +739,8 @@ 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/cli/catalog_websites_wiring.go b/internal/cli/catalog_websites_wiring.go index 6b519ac6..015dc9f8 100644 --- a/internal/cli/catalog_websites_wiring.go +++ b/internal/cli/catalog_websites_wiring.go @@ -427,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: diff --git a/internal/cli/websites_domains.go b/internal/cli/websites_domains.go index a338a766..1ecfd8ef 100644 --- a/internal/cli/websites_domains.go +++ b/internal/cli/websites_domains.go @@ -42,6 +42,7 @@ func renderDomainVerifyResult(output Output, r *ipfs.DomainResponse) { output.Printfln(" over HTTPS:") output.Printfln(" pinner websites domains dns-requirements %s", r.Domain) } + renderValidationChecks(output, r.Checks) return } @@ -51,6 +52,7 @@ func renderDomainVerifyResult(output Output, r *ipfs.DomainResponse) { 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 @@ -86,4 +88,9 @@ func renderDomainDelegation(output Output, result *ipfs.DomainResponse, managed // 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) + + // 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_common.go b/internal/cli/websites_domains_delegation_common.go index 4d856642..a32704d2 100644 --- a/internal/cli/websites_domains_delegation_common.go +++ b/internal/cli/websites_domains_delegation_common.go @@ -31,6 +31,56 @@ 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. diff --git a/internal/cli/websites_domains_test.go b/internal/cli/websites_domains_test.go index 8ae8e8af..dad6c2ce 100644 --- a/internal/cli/websites_domains_test.go +++ b/internal/cli/websites_domains_test.go @@ -225,6 +225,58 @@ func TestRenderDomainDelegation(t *testing.T) { assert.NotContains(t, out, "Authoritative 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) + 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) diff --git a/internal/mcptest/ipfs/server.gen.go b/internal/mcptest/ipfs/server.gen.go index f3f16d39..3974d331 100644 --- a/internal/mcptest/ipfs/server.gen.go +++ b/internal/mcptest/ipfs/server.gen.go @@ -335,6 +335,7 @@ 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"` @@ -636,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"` @@ -747,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/specs/ipfs.yaml b/internal/mcptest/specs/ipfs.yaml index 520f502b..dba34d8d 100644 --- a/internal/mcptest/specs/ipfs.yaml +++ b/internal/mcptest/specs/ipfs.yaml @@ -270,6 +270,10 @@ components: DomainResponse: additionalProperties: false properties: + checks: + items: + $ref: '#/components/schemas/ValidationCheck' + type: array delegation: $ref: '#/components/schemas/DNSDelegation' dns_hosting_enabled: @@ -894,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: @@ -1137,6 +1158,10 @@ components: WebsiteValidateResponse: additionalProperties: false properties: + checks: + items: + $ref: '#/components/schemas/ValidationCheck' + type: array domain: type: string id: From 635040c2c7f238223ab05cc1adf8a28378e8aa3a Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sun, 6 Sep 2026 08:50:00 +0000 Subject: [PATCH 4/7] feat(cli): derive on-chain records from the owning website - the backend no longer ships a delegation bundle (or checks) for on-chain bindings, so the dns-requirements handler now attaches the owning website and the HNS driver derives the authoritative record set client-side: the _dnslink TXT (name/type/value from the website's target) plus the server-served TLSA (owner_name/tlsa_rdata) - renderDomainDelegation passes the owning website through the delegation driver registry; drivers that do not need it ignore it - dns-requirements --json keeps the historical domain-response shape --- internal/catalogops/websites_domains.go | 22 +++++++- internal/catalogops/websites_domains_test.go | 40 +++++++++++++++ internal/cli/catalog_websites_wiring.go | 18 +++++-- internal/cli/websites_domains.go | 8 +-- internal/cli/websites_domains_delegation.go | 8 +-- .../websites_domains_delegation_generic.go | 2 +- .../cli/websites_domains_delegation_hns.go | 13 ++++- .../cli/websites_domains_delegation_icann.go | 2 +- internal/cli/websites_domains_test.go | 51 ++++++++++++++----- internal/cli/websites_domains_wizard.go | 5 +- 10 files changed, 139 insertions(+), 30 deletions(-) diff --git a/internal/catalogops/websites_domains.go b/internal/catalogops/websites_domains.go index b11df279..6a95b69d 100644 --- a/internal/catalogops/websites_domains.go +++ b/internal/catalogops/websites_domains.go @@ -293,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 48c22701..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)) diff --git a/internal/cli/catalog_websites_wiring.go b/internal/cli/catalog_websites_wiring.go index 015dc9f8..3599a8d7 100644 --- a/internal/cli/catalog_websites_wiring.go +++ b/internal/cli/catalog_websites_wiring.go @@ -457,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() { @@ -498,10 +512,6 @@ func renderWebsitesResult(_ context.Context, c *cli.Command, op catalog.Operatio if output.IsJSON() { return output.PrintJSON(r) } - if op.Name() == catalogops.OpWebsitesDomainsDNSRequirements { - renderDomainDelegation(output, r, r.DnsHostingEnabled) - return nil - } if op.Name() == catalogops.OpWebsitesDomainsVerify { renderDomainVerifyResult(output, r) return nil diff --git a/internal/cli/websites_domains.go b/internal/cli/websites_domains.go index 1ecfd8ef..447161e4 100644 --- a/internal/cli/websites_domains.go +++ b/internal/cli/websites_domains.go @@ -60,8 +60,10 @@ func renderDomainVerifyResult(output Output, r *ipfs.DomainResponse) { // 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 := "" @@ -87,7 +89,7 @@ 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 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_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 be6f6d2c..e13fa180 100644 --- a/internal/cli/websites_domains_delegation_hns.go +++ b/internal/cli/websites_domains_delegation_hns.go @@ -14,7 +14,7 @@ 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 domain is @@ -26,6 +26,17 @@ func (h *hnsDelegationDriver) Render(output Output, result *ipfs.DomainResponse, 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 dad6c2ce..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") @@ -211,7 +211,7 @@ func TestRenderDomainDelegation(t *testing.T) { {Type: "TLSA", Value: new("_443._tcp.mydomain.hns. 60 IN TLSA 3 1 1 abcdef")}, }, }, - }, false) + }, false, nil) out := buf.String() // The driver must not crash and must explain the on-chain hosting // shape in user-friendly terms, and the TLSA must reach the user — @@ -244,7 +244,7 @@ func TestRenderDomainDelegation(t *testing.T) { {Name: "dnslink record", Ok: false, Expected: new("_dnslink.mydomain.hns. 60 IN TXT \"dnslink=/ipfs/QmXyz\"")}, {Name: "validation token", Ok: true}, }, - }, 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") @@ -287,13 +287,36 @@ func TestRenderDomainDelegation(t *testing.T) { renderDomainDelegation(output, &ipfs.DomainResponse{ Id: 1, Domain: "mydomain", Namespace: ipfs.DomainNamespaceHNS, Status: new(ipfs.DomainResponseStatusOnchainManaged), TlsaRdata: &rdata, - }, false) + }, 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) @@ -302,7 +325,7 @@ func TestRenderDomainDelegation(t *testing.T) { // the gap must not pass silently. renderDomainDelegation(output, &ipfs.DomainResponse{ Id: 1, Domain: "mydomain", Namespace: ipfs.DomainNamespaceHNS, Status: new(ipfs.DomainResponseStatusOnchainManaged), - }, false) + }, false, nil) out := buf.String() assert.Contains(t, out, "on-chain managed") assert.Contains(t, out, "TLSA record") @@ -375,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..b0822aaf 100644 --- a/internal/cli/websites_domains_wizard.go +++ b/internal/cli/websites_domains_wizard.go @@ -183,14 +183,17 @@ func (w *DomainAddWizard) executeDelegationSetup(ctx context.Context) error { // 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 } From 58666a946d53c71cdd65a54ce2ec2634da32b6c4 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sun, 6 Sep 2026 08:57:16 +0000 Subject: [PATCH 5/7] fix(cli): route the wizard through the on-chain renderer - the nil-Delegation early return in the domains wizard's delegation step bypassed renderDomainDelegation, so on-chain managed bindings - which carry no bundle - never got the derived dnslink/TLSA records; only a nil response is nothing-available now --- internal/cli/websites_domains_wizard.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/cli/websites_domains_wizard.go b/internal/cli/websites_domains_wizard.go index b0822aaf..37f3b44b 100644 --- a/internal/cli/websites_domains_wizard.go +++ b/internal/cli/websites_domains_wizard.go @@ -179,6 +179,15 @@ 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 From faa0146a3006c818eff78997a3bb73f6288456f9 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sun, 6 Sep 2026 11:00:53 +0000 Subject: [PATCH 6/7] fix(cli): show the TLSA owner name in on-chain requirements - bare tlsa_rdata tells the user what to publish but not where; the record lives at the TCP port 443 service of the domain, so the owner name (_443._tcp., or the server-provided owner_name when present) is now in both the copy line and derivable - kept the TYPE/VALUE table layout: the no-hang wrap rule for long digest values keys off TYPE in the first column --- .../cli/websites_domains_delegation_common.go | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/cli/websites_domains_delegation_common.go b/internal/cli/websites_domains_delegation_common.go index a32704d2..4f44fa1e 100644 --- a/internal/cli/websites_domains_delegation_common.go +++ b/internal/cli/websites_domains_delegation_common.go @@ -118,13 +118,28 @@ func tlsaRecords(d *ipfs.DNSDelegation) []ipfs.DNSDelegationRecord { 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 tlsa_rdata field (schema v0.1.96). TLSA-bearing -// groups are rendered wherever they appear; on-chain domains get the record -// called out explicitly so it is never missed. +// 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 @@ -143,9 +158,14 @@ func renderOnchainTLSA(output Output, result *ipfs.DomainResponse, d *ipfs.DNSDe output.Printfln(" pinner websites domains dane republish ") return } + owner := tlsaOwnerName(result) output.Printfln("") - output.Printfln("TLSA — publish this alongside your on-chain records so your site") - output.Printfln("loads over HTTPS:") + 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:") + // TYPE/VALUE table (never NAME/TYPE/VALUE): the no-wrap rule for long + // record values keys off TYPE sitting in the first column, and the owner + // name is already in the copy line above — a NAME column would push the + // digest into a hard wrap. rows := make([][]string, 0, len(records)) for _, r := range records { value := "" From 8540cfc927f62cdfa9efdda83e21a718813a7974 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sun, 6 Sep 2026 11:13:08 +0000 Subject: [PATCH 7/7] feat(cli): TLSA record carries an owner-name column - the on-chain TLSA table is NAME/TYPE/VALUE now, so the copyable record includes where it goes (_443._tcp.) - keepWholeValue keeps the digest in the VALUE column of this layout whole-token, extending the no-wrap guarantee to the new shape --- internal/cli/dns_test.go | 6 ++++++ internal/cli/output.go | 10 ++++++++++ internal/cli/websites_domains_delegation_common.go | 11 +++++------ 3 files changed, 21 insertions(+), 6 deletions(-) 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_delegation_common.go b/internal/cli/websites_domains_delegation_common.go index 4f44fa1e..4e2f04b5 100644 --- a/internal/cli/websites_domains_delegation_common.go +++ b/internal/cli/websites_domains_delegation_common.go @@ -162,19 +162,18 @@ func renderOnchainTLSA(output Output, result *ipfs.DomainResponse, d *ipfs.DNSDe 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:") - // TYPE/VALUE table (never NAME/TYPE/VALUE): the no-wrap rule for long - // record values keys off TYPE sitting in the first column, and the owner - // name is already in the copy line above — a NAME column would push the - // digest into a hard wrap. + // 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{tlsaRecordType, value}) + rows = append(rows, []string{owner, tlsaRecordType, value}) } - output.PrintTable([]string{"TYPE", "VALUE"}, rows) + output.PrintTable([]string{"NAME", "TYPE", "VALUE"}, rows) } // printDelegationRecords renders a group of DNS records as a TYPE/VALUE table.