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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions