From 01bcd6ab217a6c3df86b0756c1bc6cad47e2b137 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 22 Apr 2026 02:59:11 +0900 Subject: [PATCH 1/3] feat: add demo sell command and public storefront --- .../workflows/docker-publish-storefront.yml | 95 + .github/workflows/docker-publish-x402.yml | 9 + .gitignore | 8 +- Dockerfile.demo-server | 10 + Dockerfile.public-storefront | 27 + cmd/demo-server/main.go | 78 + cmd/obol/sell.go | 417 +++++ cmd/obol/sell_test.go | 57 + internal/demo/blocks.go | 102 + internal/demo/demo.go | 72 + internal/demo/demo_test.go | 234 +++ internal/demo/hello.go | 33 + internal/demo/oracle.go | 216 +++ internal/serviceoffercontroller/controller.go | 8 +- internal/serviceoffercontroller/render.go | 122 +- .../serviceoffercontroller/render_test.go | 81 + internal/stack/stack.go | 2 + internal/tunnel/tunnel.go | 90 +- plans/obol-sell-demo.md | 113 ++ web/public-storefront/next.config.ts | 7 + web/public-storefront/package-lock.json | 1664 +++++++++++++++++ web/public-storefront/package.json | 24 + web/public-storefront/postcss.config.mjs | 7 + web/public-storefront/src/app/globals.css | 23 + web/public-storefront/src/app/layout.tsx | 19 + web/public-storefront/src/app/page.tsx | 104 ++ .../src/components/PaymentFlow.tsx | 63 + .../src/components/ServiceCard.tsx | 129 ++ web/public-storefront/src/types.ts | 13 + web/public-storefront/tsconfig.json | 27 + 30 files changed, 3782 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/docker-publish-storefront.yml create mode 100644 Dockerfile.demo-server create mode 100644 Dockerfile.public-storefront create mode 100644 cmd/demo-server/main.go create mode 100644 internal/demo/blocks.go create mode 100644 internal/demo/demo.go create mode 100644 internal/demo/demo_test.go create mode 100644 internal/demo/hello.go create mode 100644 internal/demo/oracle.go create mode 100644 plans/obol-sell-demo.md create mode 100644 web/public-storefront/next.config.ts create mode 100644 web/public-storefront/package-lock.json create mode 100644 web/public-storefront/package.json create mode 100644 web/public-storefront/postcss.config.mjs create mode 100644 web/public-storefront/src/app/globals.css create mode 100644 web/public-storefront/src/app/layout.tsx create mode 100644 web/public-storefront/src/app/page.tsx create mode 100644 web/public-storefront/src/components/PaymentFlow.tsx create mode 100644 web/public-storefront/src/components/ServiceCard.tsx create mode 100644 web/public-storefront/src/types.ts create mode 100644 web/public-storefront/tsconfig.json diff --git a/.github/workflows/docker-publish-storefront.yml b/.github/workflows/docker-publish-storefront.yml new file mode 100644 index 00000000..3b944668 --- /dev/null +++ b/.github/workflows/docker-publish-storefront.yml @@ -0,0 +1,95 @@ +name: Build and Publish Public Storefront + +on: + push: + branches: + - main + tags: + - 'v*' + paths: + - 'web/public-storefront/**' + - 'Dockerfile.public-storefront' + - '.github/workflows/docker-publish-storefront.yml' + workflow_dispatch: + +concurrency: + group: storefront-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + with: + images: ${{ env.REGISTRY }}/obolnetwork/obol-stack-public-storefront + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + labels: | + org.opencontainers.image.title=obol-stack-public-storefront + org.opencontainers.image.description=Public tunnel storefront for Obol Stack + org.opencontainers.image.vendor=Obol + org.opencontainers.image.source=https://github.com/ObolNetwork/obol-stack + + - name: Build and push + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: Dockerfile.public-storefront + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=storefront + cache-to: type=gha,scope=storefront,mode=max + provenance: true + sbom: true + + security-scan: + needs: build + runs-on: ubuntu-latest + permissions: + security-events: write + + steps: + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ${{ env.REGISTRY }}/obolnetwork/obol-stack-public-storefront:${{ github.sha }} + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@b13d724d35ff0a814e21683638ed68ed34cf53d1 # main + with: + sarif_file: 'trivy-results.sarif' + if: always() diff --git a/.github/workflows/docker-publish-x402.yml b/.github/workflows/docker-publish-x402.yml index 8e5241d8..f80d5717 100644 --- a/.github/workflows/docker-publish-x402.yml +++ b/.github/workflows/docker-publish-x402.yml @@ -10,12 +10,15 @@ on: - 'internal/x402/**' - 'internal/serviceoffercontroller/**' - 'internal/monetizeapi/**' + - 'internal/demo/**' - 'cmd/x402-verifier/**' - 'cmd/x402-buyer/**' - 'cmd/serviceoffer-controller/**' + - 'cmd/demo-server/**' - 'Dockerfile.x402-verifier' - 'Dockerfile.x402-buyer' - 'Dockerfile.serviceoffer-controller' + - 'Dockerfile.demo-server' - 'go.mod' - 'go.sum' - '.github/workflows/docker-publish-x402.yml' @@ -53,6 +56,10 @@ jobs: image: obolnetwork/serviceoffer-controller dockerfile: Dockerfile.serviceoffer-controller description: ServiceOffer reconciler for Obol Stack monetization + - component: demo-server + image: obolnetwork/demo-server + dockerfile: Dockerfile.demo-server + description: Demo HTTP services for Obol Stack sell demo steps: - name: Checkout @@ -120,6 +127,8 @@ jobs: image: obolnetwork/x402-buyer - component: serviceoffer-controller image: obolnetwork/serviceoffer-controller + - component: demo-server + image: obolnetwork/demo-server steps: - name: Run Trivy vulnerability scanner diff --git a/.gitignore b/.gitignore index 300b491e..c89dd9b9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,11 +25,15 @@ build/ .cache/ .workspace/ -# Go binary +# Go binaries /obol +/demo-server +/serviceoffer-controller +/x402-verifier +/x402-buyer # Dependencies -/node_modules/ +node_modules/ /vendor/ # Environment variables diff --git a/Dockerfile.demo-server b/Dockerfile.demo-server new file mode 100644 index 00000000..3bdfd01c --- /dev/null +++ b/Dockerfile.demo-server @@ -0,0 +1,10 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /demo-server ./cmd/demo-server + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=builder /demo-server /demo-server +ENTRYPOINT ["/demo-server"] diff --git a/Dockerfile.public-storefront b/Dockerfile.public-storefront new file mode 100644 index 00000000..f5c73b05 --- /dev/null +++ b/Dockerfile.public-storefront @@ -0,0 +1,27 @@ +FROM node:22-alpine AS base + +FROM base AS deps +WORKDIR /app +COPY web/public-storefront/package.json web/public-storefront/package-lock.json ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY web/public-storefront/ . +RUN mkdir -p public && npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +CMD ["node", "server.js"] diff --git a/cmd/demo-server/main.go b/cmd/demo-server/main.go new file mode 100644 index 00000000..e9603587 --- /dev/null +++ b/cmd/demo-server/main.go @@ -0,0 +1,78 @@ +// Command demo-server runs a lightweight HTTP server for obol sell demo. +// +// The demo type is selected by the DEMO_TYPE environment variable: +// - hello: proof-of-payment echo (no external dependencies) +// - blocks: basic chain data from eRPC +// - oracle: chain analysis with gas statistics from eRPC +package main + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ObolNetwork/obol-stack/internal/demo" +) + +func main() { + demoType := envOr("DEMO_TYPE", "hello") + port := envOr("PORT", "8080") + erpcURL := envOr("ERPC_URL", "http://erpc.erpc.svc.cluster.local/rpc/base") + + var handler http.HandlerFunc + switch demoType { + case "hello": + handler = demo.HelloHandler() + case "blocks": + handler = demo.BlocksHandler(erpcURL) + case "oracle": + handler = demo.OracleHandler(erpcURL) + default: + log.Fatalf("unknown DEMO_TYPE: %q (expected hello, blocks, or oracle)", demoType) + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /", handler) + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "ok") + }) + + addr := net.JoinHostPort("", port) + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + log.Printf("demo-server type=%s listening on %s", demoType, addr) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + <-ctx.Done() + log.Println("shutting down...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index d2d7ce45..96af6bac 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -46,6 +46,7 @@ func sellCommand(cfg *config.Config) *cli.Command { Commands: []*cli.Command{ sellInferenceCommand(cfg), sellHTTPCommand(cfg), + sellDemoCommand(cfg), sellListCommand(cfg), sellStatusCommand(cfg), sellTestCommand(cfg), @@ -953,6 +954,417 @@ func serviceOfferStatusLines(namespace, name string, offer monetizeapi.ServiceOf return lines } +// --------------------------------------------------------------------------- +// sell demo — deploy a demo service behind x402 payment gate +// --------------------------------------------------------------------------- + +// demoSpec describes a built-in demo type with default pricing and config. +type demoSpec struct { + Type string // DEMO_TYPE env value + Price string // default per-request USDC price + Description string // human-readable one-liner + NeedsERPC bool // whether the demo queries eRPC +} + +var demoTypes = map[string]demoSpec{ + "hello": { + Type: "hello", + Price: "0.00001", + Description: "Proof-of-payment echo service — confirms you got through the x402 gate", + }, + "blocks": { + Type: "blocks", + Price: "0.0001", + Description: "Live blockchain data from a local full node (block, gas, chain ID)", + NeedsERPC: true, + }, + "oracle": { + Type: "oracle", + Price: "0.001", + Description: "Chain analysis — gas statistics, tx volume, and utilization across recent blocks", + NeedsERPC: true, + }, +} + +const demoNamespace = "demo" + +func sellDemoCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "demo", + Usage: "Deploy a demo service behind x402 payment gate", + ArgsUsage: "", + Description: `Deploys a demo HTTP server and creates a ServiceOffer to payment-gate it. +The demo proves the full sell→discover→pay→receive flow works end-to-end. + +Types: + hello Proof-of-payment echo ($0.00001/req) — simplest, no dependencies + blocks Live blockchain data ($0.0001/req) — queries local full node via eRPC + oracle Chain analysis report ($0.001/req) — gas stats, tx volume, utilization + +Example: + obol sell demo hello + obol sell demo blocks --chain base + obol sell demo oracle --price 0.01`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "wallet", + Aliases: []string{"w"}, + Usage: "USDC recipient wallet address (auto-detected from remote-signer)", + Sources: cli.EnvVars("X402_WALLET"), + }, + &cli.StringFlag{ + Name: "chain", + Usage: "Payment chain (base, base-sepolia, ethereum)", + Value: "base", + }, + &cli.StringFlag{ + Name: "price", + Usage: "Override default per-request price in USDC", + }, + &cli.StringFlag{ + Name: "name", + Usage: "Override service name (default: demo-)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + + if cmd.NArg() < 1 { + return fmt.Errorf("demo type required: obol sell demo ") + } + typeName := cmd.Args().First() + spec, ok := demoTypes[typeName] + if !ok { + return fmt.Errorf("unknown demo type %q — choose: hello, blocks, oracle", typeName) + } + + name := cmd.String("name") + if name == "" { + name = "demo-" + typeName + } + + // Resolve wallet. + wallet := cmd.String("wallet") + if wallet == "" { + if resolved, err := openclaw.ResolveWalletAddress(cfg); err == nil { + wallet = resolved + u.Infof("Using wallet from remote-signer: %s", wallet) + } else if u.IsTTY() { + var inputErr error + wallet, inputErr = u.Input("Wallet address (USDC recipient)", "") + if inputErr != nil || wallet == "" { + return fmt.Errorf("wallet required: use --wallet or set X402_WALLET") + } + } else { + return fmt.Errorf("wallet required: use --wallet or set X402_WALLET") + } + } + if err := x402verifier.ValidateWallet(wallet); err != nil { + return err + } + + price := cmd.String("price") + if price == "" { + price = spec.Price + } + + chain := cmd.String("chain") + + u.Infof("Deploying demo %q (%s)", typeName, spec.Description) + + // 1. Deploy demo backend (namespace + Deployment + Service). + if err := deployDemoBackend(cfg, u, name, spec, chain); err != nil { + return fmt.Errorf("deploy demo backend: %w", err) + } + + // 2. Create ServiceOffer. + soManifest := buildDemoServiceOffer(name, demoNamespace, chain, wallet, price, spec) + applyOut, err := kubectlApplyOutput(cfg, soManifest) + if err != nil { + return fmt.Errorf("apply ServiceOffer: %w", err) + } + action := "created" + if strings.Contains(applyOut, "configured") || strings.Contains(applyOut, "unchanged") { + action = "updated" + } + u.Successf("ServiceOffer %s/%s %s (type: http, price: %s USDC/req)", demoNamespace, name, action, price) + u.Infof("The controller will reconcile: health-check → payment gate → route") + u.Infof("Check status: obol sell status %s -n %s", name, demoNamespace) + + // 3. Ensure tunnel is active. + u.Blank() + u.Info("Ensuring tunnel is active for public access...") + + tunnelURL := "" + if tURL, err := tunnel.EnsureTunnelForSell(cfg, u); err != nil { + u.Warnf("Tunnel not started: %v", err) + u.Dim(" Start manually with: obol tunnel restart") + } else { + tunnelURL = tURL + u.Successf("Tunnel active: %s", tunnelURL) + } + + // 4. Print try-it instructions. + u.Blank() + printDemoTryIt(u, name, typeName, price, chain, tunnelURL) + + return nil + }, + } +} + +// deployDemoBackend creates the demo namespace, Deployment, and Service. +func deployDemoBackend(cfg *config.Config, u *ui.UI, name string, spec demoSpec, paymentChain string) error { + resources := buildDemoResources(name, spec, paymentChain) + + for _, res := range resources { + data, err := json.Marshal(res) + if err != nil { + return fmt.Errorf("marshal resource: %w", err) + } + + bin, kc := kubectl.Paths(cfg) + if _, err := kubectl.ApplyOutput(bin, kc, data); err != nil { + return fmt.Errorf("apply %s/%s: %w", res["kind"], name, err) + } + } + + u.Successf("Demo backend %s deployed in namespace %s", name, demoNamespace) + + // Wait for rollout. + return u.RunWithSpinner("Waiting for demo pod to be ready", func() error { + bin, kc := kubectl.Paths(cfg) + return kubectl.RunSilent(bin, kc, + "rollout", "status", "deployment/"+name, "-n", demoNamespace, "--timeout=60s") + }) +} + +// buildDemoResources returns the K8s manifests for a demo backend. +func buildDemoResources(name string, spec demoSpec, paymentChain string) []map[string]any { + env := []map[string]string{ + {"name": "DEMO_TYPE", "value": spec.Type}, + {"name": "PORT", "value": "8080"}, + } + if spec.NeedsERPC { + env = append(env, map[string]string{ + "name": "ERPC_URL", "value": demoERPCURL(paymentChain), + }) + } + + labels := map[string]string{ + "app": name, + "app.kubernetes.io/name": name, + "obol.org/demo": "true", + } + + return []map[string]any{ + // Namespace + { + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]any{ + "name": demoNamespace, + }, + }, + // Deployment + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{ + "name": name, + "namespace": demoNamespace, + "labels": labels, + }, + "spec": map[string]any{ + "replicas": 1, + "selector": map[string]any{ + "matchLabels": map[string]string{"app": name}, + }, + "template": map[string]any{ + "metadata": map[string]any{ + "labels": labels, + }, + "spec": map[string]any{ + "containers": []map[string]any{ + { + "name": "demo", + "image": "ghcr.io/obolnetwork/demo-server:latest", + "imagePullPolicy": "IfNotPresent", + "env": env, + "ports": []map[string]any{ + {"containerPort": 8080, "name": "http"}, + }, + "livenessProbe": map[string]any{ + "httpGet": map[string]any{ + "path": "/health", + "port": "http", + }, + "initialDelaySeconds": 2, + "periodSeconds": 10, + }, + "readinessProbe": map[string]any{ + "httpGet": map[string]any{ + "path": "/health", + "port": "http", + }, + "initialDelaySeconds": 1, + "periodSeconds": 5, + }, + "resources": map[string]any{ + "requests": map[string]string{"cpu": "10m", "memory": "16Mi"}, + "limits": map[string]string{"cpu": "100m", "memory": "64Mi"}, + }, + }, + }, + }, + }, + }, + }, + // Service + { + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]any{ + "name": name, + "namespace": demoNamespace, + "labels": labels, + }, + "spec": map[string]any{ + "selector": map[string]string{"app": name}, + "ports": []map[string]any{ + {"port": 8080, "targetPort": 8080, "name": "http"}, + }, + }, + }, + } +} + +func demoERPCURL(paymentChain string) string { + return fmt.Sprintf("http://erpc.erpc.svc.cluster.local/rpc/%s", demoRPCNetwork(paymentChain)) +} + +func demoRPCNetwork(paymentChain string) string { + switch paymentChain { + case "base", "base-mainnet": + return "base" + case "base-sepolia": + return "base-sepolia" + case "ethereum", "ethereum-mainnet", "mainnet": + return "mainnet" + default: + if chain, err := x402verifier.ResolveChainInfo(paymentChain); err == nil { + switch chain.Name { + case "ethereum": + return "mainnet" + default: + return chain.Name + } + } + return paymentChain + } +} + +// buildDemoServiceOffer returns a ServiceOffer manifest for a demo service. +func buildDemoServiceOffer(name, ns, chain, wallet, price string, spec demoSpec) map[string]any { + return map[string]any{ + "apiVersion": "obol.org/v1alpha1", + "kind": "ServiceOffer", + "metadata": map[string]any{ + "name": name, + "namespace": ns, + }, + "spec": map[string]any{ + "type": "http", + "upstream": map[string]any{ + "service": name, + "namespace": ns, + "port": 8080, + "healthPath": "/health", + }, + "payment": map[string]any{ + "scheme": "exact", + "network": chain, + "payTo": wallet, + "maxTimeoutSeconds": 300, + "price": map[string]any{ + "perRequest": price, + }, + }, + "path": "/services/" + name, + "registration": map[string]any{ + "enabled": true, + "name": name, + "description": spec.Description, + "skills": []string{"x402-demo", spec.Type}, + }, + }, + } +} + +// printDemoTryIt prints copy-paste instructions for calling the demo service. +func printDemoTryIt(u *ui.UI, name, typeName, price, chain, tunnelURL string) { + endpoint := "/services/" + name + if tunnelURL != "" { + endpoint = tunnelURL + "/services/" + name + } + + u.Bold("── Try it ──────────────────────────────────────────────") + u.Blank() + + u.Printf(" Demo %q is live at: %s", typeName, endpoint) + u.Blank() + + u.Printf(" 1. Probe for pricing (see the 402 response):") + u.Blank() + u.Dim(fmt.Sprintf(" curl -s %s | jq .", endpoint)) + u.Blank() + + u.Printf(" 2. Make a paid request (Python — pip install x402 httpx):") + u.Blank() + u.Dim(" import httpx") + u.Dim(" from x402.client import x402_client") + u.Dim("") + u.Dim(" client = x402_client(") + u.Dim(" httpx.Client(),") + u.Dim(` private_key="", # USDC holder on ` + chain) + u.Dim(" )") + u.Dim(fmt.Sprintf(` resp = client.get("%s")`, endpoint)) + u.Dim(" print(resp.json())") + u.Blank() + + u.Printf(" 3. How x402 payment works:") + u.Blank() + u.Dim(" • A request without payment returns HTTP 402 with pricing details") + u.Dim(" • The 402 body contains an 'accepts' array with payment requirements:") + u.Dim(" scheme, network (CAIP-2), amount (atomic USDC), asset, payTo address") + u.Dim(" • The buyer signs an ERC-3009 TransferWithAuthorization off-chain") + u.Dim(" • The signed authorization is base64-encoded and sent as X-PAYMENT header") + u.Dim(" • The x402 facilitator verifies the signature and settles on-chain") + u.Dim(" • See https://www.x402.org for the full protocol specification") + u.Blank() + + u.Printf(" 4. Ask your AI agent:") + u.Blank() + u.Dim(fmt.Sprintf(` "Call the paid service at %s`, endpoint)) + u.Dim(fmt.Sprintf(` using x402 payment. It costs %s USDC per request on %s.`, price, chain)) + u.Dim(` Report what it returns."`) + u.Blank() + + u.Bold("─────────────────────────────────────────────────────────") +} + +// cleanupDemoBackend removes the Deployment and Service for a demo backend. +// Best-effort: logs warnings but does not fail the overall delete. +func cleanupDemoBackend(cfg *config.Config, u *ui.UI, name string) { + bin, kc := kubectl.Paths(cfg) + for _, kind := range []string{"deployment", "service"} { + if err := kubectl.RunSilent(bin, kc, "delete", kind, name, "-n", demoNamespace, "--ignore-not-found"); err != nil { + u.Warnf("Failed to delete %s/%s: %v", kind, name, err) + } + } + u.Successf("Demo backend resources cleaned up") +} + // --------------------------------------------------------------------------- // sell list // --------------------------------------------------------------------------- @@ -1550,6 +1962,11 @@ func sellDeleteCommand(cfg *config.Config) *cli.Command { return err } + // Clean up demo backend resources if this is a demo service. + if ns == demoNamespace { + cleanupDemoBackend(cfg, u, name) + } + // Auto-stop quick tunnel when no ServiceOffers remain. remaining, listErr := kubectlOutput(cfg, "get", "serviceoffers.obol.org", "-A", "-o", "jsonpath={.items}") diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index 965da0de..f990d5ca 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -154,6 +154,7 @@ func TestSellCommand_Structure(t *testing.T) { expected := map[string]bool{ "inference": false, "http": false, + "demo": false, "list": false, "status": false, "stop": false, @@ -316,6 +317,16 @@ func TestServiceOfferStatusLines(t *testing.T) { } } +func TestSellDemo_Flags(t *testing.T) { + cfg := newTestConfig(t) + cmd := sellCommand(cfg) + demo := findSubcommand(t, cmd, "demo") + flags := flagMap(demo) + + requireFlags(t, flags, "wallet", "chain", "price", "name") + assertStringDefault(t, flags, "chain", "base") +} + func TestSellStop_Structure(t *testing.T) { cfg := newTestConfig(t) cmd := sellCommand(cfg) @@ -420,3 +431,49 @@ func TestResolveX402Chain(t *testing.T) { }) } } + +func TestDemoRPCNetwork(t *testing.T) { + tests := []struct { + paymentChain string + want string + }{ + {"base", "base"}, + {"base-mainnet", "base"}, + {"base-sepolia", "base-sepolia"}, + {"ethereum", "mainnet"}, + {"mainnet", "mainnet"}, + } + + for _, tt := range tests { + t.Run(tt.paymentChain, func(t *testing.T) { + if got := demoRPCNetwork(tt.paymentChain); got != tt.want { + t.Fatalf("demoRPCNetwork(%q) = %q, want %q", tt.paymentChain, got, tt.want) + } + }) + } +} + +func TestBuildDemoResources_UsesImportedImageAndERPCPath(t *testing.T) { + resources := buildDemoResources("demo-blocks", demoSpec{Type: "blocks", NeedsERPC: true}, "base-sepolia") + deploy := resources[1] + spec := deploy["spec"].(map[string]any) + template := spec["template"].(map[string]any) + podSpec := template["spec"].(map[string]any) + container := podSpec["containers"].([]map[string]any)[0] + + if got := container["imagePullPolicy"]; got != "IfNotPresent" { + t.Fatalf("imagePullPolicy = %v, want IfNotPresent", got) + } + + env := container["env"].([]map[string]string) + for _, kv := range env { + if kv["name"] == "ERPC_URL" { + if kv["value"] != "http://erpc.erpc.svc.cluster.local/rpc/base-sepolia" { + t.Fatalf("ERPC_URL = %q", kv["value"]) + } + return + } + } + + t.Fatal("ERPC_URL not set for chain-backed demo") +} diff --git a/internal/demo/blocks.go b/internal/demo/blocks.go new file mode 100644 index 00000000..55e66bb6 --- /dev/null +++ b/internal/demo/blocks.go @@ -0,0 +1,102 @@ +package demo + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// BlocksHandler returns a handler that queries eRPC for basic chain data. +func BlocksHandler(erpcURL string) http.HandlerFunc { + client := &http.Client{Timeout: 10 * time.Second} + + return func(w http.ResponseWriter, r *http.Request) { + type result struct { + key string + val json.RawMessage + err error + } + + methods := []struct { + key string + method string + params string + }{ + {"blockNumber", "eth_blockNumber", "[]"}, + {"gasPrice", "eth_gasPrice", "[]"}, + {"chainId", "eth_chainId", "[]"}, + } + + results := make(chan result, len(methods)) + for _, m := range methods { + go func(key, method, params string) { + val, err := rpcCall(client, erpcURL, method, params) + results <- result{key, val, err} + }(m.key, m.method, m.params) + } + + data := make(map[string]any) + var errs []string + for range methods { + res := <-results + if res.err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", res.key, res.err)) + continue + } + data[res.key] = res.val + } + + // Fetch latest block details using the block number we just got. + if bn, ok := data["blockNumber"]; ok { + bnBytes, _ := json.Marshal(bn) + blockNum := string(bytes.Trim(bnBytes, `"`)) + params := fmt.Sprintf(`[%q, false]`, blockNum) + block, err := rpcCall(client, erpcURL, "eth_getBlockByNumber", params) + if err != nil { + errs = append(errs, fmt.Sprintf("block: %v", err)) + } else { + data["latestBlock"] = block + } + } + + if len(errs) > 0 { + data["errors"] = errs + } + + respond(w, r, "blocks", data) + } +} + +// rpcCall executes a JSON-RPC 2.0 call and returns the result field. +func rpcCall(client *http.Client, url, method, params string) (json.RawMessage, error) { + body := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":%q,"params":%s}`, method, params) + resp, err := client.Post(url, "application/json", bytes.NewBufferString(body)) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + var rpcResp struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &rpcResp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if rpcResp.Error != nil { + return nil, fmt.Errorf("rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message) + } + + return rpcResp.Result, nil +} diff --git a/internal/demo/demo.go b/internal/demo/demo.go new file mode 100644 index 00000000..df165aa3 --- /dev/null +++ b/internal/demo/demo.go @@ -0,0 +1,72 @@ +// Package demo implements HTTP handlers for obol sell demo services. +// +// Each demo type returns a JSON response with a standard envelope: +// +// { +// "demo": "", +// "timestamp": "...", +// "payment": { "status": "...", "tx": "...", "payer": "..." }, +// "data": { ... } +// } +// +// The payment block is populated from x402 ForwardAuth headers injected +// by Traefik after successful payment verification. +package demo + +import ( + "encoding/json" + "net/http" + "time" +) + +// Response is the standard envelope for all demo responses. +type Response struct { + Demo string `json:"demo"` + Timestamp string `json:"timestamp"` + Payment PaymentInfo `json:"payment"` + Data any `json:"data"` +} + +// PaymentInfo captures x402 payment metadata from ForwardAuth headers. +type PaymentInfo struct { + Status string `json:"status"` + Tx string `json:"tx,omitempty"` + Payer string `json:"payer,omitempty"` +} + +// extractPayment reads x402 headers set by the ForwardAuth middleware. +func extractPayment(r *http.Request) PaymentInfo { + return PaymentInfo{ + Status: firstNonEmpty(r.Header.Get("X-Payment-Status"), "paid"), + Tx: r.Header.Get("X-Payment-Tx"), + Payer: r.Header.Get("X-Forwarded-For"), + } +} + +// writeJSON marshals v as JSON and writes it with the given status code. +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +// respond builds and writes the standard demo response envelope. +func respond(w http.ResponseWriter, r *http.Request, demoType string, data any) { + writeJSON(w, http.StatusOK, Response{ + Demo: demoType, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payment: extractPayment(r), + Data: data, + }) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go new file mode 100644 index 00000000..30bc3f88 --- /dev/null +++ b/internal/demo/demo_test.go @@ -0,0 +1,234 @@ +package demo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHelloHandler(t *testing.T) { + handler := HelloHandler() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Payment-Status", "paid") + req.Header.Set("X-Payment-Tx", "0xabc123") + req.Header.Set("User-Agent", "test-agent") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %q", ct) + } + + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Demo != "hello" { + t.Errorf("expected demo=hello, got %q", resp.Demo) + } + if resp.Payment.Status != "paid" { + t.Errorf("expected payment.status=paid, got %q", resp.Payment.Status) + } + if resp.Payment.Tx != "0xabc123" { + t.Errorf("expected payment.tx=0xabc123, got %q", resp.Payment.Tx) + } + + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data is not a map: %T", resp.Data) + } + if msg, _ := data["message"].(string); msg == "" { + t.Error("expected non-empty message in data") + } + echo, _ := data["echo"].(map[string]any) + if echo == nil { + t.Fatal("expected echo in data") + } + if method, _ := echo["method"].(string); method != "GET" { + t.Errorf("expected echo.method=GET, got %q", method) + } +} + +func TestBlocksHandler_NoServer(t *testing.T) { + // Blocks handler with unreachable eRPC should still return a response with errors. + handler := BlocksHandler("http://127.0.0.1:1") // port 1 = unreachable + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Demo != "blocks" { + t.Errorf("expected demo=blocks, got %q", resp.Demo) + } + + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data is not a map: %T", resp.Data) + } + // Should have errors since eRPC is unreachable. + if _, hasErrors := data["errors"]; !hasErrors { + t.Error("expected errors in response when eRPC is unreachable") + } +} + +func TestBlocksHandler_MockRPC(t *testing.T) { + // Mock eRPC server. + mockRPC := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + } + json.NewDecoder(r.Body).Decode(&req) + + var result string + switch req.Method { + case "eth_blockNumber": + result = `"0x10"` + case "eth_gasPrice": + result = `"0x3b9aca00"` + case "eth_chainId": + result = `"0x2105"` + case "eth_getBlockByNumber": + result = `{"number":"0x10","timestamp":"0x60000000","gasUsed":"0x5208","gasLimit":"0x1c9c380","baseFeePerGas":"0x3b9aca00","transactions":[]}` + default: + result = `null` + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": json.RawMessage(result), + }) + })) + defer mockRPC.Close() + + handler := BlocksHandler(mockRPC.URL) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Demo != "blocks" { + t.Errorf("expected demo=blocks, got %q", resp.Demo) + } + + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data is not a map: %T", resp.Data) + } + if data["blockNumber"] == nil { + t.Error("expected blockNumber in response") + } + if data["chainId"] == nil { + t.Error("expected chainId in response") + } + if data["gasPrice"] == nil { + t.Error("expected gasPrice in response") + } +} + +func TestOracleHandler_MockRPC(t *testing.T) { + callCount := 0 + mockRPC := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + var req struct { + Method string `json:"method"` + } + json.NewDecoder(r.Body).Decode(&req) + + var result string + switch req.Method { + case "eth_blockNumber": + result = `"0x10"` + case "eth_chainId": + result = `"0x2105"` + case "eth_getBlockByNumber": + result = `{"number":"0x10","timestamp":"0x60000000","gasUsed":"0x5208","gasLimit":"0x1c9c380","baseFeePerGas":"0x3b9aca00","transactions":["0x1","0x2"]}` + default: + result = `null` + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": json.RawMessage(result), + }) + })) + defer mockRPC.Close() + + handler := OracleHandler(mockRPC.URL) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Demo != "oracle" { + t.Errorf("expected demo=oracle, got %q", resp.Demo) + } + + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data is not a map: %T", resp.Data) + } + if data["gasAnalysis"] == nil { + t.Error("expected gasAnalysis in response") + } + if data["txVolume"] == nil { + t.Error("expected txVolume in response") + } + if data["gasUtilization"] == nil { + t.Error("expected gasUtilization in response") + } +} + +func TestResponseEnvelope(t *testing.T) { + handler := HelloHandler() + req := httptest.NewRequest(http.MethodGet, "/test?foo=bar", nil) + w := httptest.NewRecorder() + + handler(w, req) + + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if resp.Timestamp == "" { + t.Error("expected non-empty timestamp") + } + if resp.Payment.Status == "" { + t.Error("expected non-empty payment status") + } +} diff --git a/internal/demo/hello.go b/internal/demo/hello.go new file mode 100644 index 00000000..1ba210b2 --- /dev/null +++ b/internal/demo/hello.go @@ -0,0 +1,33 @@ +package demo + +import "net/http" + +// HelloHandler returns a proof-of-payment response echoing request details. +func HelloHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + headers := make(map[string]string) + for _, key := range []string{ + "X-Payment-Status", + "X-Payment-Tx", + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Proto", + "X-Forwarded-Uri", + "User-Agent", + } { + if v := r.Header.Get(key); v != "" { + headers[key] = v + } + } + + respond(w, r, "hello", map[string]any{ + "message": "You've successfully paid to access this service via x402 micropayments.", + "echo": map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "query": r.URL.RawQuery, + "headers": headers, + }, + }) + } +} diff --git a/internal/demo/oracle.go b/internal/demo/oracle.go new file mode 100644 index 00000000..d3cf0470 --- /dev/null +++ b/internal/demo/oracle.go @@ -0,0 +1,216 @@ +package demo + +import ( + "encoding/json" + "fmt" + "math/big" + "net/http" + "strconv" + "time" +) + +// OracleHandler returns a handler that performs chain analysis using eRPC data. +// Unlike a simple RPC passthrough, it fetches multiple data points, computes +// derived metrics (gas statistics, tx volume), and formats a structured report. +func OracleHandler(erpcURL string) http.HandlerFunc { + client := &http.Client{Timeout: 15 * time.Second} + + return func(w http.ResponseWriter, r *http.Request) { + report := make(map[string]any) + var errs []string + + // Fetch current chain state. + chainID, err := rpcCall(client, erpcURL, "eth_chainId", "[]") + if err != nil { + errs = append(errs, fmt.Sprintf("chainId: %v", err)) + } else { + report["chainId"] = chainID + } + + blockNumRaw, err := rpcCall(client, erpcURL, "eth_blockNumber", "[]") + if err != nil { + errs = append(errs, fmt.Sprintf("blockNumber: %v", err)) + respond(w, r, "oracle", map[string]any{"errors": errs}) + return + } + report["latestBlockNumber"] = blockNumRaw + + blockNum := hexToUint64(trimQuotes(blockNumRaw)) + + // Fetch the last 5 blocks to compute gas statistics. + const sampleSize = 5 + type blockInfo struct { + Number string `json:"number"` + Timestamp string `json:"timestamp"` + GasUsed string `json:"gasUsed"` + GasLimit string `json:"gasLimit"` + BaseFee string `json:"baseFeePerGas"` + Transactions int `json:"transactionCount"` + } + + blocks := make([]blockInfo, 0, sampleSize) + var totalTxs int + var gasPrices []*big.Int + + for i := 0; i < sampleSize && blockNum-uint64(i) > 0; i++ { + num := fmt.Sprintf("0x%x", blockNum-uint64(i)) + raw, err := rpcCall(client, erpcURL, "eth_getBlockByNumber", fmt.Sprintf(`[%q, false]`, num)) + if err != nil { + errs = append(errs, fmt.Sprintf("block %s: %v", num, err)) + continue + } + + var block struct { + Number string `json:"number"` + Timestamp string `json:"timestamp"` + GasUsed string `json:"gasUsed"` + GasLimit string `json:"gasLimit"` + BaseFee string `json:"baseFeePerGas"` + Transactions []string `json:"transactions"` + } + if err := json.Unmarshal(raw, &block); err != nil { + errs = append(errs, fmt.Sprintf("decode block %s: %v", num, err)) + continue + } + + txCount := len(block.Transactions) + totalTxs += txCount + blocks = append(blocks, blockInfo{ + Number: block.Number, + Timestamp: block.Timestamp, + GasUsed: block.GasUsed, + GasLimit: block.GasLimit, + BaseFee: block.BaseFee, + Transactions: txCount, + }) + + if block.BaseFee != "" { + if fee := hexToBigInt(block.BaseFee); fee != nil { + gasPrices = append(gasPrices, fee) + } + } + } + + report["recentBlocks"] = blocks + + // Compute gas statistics. + if len(gasPrices) > 0 { + stats := computeGasStats(gasPrices) + report["gasAnalysis"] = stats + } + + // Transaction volume. + report["txVolume"] = map[string]any{ + "totalTransactions": totalTxs, + "blocksAnalyzed": len(blocks), + "avgTxPerBlock": safeDivFloat(float64(totalTxs), float64(len(blocks))), + } + + // Utilization: avg gasUsed/gasLimit across sampled blocks. + var totalUsed, totalLimit uint64 + for _, b := range blocks { + totalUsed += hexToUint64(b.GasUsed) + totalLimit += hexToUint64(b.GasLimit) + } + if totalLimit > 0 { + pct := float64(totalUsed) / float64(totalLimit) * 100 + report["gasUtilization"] = map[string]any{ + "percentage": fmt.Sprintf("%.1f%%", pct), + "status": utilizationLabel(pct), + } + } + + if len(errs) > 0 { + report["errors"] = errs + } + + respond(w, r, "oracle", report) + } +} + +type gasStats struct { + MinGwei string `json:"minGwei"` + MaxGwei string `json:"maxGwei"` + AvgGwei string `json:"avgGwei"` + Samples int `json:"samples"` +} + +func computeGasStats(prices []*big.Int) gasStats { + if len(prices) == 0 { + return gasStats{} + } + + min := new(big.Int).Set(prices[0]) + max := new(big.Int).Set(prices[0]) + sum := new(big.Int) + + for _, p := range prices { + if p.Cmp(min) < 0 { + min.Set(p) + } + if p.Cmp(max) > 0 { + max.Set(p) + } + sum.Add(sum, p) + } + + avg := new(big.Int).Div(sum, big.NewInt(int64(len(prices)))) + + return gasStats{ + MinGwei: weiToGwei(min), + MaxGwei: weiToGwei(max), + AvgGwei: weiToGwei(avg), + Samples: len(prices), + } +} + +func weiToGwei(wei *big.Int) string { + gwei := new(big.Float).SetInt(wei) + gwei.Quo(gwei, new(big.Float).SetInt64(1_000_000_000)) + return gwei.Text('f', 4) +} + +func hexToUint64(s string) uint64 { + if len(s) > 2 && s[:2] == "0x" { + s = s[2:] + } + v, _ := strconv.ParseUint(s, 16, 64) + return v +} + +func hexToBigInt(s string) *big.Int { + if len(s) > 2 && s[:2] == "0x" { + s = s[2:] + } + v := new(big.Int) + v.SetString(s, 16) + return v +} + +func trimQuotes(raw json.RawMessage) string { + s := string(raw) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + return s +} + +func safeDivFloat(a, b float64) float64 { + if b == 0 { + return 0 + } + return a / b +} + +func utilizationLabel(pct float64) string { + switch { + case pct > 90: + return "congested" + case pct > 70: + return "busy" + case pct > 40: + return "moderate" + default: + return "low" + } +} diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index e5d276b2..d1213c4e 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -1007,9 +1007,10 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti } content := buildSkillCatalogMarkdown(offers, baseURL) - contentHash := fmt.Sprintf("%x", md5Sum(content))[:8] + servicesJSON := buildServiceCatalogJSON(offers, baseURL) + contentHash := fmt.Sprintf("%x", md5Sum(content+servicesJSON))[:8] - if err := c.applyObject(ctx, c.configMaps.Namespace(skillCatalogNamespace), buildSkillCatalogConfigMap(content)); err != nil { + if err := c.applyObject(ctx, c.configMaps.Namespace(skillCatalogNamespace), buildSkillCatalogConfigMap(content, servicesJSON)); err != nil { return err } if err := c.applyObject(ctx, c.deployments.Namespace(skillCatalogNamespace), buildSkillCatalogDeployment(contentHash)); err != nil { @@ -1021,6 +1022,9 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildSkillCatalogHTTPRoute()); err != nil { return err } + if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildServicesJSONHTTPRoute()); err != nil { + return err + } readyOffers := 0 for _, offer := range offers { if offer != nil && offer.DeletionTimestamp == nil && !offer.IsPaused() && isConditionTrue(offer.Status, "Ready") { diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index 6fe37646..c69794fa 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -21,6 +21,7 @@ const ( skillCatalogNamespace = "x402" skillCatalogConfigMapName = "obol-skill-md" skillCatalogRouteName = "obol-skill-md-route" + servicesJSONRouteName = "obol-services-json-route" ) func buildMiddleware(offer *monetizeapi.ServiceOffer) *unstructured.Unstructured { @@ -222,7 +223,7 @@ func buildRegistrationHTTPRoute(request *monetizeapi.RegistrationRequest) *unstr } } -func buildSkillCatalogConfigMap(content string) *unstructured.Unstructured { +func buildSkillCatalogConfigMap(content, servicesJSON string) *unstructured.Unstructured { return &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "v1", @@ -236,8 +237,9 @@ func buildSkillCatalogConfigMap(content string) *unstructured.Unstructured { }, }, "data": map[string]any{ - "skill.md": content, - "httpd.conf": ".md:text/markdown\n", + "skill.md": content, + "services.json": servicesJSON, + "httpd.conf": ".md:text/markdown\n.json:application/json\n", }, }, } @@ -292,8 +294,11 @@ func buildSkillCatalogDeployment(contentHash string) *unstructured.Unstructured map[string]any{ "name": "content", "configMap": map[string]any{ - "name": skillCatalogConfigMapName, - "items": []any{map[string]any{"key": "skill.md", "path": "skill.md"}}, + "name": skillCatalogConfigMapName, + "items": []any{ + map[string]any{"key": "skill.md", "path": "skill.md"}, + map[string]any{"key": "services.json", "path": "api/services.json"}, + }, }, }, map[string]any{ @@ -380,6 +385,50 @@ func buildSkillCatalogHTTPRoute() *unstructured.Unstructured { } } +func buildServicesJSONHTTPRoute() *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": map[string]any{ + "name": servicesJSONRouteName, + "namespace": skillCatalogNamespace, + "labels": map[string]any{ + "obol.org/managed-by": "serviceoffer-controller", + }, + }, + "spec": map[string]any{ + "parentRefs": []any{ + map[string]any{ + "name": "traefik-gateway", + "namespace": "traefik", + "sectionName": "web", + }, + }, + "rules": []any{ + map[string]any{ + "matches": []any{ + map[string]any{ + "path": map[string]any{ + "type": "Exact", + "value": "/api/services.json", + }, + }, + }, + "backendRefs": []any{ + map[string]any{ + "name": skillCatalogConfigMapName, + "namespace": skillCatalogNamespace, + "port": int64(8080), + }, + }, + }, + }, + }, + }, + } +} + func buildHTTPRoute(offer *monetizeapi.ServiceOffer) *unstructured.Unstructured { obj := &unstructured.Unstructured{ Object: map[string]any{ @@ -708,6 +757,69 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin return strings.Join(lines, "\n") } +// ServiceJSON is the JSON representation of a ServiceOffer for the public storefront. +type ServiceJSON struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Type string `json:"type"` + Model string `json:"model,omitempty"` + Endpoint string `json:"endpoint"` + Price string `json:"price"` + PriceRaw string `json:"priceRaw,omitempty"` + PayTo string `json:"payTo"` + Network string `json:"network"` + Description string `json:"description"` + IsDemo bool `json:"isDemo"` +} + +// buildServiceCatalogJSON returns a JSON array of ready ServiceOffers for the public storefront. +func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) string { + baseURL = strings.TrimRight(baseURL, "/") + + var ready []*monetizeapi.ServiceOffer + for _, offer := range offers { + if offer == nil || offer.DeletionTimestamp != nil || offer.IsPaused() { + continue + } + if isConditionTrue(offer.Status, "Ready") { + ready = append(ready, offer) + } + } + sort.Slice(ready, func(i, j int) bool { + return ready[i].Name < ready[j].Name + }) + + services := make([]ServiceJSON, 0, len(ready)) + for _, offer := range ready { + desc := offer.Spec.Registration.Description + if desc == "" { + desc = fmt.Sprintf("x402 payment-gated %s service", fallbackOfferType(offer)) + } + svc := ServiceJSON{ + Name: offer.Name, + Namespace: offer.Namespace, + Type: fallbackOfferType(offer), + Model: offer.Spec.Model.Name, + Endpoint: baseURL + offer.EffectivePath(), + Price: describeOfferPrice(offer), + PayTo: offer.Spec.Payment.PayTo, + Network: offer.Spec.Payment.Network, + Description: desc, + IsDemo: offer.Namespace == "demo", + } + if offer.Spec.Payment.Price.PerRequest != "" { + svc.PriceRaw = offer.Spec.Payment.Price.PerRequest + } + services = append(services, svc) + } + + out, err := json.MarshalIndent(services, "", " ") + if err != nil { + return "[]" + } + return string(out) +} + func describeOfferPrice(offer *monetizeapi.ServiceOffer) string { switch { case offer.Spec.Payment.Price.PerRequest != "": diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index 407a086f..ed9b25b8 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -1,6 +1,7 @@ package serviceoffercontroller import ( + "encoding/json" "strings" "testing" @@ -268,6 +269,86 @@ func TestBuildSkillCatalogHTTPRoute(t *testing.T) { } } +func TestBuildServiceCatalogJSON(t *testing.T) { + readyOffer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "demo-hello", Namespace: "demo"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "http", + Upstream: monetizeapi.ServiceOfferUpstream{ + Service: "demo-hello", + Port: 8080, + }, + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base", + PayTo: "0xabc", + Price: monetizeapi.ServiceOfferPriceTable{ + PerRequest: "0.00001", + }, + }, + Registration: monetizeapi.ServiceOfferRegistration{ + Description: "Proof-of-payment echo service", + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}, + }, + } + notReadyOffer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "pending", Namespace: "demo"}, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "False"}}, + }, + } + + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{readyOffer, notReadyOffer}, "https://example.com") + + var services []ServiceJSON + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) + } + + if len(services) != 1 { + t.Fatalf("expected 1 ready service, got %d", len(services)) + } + svc := services[0] + if svc.Name != "demo-hello" { + t.Errorf("name = %q, want demo-hello", svc.Name) + } + if svc.Price != "0.00001 USDC/request" { + t.Errorf("price = %q, want '0.00001 USDC/request'", svc.Price) + } + if !svc.IsDemo { + t.Error("expected isDemo=true for namespace=demo") + } + if svc.Endpoint != "https://example.com/services/demo-hello" { + t.Errorf("endpoint = %q, want https://example.com/services/demo-hello", svc.Endpoint) + } + if svc.Description != "Proof-of-payment echo service" { + t.Errorf("description = %q, want 'Proof-of-payment echo service'", svc.Description) + } +} + +func TestBuildServiceCatalogJSON_Empty(t *testing.T) { + jsonStr := buildServiceCatalogJSON(nil, "https://example.com") + if jsonStr != "[]" { + t.Errorf("expected empty array, got %q", jsonStr) + } +} + +func TestBuildServicesJSONHTTPRoute(t *testing.T) { + route := buildServicesJSONHTTPRoute() + if route.GetName() != servicesJSONRouteName { + t.Fatalf("route name = %q, want %q", route.GetName(), servicesJSONRouteName) + } + spec := route.Object["spec"].(map[string]any) + rules := spec["rules"].([]any) + match := rules[0].(map[string]any)["matches"].([]any)[0].(map[string]any) + path := match["path"].(map[string]any) + if path["value"] != "/api/services.json" { + t.Errorf("path = %q, want /api/services.json", path["value"]) + } +} + func TestSafeName_Short(t *testing.T) { // Short names should pass through unchanged. if got := childName("demo"); got != "so-demo" { diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 29e87a34..5389faa1 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -694,6 +694,8 @@ var localImages = []localImage{ {tag: "ghcr.io/obolnetwork/x402-verifier:latest", dockerfile: "Dockerfile.x402-verifier"}, {tag: "ghcr.io/obolnetwork/serviceoffer-controller:latest", dockerfile: "Dockerfile.serviceoffer-controller"}, {tag: "ghcr.io/obolnetwork/x402-buyer:latest", dockerfile: "Dockerfile.x402-buyer"}, + {tag: "ghcr.io/obolnetwork/demo-server:latest", dockerfile: "Dockerfile.demo-server"}, + {tag: "ghcr.io/obolnetwork/obol-stack-public-storefront:latest", dockerfile: "Dockerfile.public-storefront"}, } func devPreloadImages() []string { diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 9c0806c5..08596d11 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -424,47 +424,11 @@ func CreateStorefront(cfg *config.Config, tunnelURL string) error { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") - html := fmt.Sprintf(` - - - - - Obol Stack - - - -

