Skip to content

feat(sell): host-based offer routing — bind offers to dedicated hostnames (one tunnel, N origins) #668

Description

@bussyjd

Summary

Today every obol sell offer is published under a shared path prefix on the single tunnel
hostname (/services/<name>/*). This proposes binding an offer to its own hostname so
multiple services can be routed by origin (Host header) — e.g. a.example.com → service A,
b.example.com → service B — through a single Cloudflare tunnel.

Background — the host-routing mechanism already exists

Host-based routing is already how obol-stack isolates internal services: Traefik's Gateway matches
HTTPRoute.spec.hostnames against the Host header.

  • Frontend / eRPC pin hostnames: ["obol.stack"] to keep them off the public tunnel —
    internal/embed/infrastructure/base/templates/obol-frontend.yaml:41-42, erpc.yaml:41-42.
  • The storefront pins the tunnel hostname — CreateStorefront, internal/tunnel/tunnel.go:838 /
    :934 ("hostnames": []string{hostname}).

A single cloudflared connector can serve many Public Hostnames (standard Cloudflare Zero Trust):
each *.example.com → http://traefik.traefik.svc.cluster.local:80 — the same origin all tunnel modes
already point at. cloudflared forwards the original Host header; Traefik routes by hostnames.
No second tunnel is required to route by origin.

The gap

  • ServiceOfferSpec has no hostname/domain field — internal/monetizeapi/types.go (only Path, ~:125).
  • buildHTTPRoute emits only a PathPrefix /services/<name> match, with no hostnames
    internal/serviceoffercontroller/render.go:579-620.
  • No --hostname flag on obol sell http (confirmed — the lone hostname hit in sell.go is an
    unrelated comment).

Proposed change

  1. CRD field — add Hostname string (spec.hostname, FQDN-validated) to ServiceOfferSpec
    (internal/monetizeapi/types.go, alongside Path) and the embedded CRD
    (internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml; validated by
    internal/embed/embed_crd_test.go).
  2. Renderer — in buildHTTPRoute (internal/serviceoffercontroller/render.go:579), when
    spec.hostname != "", set spec.hostnames: [hostname] and match PathPrefix / (the whole origin
    maps to this backend) instead of /services/<name>. Mirror the storefront pattern at
    tunnel.go:934. Backend stays x402-verifier, so payment gating is preserved. Add a regression
    test in internal/serviceoffercontroller/render_test.go.
  3. CLI — add --hostname to obol sell http (and likely sell agent/sell inference) in
    cmd/obol/sell.go, threaded into the offer spec.
  4. Discovery host-awareness/skill.md, /api/services.json, and ERC-8004 advertise
    https://<hostname>/ instead of <tunnelbase>/services/<name> when a hostname is set
    (render.go:854, :977, :1138).
  5. Operator action (no code) — add each Public Hostname → http://traefik.traefik.svc.cluster.local:80
    in the Cloudflare dashboard. Optional polish: automate via cloudflareClient.UpdateTunnelConfiguration
    (internal/tunnel/cloudflare.go, called from provision.go:101) to push multiple ingress rules.

Acceptance criteria

  • obol sell http <name> --hostname a.example.com … publishes an HTTPRoute with
    spec.hostnames: [a.example.com] matching /, backed by x402-verifier (still 402-gated).
  • Two offers on distinct hostnames route independently; behavior is unchanged (path-prefix) when
    --hostname is omitted.
  • Catalog / /skill.md / ERC-8004 advertise the hostname-based endpoint when set.
  • render_test.go covers both modes; embed_crd_test.go accepts the new field.
  • Host-bound /-prefix routes do not shadow the internal obol.stack routes (different hostname) —
    asserted by test.

Out of scope — Option B (several named tunnels)

Running multiple cloudflared connectors (distinct tokens/credentials per hostname) is a separate,
heavier change: the tunnel is hardcoded to one Helm release / Deployment / token Secret
(cloudflared, cloudflared-tunnel-token) plus a single Hostname in tunnel state (internal/tunnel/,
~10 references). It would require parameterizing the chart/release/secret and turning tunnel state into
a list. It is only needed for distinct connector trust/exposure boundaries per hostname (e.g. separate
Cloudflare accounts/zones) and still requires this issue's HTTPRoute change on top. Track separately
if a real need arises.

Security note

Host-bound offer routes remain gated — their backend is x402-verifier (render.go:608-611), so
payment enforcement is unchanged. The /-prefix only applies to the offer's own hostname, so it cannot
shadow obol.stack internal routes; a test should assert this.


Spec derived from a code walk of internal/tunnel/, internal/serviceoffercontroller/render.go,
internal/monetizeapi/types.go, and cmd/obol/sell.go. Filed via Claude Code:
https://claude.ai/code/session_01YUTW7NfxjUoVtyKgR6nPQC

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions