diff --git a/internal/serviceoffercontroller/agent_confighash_test.go b/internal/serviceoffercontroller/agent_confighash_test.go
index 62029243..3966173f 100644
--- a/internal/serviceoffercontroller/agent_confighash_test.go
+++ b/internal/serviceoffercontroller/agent_confighash_test.go
@@ -30,32 +30,32 @@ func TestHermesConfigDecision(t *testing.T) {
wantDrift: false,
},
{
- name: "annotation matches and data hashes to desired -> skip, no drift",
- live: hermesConfigCM(t, "ns", desiredYAML, desiredHash, "1"),
+ name: "annotation matches and data hashes to desired -> skip, no drift",
+ live: hermesConfigCM(t, "ns", desiredYAML, desiredHash, "1"),
wantSkip: true,
wantDrift: false,
},
{
- name: "annotation matches but data hand-edited -> skip with drift",
- live: hermesConfigCM(t, "ns", otherYAML, desiredHash, "1"),
+ name: "annotation matches but data hand-edited -> skip with drift",
+ live: hermesConfigCM(t, "ns", otherYAML, desiredHash, "1"),
wantSkip: true,
wantDrift: true,
},
{
- name: "annotation does not match desired -> apply",
- live: hermesConfigCM(t, "ns", desiredYAML, otherHash, "1"),
+ name: "annotation does not match desired -> apply",
+ live: hermesConfigCM(t, "ns", desiredYAML, otherHash, "1"),
wantSkip: false,
wantDrift: false,
},
{
- name: "annotation absent (pre-feature migration) -> apply",
- live: hermesConfigCM(t, "ns", desiredYAML, "", "1"),
+ name: "annotation absent (pre-feature migration) -> apply",
+ live: hermesConfigCM(t, "ns", desiredYAML, "", "1"),
wantSkip: false,
wantDrift: false,
},
{
- name: "annotation matches but data key missing -> skip with drift",
- live: hermesConfigCMNoData(t, "ns", desiredHash, "1"),
+ name: "annotation matches but data key missing -> skip with drift",
+ live: hermesConfigCMNoData(t, "ns", desiredHash, "1"),
wantSkip: true,
wantDrift: true,
},
diff --git a/internal/serviceoffercontroller/catalog.go b/internal/serviceoffercontroller/catalog.go
index a33cfad6..97327377 100644
--- a/internal/serviceoffercontroller/catalog.go
+++ b/internal/serviceoffercontroller/catalog.go
@@ -40,7 +40,7 @@ func (c *Controller) listServiceOffersForCatalog(ctx context.Context, override *
return offers, nil
}
-func skillCatalogContentMatches(cm *unstructured.Unstructured, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) bool {
+func staticSiteContentMatches(cm *unstructured.Unstructured, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) bool {
if cm == nil {
return false
}
@@ -75,22 +75,22 @@ func skillCatalogContentMatches(cm *unstructured.Unstructured, content, services
return true
}
-func (c *Controller) skillCatalogContentUnchanged(ctx context.Context, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) (bool, error) {
- cm, err := c.configMaps.Namespace(skillCatalogNamespace).Get(ctx, skillCatalogConfigMapName, metav1.GetOptions{})
+func (c *Controller) staticSiteContentUnchanged(ctx context.Context, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) (bool, error) {
+ cm, err := c.configMaps.Namespace(staticSiteNamespace).Get(ctx, staticSiteConfigMapName, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
return false, nil
}
if err != nil {
return false, err
}
- return skillCatalogContentMatches(cm, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles), nil
+ return staticSiteContentMatches(cm, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles), nil
}
-func computeSkillCatalogContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) string {
+func computeStaticSiteContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) string {
return fmt.Sprintf("%x", md5Sum(content+servicesJSON+openAPIJSON+apiDocsHTML+bundleDigestInput(bundles)))[:8]
}
-func skillCatalogDeployedContentHash(deployment *unstructured.Unstructured) string {
+func staticSiteDeployedContentHash(deployment *unstructured.Unstructured) string {
if deployment == nil {
return ""
}
diff --git a/internal/serviceoffercontroller/catalog_test.go b/internal/serviceoffercontroller/catalog_test.go
index e596ae6c..184a5939 100644
--- a/internal/serviceoffercontroller/catalog_test.go
+++ b/internal/serviceoffercontroller/catalog_test.go
@@ -6,9 +6,9 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
-func TestComputeSkillCatalogContentHashDeterministic(t *testing.T) {
- a := computeSkillCatalogContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil)
- b := computeSkillCatalogContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil)
+func TestComputeStaticSiteContentHashDeterministic(t *testing.T) {
+ a := computeStaticSiteContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil)
+ b := computeStaticSiteContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil)
if a != b {
t.Fatalf("hash not deterministic: %q vs %q", a, b)
}
@@ -16,36 +16,36 @@ func TestComputeSkillCatalogContentHashDeterministic(t *testing.T) {
t.Fatalf("hash length = %d, want 8", len(a))
}
- changed := computeSkillCatalogContentHash("# cat", `{"services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "", nil)
+ changed := computeStaticSiteContentHash("# cat", `{"services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "", nil)
if changed == a {
t.Fatal("expected different hash when catalog content changes")
}
}
-func TestSkillCatalogContentMatches(t *testing.T) {
- cm := buildSkillCatalogConfigMap("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil)
- if !skillCatalogContentMatches(cm, "# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) {
+func TestStaticSiteContentMatches(t *testing.T) {
+ cm := buildStaticSiteConfigMap("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil)
+ if !staticSiteContentMatches(cm, "# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) {
t.Fatal("expected matching catalog content")
}
- if skillCatalogContentMatches(cm, "# changed", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) {
+ if staticSiteContentMatches(cm, "# changed", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) {
t.Fatal("expected different skill.md to not match")
}
- if skillCatalogContentMatches(nil, "# cat", `{}`, `{}`, "", nil) {
+ if staticSiteContentMatches(nil, "# cat", `{}`, `{}`, "", nil) {
t.Fatal("nil configmap must not match")
}
}
-func TestSkillCatalogDeployedContentHash(t *testing.T) {
- deployment := buildSkillCatalogDeployment("abc12345", nil)
- if got := skillCatalogDeployedContentHash(deployment); got != "abc12345" {
+func TestStaticSiteDeployedContentHash(t *testing.T) {
+ deployment := buildStaticSiteDeployment("abc12345", nil)
+ if got := staticSiteDeployedContentHash(deployment); got != "abc12345" {
t.Fatalf("hash = %q, want abc12345", got)
}
- if got := skillCatalogDeployedContentHash(nil); got != "" {
+ if got := staticSiteDeployedContentHash(nil); got != "" {
t.Fatalf("nil deployment hash = %q, want empty", got)
}
empty := &unstructured.Unstructured{Object: map[string]any{"spec": map[string]any{}}}
- if got := skillCatalogDeployedContentHash(empty); got != "" {
+ if got := staticSiteDeployedContentHash(empty); got != "" {
t.Fatalf("missing annotation hash = %q, want empty", got)
}
}
diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go
index 0d45c95b..20a5714f 100644
--- a/internal/serviceoffercontroller/controller.go
+++ b/internal/serviceoffercontroller/controller.go
@@ -76,7 +76,7 @@ type Controller struct {
identityQueue workqueue.TypedRateLimitingInterface[string]
purchaseQueue workqueue.TypedRateLimitingInterface[string]
agentQueue workqueue.TypedRateLimitingInterface[string]
- catalogMu sync.Mutex
+ staticSiteMu sync.Mutex
pendingAuths sync.Map // key: "ns/name" → []map[string]string
@@ -351,25 +351,25 @@ func (c *Controller) enqueueStorefrontProfileRefresh(obj any) {
if u.GetNamespace() != storefront.ProfileNamespace || u.GetName() != storefront.ProfileConfigMap {
return
}
- log.Printf("serviceoffer-controller: storefront profile change detected, refreshing skill catalog")
- c.enqueueSkillCatalogRefresh()
+ log.Printf("serviceoffer-controller: storefront profile change detected, refreshing static site")
+ c.enqueueStaticSiteRefresh()
}
-func (c *Controller) enqueueSkillCatalogRefresh() {
+func (c *Controller) enqueueStaticSiteRefresh() {
items := c.offerInformer.GetStore().List()
if len(items) > 0 {
// Any single offer reconcile rebuilds the full catalog.
c.enqueueOffer(items[0])
return
}
- go c.refreshSkillCatalogAsync()
+ go c.refreshStaticSiteAsync()
}
-func (c *Controller) refreshSkillCatalogAsync() {
+func (c *Controller) refreshStaticSiteAsync() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
- if err := c.reconcileSkillCatalog(ctx, nil); err != nil {
- log.Printf("serviceoffer-controller: refresh skill catalog: %v", err)
+ if err := c.reconcileStaticSite(ctx, nil); err != nil {
+ log.Printf("serviceoffer-controller: refresh static site: %v", err)
}
}
@@ -467,7 +467,7 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error {
now := metav1.Now()
tombstone.DeletionTimestamp = &now
}
- if err := c.reconcileSkillCatalog(ctx, &tombstone); err != nil {
+ if err := c.reconcileStaticSite(ctx, &tombstone); err != nil {
return err
}
return c.removeFinalizer(ctx, raw, serviceOfferFinalizer)
@@ -661,10 +661,10 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error {
// requiring a spec mutation or unrelated ConfigMap update.
c.offerQueue.AddAfter(offer.Namespace+"/"+offer.Name, 5*time.Second)
}
- // Rebuild the skill catalog on every reconcile so tunnel URL changes and
- // offer status updates propagate immediately. reconcileSkillCatalog skips
+ // Rebuild the static site on every reconcile so tunnel URL changes and
+ // offer status updates propagate immediately. reconcileStaticSite skips
// ConfigMap/Deployment writes when the rendered hash is unchanged.
- return c.reconcileSkillCatalog(ctx, nil)
+ return c.reconcileStaticSite(ctx, nil)
}
func (c *Controller) reconcileDeletingOffer(ctx context.Context, offer *monetizeapi.ServiceOffer) error {
@@ -1246,16 +1246,16 @@ func (c *Controller) loadStorefrontProfile(ctx context.Context) (*schemas.Storef
return storefront.ParseProfile(raw)
}
-// reconcileSkillCatalog rebuilds the /skill.md ConfigMap/Deployment/Service/
+// reconcileStaticSite rebuilds the /skill.md ConfigMap/Deployment/Service/
// HTTPRoute from the current set of operationally-ready ServiceOffers. Offers
// are listed from the API server so every reconcile sees a consistent snapshot;
// override replaces or appends a just-created offer the list has not observed yet.
// ConfigMap and Deployment are only applied when the rendered catalog hash differs
// from the live obol-skill-md Deployment annotation, so idle reconciles do not
-// roll the skill-catalog pod.
-func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *monetizeapi.ServiceOffer) error {
- c.catalogMu.Lock()
- defer c.catalogMu.Unlock()
+// roll the static-site pod.
+func (c *Controller) reconcileStaticSite(ctx context.Context, override *monetizeapi.ServiceOffer) error {
+ c.staticSiteMu.Lock()
+ defer c.staticSiteMu.Unlock()
baseURL, err := c.registrationBaseURL(ctx)
if err != nil {
@@ -1271,7 +1271,7 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti
if err != nil {
return err
}
- content := buildSkillCatalogMarkdown(offers, baseURL, storefrontProfile)
+ content := buildSkillMarkdown(offers, baseURL, storefrontProfile)
servicesJSON := buildServiceCatalogJSON(offers, baseURL, storefrontProfile)
resolvedProfile := storefront.ResolvePublished(storefrontProfile, baseURL)
// buildOpenAPIDocument prefers the tunnel URL for the public `servers[0]`
@@ -1283,9 +1283,9 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti
openAPIJSON := buildOpenAPIDocument(offers, baseURL, resolvedProfile)
apiDocsHTML := scalarHTML(resolvedProfile)
bundles := buildOfferBundles(offers, resolvedProfile)
- contentHash := computeSkillCatalogContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)
+ contentHash := computeStaticSiteContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)
- unchanged, err := c.skillCatalogContentUnchanged(ctx, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)
+ unchanged, err := c.staticSiteContentUnchanged(ctx, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)
if err != nil {
return err
}
@@ -1295,30 +1295,30 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti
return nil
}
- if err := c.applyObject(ctx, c.configMaps.Namespace(skillCatalogNamespace), buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)); err != nil {
+ if err := c.applyObject(ctx, c.configMaps.Namespace(staticSiteNamespace), buildStaticSiteConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)); err != nil {
return err
}
- if err := c.applyObject(ctx, c.deployments.Namespace(skillCatalogNamespace), buildSkillCatalogDeployment(contentHash, bundles)); err != nil {
+ if err := c.applyObject(ctx, c.deployments.Namespace(staticSiteNamespace), buildStaticSiteDeployment(contentHash, bundles)); err != nil {
return err
}
- if err := c.applyObject(ctx, c.services.Namespace(skillCatalogNamespace), buildSkillCatalogService()); err != nil {
+ if err := c.applyObject(ctx, c.services.Namespace(staticSiteNamespace), buildStaticSiteService()); err != nil {
return err
}
// Headers Middleware must exist before the routes that reference it, or
// Traefik drops the routes for a dangling ExtensionRef.
- if err := c.applyObject(ctx, c.middlewares.Namespace(skillCatalogNamespace), buildCatalogHeadersMiddleware()); err != nil {
+ if err := c.applyObject(ctx, c.middlewares.Namespace(staticSiteNamespace), buildCatalogHeadersMiddleware()); err != nil {
return err
}
- if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildSkillCatalogHTTPRoute()); err != nil {
+ if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildStaticSiteHTTPRoute()); err != nil {
return err
}
- if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildServicesJSONHTTPRoute()); err != nil {
+ if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildServicesJSONHTTPRoute()); err != nil {
return err
}
- if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildOpenAPIHTTPRoute()); err != nil {
+ if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildOpenAPIHTTPRoute()); err != nil {
return err
}
- if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildAPIDocsHTTPRoute()); err != nil {
+ if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildAPIDocsHTTPRoute()); err != nil {
return err
}
readyOffers := countReadyServiceOffers(offers)
diff --git a/internal/serviceoffercontroller/hostoffer_test.go b/internal/serviceoffercontroller/hostoffer_test.go
index 43795bb8..5036df96 100644
--- a/internal/serviceoffercontroller/hostoffer_test.go
+++ b/internal/serviceoffercontroller/hostoffer_test.go
@@ -59,7 +59,7 @@ func TestBuildHostHTTPRoute(t *testing.T) {
t.Errorf("rule %d (%s) rewrites to %v, want %s", i, public, got, wantExact[public])
}
backend := rule["backendRefs"].([]any)[0].(map[string]any)
- if backend["name"] != skillCatalogConfigMapName || backend["namespace"] != skillCatalogNamespace {
+ if backend["name"] != staticSiteConfigMapName || backend["namespace"] != staticSiteNamespace {
t.Errorf("rule %d backend = %v", i, backend)
}
}
@@ -262,7 +262,7 @@ func TestCatalogAdvertisesDedicatedOrigin(t *testing.T) {
t.Fatalf("catalog endpoint = %+v, want dedicated origin", catalog.Services)
}
- skill := buildSkillCatalogMarkdown([]*monetizeapi.ServiceOffer{offer}, "https://shared.example", nil)
+ skill := buildSkillMarkdown([]*monetizeapi.ServiceOffer{offer}, "https://shared.example", nil)
if !strings.Contains(skill, "`POST https://audit.v1337.example/submit`") {
t.Errorf("skill.md routes not rooted at dedicated origin:\n%.400s", skill)
}
diff --git a/internal/serviceoffercontroller/offerbundle.go b/internal/serviceoffercontroller/offerbundle.go
index 51db1778..63fc4854 100644
--- a/internal/serviceoffercontroller/offerbundle.go
+++ b/internal/serviceoffercontroller/offerbundle.go
@@ -1,6 +1,7 @@
package serviceoffercontroller
import (
+ _ "embed"
"encoding/json"
"fmt"
"html/template"
@@ -19,7 +20,7 @@ import (
// resources per origin. An offer with spec.hostname therefore gets its own
// discovery documents — an openapi.json scoped to just that offer with
// paths rooted at "/", a /.well-known/x402 resource list, and a minimal
-// landing page — served by the same catalog httpd via per-offer ConfigMap
+// landing page — served by the same static-site httpd via per-offer ConfigMap
// keys and Exact-match rewrite routes on the offer's hostname.
// offerBundleFile is one generated file: Key is the ConfigMap data key,
@@ -265,71 +266,12 @@ func wellKnownAccept(p monetizeapi.ServiceOfferPayment) map[string]any {
return entry
}
-var offerLandingTmpl = template.Must(template.New("offer_landing").Parse(`
-
-
-
-
- {{.Title}}
-
-
-
-
-
-
- {{if .OGImageURL}}
-
- {{end}}
- {{if .FaviconURL}} {{end}}
-
- {{if .CustomCSS}}{{end}}
-
-
-
- {{if .LogoURL}}
{{if .ShowName}}
{{.Operator}} {{end}}
{{end}}
-
{{.Price}}
-
{{.Title}}
-
{{.DescriptionHTML}}
-
-
-
-
For agents & developers
-
/openapi.json — request shapes + per-route pricing
-
/.well-known/x402 — signable x402 payment requirements
-
Payment is per-request via x402 micropayments: call an endpoint with no payment to receive the 402 challenge, sign one accepts[] entry, retry with the X-PAYMENT header.
-
- {{if .AboutHTML}}
-
-
About {{.Operator}}
-
{{.AboutHTML}}
-
- {{end}}
-
Sold by {{.Operator}} · Powered by Obol
-
-
-
-`))
+//go:embed templates/offer_landing.html
+var offerLandingHTMLSrc string
+
+var offerLandingTmpl = template.Must(
+ template.New("offer_landing").Parse(offerLandingHTMLSrc),
+)
// buildOfferLandingHTML renders the hostname's "/" landing page: enough for
// a human to understand the product and for previews/scrapers to get sane
diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go
index c7340b09..3e8e3b4d 100644
--- a/internal/serviceoffercontroller/render.go
+++ b/internal/serviceoffercontroller/render.go
@@ -22,12 +22,21 @@ import (
)
const (
- skillCatalogNamespace = "x402"
- skillCatalogConfigMapName = "obol-skill-md"
- skillCatalogRouteName = "obol-skill-md-route"
- servicesJSONRouteName = "obol-services-json-route"
- openAPIRouteName = "obol-openapi-route"
- apiDocsRouteName = "obol-api-docs-route"
+ // The static site is the busybox httpd that serves every public artifact:
+ // skill.md, services.json (the storefront's own backend), openapi.json,
+ // the API docs, and one bundle per hostname-bound offer (landing page,
+ // scoped openapi.json, .well-known/x402). The "obol-skill-md" object names
+ // predate all but the first of those and are load-bearing — they are
+ // referenced by internal/tunnel (SERVICES_URL), cmd/obol/sell_info.go,
+ // internal/stackbackup, the embedded x402.yaml, and the storefront's
+ // next.config.ts. Renaming them is a live-cluster migration, so the Go
+ // identifiers say what this is and the wire names stay put.
+ staticSiteNamespace = "x402"
+ staticSiteConfigMapName = "obol-skill-md"
+ staticSiteRouteName = "obol-skill-md-route"
+ servicesJSONRouteName = "obol-services-json-route"
+ openAPIRouteName = "obol-openapi-route"
+ apiDocsRouteName = "obol-api-docs-route"
// catalogHeadersMiddlewareName is the Traefik headers Middleware attached
// to the public catalog HTTPRoutes (/skill.md, /openapi.json, /api,
@@ -255,7 +264,7 @@ func agentIdentityLabels(identity *monetizeapi.AgentIdentity, appName string) ma
}
}
-func buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) *unstructured.Unstructured {
+func buildStaticSiteConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) *unstructured.Unstructured {
data := map[string]any{
"skill.md": content,
"services.json": servicesJSON,
@@ -271,10 +280,10 @@ func buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]any{
- "name": skillCatalogConfigMapName,
- "namespace": skillCatalogNamespace,
+ "name": staticSiteConfigMapName,
+ "namespace": staticSiteNamespace,
"labels": map[string]any{
- "app": skillCatalogConfigMapName,
+ "app": staticSiteConfigMapName,
"obol.org/managed-by": "serviceoffer-controller",
},
},
@@ -283,10 +292,10 @@ func buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML
}
}
-// skillCatalogVolumeItems projects the ConfigMap keys into the httpd's /www
+// staticSiteVolumeItems projects the ConfigMap keys into the httpd's /www
// tree: the four aggregate documents plus one file per hostname-offer
// bundle entry (offers///…).
-func skillCatalogVolumeItems(bundles []offerBundleFile) []any {
+func staticSiteVolumeItems(bundles []offerBundleFile) []any {
items := []any{
map[string]any{"key": "skill.md", "path": "skill.md"},
map[string]any{"key": "services.json", "path": "api/services.json"},
@@ -303,9 +312,9 @@ func skillCatalogVolumeItems(bundles []offerBundleFile) []any {
return items
}
-func buildSkillCatalogDeployment(contentHash string, bundles []offerBundleFile) *unstructured.Unstructured {
+func buildStaticSiteDeployment(contentHash string, bundles []offerBundleFile) *unstructured.Unstructured {
labels := map[string]any{
- "app": skillCatalogConfigMapName,
+ "app": staticSiteConfigMapName,
"obol.org/managed-by": "serviceoffer-controller",
}
return &unstructured.Unstructured{
@@ -313,8 +322,8 @@ func buildSkillCatalogDeployment(contentHash string, bundles []offerBundleFile)
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]any{
- "name": skillCatalogConfigMapName,
- "namespace": skillCatalogNamespace,
+ "name": staticSiteConfigMapName,
+ "namespace": staticSiteNamespace,
"labels": labels,
},
"spec": map[string]any{
@@ -354,14 +363,14 @@ func buildSkillCatalogDeployment(contentHash string, bundles []offerBundleFile)
map[string]any{
"name": "content",
"configMap": map[string]any{
- "name": skillCatalogConfigMapName,
- "items": skillCatalogVolumeItems(bundles),
+ "name": staticSiteConfigMapName,
+ "items": staticSiteVolumeItems(bundles),
},
},
map[string]any{
"name": "httpdconf",
"configMap": map[string]any{
- "name": skillCatalogConfigMapName,
+ "name": staticSiteConfigMapName,
"items": []any{map[string]any{"key": "httpd.conf", "path": "httpd.conf"}},
},
},
@@ -373,9 +382,9 @@ func buildSkillCatalogDeployment(contentHash string, bundles []offerBundleFile)
}
}
-func buildSkillCatalogService() *unstructured.Unstructured {
+func buildStaticSiteService() *unstructured.Unstructured {
labels := map[string]any{
- "app": skillCatalogConfigMapName,
+ "app": staticSiteConfigMapName,
"obol.org/managed-by": "serviceoffer-controller",
}
return &unstructured.Unstructured{
@@ -383,8 +392,8 @@ func buildSkillCatalogService() *unstructured.Unstructured {
"apiVersion": "v1",
"kind": "Service",
"metadata": map[string]any{
- "name": skillCatalogConfigMapName,
- "namespace": skillCatalogNamespace,
+ "name": staticSiteConfigMapName,
+ "namespace": staticSiteNamespace,
"labels": labels,
},
"spec": map[string]any{
@@ -417,7 +426,7 @@ func buildCatalogHeadersMiddleware() *unstructured.Unstructured {
"kind": "Middleware",
"metadata": map[string]any{
"name": catalogHeadersMiddlewareName,
- "namespace": skillCatalogNamespace,
+ "namespace": staticSiteNamespace,
"labels": map[string]any{
"obol.org/managed-by": "serviceoffer-controller",
},
@@ -451,14 +460,14 @@ func catalogHeadersFilters() []any {
}
}
-func buildSkillCatalogHTTPRoute() *unstructured.Unstructured {
+func buildStaticSiteHTTPRoute() *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "gateway.networking.k8s.io/v1",
"kind": "HTTPRoute",
"metadata": map[string]any{
- "name": skillCatalogRouteName,
- "namespace": skillCatalogNamespace,
+ "name": staticSiteRouteName,
+ "namespace": staticSiteNamespace,
"labels": map[string]any{
"obol.org/managed-by": "serviceoffer-controller",
},
@@ -484,8 +493,8 @@ func buildSkillCatalogHTTPRoute() *unstructured.Unstructured {
"filters": catalogHeadersFilters(),
"backendRefs": []any{
map[string]any{
- "name": skillCatalogConfigMapName,
- "namespace": skillCatalogNamespace,
+ "name": staticSiteConfigMapName,
+ "namespace": staticSiteNamespace,
"port": int64(8080),
},
},
@@ -511,7 +520,7 @@ func buildOpenAPIHTTPRoute() *unstructured.Unstructured {
"kind": "HTTPRoute",
"metadata": map[string]any{
"name": openAPIRouteName,
- "namespace": skillCatalogNamespace,
+ "namespace": staticSiteNamespace,
"labels": map[string]any{
"obol.org/managed-by": "serviceoffer-controller",
},
@@ -537,8 +546,8 @@ func buildOpenAPIHTTPRoute() *unstructured.Unstructured {
"filters": catalogHeadersFilters(),
"backendRefs": []any{
map[string]any{
- "name": skillCatalogConfigMapName,
- "namespace": skillCatalogNamespace,
+ "name": staticSiteConfigMapName,
+ "namespace": staticSiteNamespace,
"port": int64(8080),
},
},
@@ -567,7 +576,7 @@ func buildAPIDocsHTTPRoute() *unstructured.Unstructured {
"kind": "HTTPRoute",
"metadata": map[string]any{
"name": apiDocsRouteName,
- "namespace": skillCatalogNamespace,
+ "namespace": staticSiteNamespace,
"labels": map[string]any{
"obol.org/managed-by": "serviceoffer-controller",
},
@@ -599,8 +608,8 @@ func buildAPIDocsHTTPRoute() *unstructured.Unstructured {
"filters": catalogHeadersFilters(),
"backendRefs": []any{
map[string]any{
- "name": skillCatalogConfigMapName,
- "namespace": skillCatalogNamespace,
+ "name": staticSiteConfigMapName,
+ "namespace": staticSiteNamespace,
"port": int64(8080),
},
},
@@ -618,7 +627,7 @@ func buildServicesJSONHTTPRoute() *unstructured.Unstructured {
"kind": "HTTPRoute",
"metadata": map[string]any{
"name": servicesJSONRouteName,
- "namespace": skillCatalogNamespace,
+ "namespace": staticSiteNamespace,
"labels": map[string]any{
"obol.org/managed-by": "serviceoffer-controller",
},
@@ -644,8 +653,8 @@ func buildServicesJSONHTTPRoute() *unstructured.Unstructured {
"filters": catalogHeadersFilters(),
"backendRefs": []any{
map[string]any{
- "name": skillCatalogConfigMapName,
- "namespace": skillCatalogNamespace,
+ "name": staticSiteConfigMapName,
+ "namespace": staticSiteNamespace,
"port": int64(8080),
},
},
@@ -738,7 +747,7 @@ func buildHTTPRoute(offer *monetizeapi.ServiceOffer) *unstructured.Unstructured
// offer. Topology (proven live before being generalized here — see
// docs/proposals/multistore-storefront-routing.md appendix):
//
-// - Exact /, /openapi.json, /.well-known/x402 → the catalog httpd, with
+// - Exact /, /openapi.json, /.well-known/x402 → the static-site httpd, with
// full-path rewrites into the offer's generated bundle files. These are
// structurally free — they never touch the payment gate.
// - PathPrefix / → x402-verifier, with the public path rewritten into the
@@ -764,7 +773,7 @@ func buildHostHTTPRoute(offer *monetizeapi.ServiceOffer) *unstructured.Unstructu
},
},
"backendRefs": []any{
- map[string]any{"name": skillCatalogConfigMapName, "namespace": skillCatalogNamespace, "port": int64(8080)},
+ map[string]any{"name": staticSiteConfigMapName, "namespace": staticSiteNamespace, "port": int64(8080)},
},
}
}
@@ -890,11 +899,11 @@ func buildReferenceGrant(offer *monetizeapi.ServiceOffer) *unstructured.Unstruct
},
// Hostname-bound offers backend their discovery bundle
// (Exact / + /openapi.json + /.well-known/x402 rules)
- // to the catalog httpd in this namespace.
+ // to the static-site httpd in this namespace.
map[string]any{
"group": "",
"kind": "Service",
- "name": skillCatalogConfigMapName,
+ "name": staticSiteConfigMapName,
},
},
},
@@ -1140,7 +1149,7 @@ func offerPublishedForRegistration(offer *monetizeapi.ServiceOffer) bool {
isConditionTrue(offer.Status, "RoutePublished")
}
-func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL string, explicit *schemas.StorefrontProfile) string {
+func buildSkillMarkdown(offers []*monetizeapi.ServiceOffer, baseURL string, explicit *schemas.StorefrontProfile) string {
baseURL = strings.TrimRight(baseURL, "/")
profile := storefront.ResolvePublished(explicit, baseURL)
@@ -1186,7 +1195,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin
"",
}
- lines = append(lines, skillCatalogHowToPay(baseURL)...)
+ lines = append(lines, skillMarkdownHowToPay(baseURL)...)
if len(ready) == 0 {
lines = append(lines, "## Services", "", "**No services currently available.**", "")
@@ -1250,7 +1259,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin
lines = append(lines, fmt.Sprintf(" %d. %s", i+1, describePaymentDetail(payments[i])))
}
}
- lines = append(lines, skillCatalogRouteLines(offer, endpoint)...)
+ lines = append(lines, skillMarkdownRouteLines(offer, endpoint)...)
if offer.Spec.Async.Enabled {
access := "results are gated to the paying wallet (SIWX sign-in) or the `jobToken` from the 202 body"
if offer.Spec.Async.EffectiveResultVisibility() == monetizeapi.ResultVisibilityPublic {
@@ -1268,17 +1277,17 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin
description = fmt.Sprintf("x402 payment-gated %s service", fallbackOfferType(offer))
}
lines = append(lines, fmt.Sprintf("- **Description**: %s", description), "")
- lines = append(lines, skillCatalogTryIt(offer, endpoint)...)
+ lines = append(lines, skillMarkdownTryIt(offer, endpoint)...)
}
return strings.Join(lines, "\n")
}
-// skillCatalogHowToPay returns the self-contained "How to pay" section. It
+// skillMarkdownHowToPay returns the self-contained "How to pay" section. It
// is written so any LLM agent — not just one running on Obol Stack — can
// pay these endpoints by following the x402 v2 loop, without first reading
// any external doc. baseURL points the reader at the machine-readable specs.
-func skillCatalogHowToPay(baseURL string) []string {
+func skillMarkdownHowToPay(baseURL string) []string {
return []string{
"## How to pay (x402)",
"",
@@ -1330,12 +1339,12 @@ func catalogModelName(offer *monetizeapi.ServiceOffer) string {
return ""
}
-// skillCatalogTryIt renders the per-offer "Try it" subsection: one curl that
+// skillMarkdownTryIt renders the per-offer "Try it" subsection: one curl that
// probes the 402 pricing, and one worked paid request. The paid example for
// chat-shaped offers is buyprompts.Build's Example — the exact same bytes
// /api/services.json publishes in the entry's buy.example — so the two
// surfaces cannot drift. Agent buyers convert off copy-paste, not prose.
-func skillCatalogTryIt(offer *monetizeapi.ServiceOffer, endpoint string) []string {
+func skillMarkdownTryIt(offer *monetizeapi.ServiceOffer, endpoint string) []string {
// Route-table offers: probe + pay against the primary paid route, not
// the offer root (which may not be served at all when the table has no
// catch-all).
@@ -1391,7 +1400,7 @@ func skillCatalogTryIt(offer *monetizeapi.ServiceOffer, endpoint string) []strin
// active, and upstream healthy serves buyers correctly regardless of
// whether the on-chain identity has been minted yet.
//
-// Used by the storefront catalog (and the skill catalog) so an offer that
+// Used by the services.json catalog (and skill.md) so an offer that
// is functionally usable doesn't disappear from the operator's own
// dashboard just because the agent wallet hasn't been funded with gas
// yet. Callers should set ServiceCatalogEntry.RegistrationPending = true
@@ -1877,12 +1886,12 @@ func describePaymentDetail(p monetizeapi.ServiceOfferPayment) string {
return b.String()
}
-// skillCatalogRouteLines renders the per-route list for offers with a
+// skillMarkdownRouteLines renders the per-route list for offers with a
// declared route table (spec.routes). One line per route: methods, full
// URL, gate/price, and the route summary. Offers without a route table
// contribute nothing — their single implicit catch-all is already fully
// described by the Endpoint/Payment lines above.
-func skillCatalogRouteLines(offer *monetizeapi.ServiceOffer, endpoint string) []string {
+func skillMarkdownRouteLines(offer *monetizeapi.ServiceOffer, endpoint string) []string {
if len(offer.Spec.Routes) == 0 {
return nil
}
diff --git a/internal/serviceoffercontroller/render_builders_test.go b/internal/serviceoffercontroller/render_builders_test.go
index bac5fa10..a650ffc5 100644
--- a/internal/serviceoffercontroller/render_builders_test.go
+++ b/internal/serviceoffercontroller/render_builders_test.go
@@ -74,14 +74,14 @@ func assertRestrictedPSS(t *testing.T, deploymentName string, spec map[string]an
}
}
-// TestBuildSkillCatalogDeployment_RestrictedPSS verifies the skill-md
+// TestBuildStaticSiteDeployment_RestrictedPSS verifies the skill-md
// httpd Deployment ships a Restricted-PSS-compliant securityContext.
// Regression test for the cross-PR interaction with #521 surfaced by
// the 14-PR integration test (Bug #3).
-func TestBuildSkillCatalogDeployment_RestrictedPSS(t *testing.T) {
- d := buildSkillCatalogDeployment("hash-x", nil)
+func TestBuildStaticSiteDeployment_RestrictedPSS(t *testing.T) {
+ d := buildStaticSiteDeployment("hash-x", nil)
spec, _ := d.Object["spec"].(map[string]any)
- assertRestrictedPSS(t, skillCatalogConfigMapName, spec)
+ assertRestrictedPSS(t, staticSiteConfigMapName, spec)
}
// TestBuildAgentIdentityRegistrationDeployment_RestrictedPSS verifies the
@@ -100,16 +100,16 @@ func TestBuildAgentIdentityRegistrationDeployment_RestrictedPSS(t *testing.T) {
assertRestrictedPSS(t, agentIdentityRegistrationName(identity), spec)
}
-// TestBuildSkillCatalogConfigMap: exposes skill.md + services.json + openapi.json
+// TestBuildStaticSiteConfigMap: exposes skill.md + services.json + openapi.json
// + api docs HTML + httpd conf.
-func TestBuildSkillCatalogConfigMap(t *testing.T) {
- cm := buildSkillCatalogConfigMap("# Catalog", `{"displayName":"Acme","tagline":"t","logoUrl":"https://x/logo.png","services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "shell", nil)
+func TestBuildStaticSiteConfigMap(t *testing.T) {
+ cm := buildStaticSiteConfigMap("# Catalog", `{"displayName":"Acme","tagline":"t","logoUrl":"https://x/logo.png","services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "shell", nil)
- if cm.GetName() != skillCatalogConfigMapName {
- t.Errorf("name = %q, want %q", cm.GetName(), skillCatalogConfigMapName)
+ if cm.GetName() != staticSiteConfigMapName {
+ t.Errorf("name = %q, want %q", cm.GetName(), staticSiteConfigMapName)
}
- if cm.GetNamespace() != skillCatalogNamespace {
- t.Errorf("namespace = %q, want %q", cm.GetNamespace(), skillCatalogNamespace)
+ if cm.GetNamespace() != staticSiteNamespace {
+ t.Errorf("namespace = %q, want %q", cm.GetNamespace(), staticSiteNamespace)
}
data, _ := cm.Object["data"].(map[string]any)
if data["skill.md"] != "# Catalog" {
@@ -134,11 +134,11 @@ func TestBuildSkillCatalogConfigMap(t *testing.T) {
}
}
-// TestBuildSkillCatalogDeployment: content-hash annotation + correct volume wiring
+// TestBuildStaticSiteDeployment: content-hash annotation + correct volume wiring
// (skill.md and api/services.json paths).
-func TestBuildSkillCatalogDeployment(t *testing.T) {
- d1 := buildSkillCatalogDeployment("hash-1", nil)
- d2 := buildSkillCatalogDeployment("hash-2", nil)
+func TestBuildStaticSiteDeployment(t *testing.T) {
+ d1 := buildStaticSiteDeployment("hash-1", nil)
+ d2 := buildStaticSiteDeployment("hash-2", nil)
spec1, _ := d1.Object["spec"].(map[string]any)
template1, _ := spec1["template"].(map[string]any)
@@ -187,13 +187,13 @@ func TestBuildSkillCatalogDeployment(t *testing.T) {
}
}
-// TestBuildSkillCatalogService: ClusterIP service on port 8080 with the
+// TestBuildStaticSiteService: ClusterIP service on port 8080 with the
// managed-by selector.
-func TestBuildSkillCatalogService(t *testing.T) {
- svc := buildSkillCatalogService()
+func TestBuildStaticSiteService(t *testing.T) {
+ svc := buildStaticSiteService()
- if svc.GetName() != skillCatalogConfigMapName {
- t.Errorf("name = %q, want %q", svc.GetName(), skillCatalogConfigMapName)
+ if svc.GetName() != staticSiteConfigMapName {
+ t.Errorf("name = %q, want %q", svc.GetName(), staticSiteConfigMapName)
}
spec, _ := svc.Object["spec"].(map[string]any)
if spec["type"] != "ClusterIP" {
diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go
index 036ac28c..677c980d 100644
--- a/internal/serviceoffercontroller/render_test.go
+++ b/internal/serviceoffercontroller/render_test.go
@@ -611,7 +611,7 @@ func TestRegistrationDataURL(t *testing.T) {
}
}
-func TestBuildSkillCatalogMarkdown(t *testing.T) {
+func TestBuildSkillMarkdown(t *testing.T) {
readyOffer := &monetizeapi.ServiceOffer{
ObjectMeta: metav1.ObjectMeta{Name: "flow-qwen", Namespace: "llm"},
Spec: monetizeapi.ServiceOfferSpec{
@@ -639,7 +639,7 @@ func TestBuildSkillCatalogMarkdown(t *testing.T) {
},
}
- content := buildSkillCatalogMarkdown([]*monetizeapi.ServiceOffer{readyOffer, notReadyOffer}, "https://example.com", nil)
+ content := buildSkillMarkdown([]*monetizeapi.ServiceOffer{readyOffer, notReadyOffer}, "https://example.com", nil)
if !strings.Contains(content, "# Obol Stack Service Catalog") {
t.Fatalf("catalog missing title:\n%s", content)
@@ -655,13 +655,13 @@ func TestBuildSkillCatalogMarkdown(t *testing.T) {
}
}
-// TestBuildSkillCatalogMarkdown_DrainAdditiveDetail locks in the
+// TestBuildSkillMarkdown_DrainAdditiveDetail locks in the
// pure-additive markdown surface: active offers must NOT emit a
// `- **Available**:` detail bullet (that wire was removed when drain
// landed). Draining offers may have a `- **Drain ends at**:` bullet
// but never a separate Available bullet, because consumers detect
// drain solely via the timestamp's presence.
-func TestBuildSkillCatalogMarkdown_DrainAdditiveDetail(t *testing.T) {
+func TestBuildSkillMarkdown_DrainAdditiveDetail(t *testing.T) {
readyCond := []monetizeapi.Condition{{Type: "Ready", Status: "True"}}
activeOffer := &monetizeapi.ServiceOffer{
ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "llm"},
@@ -693,7 +693,7 @@ func TestBuildSkillCatalogMarkdown_DrainAdditiveDetail(t *testing.T) {
Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond},
}
- content := buildSkillCatalogMarkdown(
+ content := buildSkillMarkdown(
[]*monetizeapi.ServiceOffer{activeOffer, drainingOffer},
"https://example.com",
nil,
@@ -717,10 +717,10 @@ func TestBuildSkillCatalogMarkdown_DrainAdditiveDetail(t *testing.T) {
}
}
-func TestBuildSkillCatalogHTTPRoute(t *testing.T) {
- route := buildSkillCatalogHTTPRoute()
- if route.GetName() != skillCatalogRouteName {
- t.Fatalf("route name = %q, want %q", route.GetName(), skillCatalogRouteName)
+func TestBuildStaticSiteHTTPRoute(t *testing.T) {
+ route := buildStaticSiteHTTPRoute()
+ if route.GetName() != staticSiteRouteName {
+ t.Fatalf("route name = %q, want %q", route.GetName(), staticSiteRouteName)
}
spec := route.Object["spec"].(map[string]any)
rules := spec["rules"].([]any)
@@ -732,8 +732,8 @@ func TestBuildSkillCatalogHTTPRoute(t *testing.T) {
}
backends := firstRule["backendRefs"].([]any)
backend := backends[0].(map[string]any)
- if backend["name"] != skillCatalogConfigMapName {
- t.Fatalf("backend name = %v, want %s", backend["name"], skillCatalogConfigMapName)
+ if backend["name"] != staticSiteConfigMapName {
+ t.Fatalf("backend name = %v, want %s", backend["name"], staticSiteConfigMapName)
}
}
@@ -1622,8 +1622,8 @@ func TestBuildCatalogHeadersMiddleware(t *testing.T) {
if mw.GetName() != catalogHeadersMiddlewareName {
t.Fatalf("name = %q, want %q", mw.GetName(), catalogHeadersMiddlewareName)
}
- if mw.GetNamespace() != skillCatalogNamespace {
- t.Fatalf("namespace = %q, want %q", mw.GetNamespace(), skillCatalogNamespace)
+ if mw.GetNamespace() != staticSiteNamespace {
+ t.Fatalf("namespace = %q, want %q", mw.GetNamespace(), staticSiteNamespace)
}
headers := mw.Object["spec"].(map[string]any)["headers"].(map[string]any)
@@ -1648,7 +1648,7 @@ func TestBuildCatalogHeadersMiddleware(t *testing.T) {
// (locked separately by TestBuildHTTPRoute's no-filters assertion).
func TestCatalogRoutesCarryHeadersFilter(t *testing.T) {
routes := map[string]*unstructured.Unstructured{
- "/skill.md": buildSkillCatalogHTTPRoute(),
+ "/skill.md": buildStaticSiteHTTPRoute(),
"/api/services.json": buildServicesJSONHTTPRoute(),
"/openapi.json": buildOpenAPIHTTPRoute(),
"/api": buildAPIDocsHTTPRoute(),
@@ -1699,12 +1699,12 @@ func TestBuildServiceCatalogJSON_SchemaVersion(t *testing.T) {
}
}
-// TestBuildSkillCatalogMarkdown_TryIt asserts every offer detail block ends
+// TestBuildSkillMarkdown_TryIt asserts every offer detail block ends
// with a copy-paste "Try it" section: a 402 probe curl plus a worked paid
// request. Chat-shaped offers must show the REAL model id (including the
// AgentResolution fallback for type=agent) — the same id services.json
// publishes — and http offers a curl of the gated path.
-func TestBuildSkillCatalogMarkdown_TryIt(t *testing.T) {
+func TestBuildSkillMarkdown_TryIt(t *testing.T) {
readyCond := []monetizeapi.Condition{{Type: "Ready", Status: "True"}}
inferenceOffer := &monetizeapi.ServiceOffer{
ObjectMeta: metav1.ObjectMeta{Name: "flow-qwen", Namespace: "llm"},
@@ -1747,7 +1747,7 @@ func TestBuildSkillCatalogMarkdown_TryIt(t *testing.T) {
Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond},
}
- content := buildSkillCatalogMarkdown(
+ content := buildSkillMarkdown(
[]*monetizeapi.ServiceOffer{inferenceOffer, agentOffer, httpOffer},
"https://seller.example", nil,
)
diff --git a/internal/serviceoffercontroller/routesurface_golden_test.go b/internal/serviceoffercontroller/routesurface_golden_test.go
index 60a7d85e..601cabe1 100644
--- a/internal/serviceoffercontroller/routesurface_golden_test.go
+++ b/internal/serviceoffercontroller/routesurface_golden_test.go
@@ -137,7 +137,7 @@ func TestRouteSurface_Golden_VerifierAndOpenAPIAgree(t *testing.T) {
// appears in skill.md with its method, full URL, and effective price.
func TestRouteSurface_Golden_SkillMDListsEveryRoute(t *testing.T) {
offer := routeTableOffer()
- content := buildSkillCatalogMarkdown([]*monetizeapi.ServiceOffer{offer}, "https://example.com", nil)
+ content := buildSkillMarkdown([]*monetizeapi.ServiceOffer{offer}, "https://example.com", nil)
for _, want := range []string{
"`POST https://example.com/services/audit/submit` — 0.5 USDC/request — Submit source for audit",
diff --git a/internal/serviceoffercontroller/templates/offer_landing.html b/internal/serviceoffercontroller/templates/offer_landing.html
new file mode 100644
index 00000000..25d5968e
--- /dev/null
+++ b/internal/serviceoffercontroller/templates/offer_landing.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+ {{.Title}}
+
+
+
+
+
+
+ {{if .OGImageURL}}
+
+ {{end}}
+ {{if .FaviconURL}} {{end}}
+
+ {{if .CustomCSS}}{{end}}
+
+
+
+ {{if .LogoURL}}
{{if .ShowName}}
{{.Operator}} {{end}}
{{end}}
+
{{.Price}}
+
{{.Title}}
+
{{.DescriptionHTML}}
+
+
+
+
For agents & developers
+
/openapi.json — request shapes + per-route pricing
+
/.well-known/x402 — signable x402 payment requirements
+
Payment is per-request via x402 micropayments: call an endpoint with no payment to receive the 402 challenge, sign one accepts[] entry, retry with the X-PAYMENT header.
+
+ {{if .AboutHTML}}
+
+
About {{.Operator}}
+
{{.AboutHTML}}
+
+ {{end}}
+
Sold by {{.Operator}} · Powered by Obol
+
+
+
diff --git a/internal/x402/templates/DESIGN.md b/internal/x402/templates/DESIGN.md
index 6b000df0..6b4362ed 100644
--- a/internal/x402/templates/DESIGN.md
+++ b/internal/x402/templates/DESIGN.md
@@ -9,8 +9,32 @@ Canonical design system:
[`@obolnetwork/obol-ui`](../../../../obol-packages/packages/obol-ui/DESIGN.md).
Sister surface that this page is closest to:
[`obol-stack/web/public-storefront`](../../../web/public-storefront/DESIGN.md).
-This page is deliberately the **smallest, most constrained** of the four
-surfaces.
+
+## 0. The five public surfaces
+
+Each is identified by a `data-obol="page-*"` marker on its root element —
+the stable hook custom CSS targets. All but the storefront are Go-rendered.
+
+| Marker | Surface | Rendered by |
+| --- | --- | --- |
+| `page-storefront` | Catalog storefront — lists every offer | `web/public-storefront` (Next.js) |
+| `page-landing` | Per-offer landing, on the offer's own hostname | `internal/serviceoffercontroller/offerbundle.go` |
+| `page-402` | **This page** — the payment gate | `internal/x402/paymentrequired.go` |
+| `page-signin` | SIWX challenge | `internal/x402/authgate.go` |
+| `page-error` | Auth/verifier error | `internal/x402/authgate.go` |
+
+Two distinctions worth holding onto, because both are easy to get backwards:
+
+- **`page-402` is not the storefront and not the landing page.** It is served
+ on a *paid resource path*, and only when `Accept` advertises `text/html`
+ (`prefersHTML`, `paymentrequired.go`); everything else gets the JSON
+ challenge. For a root-priced offer the paid resource is `POST /`, so a
+ browser visiting that origin's `/` gets `page-landing`, never this page.
+- **`data-obol="checkout"` is not this page.** It is a reserved mount div that
+ appears on *both* `page-402` and `page-landing`. Custom CSS targeting it
+ hits two surfaces at once.
+
+This page is deliberately the **smallest, most constrained** of the five.
---
@@ -37,24 +61,30 @@ buttons.
---
-## 2. Brand contract (mirrored)
+## 2. Brand contract (server-resolved)
Same palette, type, and shape language as
[`obol-ui/DESIGN.md § 1`](../../../../obol-packages/packages/obol-ui/DESIGN.md#1-brand-contract).
-Token values are duplicated inline at the top of the `