Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ 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.89
go.lumeweb.com/ipfs-sdk v0.1.95
go.lumeweb.com/ipfs-sdk/dnsname v0.1.64
go.lumeweb.com/oauth v0.1.6
go.lumeweb.com/portal-sdk v0.1.71
go.lumeweb.com/portal-sdk v0.1.72
go.lumeweb.com/queryutil v0.3.19
go.sia.tech/core v0.21.7
go.sia.tech/indexd v0.4.4
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -735,8 +735,8 @@ go.lumeweb.com/gswagger v0.20.12 h1:FNSrDAeMQVAGIogzJ4SQX+X8U/FMeWQ80yxdyfsM2/M=
go.lumeweb.com/gswagger v0.20.12/go.mod h1:qz+9/8qstYy7WJ48VZJW/0FonlExw/Igv7vWGgp5uoU=
go.lumeweb.com/ipfs-content v0.1.18 h1:Lq3/A+2kZcS4RK+bpdk+m4iJ95ZgpEa4DKhiH6bQWUg=
go.lumeweb.com/ipfs-content v0.1.18/go.mod h1:idWCsfndMDCsE5LtU0ZBNaFTBAaiuIe50dce0SDJLxw=
go.lumeweb.com/ipfs-sdk v0.1.89 h1:BCivMn9vKEiQ7A9tqq2U/fyzc9J+lWZKJq7TnAl+GCo=
go.lumeweb.com/ipfs-sdk v0.1.89/go.mod h1:/ZKw5UlfGIxBBLTdX9Ixq/ObGVTgHjRGKwsNf8UHuPo=
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/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=
Expand All @@ -747,8 +747,8 @@ go.lumeweb.com/portal-middleware v0.3.7 h1:kq4SZq4T/uhauqHehw2JaTbNJpPpE8/EQAC3R
go.lumeweb.com/portal-middleware v0.3.7/go.mod h1:Yn9ZsFy5n3x2jUJ085M9B/aoZ+ANQV5QD/MAE0BpJXo=
go.lumeweb.com/portal-router v0.7.7 h1:6mUGkG2BtHDygIPqRZs1sRkPLkz9l2wTXMUhZmdp24E=
go.lumeweb.com/portal-router v0.7.7/go.mod h1:hl51sHDKcgw8yxJ6RraYBOI1Vnufq33FhKwfKiqCkVg=
go.lumeweb.com/portal-sdk v0.1.71 h1:wPnR2zufo96Ao/vML5f5P2uyK0mDTPW2JOsePuy/WdI=
go.lumeweb.com/portal-sdk v0.1.71/go.mod h1:KKxa2hV+44EI+0riJIjREvz7/sqgWGRTxtLUGgnADZg=
go.lumeweb.com/portal-sdk v0.1.72 h1:MqbLlNLfjFxOjCcgg7ArxOCqD8IV5AR+KKJ3droidhs=
go.lumeweb.com/portal-sdk v0.1.72/go.mod h1:Xrm8rbyHc5QZGmn6fxZGQbtChHPbVZzX1fVkqtcw+Io=
go.lumeweb.com/queryutil v0.3.19 h1:GV+zKsyGJ+YYPZi0mqOocDAROCQruGdizrE8Aw8cNFk=
go.lumeweb.com/queryutil v0.3.19/go.mod h1:YPrXdEelsjJNBPXrG4IiYp4aq5YUk/l4uXw8z2cSAuw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
Expand Down
14 changes: 8 additions & 6 deletions internal/catalogops/admin_platform_domains.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,16 @@ func adminPlatformDomainsRegister(d AdminDeps) catalog.Operation {
Name: OpAdminPlatformDomainsRegister,
Title: "Register a platform domain",
Summary: "Register a platform-owned root domain",
Description: "Register a platform-owned root domain that users can claim free subdomains under, e.g. pinned.site. Requires admin privileges.",
Description: "Register a platform-owned root domain that users can claim free subdomains under, e.g. pinned.site. The namespace defaults to hns when omitted; pass icann for a traditional DNS root. Requires admin privileges.",
Category: "admin",
Safety: catalog.SafetyMutate,
Interaction: catalog.InteractionAgentSafe,
Visibility: catalog.VisibilityBoth,
Positional: "<domain>",
Args: []catalog.OperationArg{
{Name: "domain", Type: catalog.ArgTypeString, Required: true, Help: "Platform root domain, e.g. pinned.site", PositionalOnly: true},
{Name: "namespace", Type: catalog.ArgTypeString, Required: true, Help: "Domain namespace: icann, hns, etc."},
// Optional: the backend defaults omitted namespaces to hns.
{Name: "namespace", Type: catalog.ArgTypeString, Required: false, Help: "Domain namespace (default hns): icann or hns", PositionalOnly: true},
// Nullable so an omitted flag leaves Enabled nil (backend default)
// rather than forcing false; an explicit --enabled=false disables.
{Name: "enabled", Type: catalog.ArgTypeNullableBool, Required: false, Help: "Enable the platform domain so users can claim subdomains under it"},
Expand All @@ -89,11 +90,12 @@ func adminPlatformDomainsRegister(d AdminDeps) catalog.Operation {
if domain == "" {
return nil, fmt.Errorf("admin_platform_domains_register: domain is required")
}
namespace := catalog.StrArg(input, "namespace", "")
if namespace == "" {
return nil, fmt.Errorf("admin_platform_domains_register: namespace is required")
req := &admin.PlatformDomainRequest{Domain: domain}
// Namespace is optional: the backend defaults an omitted namespace
// to hns, so nil is passed through unchanged when not supplied.
if namespace := catalog.StrArg(input, "namespace", ""); namespace != "" {
req.Namespace = &namespace
}
req := &admin.PlatformDomainRequest{Domain: domain, Namespace: namespace}
if enabled := catalog.BoolArgPtr(input, "enabled"); enabled != nil {
req.Enabled = enabled
}
Expand Down
1 change: 1 addition & 0 deletions internal/catalogops/websites.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ func WebsitesOperations(d WebsitesDeps) []catalog.Operation {
websitesDomainsVerify(d),
websitesDomainsDNSRequirements(d),
websitesDomainsDANERepublish(d),
websitesDomainsConvertOnChain(d),
websitesDomainsUpdate(d),
websitesPlatformDomainsList(d),
websitesPlatformDomainAvailability(d),
Expand Down
119 changes: 119 additions & 0 deletions internal/catalogops/websites_convert_onchain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package catalogops

import (
"context"
"testing"

"github.com/stretchr/testify/require"

ipfs "go.lumeweb.com/ipfs-sdk"

"go.lumeweb.com/pinner-cli/internal/core/config"
configmocks "go.lumeweb.com/pinner-cli/internal/core/config/mocks"
"go.lumeweb.com/pinner-cli/internal/core/websites"
)

// convertOnChainCaptureService satisfies websites.Service for the
// websites_domains_convert_onchain operation: it serves a one-website list
// with one HNS binding and records the website/domain IDs the handler called
// the SDK with.
type convertOnChainService struct {
websites.Service

websiteID string
domainID string
called bool
}

func (f *convertOnChainService) RequireAuthenticated() error { return nil }

func (f *convertOnChainService) List(_ context.Context, _ websites.ListOptions) ([]ipfs.WebsiteItem, error) {
return []ipfs.WebsiteItem{{Id: 7, Domain: "example.test", TargetType: "ipfs"}}, nil
}

func (f *convertOnChainService) ListDomains(_ context.Context, _ string) ([]ipfs.DomainResponse, error) {
return []ipfs.DomainResponse{{
Id: 3,
Domain: "acme",
Namespace: ipfs.DomainNamespaceHNS,
Status: new(ipfs.DomainResponseStatusOnchainManaged),
}}, nil
}

func (f *convertOnChainService) ConvertDomainToOnChain(_ context.Context, websiteID, domainID string) (*ipfs.DomainResponse, error) {
f.called = true
f.websiteID = websiteID
f.domainID = domainID
return &ipfs.DomainResponse{Id: 3, Domain: "acme", Namespace: ipfs.DomainNamespaceHNS, Status: new(ipfs.DomainResponseStatusOnchainManaged)}, nil
}

func convertOnChainDeps(t testing.TB, fake *convertOnChainService) WebsitesDeps {
return WebsitesDeps{
CfgMgr: func() config.Manager { return configmocks.NewMockManager(t) },
NewAuthenticated: func(_ config.Manager, _ bool, _ string) (websites.Service, error) {
return fake, nil
},
ServiceFactory: func(_ config.Manager, _ bool, _ ...websites.Option) websites.Service {
return fake
},
GetAuthToken: func() string { return "" },
}
}

// TestWebsitesDomainsConvertOnChain_RequiresConfirm guards the destructive
// gate: the one-way conversion refuses to run without confirm=true.
func TestWebsitesDomainsConvertOnChain_RequiresConfirm(t *testing.T) {
fake := &convertOnChainService{}
op := websitesDomainsConvertOnChain(convertOnChainDeps(t, fake))

_, err := op.Handler().Execute(context.Background(), map[string]any{
"domain": "acme",
})
require.Error(t, err)
require.Contains(t, err.Error(), "confirmation is required")
require.False(t, fake.called, "the SDK conversion must never run without confirm")
}

// TestWebsitesDomainsConvertOnChain_WithConfirm guards the happy path: the op
// resolves the binding by name, sends the website + numeric binding IDs to the
// SDK, and returns the (onchain_managed) domain response.
func TestWebsitesDomainsConvertOnChain_WithConfirm(t *testing.T) {
fake := &convertOnChainService{}
op := websitesDomainsConvertOnChain(convertOnChainDeps(t, fake))

result, err := op.Handler().Execute(context.Background(), map[string]any{
"domain": "acme",
"confirm": true,
})
require.NoError(t, err)
require.True(t, fake.called)
require.Equal(t, "7", fake.websiteID)
require.Equal(t, "3", fake.domainID)

domain, ok := result.(*ipfs.DomainResponse)
require.True(t, ok)
require.NotNil(t, domain.Status)
require.Equal(t, ipfs.DomainResponseStatusOnchainManaged, *domain.Status)
}

// TestWebsitesDomainsConvertOnChain_RegisteredCatalogOp guards that the op is
// part of the canonical websites catalog so both frontends (CLI + MCP) expose
// it — a missing entry here would silently drop the tool from one surface.
func TestWebsitesDomainsConvertOnChain_RegisteredCatalogOp(t *testing.T) {
d := WebsitesDeps{
CfgMgr: func() config.Manager { return nil },
NewAuthenticated: func(_ config.Manager, _ bool, _ string) (websites.Service, error) {
return nil, nil
},
ServiceFactory: func(_ config.Manager, _ bool, _ ...websites.Option) websites.Service {
return nil
},
GetAuthToken: func() string { return "" },
}
names := make(map[string]bool)
for _, op := range WebsitesOperations(d) {
names[op.Name()] = true
}
require.True(t, names["websites_domains_convert_onchain"],
"websites_domains_convert_onchain must be in WebsitesOperations")
}
46 changes: 46 additions & 0 deletions internal/catalogops/websites_domains.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,52 @@ func websitesDomainsDANERepublish(d WebsitesDeps) catalog.Operation {
})
}

// websitesDomainsConvertOnChain is the `websites domains convert-onchain`
// operation. Returns *ipfs.DomainResponse. Reclassifies the binding as
// on-chain managed: the HNS name's DNS is served by an external contract
// (its NS record points at one), so the portal
// deletes its managed PowerDNS zone/DNSSEC and switches ownership verification
// to the TXT token. One-way; DANE/SSL state is retained.
func websitesDomainsConvertOnChain(d WebsitesDeps) catalog.Operation {
return catalog.NewOperation(catalog.OperationSpec{
Name: "websites_domains_convert_onchain",
Title: "Convert a domain to on-chain managed",
Summary: "Reclassify a bound HNS domain as on-chain managed",
Description: "Reclassify a bound HNS domain as on-chain managed (status onchain_managed): the name's DNS is served by an external contract on the Handshake chain, so Pinner deletes its managed zone and DNSSEC and verifies ownership via a TXT token instead of delegation. DANE/SSL state is retained. ONE-WAY and irreversible as a hosting change: requires confirm=true. Only applies to HNS-namespace bindings; the backend refuses an already on-chain, ineligible, or zone-sharing binding. The domain argument can be the domain name or its numeric binding ID; the owning website is resolved automatically. Returns the updated domain object (status onchain_managed, delegation null).",
Category: "core",
Safety: catalog.SafetyDestructive,
Interaction: catalog.InteractionAgentSafe,
Visibility: catalog.VisibilityBoth,
Positional: "<domain>",
Args: []catalog.OperationArg{
{Name: "domain", Type: catalog.ArgTypeString, Required: true, Help: "Domain name or binding ID to convert to on-chain managed"},
{Name: "confirm", Type: catalog.ArgTypeBool, AgentRequired: true, Help: "Confirm the one-way conversion (deletes Pinner's managed zone/DNSSEC for the binding)", AgentHelp: "Must be true to convert the domain to on-chain managed; this drops Pinner's managed zone/DNSSEC and is one-way. Only a human sets this on confirmation; a model alone cannot confirm a destructive operation."},
},
Handler: handler(func(ctx context.Context, input map[string]any) (any, error) {
if !catalog.BoolArg(input, "confirm", false) {
return nil, fmt.Errorf("websites_domains_convert_onchain: confirmation is required — the conversion deletes Pinner's managed zone/DNSSEC for the binding and is one-way")
}
svc, svcErr := d.service(input)
if svcErr != nil {
return nil, svcErr
}
if err := svc.RequireAuthenticated(); err != nil {
return nil, err
}
domainArg := catalog.StrArg(input, "domain", "")
if domainArg == "" {
return nil, fmt.Errorf("websites_domains_convert_onchain: domain is required")
}
websiteID, domainID, err := websites.ResolveDomainBinding(ctx, svc, domainArg)
if err != nil {
return nil, err
}
// *ipfs.DomainResponse
return svc.ConvertDomainToOnChain(ctx, websiteID, domainID)
}),
})
}

// websitesDomainsUpdate is the `websites domains update` operation. Returns
// *ipfs.DomainResponse.
//
Expand Down
8 changes: 4 additions & 4 deletions internal/catalogops/websites_domains_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func singleWebsiteFixture() *domainsService {
return []ipfs.WebsiteItem{{Id: 7, Domain: "example.test"}}, nil
},
listDomainsFn: func(_ context.Context, websiteID string) ([]ipfs.DomainResponse, error) {
return []ipfs.DomainResponse{{Id: 3, Domain: "example.test", Namespace: "icann"}}, nil
return []ipfs.DomainResponse{{Id: 3, Domain: "example.test", Namespace: ipfs.DomainNamespaceICANN}}, nil
},
}
}
Expand All @@ -121,7 +121,7 @@ func TestWebsitesDomainsListResolvesWebsite(t *testing.T) {
var gotWebsiteID string
fake.listDomainsFn = func(_ context.Context, websiteID string) ([]ipfs.DomainResponse, error) {
gotWebsiteID = websiteID
return []ipfs.DomainResponse{{Id: 3, Domain: "example.test", Namespace: "icann"}}, nil
return []ipfs.DomainResponse{{Id: 3, Domain: "example.test", Namespace: ipfs.DomainNamespaceICANN}}, nil
}

op := websitesDomainsList(domainsDeps(t, fake))
Expand Down Expand Up @@ -179,7 +179,7 @@ func TestWebsitesDomainsAddRejectsInvalidNamespace(t *testing.T) {
var bindCalled bool
fake.bindDomainFn = func(_ context.Context, websiteID string, req ipfs.DomainRequest) (*ipfs.DomainResponse, error) {
bindCalled = true
return &ipfs.DomainResponse{Id: 9, Domain: req.Domain, Namespace: req.Namespace}, nil
return &ipfs.DomainResponse{Id: 9, Domain: req.Domain, Namespace: ipfs.DomainNamespace(req.Namespace)}, nil
}

op := websitesDomainsAdd(domainsDeps(t, fake))
Expand All @@ -199,7 +199,7 @@ func TestWebsitesDomainsAddValidNamespaceReachesBindDomain(t *testing.T) {
var gotReq ipfs.DomainRequest
fake.bindDomainFn = func(_ context.Context, websiteID string, req ipfs.DomainRequest) (*ipfs.DomainResponse, error) {
gotReq = req
return &ipfs.DomainResponse{Id: 9, Domain: req.Domain, Namespace: req.Namespace}, nil
return &ipfs.DomainResponse{Id: 9, Domain: req.Domain, Namespace: ipfs.DomainNamespace(req.Namespace)}, nil
}

op := websitesDomainsAdd(domainsDeps(t, fake))
Expand Down
16 changes: 8 additions & 8 deletions internal/cli/catalog_websites_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ func websitesActionAdapter(op catalog.Operation) cli.ActionFunc {
}
headers := []string{"Field", "Value"}
rows := [][]string{
{"Status", website.Ssl.Status},
{"Status", string(website.Ssl.Status)},
{"Issued At", formatTimePtr(website.Ssl.IssuedAt)},
{"Last Updated", formatTimePtr(website.Ssl.LastUpdatedAt)},
}
Expand Down Expand Up @@ -471,10 +471,10 @@ func renderWebsitesResult(_ context.Context, c *cli.Command, op catalog.Operatio
}
status := ""
if d.Status != nil {
status = *d.Status
status = string(*d.Status)
}
return []string{
fmt.Sprintf("%d", d.Id), d.Domain, d.Namespace, status, zoneName,
fmt.Sprintf("%d", d.Id), d.Domain, string(d.Namespace), status, zoneName,
}
})
output.PrintTable(headers, rows)
Expand Down Expand Up @@ -572,7 +572,7 @@ func renderWebsitesResult(_ context.Context, c *cli.Command, op catalog.Operatio
func renderDomainResponse(output Output, r *ipfs.DomainResponse) {
status := ""
if r.Status != nil {
status = *r.Status
status = string(*r.Status)
}
zoneName := ""
if r.ZoneName != nil {
Expand All @@ -581,7 +581,7 @@ func renderDomainResponse(output Output, r *ipfs.DomainResponse) {
fields := []Field{
{"ID", fmt.Sprintf("%d", r.Id)},
{"Domain", r.Domain},
{"Namespace", r.Namespace},
{"Namespace", string(r.Namespace)},
{"Status", status},
{"Zone Name", zoneName},
// Surface the per-domain DNS hosting state so the user can verify
Expand All @@ -603,7 +603,7 @@ func renderDomainResponse(output Output, r *ipfs.DomainResponse) {
func renderDomainDANEResponse(output Output, r *ipfs.DomainDANERepublishResponse) {
status := ""
if r.Status != nil {
status = *r.Status
status = string(*r.Status)
}
ownerName := ""
if r.OwnerName != nil {
Expand All @@ -617,7 +617,7 @@ func renderDomainDANEResponse(output Output, r *ipfs.DomainDANERepublishResponse
Fields: []Field{
{"ID", fmt.Sprintf("%d", r.Id)},
{"Domain", r.Domain},
{"Namespace", r.Namespace},
{"Namespace", string(r.Namespace)},
{"Status", status},
{"Owner Name", ownerName},
{"TLSA Record", tlsaRecord},
Expand Down Expand Up @@ -683,7 +683,7 @@ func renderWebsiteSSLStatusHuman(output Output, r *ipfs.WebsiteResponse) {
}
headers := []string{"Field", "Value"}
rows := [][]string{
{"Status", r.Ssl.Status},
{"Status", string(r.Ssl.Status)},
{"Issued At", formatTimePtr(r.Ssl.IssuedAt)},
{"Last Updated", formatTimePtr(r.Ssl.LastUpdatedAt)},
}
Expand Down
13 changes: 6 additions & 7 deletions internal/cli/websites_domains.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ func renderDomainDelegation(output Output, result *ipfs.DomainResponse, managed

status := ""
if result.Status != nil {
status = *result.Status
status = string(*result.Status)
}
fields := []Field{
{"Domain", result.Domain},
{"Namespace", result.Namespace},
{"Namespace", string(result.Namespace)},
{"Status", status},
}
// Surface the explicit DNSSEC state (enabled/disabled/error) + reason so an
Expand All @@ -39,10 +39,9 @@ func renderDomainDelegation(output Output, result *ipfs.DomainResponse, managed
}
output.PrintFields(FieldGroup{Fields: fields})

if result.Delegation == nil {
output.Printfln("No delegation records are available for %s.", result.Domain)
return
}

// The registry renders even without a delegation bundle: a nil Delegation is
// 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)
}
Loading
Loading