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/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/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/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/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/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/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/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.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_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) + } +} diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index 407a086f..9280bc0e 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,287 @@ 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) + } +} + +// 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 { + 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"] +}