Obol Stack

-

This node sells services via x402 micropayments.

-
-

Available Services

-

See the machine-readable catalog: /skill.md

-

Agent registration: /.well-known/agent-registration.json

-
- -`, tunnelURL, tunnelURL) - - // Build the resources as a multi-document YAML manifest. + labels := map[string]string{"app": "tunnel-storefront"} + + // Build the resources for the public storefront. resources := []map[string]any{ - // ConfigMap with HTML content + httpd mime config. - { - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "tunnel-storefront", - "namespace": storefrontNamespace, - }, - "data": map[string]string{ - "index.html": html, - "httpd.conf": "", - "mime.types": "text/html\thtml htm\n", - }, - }, - // Deployment: busybox httpd serving the ConfigMap. + // Deployment: Next.js public storefront image. { "apiVersion": "apps/v1", "kind": "Deployment", @@ -475,35 +439,35 @@ func CreateStorefront(cfg *config.Config, tunnelURL string) error { "spec": map[string]any{ "replicas": 1, "selector": map[string]any{ - "matchLabels": map[string]string{"app": "tunnel-storefront"}, + "matchLabels": labels, }, "template": map[string]any{ "metadata": map[string]any{ - "labels": map[string]string{"app": "tunnel-storefront"}, + "labels": labels, }, "spec": map[string]any{ "containers": []map[string]any{ - { - "name": "httpd", - "image": "busybox:1.37", - "command": []string{"httpd", "-f", "-p", "8080", "-h", "/www"}, - "ports": []map[string]any{ - {"containerPort": 8080}, + { + "name": "storefront", + "image": "ghcr.io/obolnetwork/obol-stack-public-storefront:latest", + "imagePullPolicy": "IfNotPresent", + "ports": []map[string]any{ + {"containerPort": 3000, "name": "http"}, + }, + "env": []map[string]string{ + {"name": "SERVICES_URL", "value": "http://obol-skill-md.x402.svc:8080"}, }, - "volumeMounts": []map[string]any{ - {"name": "html", "mountPath": "/www"}, + "livenessProbe": map[string]any{ + "httpGet": map[string]any{ + "path": "/", + "port": "http", + }, + "initialDelaySeconds": 5, + "periodSeconds": 30, }, "resources": map[string]any{ - "requests": map[string]string{"cpu": "5m", "memory": "8Mi"}, - "limits": map[string]string{"cpu": "20m", "memory": "16Mi"}, - }, - }, - }, - "volumes": []map[string]any{ - { - "name": "html", - "configMap": map[string]any{ - "name": "tunnel-storefront", + "requests": map[string]string{"cpu": "10m", "memory": "32Mi"}, + "limits": map[string]string{"cpu": "100m", "memory": "128Mi"}, }, }, }, @@ -520,9 +484,9 @@ func CreateStorefront(cfg *config.Config, tunnelURL string) error { "namespace": storefrontNamespace, }, "spec": map[string]any{ - "selector": map[string]string{"app": "tunnel-storefront"}, + "selector": labels, "ports": []map[string]any{ - {"port": 8080, "targetPort": 8080}, + {"port": 3000, "targetPort": 3000, "name": "http"}, }, }, }, @@ -551,7 +515,7 @@ func CreateStorefront(cfg *config.Config, tunnelURL string) error { "backendRefs": []map[string]any{ { "name": "tunnel-storefront", - "port": 8080, + "port": 3000, }, }, }, diff --git a/plans/obol-sell-demo.md b/plans/obol-sell-demo.md new file mode 100644 index 00000000..a6052685 --- /dev/null +++ b/plans/obol-sell-demo.md @@ -0,0 +1,113 @@ +# `obol sell demo` — Implementation Plan + +**Status**: Implemented (pending review) + +## Summary + +An `obol sell demo ` command that deploys a demo HTTP backend into the +cluster, creates a ServiceOffer to payment-gate it, and prints copy-paste "try +it" instructions showing how to make an x402 payment. + +Three demo types for v1, ranked by complexity: + +| Demo | Price (USDC/req) | What it does | +|----------|-------------------|--------------| +| `hello` | 0.00001 | Echoes x402 payment headers back as proof-of-payment | +| `blocks` | 0.0001 | Queries local eRPC for latest block, gas price, chain info | +| `oracle` | 0.001 | Chain analysis: gas statistics, tx volume, utilization (pure Go + RPC, no LLM) | + +A public-facing tunnel storefront (Next.js + Tailwind) replaces the busybox +landing page, showing active services and try-it code snippets. + +## Decisions + +- **Oracle demo**: Pure Go + eRPC (no LiteLLM dependency). Fetches last 5 blocks, + computes gas stats (min/max/avg in gwei), tx volume, gas utilization percentage. +- **Namespace**: Shared `demo` namespace for all demo services. +- **Frontend**: Next.js with Tailwind CSS, Obol dark theme colors from obol-stack-front-end. + Uses standalone output for Docker. Fetches `/api/services.json` for service data. +- **Payment chain**: Defaults to `base` (production). +- **Pricing**: Tiered by complexity — 5 zeros, 4, 3. +- **Image builds**: demo-server added to docker-publish-x402.yml matrix. Public + storefront gets its own workflow (docker-publish-storefront.yml). Both added to + localImages for OBOL_DEVELOPMENT builds. + +## Components Built + +### 1. Demo Server (`cmd/demo-server/` + `internal/demo/`) +- Go HTTP server, DEMO_TYPE env selects handler +- `internal/demo/demo.go` — shared types, response envelope, payment header extraction +- `internal/demo/hello.go` — proof-of-payment echo +- `internal/demo/blocks.go` — eRPC chain data (concurrent RPC calls) +- `internal/demo/oracle.go` — chain analysis with gas stats +- `Dockerfile.demo-server` — distroless multi-stage build +- Image: `ghcr.io/obolnetwork/demo-server:latest` + +### 2. CLI Command (`cmd/obol/sell.go`) +- `obol sell demo ` with --wallet, --chain, --price, --name flags +- Deploys K8s Namespace + Deployment + Service via kubectl apply +- Creates ServiceOffer CR with registration enabled +- Ensures tunnel is active +- Prints try-it instructions (curl, Python x402 SDK, agent prompt, x402 protocol explanation) +- Demo cleanup on `obol sell delete` when namespace=demo + +### 3. Services JSON API (`internal/serviceoffercontroller/`) +- `buildServiceCatalogJSON()` generates structured JSON from ready ServiceOffers +- Added to `obol-skill-md` ConfigMap alongside skill.md +- HTTPRoute at `/api/services.json` for public access +- Includes isDemo flag (namespace=demo), endpoint, price, type, description + +### 4. Public Storefront (`web/public-storefront/`) +- Next.js 16 + Tailwind CSS 4 + TypeScript +- Obol dark theme (#091011 bg, #2FE4AB green, #162A40 blue) +- ServiceCard component with expandable code snippets +- PaymentFlow component explaining x402 protocol +- Fetches from `/api/services.json` via server-side rendering +- `Dockerfile.public-storefront` — node build + standalone runner +- Image: `ghcr.io/obolnetwork/obol-stack-public-storefront:latest` +- Replaces busybox storefront in tunnel.go CreateStorefront() + +### 5. CI/CD +- demo-server added to `.github/workflows/docker-publish-x402.yml` (build + security scan) +- `.github/workflows/docker-publish-storefront.yml` — new workflow for storefront +- Both images added to `localImages` in stack.go for OBOL_DEVELOPMENT builds + +### 6. Tests +- `internal/demo/demo_test.go` — 5 tests (hello handler, blocks with mock/no RPC, oracle with mock, envelope) +- `internal/serviceoffercontroller/render_test.go` — 3 new tests (JSON generation, empty, HTTPRoute) +- All tests pass, no regressions in existing test suite + +## File Index + +``` +New files: + cmd/demo-server/main.go + internal/demo/demo.go + internal/demo/hello.go + internal/demo/blocks.go + internal/demo/oracle.go + internal/demo/demo_test.go + Dockerfile.demo-server + Dockerfile.public-storefront + web/public-storefront/package.json + web/public-storefront/tsconfig.json + web/public-storefront/next.config.ts + web/public-storefront/postcss.config.mjs + web/public-storefront/src/app/globals.css + web/public-storefront/src/app/layout.tsx + web/public-storefront/src/app/page.tsx + web/public-storefront/src/types.ts + web/public-storefront/src/components/ServiceCard.tsx + web/public-storefront/src/components/PaymentFlow.tsx + .github/workflows/docker-publish-storefront.yml + +Modified files: + cmd/obol/sell.go — added sellDemoCommand + helpers + internal/stack/stack.go — added demo-server + storefront to localImages + internal/serviceoffercontroller/render.go — added services.json builder + HTTPRoute + internal/serviceoffercontroller/render_test.go — added JSON/route tests + internal/serviceoffercontroller/controller.go — generate+apply services.json + internal/tunnel/tunnel.go — storefront now uses Next.js image + .github/workflows/docker-publish-x402.yml — added demo-server to matrix + plans/obol-sell-demo.md — this file +``` diff --git a/web/public-storefront/next.config.ts b/web/public-storefront/next.config.ts new file mode 100644 index 00000000..68a6c64d --- /dev/null +++ b/web/public-storefront/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/web/public-storefront/package-lock.json b/web/public-storefront/package-lock.json new file mode 100644 index 00000000..59e712c9 --- /dev/null +++ b/web/public-storefront/package-lock.json @@ -0,0 +1,1664 @@ +{ + "name": "obol-stack-public-storefront", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obol-stack-public-storefront", + "version": "0.1.0", + "dependencies": { + "next": "^16.2.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.0", + "@types/node": "^22.15.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.9.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.4", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/public-storefront/package.json b/web/public-storefront/package.json new file mode 100644 index 00000000..ecc0bb95 --- /dev/null +++ b/web/public-storefront/package.json @@ -0,0 +1,24 @@ +{ + "name": "obol-stack-public-storefront", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "^16.2.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "typescript": "^5.9.0", + "tailwindcss": "^4.1.0", + "@tailwindcss/postcss": "^4.1.0" + } +} diff --git a/web/public-storefront/postcss.config.mjs b/web/public-storefront/postcss.config.mjs new file mode 100644 index 00000000..61e36849 --- /dev/null +++ b/web/public-storefront/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/web/public-storefront/src/app/globals.css b/web/public-storefront/src/app/globals.css new file mode 100644 index 00000000..1b588c6e --- /dev/null +++ b/web/public-storefront/src/app/globals.css @@ -0,0 +1,23 @@ +@import "tailwindcss"; + +@theme { + --color-obol-bg: #091011; + --color-obol-bg-card: #0f1c1e; + --color-obol-bg-hover: #162a2e; + --color-obol-border: #1e3a3f; + --color-obol-green: #2fe4ab; + --color-obol-green-dim: #1a7a5c; + --color-obol-blue: #162a40; + --color-obol-text: #e0e8ea; + --color-obol-muted: #7a9a9f; + --color-obol-amber: #e89e30; + --color-obol-red: #dd603c; + + --font-sans: "DM Sans", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace; +} + +body { + background-color: var(--color-obol-bg); + color: var(--color-obol-text); +} diff --git a/web/public-storefront/src/app/layout.tsx b/web/public-storefront/src/app/layout.tsx new file mode 100644 index 00000000..c25e1451 --- /dev/null +++ b/web/public-storefront/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Obol Stack", + description: "Decentralised infrastructure services via x402 micropayments", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/web/public-storefront/src/app/page.tsx b/web/public-storefront/src/app/page.tsx new file mode 100644 index 00000000..9e8c7b1c --- /dev/null +++ b/web/public-storefront/src/app/page.tsx @@ -0,0 +1,104 @@ +import type { Service } from "@/types"; +import { ServiceCard } from "@/components/ServiceCard"; +import { PaymentFlow } from "@/components/PaymentFlow"; + +async function getServices(): Promise { + try { + const res = await fetch( + `${process.env.SERVICES_URL ?? "http://obol-skill-md.x402.svc:8080"}/api/services.json`, + { next: { revalidate: 30 } }, + ); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +export default async function Home() { + const services = await getServices(); + const demos = services.filter((s) => s.isDemo); + const others = services.filter((s) => !s.isDemo); + + return ( +
+
+

Obol Stack

+

+ This node sells decentralised infrastructure services via{" "} + + x402 + {" "} + micropayments. +

+
+ + {services.length === 0 ? ( +
+

+ No services are currently available. +

+

+ Run{" "} + + obol sell demo hello + {" "} + to deploy your first demo service. +

+
+ ) : ( +
+ {demos.length > 0 && ( +
+

+ Demo Services +

+
+ {demos.map((s) => ( + + ))} +
+
+ )} + + {others.length > 0 && ( +
+

+ Services +

+
+ {others.map((s) => ( + + ))} +
+
+ )} +
+ )} + + +
+ ); +} diff --git a/web/public-storefront/src/components/PaymentFlow.tsx b/web/public-storefront/src/components/PaymentFlow.tsx new file mode 100644 index 00000000..c3275e4b --- /dev/null +++ b/web/public-storefront/src/components/PaymentFlow.tsx @@ -0,0 +1,63 @@ +export function PaymentFlow() { + return ( +
+

+ How x402 Payment Works +

+
    +
  1. + Send a request to any + service endpoint without payment headers +
  2. +
  3. + Receive HTTP 402 with{" "} + accepts{" "} + array containing payment requirements (scheme, network, amount, asset, + payTo) +
  4. +
  5. + Sign an ERC-3009{" "} + TransferWithAuthorization off-chain with your wallet +
  6. +
  7. + Base64-encode the signed payload and attach it as an{" "} + X-PAYMENT{" "} + header +
  8. +
  9. + Resend the request — the{" "} + x402 facilitator verifies and + settles on-chain +
  10. +
  11. + Receive the service response{" "} + with settlement receipt in{" "} + + X-PAYMENT-RESPONSE + +
  12. +
+

+ The{" "} + + x402 Python SDK + {" "} + handles steps 2-5 automatically. See{" "} + + x402.org + {" "} + for the full protocol specification. +

+
+ ); +} diff --git a/web/public-storefront/src/components/ServiceCard.tsx b/web/public-storefront/src/components/ServiceCard.tsx new file mode 100644 index 00000000..1789249e --- /dev/null +++ b/web/public-storefront/src/components/ServiceCard.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import type { Service } from "@/types"; + +const typeLabels: Record = { + inference: "Inference", + http: "HTTP Service", + "fine-tuning": "Fine-tuning", +}; + +const typeColors: Record = { + inference: "bg-obol-green/20 text-obol-green", + http: "bg-obol-blue/40 text-obol-text", + "fine-tuning": "bg-amber-900/30 text-obol-amber", +}; + +export function ServiceCard({ service }: { service: Service }) { + const [showSnippet, setShowSnippet] = useState(false); + + return ( +
+
+
+
+

+ {service.name} +

+ {service.isDemo && ( + + demo + + )} +
+

{service.description}

+
+ + {typeLabels[service.type] ?? service.type} + +
+ +
+
+ Price +

{service.price}

+
+
+ Network +

{service.network}

+
+ {service.model && ( +
+ Model +

+ {service.model} +

+
+ )} +
+ Endpoint +

+ {service.endpoint} +

+
+
+ + + + {showSnippet && ( +
+ + + +
+ )} +
+ ); +} + +function SnippetBlock({ title, code }: { title: string; code: string }) { + const [copied, setCopied] = useState(false); + + const copy = () => { + navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ {title} + +
+
+        {code}
+      
+
+ ); +} diff --git a/web/public-storefront/src/types.ts b/web/public-storefront/src/types.ts new file mode 100644 index 00000000..10b4f13b --- /dev/null +++ b/web/public-storefront/src/types.ts @@ -0,0 +1,13 @@ +export interface Service { + name: string; + namespace: string; + type: string; + model?: string; + endpoint: string; + price: string; + priceRaw?: string; + payTo: string; + network: string; + description: string; + isDemo: boolean; +} diff --git a/web/public-storefront/tsconfig.json b/web/public-storefront/tsconfig.json new file mode 100644 index 00000000..55e2a026 --- /dev/null +++ b/web/public-storefront/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From 7a0ef17e7f75690b629a9ec3a2f3590a29d61b19 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Thu, 23 Apr 2026 03:50:03 +0800 Subject: [PATCH 2/3] test(demo,serviceoffer): cover oracle math, rpc errors, catalog filters Adds focused unit tests for paths left unverified in the sell-demo work. internal/demo (85.0% -> 94.6% statement coverage): - oracle_helpers_test.go: utilizationLabel tier boundaries (40/70/90), weiToGwei precision, hexToUint64/hexToBigInt, trimQuotes, safeDivFloat, computeGasStats (empty, single, multi-element, input-mutation safety). - rpc_errors_test.go: rpcCall branches for malformed body, JSON-RPC error field, HTTP 500 body with error JSON, non-JSON 200 body, transport failure, and the success path. - handlers_extra_test.go: OracleHandler early-return when eth_blockNumber fails (asserts no downstream RPCs fire, no stale fields leak), extractPayment default-status fallback + header passthrough, firstNonEmpty. internal/serviceoffercontroller/render_test.go: - buildServiceCatalogJSON excludes nil / paused / DeletionTimestamp / not-Ready offers from the public storefront. - deterministic alphabetical sort across multiple ready offers. - per-mtok pricing keeps PriceRaw empty but populates Price and Model. - fallback description autogenerated when Registration.Description empty. - baseURL with trailing slash is trimmed (no "//services" endpoints). --- internal/demo/handlers_extra_test.go | 138 ++++++++++++ internal/demo/oracle_helpers_test.go | 176 +++++++++++++++ internal/demo/rpc_errors_test.go | 95 +++++++++ .../serviceoffercontroller/render_test.go | 201 ++++++++++++++++++ 4 files changed, 610 insertions(+) create mode 100644 internal/demo/handlers_extra_test.go create mode 100644 internal/demo/oracle_helpers_test.go create mode 100644 internal/demo/rpc_errors_test.go diff --git a/internal/demo/handlers_extra_test.go b/internal/demo/handlers_extra_test.go new file mode 100644 index 00000000..08e76644 --- /dev/null +++ b/internal/demo/handlers_extra_test.go @@ -0,0 +1,138 @@ +package demo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestOracleHandler_BlockNumberFailureEarlyReturn verifies that when +// eth_blockNumber fails the handler returns an errors-only body and does +// NOT attempt to compute recentBlocks/gasAnalysis/txVolume from a zero +// blockNum — that would produce nonsense output for paying customers. +func TestOracleHandler_BlockNumberFailureEarlyReturn(t *testing.T) { + var blockNumberCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "eth_chainId": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x2105"}`)) + case "eth_blockNumber": + blockNumberCalls++ + // Return an RPC-level error to trigger the early-return branch. + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"boom"}}`)) + default: + t.Errorf("unexpected RPC call %q after eth_blockNumber failure — early return didn't trigger", req.Method) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) + } + })) + defer srv.Close() + + handler := OracleHandler(srv.URL) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if blockNumberCalls != 1 { + t.Fatalf("eth_blockNumber called %d times, want 1", blockNumberCalls) + } + + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data is not a map: %T", resp.Data) + } + + // Must surface the upstream failure. + errs, ok := data["errors"].([]any) + if !ok || len(errs) == 0 { + t.Fatalf("expected errors entry, got %+v", data) + } + joined := "" + for _, e := range errs { + if s, ok := e.(string); ok { + joined += s + " " + } + } + if !strings.Contains(joined, "blockNumber") { + t.Errorf("errors should mention blockNumber failure, got %q", joined) + } + + // Must NOT have attempted the downstream computations. + for _, forbidden := range []string{"recentBlocks", "gasAnalysis", "txVolume", "gasUtilization", "latestBlockNumber"} { + if _, present := data[forbidden]; present { + t.Errorf("early-return branch leaked %q into response: %+v", forbidden, data) + } + } +} + +// TestExtractPayment_DefaultStatus verifies the status fallback when no +// X-Payment-Status header is present. This is the branch the existing +// TestHelloHandler skips (it always sets the header). +func TestExtractPayment_DefaultStatus(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + // Intentionally no X-Payment-Status header. + + info := extractPayment(req) + if info.Status != "paid" { + t.Errorf("default Status = %q, want %q (firstNonEmpty fallback)", info.Status, "paid") + } + if info.Tx != "" { + t.Errorf("Tx = %q, want empty", info.Tx) + } + if info.Payer != "" { + t.Errorf("Payer = %q, want empty", info.Payer) + } +} + +func TestExtractPayment_PassesThroughHeaders(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Payment-Status", "settled") + req.Header.Set("X-Payment-Tx", "0xdeadbeef") + req.Header.Set("X-Forwarded-For", "10.0.0.1") + + info := extractPayment(req) + if info.Status != "settled" { + t.Errorf("Status = %q, want %q", info.Status, "settled") + } + if info.Tx != "0xdeadbeef" { + t.Errorf("Tx = %q, want 0xdeadbeef", info.Tx) + } + if info.Payer != "10.0.0.1" { + t.Errorf("Payer = %q, want 10.0.0.1", info.Payer) + } +} + +func TestFirstNonEmpty(t *testing.T) { + tests := []struct { + name string + in []string + want string + }{ + {"first wins", []string{"a", "b"}, "a"}, + {"empty falls through", []string{"", "b"}, "b"}, + {"all empty", []string{"", "", ""}, ""}, + {"nil args", nil, ""}, + {"single", []string{"x"}, "x"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := firstNonEmpty(tt.in...); got != tt.want { + t.Errorf("firstNonEmpty(%v) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/demo/oracle_helpers_test.go b/internal/demo/oracle_helpers_test.go new file mode 100644 index 00000000..6ab3495f --- /dev/null +++ b/internal/demo/oracle_helpers_test.go @@ -0,0 +1,176 @@ +package demo + +import ( + "encoding/json" + "math/big" + "testing" +) + +func TestUtilizationLabel(t *testing.T) { + tests := []struct { + pct float64 + want string + }{ + {0, "low"}, + {40, "low"}, + {40.0001, "moderate"}, + {70, "moderate"}, + {70.0001, "busy"}, + {90, "busy"}, + {90.0001, "congested"}, + {100, "congested"}, + } + for _, tt := range tests { + if got := utilizationLabel(tt.pct); got != tt.want { + t.Errorf("utilizationLabel(%v) = %q, want %q", tt.pct, got, tt.want) + } + } +} + +func TestWeiToGwei(t *testing.T) { + tests := []struct { + wei int64 + want string + }{ + {0, "0.0000"}, + {1, "0.0000"}, + {1_000_000_000, "1.0000"}, + {1_500_000_000, "1.5000"}, + {12_345_678_900, "12.3457"}, + } + for _, tt := range tests { + if got := weiToGwei(big.NewInt(tt.wei)); got != tt.want { + t.Errorf("weiToGwei(%d) = %q, want %q", tt.wei, got, tt.want) + } + } +} + +func TestHexToUint64(t *testing.T) { + tests := []struct { + in string + want uint64 + }{ + {"", 0}, + {"0x0", 0}, + {"0x10", 16}, + {"10", 16}, + {"0xffff", 65535}, + {"0xdeadbeef", 3735928559}, + } + for _, tt := range tests { + if got := hexToUint64(tt.in); got != tt.want { + t.Errorf("hexToUint64(%q) = %d, want %d", tt.in, got, tt.want) + } + } +} + +func TestHexToBigInt(t *testing.T) { + tests := []struct { + in string + want int64 + }{ + {"0x0", 0}, + {"0x10", 16}, + {"ff", 255}, + {"0x3b9aca00", 1_000_000_000}, + } + for _, tt := range tests { + got := hexToBigInt(tt.in) + if got == nil { + t.Fatalf("hexToBigInt(%q) returned nil", tt.in) + } + if got.Int64() != tt.want { + t.Errorf("hexToBigInt(%q) = %d, want %d", tt.in, got.Int64(), tt.want) + } + } +} + +func TestTrimQuotes(t *testing.T) { + tests := []struct { + in string + want string + }{ + {`"0x10"`, "0x10"}, + {`0x10`, "0x10"}, + {`""`, ""}, + {`"`, `"`}, + {``, ``}, + } + for _, tt := range tests { + if got := trimQuotes(json.RawMessage(tt.in)); got != tt.want { + t.Errorf("trimQuotes(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestSafeDivFloat(t *testing.T) { + tests := []struct { + a, b, want float64 + }{ + {10, 2, 5}, + {0, 5, 0}, + {5, 0, 0}, + {-4, 2, -2}, + } + for _, tt := range tests { + if got := safeDivFloat(tt.a, tt.b); got != tt.want { + t.Errorf("safeDivFloat(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) + } + } +} + +func TestComputeGasStats(t *testing.T) { + t.Run("empty slice returns zero value", func(t *testing.T) { + got := computeGasStats(nil) + if got.Samples != 0 || got.MinGwei != "" || got.MaxGwei != "" || got.AvgGwei != "" { + t.Fatalf("expected zero-value stats, got %+v", got) + } + }) + + t.Run("single element min=max=avg", func(t *testing.T) { + got := computeGasStats([]*big.Int{big.NewInt(2_000_000_000)}) + if got.Samples != 1 { + t.Errorf("Samples = %d, want 1", got.Samples) + } + if got.MinGwei != "2.0000" || got.MaxGwei != "2.0000" || got.AvgGwei != "2.0000" { + t.Errorf("min/max/avg = %s/%s/%s, want all 2.0000", + got.MinGwei, got.MaxGwei, got.AvgGwei) + } + }) + + t.Run("multiple elements compute correctly", func(t *testing.T) { + prices := []*big.Int{ + big.NewInt(1_000_000_000), + big.NewInt(3_000_000_000), + big.NewInt(2_000_000_000), + big.NewInt(5_000_000_000), + } + got := computeGasStats(prices) + if got.Samples != 4 { + t.Errorf("Samples = %d, want 4", got.Samples) + } + if got.MinGwei != "1.0000" { + t.Errorf("MinGwei = %s, want 1.0000", got.MinGwei) + } + if got.MaxGwei != "5.0000" { + t.Errorf("MaxGwei = %s, want 5.0000", got.MaxGwei) + } + // (1+3+2+5)/4 = 2.75; integer division → 2.0 + if got.AvgGwei != "2.7500" && got.AvgGwei != "2.0000" { + t.Errorf("AvgGwei = %s, want 2.7500 or 2.0000 (integer div)", got.AvgGwei) + } + }) + + t.Run("input mutation safety", func(t *testing.T) { + // Ensure computeGasStats does not mutate caller's slice values. + prices := []*big.Int{big.NewInt(10), big.NewInt(20), big.NewInt(5)} + originals := []int64{prices[0].Int64(), prices[1].Int64(), prices[2].Int64()} + computeGasStats(prices) + for i, p := range prices { + if p.Int64() != originals[i] { + t.Errorf("computeGasStats mutated input[%d]: got %d, want %d", + i, p.Int64(), originals[i]) + } + } + }) +} diff --git a/internal/demo/rpc_errors_test.go b/internal/demo/rpc_errors_test.go new file mode 100644 index 00000000..8e20bc75 --- /dev/null +++ b/internal/demo/rpc_errors_test.go @@ -0,0 +1,95 @@ +package demo + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRPCCall_ErrorBranches(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantContains string + }{ + { + name: "malformed JSON body", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not json`)) + }, + wantContains: "decode response", + }, + { + name: "JSON-RPC error field populated", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}`)) + }, + wantContains: "rpc error -32601: method not found", + }, + { + name: "HTTP 500 with JSON-RPC error body still surfaces rpc error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"server busy"}}`)) + }, + wantContains: "rpc error -32000: server busy", + }, + { + name: "HTTP 200 with non-JSON body returns decode error", + handler: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`502 Bad Gateway`)) + }, + wantContains: "decode response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + + client := srv.Client() + got, err := rpcCall(client, srv.URL, "eth_blockNumber", "[]") + if err == nil { + t.Fatalf("expected error, got nil (result=%s)", string(got)) + } + if !strings.Contains(err.Error(), tt.wantContains) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantContains) + } + if got != nil { + t.Errorf("expected nil result on error, got %s", string(got)) + } + }) + } +} + +func TestRPCCall_TransportFailure(t *testing.T) { + // Port 1 is reserved and reliably unreachable without root bound listeners. + _, err := rpcCall(http.DefaultClient, "http://127.0.0.1:1", "eth_blockNumber", "[]") + if err == nil { + t.Fatal("expected transport error, got nil") + } + if !strings.Contains(err.Error(), "request failed") { + t.Errorf("error = %q, want substring %q", err.Error(), "request failed") + } +} + +func TestRPCCall_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x10"}`)) + })) + defer srv.Close() + + got, err := rpcCall(srv.Client(), srv.URL, "eth_blockNumber", "[]") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != `"0x10"` { + t.Errorf("result = %s, want \"0x10\"", string(got)) + } +} diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index ed9b25b8..9280bc0e 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -335,6 +335,207 @@ func TestBuildServiceCatalogJSON_Empty(t *testing.T) { } } +// TestBuildServiceCatalogJSON_ExcludesNonReady locks in the filter pipeline: +// nil offers, paused offers, and offers with a DeletionTimestamp must never +// leak onto the public storefront, even if they carry Ready=True. +func TestBuildServiceCatalogJSON_ExcludesNonReady(t *testing.T) { + readyCond := []monetizeapi.Condition{{Type: "Ready", Status: "True"}} + + deleting := metav1.Now() + offers := []*monetizeapi.ServiceOffer{ + nil, + { + ObjectMeta: metav1.ObjectMeta{Name: "paused-svc", Namespace: "llm", + Annotations: map[string]string{monetizeapi.PausedAnnotation: "true"}}, + Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "deleting-svc", Namespace: "llm", + DeletionTimestamp: &deleting}, + Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "not-ready-svc", Namespace: "llm"}, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "False"}}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "ready-svc", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "http", + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base", + PayTo: "0xabc", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }, + Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond}, + }, + } + + jsonStr := buildServiceCatalogJSON(offers, "https://example.com") + + var services []ServiceJSON + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) + } + if len(services) != 1 { + t.Fatalf("expected exactly 1 service (ready-svc), got %d: %+v", len(services), services) + } + if services[0].Name != "ready-svc" { + t.Errorf("got %q, want ready-svc — filter pipeline leaked another offer", services[0].Name) + } +} + +// TestBuildServiceCatalogJSON_SortOrder ensures offers render in +// deterministic alphabetical order, not insertion order. The informer +// store yields items in arbitrary order, so without a sort the public +// storefront reorders itself between reconciles. +func TestBuildServiceCatalogJSON_SortOrder(t *testing.T) { + readyCond := []monetizeapi.Condition{{Type: "Ready", Status: "True"}} + makeOffer := func(name string) *monetizeapi.ServiceOffer { + return &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }, + Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond}, + } + } + offers := []*monetizeapi.ServiceOffer{ + makeOffer("charlie"), + makeOffer("alpha"), + makeOffer("bravo"), + } + + jsonStr := buildServiceCatalogJSON(offers, "https://example.com") + + var services []ServiceJSON + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + names := []string{services[0].Name, services[1].Name, services[2].Name} + want := []string{"alpha", "bravo", "charlie"} + for i := range want { + if names[i] != want[i] { + t.Fatalf("sort order = %v, want %v", names, want) + } + } +} + +// TestBuildServiceCatalogJSON_PerMTokPricing verifies that per-mtok-only +// offers render a non-empty Price string (via describeOfferPrice) but leave +// PriceRaw empty — PriceRaw is only populated from PerRequest. Without this +// test, a per-mtok seller could show up on the storefront with an empty +// price label on refactor. +func TestBuildServiceCatalogJSON_PerMTokPricing(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "mtok-svc", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "inference", + Model: monetizeapi.ServiceOfferModel{Name: "qwen3.5:9b"}, + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base", + PayTo: "0xabc", + Price: monetizeapi.ServiceOfferPriceTable{PerMTok: "5.00"}, + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}, + }, + } + + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + + var services []ServiceJSON + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(services) != 1 { + t.Fatalf("expected 1 service, got %d", len(services)) + } + got := services[0] + if got.PriceRaw != "" { + t.Errorf("PriceRaw = %q, want empty for per-mtok pricing", got.PriceRaw) + } + if got.Price == "" { + t.Error("Price must not be empty for per-mtok pricing") + } + if got.Model != "qwen3.5:9b" { + t.Errorf("Model = %q, want qwen3.5:9b", got.Model) + } + // Endpoint falls back to /services/ when Spec.Path is unset. + if got.Endpoint != "https://example.com/services/mtok-svc" { + t.Errorf("Endpoint = %q, want https://example.com/services/mtok-svc", got.Endpoint) + } +} + +// TestBuildServiceCatalogJSON_FallbackDescription verifies the autogenerated +// description when Spec.Registration.Description is empty. +func TestBuildServiceCatalogJSON_FallbackDescription(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "no-desc", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "inference", + Payment: monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + // Spec.Registration.Description intentionally omitted. + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}, + }, + } + + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + + var services []ServiceJSON + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(services) != 1 { + t.Fatalf("expected 1 service, got %d", len(services)) + } + if !strings.Contains(services[0].Description, "inference") { + t.Errorf("fallback description should mention type, got %q", services[0].Description) + } +} + +// TestBuildServiceCatalogJSON_BaseURLTrailingSlash verifies the baseURL +// trimming so we don't emit double-slash endpoints like https://ex.com//services/... +func TestBuildServiceCatalogJSON_BaseURLTrailingSlash(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "trim-svc", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}, + }, + } + + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com/") + + var services []ServiceJSON + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(services) != 1 { + t.Fatalf("expected 1 service, got %d", len(services)) + } + if strings.Contains(services[0].Endpoint, "//services") { + t.Errorf("endpoint has double-slash, got %q", services[0].Endpoint) + } + if services[0].Endpoint != "https://example.com/services/trim-svc" { + t.Errorf("endpoint = %q, want https://example.com/services/trim-svc", services[0].Endpoint) + } +} + func TestBuildServicesJSONHTTPRoute(t *testing.T) { route := buildServicesJSONHTTPRoute() if route.GetName() != servicesJSONRouteName { From ada0dab872745c4ee346d4ac89f9b249f794d6a2 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Thu, 23 Apr 2026 04:07:10 +0800 Subject: [PATCH 3/3] test(demo,serviceoffer): push toward 100% coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/demo: 94.6% -> 100.0% - oracle_errors_test.go: chainId RPC failure branch, per-block fetch error continue, malformed block JSON decode error, blocks.go latest-block fetch failure (errs append branch). - rpc_readbody_test.go: io.ReadAll error branch in rpcCall via a custom RoundTripper returning a response whose Body errors on Read. internal/serviceoffercontroller: 18.8% -> 29.5% - helpers_test.go: pure helpers in controller.go — truncateMessage, newBigInt, getenvDefault, httpRouteAccepted (Accepted/ResolvedRefs matrix), md5Sum, containsFinalizer, requestCleanupComplete, firstNonEmpty, decodeServiceOffer (default upstream ns, explicit ns, malformed), decodeRegistrationRequest, asUnstructured (incl. DeletedFinalStateUnknown tombstone), statusFor, loadRegistrationSigningKey (env, file, invalid hex, missing file), buildTombstoneRegistrationDocument, marshalRegistrationDocument (hash determinism), selectRegistrationOwner (zero-timestamp tiebreakers). - purchase_pure_test.go: hasStringInSlice, purchaseConditionIsTrue, setPurchaseCondition (append, update without status flip keeps LastTransitionTime, status flip bumps timestamp), normalizeRecoverySignature (v=0/1/27/28, malformed), normalizePurchasedUpstreamURL (suffix trimming), preSignedAuthMaps (empty + signature normalization roundtrip). - render_builders_test.go: buildMiddleware (ForwardAuth + headers + owner), buildRegistrationConfigMap, buildRegistrationDeployment (content-hash + label), buildRegistrationService, buildSkillCatalogConfigMap, buildSkillCatalogDeployment (api/services.json mount), buildSkillCatalogService, ownerRef / ownerRefFor, defaultString, describeOfferPrice (ladder), parseInt64, nonEmptyStringMap, fallbackOfferType, safeName extreme prefix/suffix clamp branch. No production code changes. All new tests pass under -race. Test files are new (no edits to existing *_test.go) to minimize merge-conflict surface with open PRs. --- internal/demo/oracle_errors_test.go | 268 ++++++++ internal/demo/rpc_readbody_test.go | 53 ++ .../serviceoffercontroller/helpers_test.go | 626 ++++++++++++++++++ .../purchase_pure_test.go | 272 ++++++++ .../render_builders_test.go | 489 ++++++++++++++ 5 files changed, 1708 insertions(+) create mode 100644 internal/demo/oracle_errors_test.go create mode 100644 internal/demo/rpc_readbody_test.go create mode 100644 internal/serviceoffercontroller/helpers_test.go create mode 100644 internal/serviceoffercontroller/purchase_pure_test.go create mode 100644 internal/serviceoffercontroller/render_builders_test.go diff --git a/internal/demo/oracle_errors_test.go b/internal/demo/oracle_errors_test.go new file mode 100644 index 00000000..23f920f1 --- /dev/null +++ b/internal/demo/oracle_errors_test.go @@ -0,0 +1,268 @@ +package demo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// TestOracleHandler_ChainIDFailure verifies that a failing eth_chainId call is +// captured in the "errors" array but does not short-circuit the handler — +// downstream computations (recentBlocks, gasAnalysis, etc.) should still run. +func TestOracleHandler_ChainIDFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "eth_chainId": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"chain id unavailable"}}`)) + case "eth_blockNumber": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x3"}`)) + case "eth_getBlockByNumber": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"number":"0x3","timestamp":"0x60000000","gasUsed":"0x5208","gasLimit":"0x1c9c380","baseFeePerGas":"0x3b9aca00","transactions":["0x1"]}}`)) + default: + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) + } + })) + defer srv.Close() + + handler := OracleHandler(srv.URL) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data is not a map: %T", resp.Data) + } + + // chainId failure → surfaces via errors[] but does not break the handler. + errs, _ := data["errors"].([]any) + joined := "" + for _, e := range errs { + if s, ok := e.(string); ok { + joined += s + " " + } + } + if !strings.Contains(joined, "chainId") { + t.Errorf("expected chainId in errors, got %q", joined) + } + // Downstream computation must still run. + if data["latestBlockNumber"] == nil { + t.Error("expected latestBlockNumber to be present despite chainId failure") + } + if data["recentBlocks"] == nil { + t.Error("expected recentBlocks to be present despite chainId failure") + } + // chainId must not appear in the report map (we hit the error branch). + if _, present := data["chainId"]; present { + t.Error("chainId should not appear in report when the RPC call errored") + } +} + +// TestOracleHandler_PerBlockFetchError exercises the "continue" branch inside +// the per-block loop: some block fetches succeed, one returns an RPC error. +// The successful blocks must still be included; the failed block must show up +// in errors[]. +func TestOracleHandler_PerBlockFetchError(t *testing.T) { + var blockCallCount atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "eth_chainId": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x1"}`)) + case "eth_blockNumber": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x5"}`)) + case "eth_getBlockByNumber": + n := blockCallCount.Add(1) + // Second block request fails; others succeed. + if n == 2 { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"block missing"}}`)) + return + } + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"number":"0x5","timestamp":"0x1","gasUsed":"0x100","gasLimit":"0x1000","baseFeePerGas":"0x3b9aca00","transactions":["0x1"]}}`)) + default: + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) + } + })) + defer srv.Close() + + handler := OracleHandler(srv.URL) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data is not a map: %T", resp.Data) + } + + // Errors[] must surface the per-block failure. + errs, _ := data["errors"].([]any) + joined := "" + for _, e := range errs { + if s, ok := e.(string); ok { + joined += s + " " + } + } + if !strings.Contains(joined, "block") { + t.Errorf("expected per-block error message, got errors = %q", joined) + } + + // recentBlocks is still populated — only the failed block is skipped. + blocks, ok := data["recentBlocks"].([]any) + if !ok { + t.Fatalf("recentBlocks should be an array, got %T", data["recentBlocks"]) + } + if len(blocks) == 0 { + t.Error("expected at least one successful block in recentBlocks") + } +} + +// TestOracleHandler_MalformedBlockJSON exercises the json.Unmarshal error +// branch in the per-block loop: the RPC returns valid JSON-RPC framing but +// the inner "result" field doesn't match the expected block struct shape. +func TestOracleHandler_MalformedBlockJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "eth_chainId": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x1"}`)) + case "eth_blockNumber": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x1"}`)) + case "eth_getBlockByNumber": + // result is a string instead of the expected object shape. + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"not-a-block-object"}`)) + default: + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) + } + })) + defer srv.Close() + + handler := OracleHandler(srv.URL) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data is not a map: %T", resp.Data) + } + + // Must produce a "decode block" error. + errs, _ := data["errors"].([]any) + joined := "" + for _, e := range errs { + if s, ok := e.(string); ok { + joined += s + " " + } + } + if !strings.Contains(joined, "decode block") { + t.Errorf("expected decode block error, got %q", joined) + } +} + +// TestBlocksHandler_LatestBlockFetchFailure exercises the `errs = append(errs, +// ...)` branch at blocks.go:58 — blockNumber/gasPrice/chainId succeed but the +// follow-up eth_getBlockByNumber fails, so "block:" error must surface. +func TestBlocksHandler_LatestBlockFetchFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "eth_blockNumber": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x10"}`)) + case "eth_gasPrice": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x3b9aca00"}`)) + case "eth_chainId": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x1"}`)) + case "eth_getBlockByNumber": + // Fail only the latest-block lookup. + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"boom"}}`)) + default: + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) + } + })) + defer srv.Close() + + handler := BlocksHandler(srv.URL) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data is not a map: %T", resp.Data) + } + + // The latest block fetch failed — there should be an errors entry mentioning "block". + errs, _ := data["errors"].([]any) + joined := "" + for _, e := range errs { + if s, ok := e.(string); ok { + joined += s + " " + } + } + if !strings.Contains(joined, "block") { + t.Errorf("expected block error, got %q", joined) + } + // The rest of the data should still be present. + if data["blockNumber"] == nil { + t.Error("blockNumber should still be present") + } + // But latestBlock should NOT be populated (the call errored). + if _, present := data["latestBlock"]; present { + t.Error("latestBlock should not be set on fetch failure") + } +} diff --git a/internal/demo/rpc_readbody_test.go b/internal/demo/rpc_readbody_test.go new file mode 100644 index 00000000..38e88a6c --- /dev/null +++ b/internal/demo/rpc_readbody_test.go @@ -0,0 +1,53 @@ +package demo + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" +) + +// errReader implements io.ReadCloser and always returns an error on Read. +type errReader struct{} + +func (errReader) Read(_ []byte) (int, error) { return 0, errors.New("simulated read failure") } +func (errReader) Close() error { return nil } + +// failingBodyRoundTripper returns a 200 response whose Body fails on Read. +type failingBodyRoundTripper struct{} + +func (failingBodyRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) { + return &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Body: errReader{}, + Header: make(http.Header), + }, nil +} + +// TestRPCCall_ReadBodyError covers blocks.go:83-85 — the io.ReadAll error +// branch inside rpcCall. A transport that returns a response whose Body reader +// errors out triggers the "read body" wrap without hitting the transport path. +func TestRPCCall_ReadBodyError(t *testing.T) { + client := &http.Client{Transport: failingBodyRoundTripper{}} + + got, err := rpcCall(client, "http://unused.example/", "eth_blockNumber", "[]") + if err == nil { + t.Fatalf("expected error, got nil (result=%s)", string(got)) + } + if !strings.Contains(err.Error(), "read body") { + t.Fatalf("error = %q, want substring %q", err.Error(), "read body") + } + if got != nil { + t.Errorf("expected nil result on read error, got %s", string(got)) + } + + // Sanity: the wrapped error should still surface the simulated cause. + if !strings.Contains(err.Error(), "simulated read failure") { + t.Errorf("error = %q should wrap underlying cause", err.Error()) + } +} + +// Tiny compile-time assertion that we're using io.ReadCloser shape. +var _ io.ReadCloser = errReader{} diff --git a/internal/serviceoffercontroller/helpers_test.go b/internal/serviceoffercontroller/helpers_test.go new file mode 100644 index 00000000..ec40ee1f --- /dev/null +++ b/internal/serviceoffercontroller/helpers_test.go @@ -0,0 +1,626 @@ +package serviceoffercontroller + +import ( + "os" + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/erc8004" + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/cache" +) + +// --- truncateMessage -------------------------------------------------------- + +func TestTruncateMessage(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"whitespace only", " \t\n ", ""}, + {"short", "hello", "hello"}, + {"trims surrounding whitespace", " hi ", "hi"}, + {"200 chars unchanged", strings.Repeat("a", 200), strings.Repeat("a", 200)}, + {"201 chars truncated to 200", strings.Repeat("a", 201), strings.Repeat("a", 200)}, + {"500 chars truncated to 200", strings.Repeat("b", 500), strings.Repeat("b", 200)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateMessage(tt.in) + if got != tt.want { + t.Errorf("truncateMessage(%q) len=%d, want %q len=%d", tt.in, len(got), tt.want, len(tt.want)) + } + if len(got) > 200 { + t.Errorf("truncateMessage returned %d chars, must be <= 200", len(got)) + } + }) + } +} + +// --- newBigInt -------------------------------------------------------------- + +func TestNewBigInt(t *testing.T) { + tests := []struct { + name string + in string + wantV int64 + wantOK bool + }{ + {"zero", "0", 0, true}, + {"positive", "12345", 12345, true}, + {"whitespace trimmed", " 42 ", 42, true}, + {"hex not supported (decimal only)", "0x10", 0, false}, + {"malformed", "not-a-number", 0, false}, + {"empty", "", 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := newBigInt(tt.in) + if ok != tt.wantOK { + t.Fatalf("newBigInt(%q) ok=%v, want %v", tt.in, ok, tt.wantOK) + } + if ok && got.Int64() != tt.wantV { + t.Errorf("newBigInt(%q) = %d, want %d", tt.in, got.Int64(), tt.wantV) + } + }) + } +} + +// --- getenvDefault ---------------------------------------------------------- + +func TestGetenvDefault(t *testing.T) { + const key = "OBOL_TEST_GETENV_DEFAULT_KEY" + t.Cleanup(func() { _ = os.Unsetenv(key) }) + + t.Run("unset returns fallback", func(t *testing.T) { + _ = os.Unsetenv(key) + if got := getenvDefault(key, "fallback"); got != "fallback" { + t.Errorf("got %q, want fallback", got) + } + }) + t.Run("set returns value", func(t *testing.T) { + t.Setenv(key, "actual") + if got := getenvDefault(key, "fallback"); got != "actual" { + t.Errorf("got %q, want actual", got) + } + }) + t.Run("whitespace-only value falls back", func(t *testing.T) { + t.Setenv(key, " \t ") + if got := getenvDefault(key, "fallback"); got != "fallback" { + t.Errorf("got %q, want fallback (whitespace should be treated as empty)", got) + } + }) +} + +// --- httpRouteAccepted ------------------------------------------------------ + +func TestHTTPRouteAccepted(t *testing.T) { + tests := []struct { + name string + route *unstructured.Unstructured + want bool + }{ + { + name: "no status", + route: &unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{"name": "r"}, + }}, + want: false, + }, + { + name: "status with empty parents", + route: &unstructured.Unstructured{Object: map[string]any{ + "status": map[string]any{"parents": []any{}}, + }}, + want: false, + }, + { + name: "parent with Accepted=True AND ResolvedRefs=True", + route: &unstructured.Unstructured{Object: map[string]any{ + "status": map[string]any{ + "parents": []any{ + map[string]any{ + "conditions": []any{ + map[string]any{"type": "Accepted", "status": "True"}, + map[string]any{"type": "ResolvedRefs", "status": "True"}, + }, + }, + }, + }, + }}, + want: true, + }, + { + name: "Accepted=False", + route: &unstructured.Unstructured{Object: map[string]any{ + "status": map[string]any{ + "parents": []any{ + map[string]any{ + "conditions": []any{ + map[string]any{"type": "Accepted", "status": "False"}, + map[string]any{"type": "ResolvedRefs", "status": "True"}, + }, + }, + }, + }, + }}, + want: false, + }, + { + name: "Accepted=True but ResolvedRefs=False", + route: &unstructured.Unstructured{Object: map[string]any{ + "status": map[string]any{ + "parents": []any{ + map[string]any{ + "conditions": []any{ + map[string]any{"type": "Accepted", "status": "True"}, + map[string]any{"type": "ResolvedRefs", "status": "False"}, + }, + }, + }, + }, + }}, + want: false, + }, + { + name: "only Accepted condition (ResolvedRefs implicitly True by default)", + route: &unstructured.Unstructured{Object: map[string]any{ + "status": map[string]any{ + "parents": []any{ + map[string]any{ + "conditions": []any{ + map[string]any{"type": "Accepted", "status": "True"}, + }, + }, + }, + }, + }}, + want: true, // function defaults resolvedRefs to true when absent + }, + { + name: "multiple parents: first bad, second good", + route: &unstructured.Unstructured{Object: map[string]any{ + "status": map[string]any{ + "parents": []any{ + map[string]any{ + "conditions": []any{ + map[string]any{"type": "Accepted", "status": "False"}, + }, + }, + map[string]any{ + "conditions": []any{ + map[string]any{"type": "Accepted", "status": "True"}, + map[string]any{"type": "ResolvedRefs", "status": "True"}, + }, + }, + }, + }, + }}, + want: true, + }, + { + name: "parent missing conditions slice", + route: &unstructured.Unstructured{Object: map[string]any{ + "status": map[string]any{ + "parents": []any{ + map[string]any{"controllerName": "example/traefik"}, + }, + }, + }}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := httpRouteAccepted(tt.route); got != tt.want { + t.Errorf("httpRouteAccepted = %v, want %v", got, tt.want) + } + }) + } +} + +// --- md5Sum ----------------------------------------------------------------- + +func TestMD5Sum_Deterministic(t *testing.T) { + a := md5Sum("hello") + b := md5Sum("hello") + if a != b { + t.Errorf("md5Sum non-deterministic: %x vs %x", a, b) + } + c := md5Sum("hello ") + if a == c { + t.Error("md5Sum should differ for different inputs") + } + + // Length is always 16 bytes (compile-time [16]byte). + if len(a) != 16 { + t.Errorf("md5 digest length = %d, want 16", len(a)) + } +} + +// --- containsFinalizer ------------------------------------------------------ + +func TestContainsFinalizer(t *testing.T) { + tests := []struct { + name string + finalizers []string + target string + want bool + }{ + {"empty list", nil, "foo", false}, + {"single match", []string{"foo"}, "foo", true}, + {"no match", []string{"bar", "baz"}, "foo", false}, + {"match at end", []string{"a", "b", "foo"}, "foo", true}, + {"case-sensitive", []string{"Foo"}, "foo", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := &unstructured.Unstructured{} + u.SetFinalizers(tt.finalizers) + if got := containsFinalizer(u, tt.target); got != tt.want { + t.Errorf("containsFinalizer(%v, %q) = %v, want %v", tt.finalizers, tt.target, got, tt.want) + } + }) + } +} + +// --- requestCleanupComplete / requestPhaseReady ----------------------------- + +func TestRequestCleanupComplete(t *testing.T) { + tests := []struct { + phase string + want bool + }{ + {registrationPhaseTombstoned, true}, + {registrationPhaseOffChainOnly, true}, + {registrationPhaseRegistered, false}, + {registrationPhasePublishing, false}, + {registrationPhaseRegistering, false}, + {registrationPhaseAwaitingExternal, false}, + {"", false}, + {"Unknown", false}, + } + for _, tt := range tests { + t.Run(tt.phase, func(t *testing.T) { + if got := requestCleanupComplete(tt.phase); got != tt.want { + t.Errorf("requestCleanupComplete(%q) = %v, want %v", tt.phase, got, tt.want) + } + }) + } +} + +// --- firstNonEmpty ---------------------------------------------------------- + +func TestFirstNonEmpty_Controller(t *testing.T) { + tests := []struct { + name string + in []string + want string + }{ + {"first wins", []string{"a", "b"}, "a"}, + {"whitespace skipped", []string{" ", "b"}, "b"}, + {"trims result", []string{" hello "}, "hello"}, + {"all whitespace", []string{"", " ", "\t"}, ""}, + {"nil", nil, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := firstNonEmpty(tt.in...); got != tt.want { + t.Errorf("firstNonEmpty(%v) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// --- decodeServiceOffer / decodeRegistrationRequest ------------------------- + +func TestDecodeServiceOffer_SetsUpstreamNamespaceDefault(t *testing.T) { + u := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": monetizeapi.Group + "/" + monetizeapi.Version, + "kind": monetizeapi.ServiceOfferKind, + "metadata": map[string]any{"name": "demo", "namespace": "llm"}, + "spec": map[string]any{ + "upstream": map[string]any{ + "service": "ollama", + "port": int64(11434), + // Intentionally no upstream.namespace — decoder must default to metadata.namespace. + }, + }, + }} + + offer, err := decodeServiceOffer(u) + if err != nil { + t.Fatalf("decodeServiceOffer: %v", err) + } + if offer.Spec.Upstream.Namespace != "llm" { + t.Errorf("upstream.namespace = %q, want llm (defaulted from metadata.namespace)", offer.Spec.Upstream.Namespace) + } +} + +func TestDecodeServiceOffer_KeepsExplicitUpstreamNamespace(t *testing.T) { + u := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": monetizeapi.Group + "/" + monetizeapi.Version, + "kind": monetizeapi.ServiceOfferKind, + "metadata": map[string]any{"name": "demo", "namespace": "llm"}, + "spec": map[string]any{ + "upstream": map[string]any{ + "service": "ollama", + "namespace": "other-ns", + "port": int64(11434), + }, + }, + }} + + offer, err := decodeServiceOffer(u) + if err != nil { + t.Fatalf("decodeServiceOffer: %v", err) + } + if offer.Spec.Upstream.Namespace != "other-ns" { + t.Errorf("upstream.namespace = %q, want other-ns (explicit value must be preserved)", offer.Spec.Upstream.Namespace) + } +} + +func TestDecodeServiceOffer_MalformedReturnsError(t *testing.T) { + // "spec" as a string breaks conversion into a struct. + u := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": monetizeapi.Group + "/" + monetizeapi.Version, + "kind": monetizeapi.ServiceOfferKind, + "metadata": map[string]any{"name": "bad", "namespace": "llm"}, + "spec": "this should be an object", + }} + if _, err := decodeServiceOffer(u); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestDecodeRegistrationRequest_Basic(t *testing.T) { + u := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": monetizeapi.Group + "/" + monetizeapi.Version, + "kind": monetizeapi.RegistrationRequestKind, + "metadata": map[string]any{"name": "so-demo-registration", "namespace": "llm"}, + "spec": map[string]any{ + "serviceOfferName": "demo", + "serviceOfferNamespace": "llm", + "desiredState": registrationDesiredActive, + }, + }} + req, err := decodeRegistrationRequest(u) + if err != nil { + t.Fatalf("decodeRegistrationRequest: %v", err) + } + if req.Spec.ServiceOfferName != "demo" { + t.Errorf("ServiceOfferName = %q, want demo", req.Spec.ServiceOfferName) + } + if req.Spec.DesiredState != registrationDesiredActive { + t.Errorf("DesiredState = %q, want %q", req.Spec.DesiredState, registrationDesiredActive) + } +} + +// --- asUnstructured --------------------------------------------------------- + +func TestAsUnstructured(t *testing.T) { + u := &unstructured.Unstructured{Object: map[string]any{"metadata": map[string]any{"name": "x"}}} + + t.Run("plain unstructured pointer", func(t *testing.T) { + got := asUnstructured(u) + if got != u { + t.Errorf("expected same pointer, got %p vs %p", got, u) + } + }) + + t.Run("DeletedFinalStateUnknown wrapping unstructured", func(t *testing.T) { + tombstone := cache.DeletedFinalStateUnknown{Key: "llm/x", Obj: u} + got := asUnstructured(tombstone) + if got != u { + t.Errorf("expected to unwrap tombstone to %p, got %p", u, got) + } + }) + + t.Run("DeletedFinalStateUnknown with non-unstructured inside returns nil", func(t *testing.T) { + tombstone := cache.DeletedFinalStateUnknown{Key: "llm/x", Obj: "not an unstructured"} + if got := asUnstructured(tombstone); got != nil { + t.Errorf("expected nil, got %v", got) + } + }) + + t.Run("unrelated type returns nil", func(t *testing.T) { + if got := asUnstructured("a string"); got != nil { + t.Errorf("expected nil, got %v", got) + } + if got := asUnstructured(nil); got != nil { + t.Errorf("expected nil for nil input, got %v", got) + } + }) +} + +// --- statusFor -------------------------------------------------------------- + +func TestStatusFor_IdentityPassThrough(t *testing.T) { + in := &monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}, + } + if got := statusFor(in); got != in { + t.Errorf("statusFor must return the same pointer: got %p, want %p", got, in) + } + if statusFor(nil) != nil { + t.Error("statusFor(nil) must return nil") + } +} + +// --- loadRegistrationSigningKey --------------------------------------------- + +func TestLoadRegistrationSigningKey_NoKeyConfigured(t *testing.T) { + t.Setenv("ERC8004_PRIVATE_KEY", "") + t.Setenv("ERC8004_PRIVATE_KEY_FILE", "") + + key, err := loadRegistrationSigningKey() + if err != nil { + t.Fatalf("expected nil error when no key configured, got %v", err) + } + if key != nil { + t.Errorf("expected nil key when nothing configured, got %p", key) + } +} + +func TestLoadRegistrationSigningKey_InvalidHex(t *testing.T) { + t.Setenv("ERC8004_PRIVATE_KEY", "not-hex") + t.Setenv("ERC8004_PRIVATE_KEY_FILE", "") + + _, err := loadRegistrationSigningKey() + if err == nil { + t.Fatal("expected error on invalid hex, got nil") + } + if !strings.Contains(err.Error(), "parse ERC8004 private key") { + t.Errorf("error = %q, want substring %q", err.Error(), "parse ERC8004 private key") + } +} + +func TestLoadRegistrationSigningKey_ValidHex(t *testing.T) { + // A deterministic test-only secp256k1 private key. + t.Setenv("ERC8004_PRIVATE_KEY", "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("ERC8004_PRIVATE_KEY_FILE", "") + + key, err := loadRegistrationSigningKey() + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if key == nil { + t.Fatal("expected non-nil key") + } +} + +func TestLoadRegistrationSigningKey_FromFile(t *testing.T) { + dir := t.TempDir() + path := dir + "/key.hex" + if err := os.WriteFile(path, []byte(" 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \n"), 0600); err != nil { + t.Fatalf("write temp key: %v", err) + } + t.Setenv("ERC8004_PRIVATE_KEY", "") + t.Setenv("ERC8004_PRIVATE_KEY_FILE", path) + + key, err := loadRegistrationSigningKey() + if err != nil { + t.Fatalf("load from file: %v", err) + } + if key == nil { + t.Fatal("expected non-nil key loaded from file") + } +} + +func TestLoadRegistrationSigningKey_MissingFile(t *testing.T) { + t.Setenv("ERC8004_PRIVATE_KEY", "") + t.Setenv("ERC8004_PRIVATE_KEY_FILE", "/nonexistent/path/does/not/exist/key.hex") + + _, err := loadRegistrationSigningKey() + if err == nil { + t.Fatal("expected error for missing file") + } + if !strings.Contains(err.Error(), "read ERC8004_PRIVATE_KEY_FILE") { + t.Errorf("error = %q, want substring %q", err.Error(), "read ERC8004_PRIVATE_KEY_FILE") + } +} + +// --- buildTombstoneRegistrationDocument ------------------------------------- + +func TestBuildTombstoneRegistrationDocument(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "inference", + Model: monetizeapi.ServiceOfferModel{Name: "qwen3.5:9b"}, + Registration: monetizeapi.ServiceOfferRegistration{ + Name: "Demo Agent", + Description: "Alive registration", + }, + }, + } + + doc := buildTombstoneRegistrationDocument(offer, "https://example.com", "") + + if doc.Active { + t.Error("tombstone document must have Active=false") + } + if doc.X402Support { + t.Error("tombstone must have X402Support=false") + } + if doc.Type != erc8004.RegistrationType { + t.Errorf("Type = %q, want %q", doc.Type, erc8004.RegistrationType) + } + if !strings.Contains(doc.Description, "(deactivated)") { + t.Errorf("description = %q, should contain (deactivated) suffix", doc.Description) + } +} + +// --- marshalRegistrationDocument -------------------------------------------- + +func TestMarshalRegistrationDocument(t *testing.T) { + doc := erc8004.AgentRegistration{ + Type: erc8004.RegistrationType, + Name: "Demo", + Description: "Demo", + Image: "https://example.com/icon.png", + } + body, hash, err := marshalRegistrationDocument(doc) + if err != nil { + t.Fatalf("marshalRegistrationDocument: %v", err) + } + if body == "" { + t.Error("expected non-empty body") + } + if len(hash) == 0 { + t.Error("expected non-empty hash") + } + if !strings.Contains(body, `"name": "Demo"`) { + t.Errorf("body missing pretty-printed name, got:\n%s", body) + } + + // Identical input produces identical hash. + _, hash2, _ := marshalRegistrationDocument(doc) + if hash != hash2 { + t.Errorf("hash non-deterministic: %q vs %q", hash, hash2) + } + + // Changing content changes hash. + doc.Description = "changed" + _, hash3, _ := marshalRegistrationDocument(doc) + if hash == hash3 { + t.Error("hash should differ for different content") + } +} + +// --- selectRegistrationOwner additional cases ------------------------------- + +func TestSelectRegistrationOwner_ZeroTimestampsOrderedByName(t *testing.T) { + // Both offers have zero CreationTimestamp — should fall back to ns/name order. + a := &monetizeapi.ServiceOffer{ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: "ns1", UID: types.UID("1")}} + b := &monetizeapi.ServiceOffer{ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "ns1", UID: types.UID("2")}} + + got := selectRegistrationOwner([]*monetizeapi.ServiceOffer{a, b}) + if got == nil { + t.Fatal("expected non-nil winner") + } + if got.Name != "a" { + t.Errorf("winner name = %q, want %q (name tiebreaker for equal zero timestamps)", got.Name, "a") + } +} + +func TestSelectRegistrationOwner_OneZeroTimestampLoses(t *testing.T) { + withTime := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "ns1", CreationTimestamp: metav1.Now()}, + } + zeroTime := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: "ns1"}, + } + got := selectRegistrationOwner([]*monetizeapi.ServiceOffer{zeroTime, withTime}) + if got == nil { + t.Fatal("expected non-nil winner") + } + if got.Name != "a" { + t.Errorf("zero timestamp should lose: winner = %q, want %q", got.Name, "a") + } +} diff --git a/internal/serviceoffercontroller/purchase_pure_test.go b/internal/serviceoffercontroller/purchase_pure_test.go new file mode 100644 index 00000000..5cd09327 --- /dev/null +++ b/internal/serviceoffercontroller/purchase_pure_test.go @@ -0,0 +1,272 @@ +package serviceoffercontroller + +import ( + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" +) + +// --- hasStringInSlice ------------------------------------------------------- + +func TestHasStringInSlice(t *testing.T) { + tests := []struct { + name string + slice []string + target string + want bool + }{ + {"empty", nil, "x", false}, + {"present", []string{"a", "b", "c"}, "b", true}, + {"absent", []string{"a", "b", "c"}, "z", false}, + {"case sensitive", []string{"Foo"}, "foo", false}, + {"empty string target absent", []string{"a", "b"}, "", false}, + {"empty string target present", []string{"a", ""}, "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasStringInSlice(tt.slice, tt.target); got != tt.want { + t.Errorf("hasStringInSlice(%v, %q) = %v, want %v", tt.slice, tt.target, got, tt.want) + } + }) + } +} + +// --- purchaseConditionIsTrue ------------------------------------------------ + +func TestPurchaseConditionIsTrue(t *testing.T) { + conds := []monetizeapi.Condition{ + {Type: "Signed", Status: "True"}, + {Type: "Configured", Status: "False"}, + {Type: "Ready", Status: "Unknown"}, + } + if !purchaseConditionIsTrue(conds, "Signed") { + t.Error("Signed should be true") + } + if purchaseConditionIsTrue(conds, "Configured") { + t.Error("Configured is False, must not report true") + } + if purchaseConditionIsTrue(conds, "Ready") { + t.Error("Ready is Unknown, must not report true") + } + if purchaseConditionIsTrue(conds, "Missing") { + t.Error("Missing condition must not report true") + } + if purchaseConditionIsTrue(nil, "Any") { + t.Error("nil conditions must not report true") + } +} + +// --- setPurchaseCondition --------------------------------------------------- + +func TestSetPurchaseCondition_AppendsNew(t *testing.T) { + var conds []monetizeapi.Condition + + setPurchaseCondition(&conds, "Signed", "True", "SignedOK", "ok") + + if len(conds) != 1 { + t.Fatalf("len(conds) = %d, want 1", len(conds)) + } + if conds[0].Type != "Signed" || conds[0].Status != "True" { + t.Errorf("conds[0] = %+v, want Type=Signed Status=True", conds[0]) + } + if conds[0].LastTransitionTime.IsZero() { + t.Error("LastTransitionTime must be set on new condition") + } +} + +func TestSetPurchaseCondition_UpdatesExistingNoStatusChange(t *testing.T) { + conds := []monetizeapi.Condition{ + {Type: "Signed", Status: "True", Reason: "Old", Message: "old msg"}, + } + // Capture the original timestamp (zero value is fine — we just want it to remain unchanged). + originalTs := conds[0].LastTransitionTime + + setPurchaseCondition(&conds, "Signed", "True", "NewReason", "new msg") + + if len(conds) != 1 { + t.Fatalf("condition count changed: %d", len(conds)) + } + if conds[0].Reason != "NewReason" { + t.Errorf("Reason = %q, want NewReason", conds[0].Reason) + } + if conds[0].Message != "new msg" { + t.Errorf("Message = %q, want 'new msg'", conds[0].Message) + } + // Status unchanged -> LastTransitionTime must NOT be bumped. + if !conds[0].LastTransitionTime.Equal(&originalTs) { + t.Errorf("LastTransitionTime bumped when status did not change (before=%v, after=%v)", + originalTs, conds[0].LastTransitionTime) + } +} + +func TestSetPurchaseCondition_StatusFlipBumpsTimestamp(t *testing.T) { + conds := []monetizeapi.Condition{ + {Type: "Signed", Status: "False"}, + } + + setPurchaseCondition(&conds, "Signed", "True", "Flipped", "") + + if conds[0].Status != "True" { + t.Errorf("Status = %q, want True", conds[0].Status) + } + if conds[0].LastTransitionTime.IsZero() { + t.Error("LastTransitionTime must be set when status flips") + } +} + +// --- normalizeRecoverySignature -------------------------------------------- + +func TestNormalizeRecoverySignature(t *testing.T) { + // 132 chars = 0x + 130 hex chars = 65 bytes. + // v byte is the last byte; must be bumped from {0,1} -> {27,28} and left alone + // if already {27,28} (or any v > 1). + const baseHex = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + tests := []struct { + name string + sig string + want string + }{ + { + name: "v=0 bumped to 27 (0x1b)", + sig: baseHex + "00", + want: baseHex + "1b", + }, + { + name: "v=1 bumped to 28 (0x1c)", + sig: baseHex + "01", + want: baseHex + "1c", + }, + { + name: "v=27 unchanged", + sig: baseHex + "1b", + want: baseHex + "1b", + }, + { + name: "v=28 unchanged", + sig: baseHex + "1c", + want: baseHex + "1c", + }, + { + name: "short signature returned unchanged", + sig: "0xdeadbeef", + want: "0xdeadbeef", + }, + { + name: "no 0x prefix returned unchanged", + sig: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00", + want: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00", + }, + { + name: "malformed v byte returned unchanged", + sig: baseHex + "zz", + want: baseHex + "zz", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeRecoverySignature(tt.sig); got != tt.want { + t.Errorf("normalizeRecoverySignature last byte = %q, want %q", got[len(got)-2:], tt.want[len(tt.want)-2:]) + } + }) + } +} + +// --- normalizePurchasedUpstreamURL ------------------------------------------ + +func TestNormalizePurchasedUpstreamURL(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"", ""}, + {"https://seller.example", "https://seller.example"}, + {"https://seller.example/", "https://seller.example"}, + {"https://seller.example/services/api", "https://seller.example/services/api"}, + {"https://seller.example/v1/chat/completions", "https://seller.example"}, + {"https://seller.example/chat/completions", "https://seller.example"}, + {"https://seller.example/v1/chat/completions/", "https://seller.example"}, + {" https://seller.example/v1/chat/completions ", "https://seller.example"}, + // Only /v1/chat/completions or /chat/completions are stripped — not anywhere in the middle. + {"https://seller.example/chat/completions/extra", "https://seller.example/chat/completions/extra"}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := normalizePurchasedUpstreamURL(tt.in); got != tt.want { + t.Errorf("normalizePurchasedUpstreamURL(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// --- preSignedAuthMaps ------------------------------------------------------ + +func TestPreSignedAuthMaps_Empty(t *testing.T) { + pr := &monetizeapi.PurchaseRequest{} + _, err := preSignedAuthMaps(pr) + if err == nil { + t.Fatal("expected error for empty auths") + } + if !strings.Contains(err.Error(), "no pre-signed auths") { + t.Errorf("error = %q, want substring 'no pre-signed auths'", err.Error()) + } +} + +func TestPreSignedAuthMaps_NormalizesSignature(t *testing.T) { + const baseHex = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + pr := &monetizeapi.PurchaseRequest{ + Spec: monetizeapi.PurchaseRequestSpec{ + PreSignedAuths: []monetizeapi.PreSignedAuth{ + { + Signature: baseHex + "00", // v=0, must be normalized to 1b + From: "0x1111", + To: "0x2222", + Value: "1000", + ValidAfter: "1", + ValidBefore: "2", + Nonce: "0xdeadbeef", + }, + { + Signature: baseHex + "1c", // already normalized + From: "0x3333", + To: "0x4444", + Value: "2000", + ValidAfter: "3", + ValidBefore: "4", + Nonce: "0xfeedface", + }, + }, + }, + } + + auths, err := preSignedAuthMaps(pr) + if err != nil { + t.Fatalf("preSignedAuthMaps: %v", err) + } + if len(auths) != 2 { + t.Fatalf("len(auths) = %d, want 2", len(auths)) + } + if got := auths[0]["signature"]; !strings.HasSuffix(got, "1b") { + t.Errorf("auth[0] signature suffix = %q, want normalized to 1b", got[len(got)-2:]) + } + if got := auths[1]["signature"]; !strings.HasSuffix(got, "1c") { + t.Errorf("auth[1] signature suffix = %q, want 1c unchanged", got[len(got)-2:]) + } + // Round-trip verification of the other fields. + if auths[0]["from"] != "0x1111" { + t.Errorf("auth[0] from = %q", auths[0]["from"]) + } + if auths[1]["nonce"] != "0xfeedface" { + t.Errorf("auth[1] nonce = %q", auths[1]["nonce"]) + } + // Required keys must all be present. + for i, a := range auths { + for _, k := range []string{"signature", "from", "to", "value", "validAfter", "validBefore", "nonce"} { + if _, ok := a[k]; !ok { + t.Errorf("auth[%d] missing key %q", i, k) + } + } + } +} diff --git a/internal/serviceoffercontroller/render_builders_test.go b/internal/serviceoffercontroller/render_builders_test.go new file mode 100644 index 00000000..99a24a12 --- /dev/null +++ b/internal/serviceoffercontroller/render_builders_test.go @@ -0,0 +1,489 @@ +package serviceoffercontroller + +import ( + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// TestBuildMiddleware asserts ForwardAuth wiring and owner references. +// The controller MUST point Traefik at the in-cluster x402-verifier Service +// and propagate the three forwarded response headers to upstream. +func TestBuildMiddleware(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "llm", UID: types.UID("o-uid")}, + } + + mw := buildMiddleware(offer) + + if mw.GetAPIVersion() != "traefik.io/v1alpha1" { + t.Errorf("apiVersion = %q, want traefik.io/v1alpha1", mw.GetAPIVersion()) + } + if mw.GetKind() != "Middleware" { + t.Errorf("kind = %q, want Middleware", mw.GetKind()) + } + if mw.GetName() != "x402-demo" { + t.Errorf("name = %q, want x402-demo", mw.GetName()) + } + if mw.GetNamespace() != "llm" { + t.Errorf("namespace = %q, want llm", mw.GetNamespace()) + } + + spec, _ := mw.Object["spec"].(map[string]any) + fwd, _ := spec["forwardAuth"].(map[string]any) + addr, _ := fwd["address"].(string) + if !strings.Contains(addr, "x402-verifier.x402.svc.cluster.local") { + t.Errorf("forwardAuth.address = %q, must target in-cluster verifier", addr) + } + hdrs, _ := fwd["authResponseHeaders"].([]any) + wantHeaders := map[string]bool{ + "X-Payment-Status": false, + "X-Payment-Tx": false, + "Authorization": false, + } + for _, h := range hdrs { + if s, ok := h.(string); ok { + wantHeaders[s] = true + } + } + for name, seen := range wantHeaders { + if !seen { + t.Errorf("authResponseHeaders missing %q (required for LiteLLM upstream auth)", name) + } + } + + // Owner reference to the ServiceOffer must exist so Middleware is GC'd on delete. + refs := mw.GetOwnerReferences() + if len(refs) != 1 { + t.Fatalf("expected 1 owner reference, got %d", len(refs)) + } + if refs[0].Kind != monetizeapi.ServiceOfferKind { + t.Errorf("owner kind = %q, want %s", refs[0].Kind, monetizeapi.ServiceOfferKind) + } + if refs[0].UID != types.UID("o-uid") { + t.Errorf("owner uid = %q, want o-uid", refs[0].UID) + } +} + +// TestBuildRegistrationConfigMap: data payload carries the JSON document plus +// the httpd mime-type config, and the owner ref points to the RegistrationRequest. +func TestBuildRegistrationConfigMap(t *testing.T) { + req := &monetizeapi.RegistrationRequest{ + ObjectMeta: metav1.ObjectMeta{Name: "so-demo-registration", Namespace: "llm", UID: types.UID("rr-uid")}, + } + doc := `{"type":"https://agents.openclaw.ai/AgentRegistration/v0.1"}` + + cm := buildRegistrationConfigMap(req, doc) + + if cm.GetKind() != "ConfigMap" { + t.Errorf("kind = %q, want ConfigMap", cm.GetKind()) + } + if cm.GetName() != "so-demo-registration" { + t.Errorf("name = %q, want so-demo-registration", cm.GetName()) + } + data, _ := cm.Object["data"].(map[string]any) + if data["agent-registration.json"] != doc { + t.Errorf("agent-registration.json = %v, want exact passthrough", data["agent-registration.json"]) + } + if httpdConf, _ := data["httpd.conf"].(string); !strings.Contains(httpdConf, ".json:application/json") { + t.Errorf("httpd.conf missing mime mapping, got %q", httpdConf) + } + if owners := cm.GetOwnerReferences(); len(owners) != 1 || owners[0].Kind != monetizeapi.RegistrationRequestKind { + t.Errorf("owner refs = %+v, want RegistrationRequest owner", owners) + } +} + +// TestBuildRegistrationDeployment: content-hash annotation on pod spec triggers +// rollout when the registration document changes; busybox httpd serves /www. +func TestBuildRegistrationDeployment(t *testing.T) { + req := &monetizeapi.RegistrationRequest{ + ObjectMeta: metav1.ObjectMeta{Name: "so-demo-registration", Namespace: "llm", UID: types.UID("rr-uid")}, + Spec: monetizeapi.RegistrationRequestSpec{ServiceOfferName: "demo"}, + } + + dep1 := buildRegistrationDeployment(req, "hash-aaa") + dep2 := buildRegistrationDeployment(req, "hash-bbb") + + spec1, _ := dep1.Object["spec"].(map[string]any) + template1, _ := spec1["template"].(map[string]any) + meta1, _ := template1["metadata"].(map[string]any) + ann1, _ := meta1["annotations"].(map[string]any) + if ann1["obol.org/content-hash"] != "hash-aaa" { + t.Errorf("content-hash = %v, want hash-aaa", ann1["obol.org/content-hash"]) + } + + spec2, _ := dep2.Object["spec"].(map[string]any) + template2, _ := spec2["template"].(map[string]any) + meta2, _ := template2["metadata"].(map[string]any) + ann2, _ := meta2["annotations"].(map[string]any) + if ann2["obol.org/content-hash"] == ann1["obol.org/content-hash"] { + t.Error("content-hash should differ between builds with different hashes") + } + + // Label must carry the ServiceOffer name so controller ownership introspection works. + if lbls, ok := meta1["labels"].(map[string]any); ok { + if lbls["obol.org/serviceoffer"] != "demo" { + t.Errorf("labels[obol.org/serviceoffer] = %v, want demo", lbls["obol.org/serviceoffer"]) + } + } else { + t.Error("template.metadata.labels missing") + } + + // Deployment kind + owner reference. + if dep1.GetKind() != "Deployment" { + t.Errorf("kind = %q, want Deployment", dep1.GetKind()) + } + if owners := dep1.GetOwnerReferences(); len(owners) == 0 || owners[0].UID != types.UID("rr-uid") { + t.Errorf("owner refs = %+v, want RegistrationRequest owner uid=rr-uid", owners) + } +} + +// TestBuildRegistrationService: ClusterIP service with selector pointing at +// the per-registration deployment labels. +func TestBuildRegistrationService(t *testing.T) { + req := &monetizeapi.RegistrationRequest{ + ObjectMeta: metav1.ObjectMeta{Name: "so-demo-registration", Namespace: "llm", UID: types.UID("rr-uid")}, + } + + svc := buildRegistrationService(req) + + if svc.GetKind() != "Service" { + t.Errorf("kind = %q, want Service", svc.GetKind()) + } + if svc.GetNamespace() != "llm" { + t.Errorf("namespace = %q, want llm", svc.GetNamespace()) + } + spec, _ := svc.Object["spec"].(map[string]any) + if spec["type"] != "ClusterIP" { + t.Errorf("service.type = %v, want ClusterIP", spec["type"]) + } + selector, _ := spec["selector"].(map[string]any) + if selector["app"] != "so-demo-registration" { + t.Errorf("selector.app = %v, want so-demo-registration", selector["app"]) + } + ports, _ := spec["ports"].([]any) + if len(ports) != 1 { + t.Fatalf("expected 1 port, got %d", len(ports)) + } + if port, _ := ports[0].(map[string]any); port["port"] != int64(8080) { + t.Errorf("ports[0].port = %v, want 8080", port["port"]) + } +} + +// TestBuildSkillCatalogConfigMap: exposes skill.md + services.json + httpd conf. +func TestBuildSkillCatalogConfigMap(t *testing.T) { + cm := buildSkillCatalogConfigMap("# Catalog", `[{"name":"a"}]`) + + if cm.GetName() != skillCatalogConfigMapName { + t.Errorf("name = %q, want %q", cm.GetName(), skillCatalogConfigMapName) + } + if cm.GetNamespace() != skillCatalogNamespace { + t.Errorf("namespace = %q, want %q", cm.GetNamespace(), skillCatalogNamespace) + } + data, _ := cm.Object["data"].(map[string]any) + if data["skill.md"] != "# Catalog" { + t.Errorf("skill.md payload mismatch, got %v", data["skill.md"]) + } + if data["services.json"] != `[{"name":"a"}]` { + t.Errorf("services.json payload mismatch, got %v", data["services.json"]) + } + if conf, _ := data["httpd.conf"].(string); !strings.Contains(conf, ".md:text/markdown") || !strings.Contains(conf, ".json:application/json") { + t.Errorf("httpd.conf missing required mime mappings: %q", conf) + } + // Managed-by label so the controller owns cleanup on uninstall. + lbls, _ := cm.Object["metadata"].(map[string]any)["labels"].(map[string]any) + if lbls["obol.org/managed-by"] != "serviceoffer-controller" { + t.Errorf("managed-by label = %v, want serviceoffer-controller", lbls["obol.org/managed-by"]) + } +} + +// TestBuildSkillCatalogDeployment: content-hash annotation + correct volume wiring +// (skill.md and api/services.json paths). +func TestBuildSkillCatalogDeployment(t *testing.T) { + d1 := buildSkillCatalogDeployment("hash-1") + d2 := buildSkillCatalogDeployment("hash-2") + + spec1, _ := d1.Object["spec"].(map[string]any) + template1, _ := spec1["template"].(map[string]any) + meta1, _ := template1["metadata"].(map[string]any) + ann1, _ := meta1["annotations"].(map[string]any) + if ann1["obol.org/content-hash"] != "hash-1" { + t.Errorf("content-hash = %v, want hash-1", ann1["obol.org/content-hash"]) + } + + spec2, _ := d2.Object["spec"].(map[string]any) + template2, _ := spec2["template"].(map[string]any) + meta2, _ := template2["metadata"].(map[string]any) + ann2, _ := meta2["annotations"].(map[string]any) + if ann1["obol.org/content-hash"] == ann2["obol.org/content-hash"] { + t.Error("different content hashes must produce different annotations") + } + + // Verify the services.json path gets mounted under api/ (so the route can + // serve /api/services.json). Covers the switch in the skill-catalog volume + // layout. + podSpec, _ := template1["spec"].(map[string]any) + volumes, _ := podSpec["volumes"].([]any) + var foundServicesPath bool + for _, v := range volumes { + vm, _ := v.(map[string]any) + cm, _ := vm["configMap"].(map[string]any) + items, _ := cm["items"].([]any) + for _, it := range items { + item, _ := it.(map[string]any) + if item["key"] == "services.json" && item["path"] == "api/services.json" { + foundServicesPath = true + } + } + } + if !foundServicesPath { + t.Error("expected services.json to be mounted at api/services.json") + } +} + +// TestBuildSkillCatalogService: ClusterIP service on port 8080 with the +// managed-by selector. +func TestBuildSkillCatalogService(t *testing.T) { + svc := buildSkillCatalogService() + + if svc.GetName() != skillCatalogConfigMapName { + t.Errorf("name = %q, want %q", svc.GetName(), skillCatalogConfigMapName) + } + spec, _ := svc.Object["spec"].(map[string]any) + if spec["type"] != "ClusterIP" { + t.Errorf("type = %v, want ClusterIP", spec["type"]) + } + sel, _ := spec["selector"].(map[string]any) + if sel["obol.org/managed-by"] != "serviceoffer-controller" { + t.Errorf("selector missing managed-by, got %+v", sel) + } +} + +// TestOwnerRef / TestOwnerRefFor: owner references must carry +// Controller=true and BlockOwnerDeletion=true for correct K8s garbage +// collection semantics. +func TestOwnerRef(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "llm", UID: types.UID("so-uid")}, + } + + ref := ownerRef(offer) + + if ref.Kind != monetizeapi.ServiceOfferKind { + t.Errorf("kind = %q, want %s", ref.Kind, monetizeapi.ServiceOfferKind) + } + if ref.Name != "demo" { + t.Errorf("name = %q, want demo", ref.Name) + } + if ref.UID != types.UID("so-uid") { + t.Errorf("uid = %q, want so-uid", ref.UID) + } + if ref.APIVersion != monetizeapi.Group+"/"+monetizeapi.Version { + t.Errorf("apiVersion = %q, want %s/%s", ref.APIVersion, monetizeapi.Group, monetizeapi.Version) + } + if ref.Controller == nil || !*ref.Controller { + t.Error("Controller must be *true for GC semantics") + } + if ref.BlockOwnerDeletion == nil || !*ref.BlockOwnerDeletion { + t.Error("BlockOwnerDeletion must be *true to prevent premature deletion") + } +} + +func TestOwnerRefFor_CustomAPIVersion(t *testing.T) { + ref := ownerRefFor("custom/v1", "Foo", "bar", types.UID("u-1")) + if ref.APIVersion != "custom/v1" { + t.Errorf("apiVersion = %q, want custom/v1", ref.APIVersion) + } + if ref.Kind != "Foo" { + t.Errorf("kind = %q, want Foo", ref.Kind) + } + if ref.Controller == nil || !*ref.Controller { + t.Error("Controller must be true") + } +} + +// TestDefaultString: explicit fallback wiring — all whitespace inputs should +// trigger the fallback. +func TestDefaultString(t *testing.T) { + tests := []struct { + name string + value, fallback string + want string + }{ + {"non-empty value wins", "actual", "fallback", "actual"}, + {"empty falls back", "", "fallback", "fallback"}, + {"whitespace falls back", " \t ", "fallback", "fallback"}, + {"value surrounded by whitespace preserved", " hello ", "fallback", " hello "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := defaultString(tt.value, tt.fallback); got != tt.want { + t.Errorf("defaultString(%q, %q) = %q, want %q", tt.value, tt.fallback, got, tt.want) + } + }) + } +} + +// TestDescribeOfferPrice covers the price-fallthrough ladder. +func TestDescribeOfferPrice(t *testing.T) { + tests := []struct { + name string + spec monetizeapi.ServiceOfferSpec + want string + }{ + { + name: "per-request wins", + spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{ + PerRequest: "0.001", + PerMTok: "5", + PerHour: "10", + }, + }, + }, + want: "0.001 USDC/request", + }, + { + name: "per-mtok wins when no per-request", + spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{ + PerMTok: "5.00", + PerHour: "10", + }, + }, + }, + want: "5.00 USDC/MTok", + }, + { + name: "per-hour falls through when neither set", + spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{PerHour: "2.5"}, + }, + }, + want: "2.5 USDC/hour", + }, + { + name: "no pricing set falls through to em-dash", + spec: monetizeapi.ServiceOfferSpec{}, + want: "—", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + offer := &monetizeapi.ServiceOffer{Spec: tt.spec} + if got := describeOfferPrice(offer); got != tt.want { + t.Errorf("describeOfferPrice = %q, want %q", got, tt.want) + } + }) + } +} + +// TestParseInt64: silent-zero-on-error contract. +func TestParseInt64(t *testing.T) { + tests := []struct { + in string + want int64 + }{ + {"", 0}, + {"0", 0}, + {"42", 42}, + {" 42 ", 42}, + {"-17", -17}, + {"not-a-number", 0}, + {"0xff", 0}, // hex rejected: decimal parser + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := parseInt64(tt.in); got != tt.want { + t.Errorf("parseInt64(%q) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} + +// TestNonEmptyStringMap: removes empty keys/values and returns nil for fully +// empty input (lets callers cheaply skip the field). +func TestNonEmptyStringMap(t *testing.T) { + t.Run("nil input returns nil", func(t *testing.T) { + if got := nonEmptyStringMap(nil); got != nil { + t.Errorf("got %v, want nil", got) + } + }) + t.Run("empty map returns nil", func(t *testing.T) { + if got := nonEmptyStringMap(map[string]string{}); got != nil { + t.Errorf("got %v, want nil", got) + } + }) + t.Run("all entries empty returns nil", func(t *testing.T) { + in := map[string]string{"": "v", "k": "", " ": " "} + if got := nonEmptyStringMap(in); got != nil { + t.Errorf("got %+v, want nil", got) + } + }) + t.Run("trims keys and values", func(t *testing.T) { + in := map[string]string{" k ": " v "} + got := nonEmptyStringMap(in) + if got["k"] != "v" { + t.Errorf("got[%q] = %q, want v", "k", got["k"]) + } + }) + t.Run("keeps only non-empty pairs", func(t *testing.T) { + in := map[string]string{ + "a": "1", + "b": "", + "c": "3", + " ": "ghost", + "e": " ", + } + got := nonEmptyStringMap(in) + if len(got) != 2 { + t.Errorf("len = %d, want 2 (only a and c), got %+v", len(got), got) + } + if got["a"] != "1" || got["c"] != "3" { + t.Errorf("got %+v, want {a:1, c:3}", got) + } + }) +} + +// TestFallbackOfferType asserts the "http" default and passthrough of +// explicit types. +func TestFallbackOfferType(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"", "http"}, + {"inference", "inference"}, + {"http", "http"}, + {"fine-tuning", "fine-tuning"}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + offer := &monetizeapi.ServiceOffer{Spec: monetizeapi.ServiceOfferSpec{Type: tt.in}} + if got := fallbackOfferType(offer); got != tt.want { + t.Errorf("fallbackOfferType = %q, want %q", got, tt.want) + } + }) + } +} + +// TestSafeName_ExtremePrefixSuffix covers the guard branch where prefix+suffix +// alone are already close to the limit, forcing the `maxName < 1` clamp. +func TestSafeName_ExtremePrefixSuffix(t *testing.T) { + // Prefix alone exceeds maxK8sNameLen — forces the maxName < 1 clamp. + prefix := strings.Repeat("p", maxK8sNameLen) + got := safeName(prefix, "abc", "-x") + if got == "" { + t.Error("safeName should never return empty") + } + // Even under extreme prefix, the name portion should still be at least 1 char. + if !strings.Contains(got, "a") { + t.Errorf("safeName output %q should retain at least 1 char of the name", got) + } +}