From b51431f72f6ba717ff5c199d70c6ca5fe32684e8 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 17 Jun 2026 17:45:04 -0400 Subject: [PATCH 1/3] feat: add Go bootstrap CLI for Auth0 + OpenMeter/Konnect provisioning Single cross-platform Go binary that provisions Auth0 (resource server, public + M2M clients, grants via go-auth0/v2) and OpenMeter/Konnect catalog (meters, features, default PPU plan via Kong sdk-konnect-go), then emits .env.livepeer and sdk-config.json for builder-sdk. Mirrors the TS admin pattern (port/adapter/factory) and uses the same config/meters.json and config/pricing.json definitions. --- .env.example | 34 ++ .github/workflows/release.yml | 25 ++ .gitignore | 13 + Makefile | 26 ++ README.md | 81 +++++ cmd/clearinghouse-bootstrap/main.go | 171 ++++++++++ config/meters.json | 34 ++ config/pricing.json | 11 + go.mod | 29 ++ go.sum | 68 ++++ internal/admin/catalog.go | 196 ++++++++++++ internal/admin/catalog_test.go | 282 +++++++++++++++++ internal/admin/errors.go | 25 ++ internal/admin/factory.go | 21 ++ internal/admin/konnect_adapter.go | 291 ++++++++++++++++++ internal/admin/port.go | 16 + internal/admin/prune.go | 153 +++++++++ internal/admin/types.go | 67 ++++ internal/auth0/provision.go | 150 +++++++++ internal/config/config.go | 175 +++++++++++ internal/config/config_test.go | 71 +++++ internal/config/envfile.go | 67 ++++ internal/config/envfile_test.go | 94 ++++++ internal/config/testdata/empty.env | 1 + internal/meters/meters.go | 91 ++++++ internal/meters/meters_test.go | 57 ++++ internal/output/env.go | 69 +++++ internal/output/env_test.go | 83 +++++ internal/output/sdkconfig.go | 69 +++++ internal/output/sdkconfig_test.go | 60 ++++ internal/output/testdata/env.golden | 44 +++ .../output/testdata/sdkconfig.golden.json | 21 ++ internal/pricing/pricing.go | 39 +++ internal/pricing/pricing_test.go | 51 +++ 34 files changed, 2685 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/clearinghouse-bootstrap/main.go create mode 100644 config/meters.json create mode 100644 config/pricing.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/admin/catalog.go create mode 100644 internal/admin/catalog_test.go create mode 100644 internal/admin/errors.go create mode 100644 internal/admin/factory.go create mode 100644 internal/admin/konnect_adapter.go create mode 100644 internal/admin/port.go create mode 100644 internal/admin/prune.go create mode 100644 internal/admin/types.go create mode 100644 internal/auth0/provision.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/envfile.go create mode 100644 internal/config/envfile_test.go create mode 100644 internal/config/testdata/empty.env create mode 100644 internal/meters/meters.go create mode 100644 internal/meters/meters_test.go create mode 100644 internal/output/env.go create mode 100644 internal/output/env_test.go create mode 100644 internal/output/sdkconfig.go create mode 100644 internal/output/sdkconfig_test.go create mode 100644 internal/output/testdata/env.golden create mode 100644 internal/output/testdata/sdkconfig.golden.json create mode 100644 internal/pricing/pricing.go create mode 100644 internal/pricing/pricing_test.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2d48138 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Copy to .env and fill in values: +# cp .env.example .env +# +# clearinghouse-bootstrap loads .env automatically from the current directory. +# CLI flags override .env values. + +# --- Auth0 (required unless --skip-auth0) --- +AUTH0_DOMAIN=your-org.us.auth0.com +AUTH0_MGMT_CLIENT_ID=your_auth0_management_m2m_client_id +AUTH0_MGMT_CLIENT_SECRET=your_auth0_management_m2m_client_secret + +APP_NAME=Livepeer Platform +AUTH0_AUDIENCE=livepeer + +# --- Konnect Metering & Billing (required unless --skip-openmeter) --- +# Docs: https://developer.konghq.com/api/konnect/metering-and-billing/v3/ +OPENMETER_URL=https://us.api.konghq.com/v3/openmeter +OPENMETER_API_KEY=kpat_your_konnect_personal_access_token +# OPENMETER_TRIAL_FEATURE_KEY=network_spend + +# --- Outputs (optional) --- +# BOOTSTRAP_OUTPUT=.env.livepeer +# SDK_CONFIG_OUTPUT=sdk-config.json + +# --- Deploy template vars (optional — written into .env.livepeer with defaults) --- +# REMOTE_SIGNER_WEBHOOK_URL=https://your-platform.vercel.app/webhooks/remote-signer +# SIGNER_PROXY_URL=https://your-platform.vercel.app/api/signer +# SIGNER_PUBLIC_URL=https://signer.your-domain.com +# SIGNER_NETWORK=arbitrum-one-mainnet +# ETH_RPC_URL=https://arb1.arbitrum.io/rpc +# SIGNER_ETH_ADDR=0x... +# KAFKA_BROKERS=kafka:9092 +# KAFKA_GATEWAY_TOPIC=livepeer-gateway-events +# ETH_USD_PRICE=3500 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d12fbeb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build cross-platform binaries + run: make cross + - name: Create release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfb8a4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Build output (binary at repo root) +/clearinghouse-bootstrap +/clearinghouse-bootstrap.exe +dist/ + +# Environment / secrets +.env +.env.livepeer +sdk-config.json + +# Go +/vendor/ +node_modules/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..91fdf48 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +BIN := clearinghouse-bootstrap +CMD := ./cmd/clearinghouse-bootstrap +GOFLAGS ?= +PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 + +.PHONY: build test clean cross + +build: + go build $(GOFLAGS) -o $(BIN) $(CMD) + +test: + go test ./... -count=1 + +clean: + rm -f $(BIN) dist/* + +cross: + @mkdir -p dist + @for platform in $(PLATFORMS); do \ + os=$${platform%/*}; \ + arch=$${platform#*/}; \ + ext=""; \ + if [ "$$os" = "windows" ]; then ext=".exe"; fi; \ + echo "Building $$os/$$arch..."; \ + GOOS=$$os GOARCH=$$arch go build $(GOFLAGS) -o dist/$(BIN)-$$os-$$arch$$ext $(CMD); \ + done diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b7ce4f --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# clearinghouse + +A single, cross-platform Go CLI that provisions **Auth0** and +**OpenMeter/Konnect** for the Livepeer remote-signer platform and emits +`.env.livepeer` + `sdk-config.json` for +[`@pymthouse/builder-sdk`](https://github.com/pymthouse/builder-sdk). + +## Quick start + +```bash +make build +cp .env.example .env # fill in secrets — see file for Auth0 + Konnect vars +./clearinghouse-bootstrap + +# Konnect only (no Auth0 creds in .env needed): +./clearinghouse-bootstrap --skip-auth0 + +# Auth0 only: +./clearinghouse-bootstrap --skip-openmeter +``` + +The CLI loads `.env` from the current directory automatically. Flags override +`.env` values. Run `./clearinghouse-bootstrap --help` for CLI flags; see +`.env.example` for all settings. + +## What it does + +1. **Auth0** — creates a resource server (`livepeer`, RS256, `sign:job`), + a public client (native, device_code + refresh_token), an M2M client + (client_credentials), and two client grants. Uses + [`go-auth0/v2`](https://github.com/auth0/go-auth0). + +2. **OpenMeter/Konnect** — idempotently ensures meters + (`network_fee_usd_micros`, `billable_usd_micros`, `signed_ticket_count`), + features (`network_spend`, `billable_spend`), and the default pay-per-use + plan with a usage rate card. Uses the official + [`Kong/sdk-konnect-go`](https://github.com/Kong/sdk-konnect-go) SDK. + +3. **Output** — writes `.env.livepeer` (all env vars for VM deploy / Docker + Compose) and `sdk-config.json` (structured config for Vercel platform deploy + via builder-sdk). + +## Configuration + +Copy `.env.example` to `.env` and fill in your secrets. The CLI loads `.env` +automatically (override with `--env-file`). Flags override `.env` values. + +If a required value is missing for the selected mode, the CLI exits with an +error before calling any APIs. See `.env.example` for variable names and +comments. + +Use `--prune` to destructively remove Konnect catalog objects (meters, +features, plans) that are not defined in `config/meters.json` and +`config/pricing.json`, or whose meter dimensions no longer match config. +Prune runs before ensure/create. **This can delete production billing +catalog data** — use only when you intend to reconcile the tenant to config. + +Deploy template vars (Kafka, signer URLs, ETH RPC) are optional in `.env` — they +get sensible defaults in `.env.livepeer` output. + +## Config files + +Meter and pricing definitions live in `config/meters.json` and +`config/pricing.json`. These control which meters, features, and plans are +bootstrapped. + +## Cross-compilation + +```bash +make cross # builds linux/darwin/windows on amd64/arm64 into dist/ +``` + +Tagged releases (`v*`) are built and published via GitHub Actions +(`.github/workflows/release.yml`). + +## Follow-ups (out of scope for this PR) + +- Per-customer provisioning (customers + subscriptions) +- Self-hosted OpenMeter adapter +- Benthos collector / Docker Compose stack +- Railway deploy scripts diff --git a/cmd/clearinghouse-bootstrap/main.go b/cmd/clearinghouse-bootstrap/main.go new file mode 100644 index 0000000..675cbcb --- /dev/null +++ b/cmd/clearinghouse-bootstrap/main.go @@ -0,0 +1,171 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "strings" + + "github.com/livepeer/clearinghouse/internal/admin" + "github.com/livepeer/clearinghouse/internal/auth0" + "github.com/livepeer/clearinghouse/internal/config" + "github.com/livepeer/clearinghouse/internal/meters" + "github.com/livepeer/clearinghouse/internal/output" + "github.com/livepeer/clearinghouse/internal/pricing" +) + +func main() { + args, envFile, envFileExplicit, help, err := config.PreprocessArgs(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) + config.PrintUsage(false) + os.Exit(1) + } + if help != config.HelpNone { + config.PrintUsage(help == config.HelpAll) + os.Exit(0) + } + + if err := config.LoadEnvFile(envFile, envFileExplicit); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if envFileExplicit || fileExists(envFile) { + log.Printf("Loaded config from %s", envFile) + } + + cfg, err := config.Parse(args) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) + config.PrintUsage(false) + os.Exit(1) + } + + ctx := context.Background() + + var auth0Result *auth0.ProvisionResult + if !cfg.SkipAuth0 { + log.Println("Provisioning Auth0...") + auth0Result, err = auth0.Provision(ctx, auth0.ProvisionConfig{ + Domain: cfg.Auth0Domain, + MgmtClientID: cfg.Auth0MgmtClientID, + MgmtClientSecret: cfg.Auth0MgmtClientSecret, + AppName: cfg.AppName, + APIAudience: cfg.APIAudience, + }) + if err != nil { + log.Fatalf("Auth0 provisioning failed: %v", err) + } + log.Printf("Auth0 provisioned: API=%s PublicClient=%s M2M=%s", + auth0Result.APIIdentifier, auth0Result.PublicClientID, auth0Result.M2MClientID) + } else { + log.Println("Skipping Auth0 (--skip-auth0)") + } + + if !cfg.SkipOpenMeter { + log.Println("Bootstrapping OpenMeter/Konnect catalog...") + + meterCfg, err := meters.Load(cfg.MetersConfigPath) + if err != nil { + log.Fatalf("Failed to load meters config: %v", err) + } + pricingCfg, err := pricing.Load(cfg.PricingConfigPath) + if err != nil { + log.Fatalf("Failed to load pricing config: %v", err) + } + + omAdmin := admin.CreateAdmin(cfg.OpenmeterURL, cfg.OpenmeterAPIKey) + result, err := admin.BootstrapCatalog(ctx, omAdmin, meterCfg, pricingCfg, cfg.TrialFeatureKey, admin.BootstrapOptions{ + Prune: cfg.Prune, + }) + if err != nil { + log.Fatalf("OpenMeter bootstrap failed: %v", err) + } + printBootstrapResult(result) + } else { + log.Println("Skipping OpenMeter (--skip-openmeter)") + } + + envContent := output.BuildEnvFile(cfg, auth0Result) + if err := os.WriteFile(cfg.OutputPath, []byte(envContent), 0644); err != nil { + log.Fatalf("Failed to write %s: %v", cfg.OutputPath, err) + } + log.Printf("Wrote %s", cfg.OutputPath) + + if auth0Result != nil { + sdkJSON, err := output.BuildSDKConfig(cfg, auth0Result) + if err != nil { + log.Fatalf("Failed to build sdk-config.json: %v", err) + } + if err := os.WriteFile(cfg.SDKConfigOutputPath, append(sdkJSON, '\n'), 0644); err != nil { + log.Fatalf("Failed to write %s: %v", cfg.SDKConfigOutputPath, err) + } + log.Printf("Wrote %s", cfg.SDKConfigOutputPath) + } + + log.Println("Bootstrap complete.") +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func printBootstrapResult(r *admin.BootstrapResult) { + if r.Prune != nil { + for _, key := range r.Prune.DeletedPlans { + log.Printf(" pruned plan: %s", key) + } + for _, key := range r.Prune.DeletedFeatures { + log.Printf(" pruned feature: %s", key) + } + for _, key := range r.Prune.DeletedMeters { + log.Printf(" pruned meter: %s", key) + } + for _, w := range r.Prune.Warnings { + log.Printf(" prune warning: %s", w) + } + } + for _, m := range r.Meters { + action := "exists" + if m.Created { + action = "created" + } + msg := fmt.Sprintf(" meter %s: %s", m.Resource.Key, action) + if len(m.Warnings) > 0 { + msg += " [" + strings.Join(m.Warnings, "; ") + "]" + } + log.Println(msg) + } + for _, f := range r.Features { + action := "exists" + if f.Created { + action = "created" + } + msg := fmt.Sprintf(" feature %s: %s", f.Resource.Key, action) + if len(f.Warnings) > 0 { + msg += " [" + strings.Join(f.Warnings, "; ") + "]" + } + log.Println(msg) + } + if r.Plan != nil { + action := "exists" + if r.Plan.Created { + action = "created" + } + log.Printf(" plan %s: %s", r.Plan.Resource.Key, action) + } + if r.PlanSkippedReason != "" { + log.Printf(" plan skipped: %s", r.PlanSkippedReason) + } + + summary := map[string]string{ + "planKey": r.PlanKey, + "billableFeatureKey": r.BillableFeatureKey, + "trialIncludedMicros": r.TrialIncludedMicros, + } + b, _ := json.MarshalIndent(summary, " ", " ") + log.Printf(" pricing: %s", string(b)) +} diff --git a/config/meters.json b/config/meters.json new file mode 100644 index 0000000..d5b7da8 --- /dev/null +++ b/config/meters.json @@ -0,0 +1,34 @@ +{ + "createSignedTicketEventType": "create_signed_ticket", + "signedTicketEventSource": "go-livepeer-remote-signer", + "defaultTrialFeatureKey": "network_spend", + "defaultBillableFeatureKey": "billable_spend", + "networkFeeUsdMicrosMeter": "network_fee_usd_micros", + "billableUsdMicrosMeter": "billable_usd_micros", + "signedTicketCountMeter": "signed_ticket_count", + "dimensions": { + "client_id": "$.client_id", + "external_user_id": "$.external_user_id", + "pipeline": "$.pipeline", + "model_id": "$.model_id" + }, + "meters": { + "networkFeeUsdMicros": { + "openmeterDescription": "Livepeer signed-ticket network fee (USD micros) — SUM of signer computed_fee_usd_micros; grouped by client, user, pipeline, model", + "konnectName": "Network fee (USD micros)", + "konnectDescription": "Livepeer signed-ticket network fee (USD micros) — sum of signer computed_fee_usd_micros; grouped by client, user, pipeline, model", + "valueProperty": "$.network_fee_usd_micros" + }, + "billableUsdMicros": { + "openmeterDescription": "Billable usage (USD micros) after pipeline/model markup — SUM of billable_usd_micros; grouped by client, user, pipeline, model", + "konnectName": "Billable usage (USD micros)", + "konnectDescription": "Post-markup billable amount in USD micros for pay-per-use billing", + "valueProperty": "$.billable_usd_micros" + }, + "signedTicketCount": { + "openmeterDescription": "Signed ticket count per user", + "konnectName": "Signed ticket count", + "konnectDescription": "Signed ticket count per user" + } + } +} diff --git a/config/pricing.json b/config/pricing.json new file mode 100644 index 0000000..08570d2 --- /dev/null +++ b/config/pricing.json @@ -0,0 +1,11 @@ +{ + "defaultTrialIncludedUsdMicros": "5000000", + "defaultPlanKey": "clearinghouse_default_ppu", + "billableFeatureKey": "billable_spend", + "billableMeterKey": "billable_usd_micros", + "unitPriceUsdPerBillableMicro": "0.000001", + "markupRules": [ + { "pipeline": "*", "model_id": "*", "markupPercent": 0 }, + { "pipeline": "live-video-to-video", "model_id": "*", "markupPercent": 15 } + ] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a982ab8 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module github.com/livepeer/clearinghouse + +go 1.25.10 + +require ( + github.com/Kong/sdk-konnect-go v0.39.0 + github.com/auth0/go-auth0/v2 v2.13.0 + github.com/joho/godotenv v1.5.1 +) + +require ( + github.com/PuerkitoBio/rehttp v1.4.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/itchyny/gojq v0.12.17 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/spyzhov/ajson v0.8.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c1d9da7 --- /dev/null +++ b/go.sum @@ -0,0 +1,68 @@ +github.com/Kong/sdk-konnect-go v0.39.0 h1:fQhfBsMOERUYkfxi5/cmXaiKC508ZsT+pWA6u7o56qE= +github.com/Kong/sdk-konnect-go v0.39.0/go.mod h1:rDWJcOR3KBkB4Mmrc+2RThAzlNZmcvGVdGpESfgTK9A= +github.com/PuerkitoBio/rehttp v1.4.0 h1:rIN7A2s+O9fmHUM1vUcInvlHj9Ysql4hE+Y0wcl/xk8= +github.com/PuerkitoBio/rehttp v1.4.0/go.mod h1:LUwKPoDbDIA2RL5wYZCNsQ90cx4OJ4AWBmq6KzWZL1s= +github.com/auth0/go-auth0/v2 v2.13.0 h1:Lf1cPRypkb879mHin1GlGS6NtWkO47Efo0Bq2HIMez4= +github.com/auth0/go-auth0/v2 v2.13.0/go.mod h1:Q/Y3VZVoI3sw87VyTPhx2TQL6Sq4Q/iCP67rW2gcn+M= +github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0 h1:0NmehRCgyk5rljDQLKUO+cRJCnduDyn11+zGZIc9Z48= +github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0/go.mod h1:6L7zgvqo0idzI7IO8de6ZC051AfXb5ipkIJ7bIA2tGA= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/spyzhov/ajson v0.8.0 h1:sFXyMbi4Y/BKjrsfkUZHSjA2JM1184enheSjjoT/zCc= +github.com/spyzhov/ajson v0.8.0/go.mod h1:63V+CGM6f1Bu/p4nLIN8885ojBdt88TbLoSFzyqMuVA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/admin/catalog.go b/internal/admin/catalog.go new file mode 100644 index 0000000..96d2d31 --- /dev/null +++ b/internal/admin/catalog.go @@ -0,0 +1,196 @@ +package admin + +import ( + "context" + "fmt" + "math" + "strconv" + + "github.com/livepeer/clearinghouse/internal/meters" + "github.com/livepeer/clearinghouse/internal/pricing" +) + +type BootstrapResult struct { + Meters []EnsureResult[Meter] + Features []EnsureResult[Feature] + Plan *EnsureResult[Plan] + + PlanSkippedReason string + Prune *PruneResult + + PlanKey string + BillableFeatureKey string + TrialIncludedMicros string +} + +func ensureMeter(ctx context.Context, admin OpenMeterAdmin, input MeterInput) (*EnsureResult[Meter], error) { + existing, err := admin.ListMeters(ctx) + if err != nil { + return nil, err + } + for _, m := range existing { + if m.Key == input.Key { + var warnings []string + _, hasPipeline := m.Dimensions["pipeline"] + _, hasModelID := m.Dimensions["model_id"] + if !hasPipeline || !hasModelID { + warnings = append(warnings, fmt.Sprintf("meter %s missing pipeline/model_id dimensions — recreate manually if needed", input.Key)) + } + return &EnsureResult[Meter]{Resource: m, Created: false, Warnings: warnings}, nil + } + } + created, err := admin.CreateMeter(ctx, input) + if err != nil { + return nil, err + } + return &EnsureResult[Meter]{Resource: *created, Created: true}, nil +} + +func ensureFeature(ctx context.Context, admin OpenMeterAdmin, input FeatureInput) (*EnsureResult[Feature], error) { + features, err := admin.ListFeatures(ctx) + if err != nil { + return nil, err + } + for _, f := range features { + if f.Key == input.Key { + return &EnsureResult[Feature]{Resource: f, Created: false}, nil + } + } + created, err := admin.CreateFeature(ctx, input) + if err != nil { + return &EnsureResult[Feature]{ + Resource: Feature{Key: input.Key, Name: input.Name, MeterID: input.MeterID}, + Created: false, + Warnings: []string{fmt.Sprintf("feature bootstrap skipped for %s: %s", input.Key, err)}, + }, nil + } + return &EnsureResult[Feature]{Resource: *created, Created: true}, nil +} + +func BootstrapCatalog( + ctx context.Context, + admin OpenMeterAdmin, + meterCfg *meters.Config, + pricingCfg *pricing.Config, + trialFeatureKey string, + opts BootstrapOptions, +) (*BootstrapResult, error) { + if err := admin.WaitForHealthy(ctx); err != nil { + return nil, err + } + + result := &BootstrapResult{ + PlanKey: pricingCfg.DefaultPlanKey, + BillableFeatureKey: pricingCfg.BillableFeatureKey, + TrialIncludedMicros: pricingCfg.DefaultTrialIncludedUsdMicros, + } + + if opts.Prune { + pruneResult, err := PruneCatalog(ctx, admin, meterCfg, pricingCfg, trialFeatureKey) + if err != nil { + return nil, err + } + result.Prune = pruneResult + } + + defs := meterCfg.KonnectMeterDefinitions() + + var ensuredMeters []EnsureResult[Meter] + for _, d := range defs { + meterResult, err := ensureMeter(ctx, admin, MeterInput{ + Key: d.Key, + Name: d.Name, + Description: d.Description, + EventType: d.EventType, + Aggregation: d.Aggregation, + ValueProperty: d.ValueProperty, + Dimensions: d.Dimensions, + }) + if err != nil { + return nil, err + } + ensuredMeters = append(ensuredMeters, *meterResult) + } + + // Re-fetch meters to get IDs + allMeters, err := admin.ListMeters(ctx) + if err != nil { + return nil, err + } + meterByKey := make(map[string]Meter) + for _, m := range allMeters { + meterByKey[m.Key] = m + } + + networkFeeMeter, ok := meterByKey[meterCfg.NetworkFeeUsdMicrosMeter] + if !ok { + return nil, fmt.Errorf("meter missing after bootstrap: %s", meterCfg.NetworkFeeUsdMicrosMeter) + } + billableMeter, ok := meterByKey[meterCfg.BillableUsdMicrosMeter] + if !ok { + return nil, fmt.Errorf("meter missing after bootstrap: %s", meterCfg.BillableUsdMicrosMeter) + } + + if trialFeatureKey == "" { + trialFeatureKey = meterCfg.DefaultTrialFeatureKey + } + + var ensuredFeatures []EnsureResult[Feature] + + nfResult, err := ensureFeature(ctx, admin, FeatureInput{ + Key: trialFeatureKey, + Name: "Network spend", + MeterID: networkFeeMeter.ID, + }) + if err != nil { + return nil, err + } + ensuredFeatures = append(ensuredFeatures, *nfResult) + + bfResult, err := ensureFeature(ctx, admin, FeatureInput{ + Key: pricingCfg.BillableFeatureKey, + Name: "Billable spend", + MeterID: billableMeter.ID, + }) + if err != nil { + return nil, err + } + ensuredFeatures = append(ensuredFeatures, *bfResult) + + included, _ := strconv.ParseFloat(pricingCfg.DefaultTrialIncludedUsdMicros, 64) + includedMicros := int(math.Max(0, math.Floor(included))) + + result.Meters = ensuredMeters + result.Features = ensuredFeatures + + // Ensure plan + plans, err := admin.ListPlans(ctx) + if err != nil { + result.PlanSkippedReason = err.Error() + return result, nil + } + for _, p := range plans { + if p.Key == pricingCfg.DefaultPlanKey { + result.Plan = &EnsureResult[Plan]{Resource: p, Created: false} + return result, nil + } + } + + plan, err := admin.EnsurePlan(ctx, PlanInput{ + Key: pricingCfg.DefaultPlanKey, + Name: "Clearinghouse Default Pay-Per-Use", + FeatureKey: pricingCfg.BillableFeatureKey, + FeatureName: "Billable spend", + BillableMetterID: billableMeter.ID, + UnitAmount: pricingCfg.UnitPriceUsdPerBillableMicro, + IncludedMicros: includedMicros, + Currency: "USD", + BillingCadence: "P1M", + }) + if err != nil { + result.PlanSkippedReason = err.Error() + return result, nil + } + result.Plan = &EnsureResult[Plan]{Resource: *plan, Created: true} + return result, nil +} diff --git a/internal/admin/catalog_test.go b/internal/admin/catalog_test.go new file mode 100644 index 0000000..2fbd15c --- /dev/null +++ b/internal/admin/catalog_test.go @@ -0,0 +1,282 @@ +package admin + +import ( + "context" + "testing" + + "github.com/livepeer/clearinghouse/internal/meters" + "github.com/livepeer/clearinghouse/internal/pricing" +) + +type mockAdmin struct { + meters []Meter + features []Feature + plans []Plan +} + +func (m *mockAdmin) WaitForHealthy(_ context.Context) error { return nil } + +func (m *mockAdmin) ListMeters(_ context.Context) ([]Meter, error) { + return m.meters, nil +} + +func (m *mockAdmin) CreateMeter(_ context.Context, input MeterInput) (*Meter, error) { + meter := Meter{ + ID: "meter-" + input.Key, + Key: input.Key, + Name: input.Name, + Dimensions: input.Dimensions, + } + m.meters = append(m.meters, meter) + return &meter, nil +} + +func (m *mockAdmin) DeleteMeter(_ context.Context, id string) error { + for i, meter := range m.meters { + if meter.ID == id { + m.meters = append(m.meters[:i], m.meters[i+1:]...) + return nil + } + } + return nil +} + +func (m *mockAdmin) ListFeatures(_ context.Context) ([]Feature, error) { + return m.features, nil +} + +func (m *mockAdmin) CreateFeature(_ context.Context, input FeatureInput) (*Feature, error) { + feat := Feature{ + ID: "feat-" + input.Key, + Key: input.Key, + Name: input.Name, + MeterID: input.MeterID, + } + m.features = append(m.features, feat) + return &feat, nil +} + +func (m *mockAdmin) DeleteFeature(_ context.Context, id string) error { + for i, feat := range m.features { + if feat.ID == id { + m.features = append(m.features[:i], m.features[i+1:]...) + return nil + } + } + return nil +} + +func (m *mockAdmin) ListPlans(_ context.Context) ([]Plan, error) { + return m.plans, nil +} + +func (m *mockAdmin) EnsurePlan(_ context.Context, input PlanInput) (*Plan, error) { + plan := Plan{ + ID: "plan-" + input.Key, + Key: input.Key, + Name: input.Name, + } + m.plans = append(m.plans, plan) + return &plan, nil +} + +func (m *mockAdmin) DeletePlan(_ context.Context, id string) error { + for i, plan := range m.plans { + if plan.ID == id { + m.plans = append(m.plans[:i], m.plans[i+1:]...) + return nil + } + } + return nil +} + +func testMeterCfg() *meters.Config { + meterCfg := &meters.Config{ + CreateSignedTicketEventType: "create_signed_ticket", + NetworkFeeUsdMicrosMeter: "network_fee_usd_micros", + BillableUsdMicrosMeter: "billable_usd_micros", + SignedTicketCountMeter: "signed_ticket_count", + DefaultTrialFeatureKey: "network_spend", + DefaultBillableFeatureKey: "billable_spend", + Dimensions: map[string]string{ + "client_id": "$.client_id", + "pipeline": "$.pipeline", + "model_id": "$.model_id", + "external_user_id": "$.external_user_id", + }, + } + meterCfg.Meters.NetworkFeeUsdMicros.KonnectName = "Network fee" + meterCfg.Meters.NetworkFeeUsdMicros.ValueProperty = "$.network_fee_usd_micros" + meterCfg.Meters.BillableUsdMicros.KonnectName = "Billable" + meterCfg.Meters.BillableUsdMicros.ValueProperty = "$.billable_usd_micros" + meterCfg.Meters.SignedTicketCount.KonnectName = "Ticket count" + return meterCfg +} + +func testPricingCfg() *pricing.Config { + return &pricing.Config{ + DefaultPlanKey: "clearinghouse_default_ppu", + BillableFeatureKey: "billable_spend", + BillableMeterKey: "billable_usd_micros", + UnitPriceUsdPerBillableMicro: "0.000001", + DefaultTrialIncludedUsdMicros: "5000000", + } +} + +func TestBootstrapCatalogCreatesAll(t *testing.T) { + mock := &mockAdmin{} + + result, err := BootstrapCatalog(context.Background(), mock, testMeterCfg(), testPricingCfg(), "", BootstrapOptions{}) + if err != nil { + t.Fatalf("BootstrapCatalog: %v", err) + } + + if len(result.Meters) != 3 { + t.Errorf("expected 3 meters, got %d", len(result.Meters)) + } + for _, m := range result.Meters { + if !m.Created { + t.Errorf("meter %s should have been created", m.Resource.Key) + } + } + + if len(result.Features) != 2 { + t.Errorf("expected 2 features, got %d", len(result.Features)) + } + for _, f := range result.Features { + if !f.Created { + t.Errorf("feature %s should have been created", f.Resource.Key) + } + } + + if result.Plan == nil { + t.Fatal("expected plan to be created") + } + if !result.Plan.Created { + t.Error("plan should have been created") + } + if result.Plan.Resource.Key != "clearinghouse_default_ppu" { + t.Errorf("plan key = %s, want clearinghouse_default_ppu", result.Plan.Resource.Key) + } +} + +func TestBootstrapCatalogIdempotent(t *testing.T) { + mock := &mockAdmin{ + meters: []Meter{ + {ID: "m1", Key: "network_fee_usd_micros", Dimensions: testMeterCfg().Dimensions}, + {ID: "m2", Key: "billable_usd_micros", Dimensions: testMeterCfg().Dimensions}, + {ID: "m3", Key: "signed_ticket_count", Dimensions: testMeterCfg().Dimensions}, + }, + features: []Feature{ + {ID: "f1", Key: "network_spend", MeterID: "m1"}, + {ID: "f2", Key: "billable_spend", MeterID: "m2"}, + }, + plans: []Plan{ + {ID: "p1", Key: "clearinghouse_default_ppu"}, + }, + } + + result, err := BootstrapCatalog(context.Background(), mock, testMeterCfg(), testPricingCfg(), "", BootstrapOptions{}) + if err != nil { + t.Fatalf("BootstrapCatalog: %v", err) + } + + for _, m := range result.Meters { + if m.Created { + t.Errorf("meter %s should NOT have been created (idempotent)", m.Resource.Key) + } + } + for _, f := range result.Features { + if f.Created { + t.Errorf("feature %s should NOT have been created (idempotent)", f.Resource.Key) + } + } + if result.Plan == nil || result.Plan.Created { + t.Error("plan should exist and NOT have been created (idempotent)") + } +} + +func TestPruneCatalogRemovesExtras(t *testing.T) { + mock := &mockAdmin{ + meters: []Meter{ + {ID: "m1", Key: "network_fee_usd_micros", Dimensions: testMeterCfg().Dimensions}, + {ID: "m-old", Key: "legacy_meter", Dimensions: map[string]string{"x": "y"}}, + }, + features: []Feature{ + {ID: "f1", Key: "network_spend", MeterID: "m1"}, + {ID: "f-old", Key: "legacy_feature", MeterID: "m-old"}, + }, + plans: []Plan{ + {ID: "p1", Key: "clearinghouse_default_ppu"}, + {ID: "p-old", Key: "legacy_plan"}, + }, + } + + result, err := PruneCatalog(context.Background(), mock, testMeterCfg(), testPricingCfg(), "") + if err != nil { + t.Fatalf("PruneCatalog: %v", err) + } + + if len(result.DeletedMeters) != 1 || result.DeletedMeters[0] != "legacy_meter" { + t.Errorf("DeletedMeters = %v", result.DeletedMeters) + } + if len(result.DeletedFeatures) != 1 || result.DeletedFeatures[0] != "legacy_feature" { + t.Errorf("DeletedFeatures = %v", result.DeletedFeatures) + } + if len(result.DeletedPlans) != 1 || result.DeletedPlans[0] != "legacy_plan" { + t.Errorf("DeletedPlans = %v", result.DeletedPlans) + } +} + +func TestPruneCatalogRemovesMismatchedMeter(t *testing.T) { + mock := &mockAdmin{ + meters: []Meter{ + { + ID: "m1", + Key: "network_fee_usd_micros", + Dimensions: map[string]string{ + "client_id": "$.client_id", + }, + }, + }, + } + + result, err := PruneCatalog(context.Background(), mock, testMeterCfg(), testPricingCfg(), "") + if err != nil { + t.Fatalf("PruneCatalog: %v", err) + } + if len(result.DeletedMeters) != 1 { + t.Fatalf("expected mismatched meter deleted, got %v", result.DeletedMeters) + } +} + +func TestBootstrapCatalogPruneRecreatesMeter(t *testing.T) { + mock := &mockAdmin{ + meters: []Meter{ + { + ID: "m1", + Key: "network_fee_usd_micros", + Dimensions: map[string]string{ + "client_id": "$.client_id", + }, + }, + }, + } + + result, err := BootstrapCatalog(context.Background(), mock, testMeterCfg(), testPricingCfg(), "", BootstrapOptions{Prune: true}) + if err != nil { + t.Fatalf("BootstrapCatalog: %v", err) + } + if result.Prune == nil || len(result.Prune.DeletedMeters) == 0 { + t.Fatal("expected prune to delete mismatched meter") + } + created := false + for _, m := range result.Meters { + if m.Resource.Key == "network_fee_usd_micros" && m.Created { + created = true + } + } + if !created { + t.Error("expected network_fee_usd_micros to be recreated after prune") + } +} diff --git a/internal/admin/errors.go b/internal/admin/errors.go new file mode 100644 index 0000000..6bb45a7 --- /dev/null +++ b/internal/admin/errors.go @@ -0,0 +1,25 @@ +package admin + +import "fmt" + +type AdminError struct { + Message string + Code string +} + +func (e *AdminError) Error() string { return e.Message } + +type BackendUnreachableError struct{ AdminError } + +func NewBackendUnreachableError(msg string) *BackendUnreachableError { + return &BackendUnreachableError{AdminError{Message: msg, Code: "BACKEND_UNREACHABLE"}} +} + +type ResourceNotFoundError struct{ AdminError } + +func NewResourceNotFoundError(resource, id string) *ResourceNotFoundError { + return &ResourceNotFoundError{AdminError{ + Message: fmt.Sprintf("%s not found: %s", resource, id), + Code: "NOT_FOUND", + }} +} diff --git a/internal/admin/factory.go b/internal/admin/factory.go new file mode 100644 index 0000000..63e9928 --- /dev/null +++ b/internal/admin/factory.go @@ -0,0 +1,21 @@ +package admin + +import "strings" + +func CreateAdmin(baseURL, apiKey string) OpenMeterAdmin { + url := strings.TrimSpace(baseURL) + key := strings.TrimSpace(apiKey) + + // Normalize to API root (strip /openmeter and /events suffixes) + url = strings.TrimSuffix(url, "/") + url = strings.TrimSuffix(url, "/events") + url = strings.TrimSuffix(url, "/openmeter") + + // Strip /v3 — the SDK adds its own base path + url = strings.TrimSuffix(url, "/v3") + + return NewKonnectAdmin(KonnectAdminConfig{ + APIKey: key, + BaseURL: url, + }) +} diff --git a/internal/admin/konnect_adapter.go b/internal/admin/konnect_adapter.go new file mode 100644 index 0000000..00c446c --- /dev/null +++ b/internal/admin/konnect_adapter.go @@ -0,0 +1,291 @@ +package admin + +import ( + "context" + "fmt" + "time" + + sdkkonnectgo "github.com/Kong/sdk-konnect-go" + "github.com/Kong/sdk-konnect-go/models/components" + "github.com/Kong/sdk-konnect-go/models/operations" +) + +type KonnectAdmin struct { + sdk *sdkkonnectgo.SDK +} + +type KonnectAdminConfig struct { + APIKey string + BaseURL string +} + +func NewKonnectAdmin(cfg KonnectAdminConfig) *KonnectAdmin { + opts := []sdkkonnectgo.SDKOption{ + sdkkonnectgo.WithSecurity(components.Security{ + PersonalAccessToken: sdkkonnectgo.Pointer(cfg.APIKey), + }), + } + if cfg.BaseURL != "" { + opts = append(opts, sdkkonnectgo.WithServerURL(cfg.BaseURL)) + } else { + opts = append(opts, sdkkonnectgo.WithServerIndex(1)) // us.api.konghq.com + } + return &KonnectAdmin{sdk: sdkkonnectgo.New(opts...)} +} + +func NewKonnectAdminWithSDK(sdk *sdkkonnectgo.SDK) *KonnectAdmin { + return &KonnectAdmin{sdk: sdk} +} + +func (k *KonnectAdmin) WaitForHealthy(ctx context.Context) error { + const maxAttempts = 15 + const delay = 2 * time.Second + + for i := range maxAttempts { + res, err := k.sdk.Meters.ListMeters(ctx, operations.ListMetersRequest{ + Page: &components.PagePaginationQuery{ + Number: sdkkonnectgo.Pointer(int64(1)), + Size: sdkkonnectgo.Pointer(int64(1)), + }, + }) + if err == nil && res.StatusCode >= 200 && res.StatusCode < 300 { + return nil + } + if i < maxAttempts-1 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + } + } + return NewBackendUnreachableError("Konnect Metering & Billing not reachable") +} + +func (k *KonnectAdmin) ListMeters(ctx context.Context) ([]Meter, error) { + res, err := k.sdk.Meters.ListMeters(ctx, operations.ListMetersRequest{}) + if err != nil { + return nil, fmt.Errorf("listing meters: %w", err) + } + var out []Meter + if res.MeterPagePaginatedResponse != nil { + for _, m := range res.MeterPagePaginatedResponse.Data { + out = append(out, Meter{ + ID: m.ID, + Key: m.Key, + Name: m.Name, + Dimensions: m.Dimensions, + }) + } + } + return out, nil +} + +func (k *KonnectAdmin) CreateMeter(ctx context.Context, input MeterInput) (*Meter, error) { + req := components.CreateMeterRequest{ + Key: input.Key, + Name: input.Name, + Description: sdkkonnectgo.Pointer(input.Description), + EventType: input.EventType, + Aggregation: components.Aggregation(input.Aggregation), + ValueProperty: nilIfEmpty(input.ValueProperty), + Dimensions: input.Dimensions, + } + res, err := k.sdk.Meters.CreateMeter(ctx, req) + if err != nil { + return nil, fmt.Errorf("creating meter %s: %w", input.Key, err) + } + if res.Meter == nil { + return nil, fmt.Errorf("creating meter %s: empty response", input.Key) + } + return &Meter{ + ID: res.Meter.ID, + Key: res.Meter.Key, + Name: res.Meter.Name, + Dimensions: res.Meter.Dimensions, + }, nil +} + +func (k *KonnectAdmin) ListFeatures(ctx context.Context) ([]Feature, error) { + res, err := k.sdk.OpenMeterFeatures.ListFeatures(ctx, operations.ListFeaturesRequest{}) + if err != nil { + return nil, fmt.Errorf("listing features: %w", err) + } + var out []Feature + if res.FeaturePagePaginatedResponse != nil { + for _, f := range res.FeaturePagePaginatedResponse.Data { + feat := Feature{ID: f.ID, Key: f.Key, Name: f.Name} + if f.Meter != nil { + feat.MeterID = f.Meter.ID + } + out = append(out, feat) + } + } + return out, nil +} + +func (k *KonnectAdmin) CreateFeature(ctx context.Context, input FeatureInput) (*Feature, error) { + req := components.CreateFeatureRequest{ + Key: input.Key, + Name: input.Name, + } + if input.MeterID != "" { + req.Meter = &components.CreateFeatureRequestMeterReference{ID: input.MeterID} + } + res, err := k.sdk.OpenMeterFeatures.CreateFeature(ctx, req) + if err != nil { + return nil, fmt.Errorf("creating feature %s: %w", input.Key, err) + } + if res.Feature == nil { + return nil, fmt.Errorf("creating feature %s: empty response", input.Key) + } + feat := &Feature{ID: res.Feature.ID, Key: res.Feature.Key, Name: res.Feature.Name} + if res.Feature.Meter != nil { + feat.MeterID = res.Feature.Meter.ID + } + return feat, nil +} + +func (k *KonnectAdmin) ListPlans(ctx context.Context) ([]Plan, error) { + res, err := k.sdk.OpenMeterProductCatalog.ListPlans(ctx, operations.ListPlansRequest{}) + if err != nil { + return nil, fmt.Errorf("listing plans: %w", err) + } + var out []Plan + if res.PlanPagePaginatedResponse != nil { + for _, p := range res.PlanPagePaginatedResponse.Data { + out = append(out, Plan{ + ID: p.ID, + Key: p.Key, + Name: p.Name, + Status: string(p.Status), + }) + } + } + return out, nil +} + +func (k *KonnectAdmin) EnsurePlan(ctx context.Context, input PlanInput) (*Plan, error) { + includedMicros := input.IncludedMicros + if includedMicros < 0 { + includedMicros = 0 + } + + // Resolve feature ID + features, err := k.ListFeatures(ctx) + if err != nil { + return nil, err + } + var featureID string + for _, f := range features { + if f.Key == input.FeatureKey { + featureID = f.ID + break + } + } + if featureID == "" { + created, err := k.CreateFeature(ctx, FeatureInput{ + Key: input.FeatureKey, + Name: input.FeatureName, + MeterID: input.BillableMetterID, + }) + if err != nil { + return nil, err + } + featureID = created.ID + } + + rateCard := components.BillingRateCard{ + Key: input.FeatureKey, + Name: "Billable usage", + Feature: &components.FeatureReference{ID: featureID}, + BillingCadence: sdkkonnectgo.Pointer("P1M"), + PaymentTerm: components.PaymentTermInArrears.ToPointer(), + Price: components.CreatePriceUnit(components.BillingPriceUnit{ + Amount: input.UnitAmount, + }), + } + if includedMicros > 0 { + rateCard.Discounts = &components.Discounts{ + Usage: sdkkonnectgo.Pointer(fmt.Sprintf("%d", includedMicros)), + } + } + + currency := input.Currency + if currency == "" { + currency = "USD" + } + cadence := input.BillingCadence + if cadence == "" { + cadence = "P1M" + } + + createRes, err := k.sdk.OpenMeterProductCatalog.CreatePlan(ctx, components.CreatePlanRequest{ + Key: input.Key, + Name: input.Name, + Currency: currency, + BillingCadence: cadence, + ProRatingEnabled: sdkkonnectgo.Pointer(true), + Phases: []components.BillingPlanPhase{ + { + Key: "default", + Name: "Default", + RateCards: []components.BillingRateCard{rateCard}, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("creating plan %s: %w", input.Key, err) + } + if createRes.BillingPlan == nil { + return nil, fmt.Errorf("creating plan %s: empty response", input.Key) + } + + plan := createRes.BillingPlan + if plan.Status == components.BillingPlanStatusDraft { + _, err := k.sdk.OpenMeterProductCatalog.PublishPlan(ctx, plan.ID) + if err != nil { + return nil, fmt.Errorf("publishing plan %s: %w", input.Key, err) + } + } + + return &Plan{ + ID: plan.ID, + Key: input.Key, + Name: input.Name, + }, nil +} + +func (k *KonnectAdmin) DeleteMeter(ctx context.Context, id string) error { + _, err := k.sdk.Meters.DeleteMeter(ctx, id) + if err != nil { + return fmt.Errorf("deleting meter %s: %w", id, err) + } + return nil +} + +func (k *KonnectAdmin) DeleteFeature(ctx context.Context, id string) error { + _, err := k.sdk.OpenMeterFeatures.DeleteFeature(ctx, id) + if err != nil { + return fmt.Errorf("deleting feature %s: %w", id, err) + } + return nil +} + +func (k *KonnectAdmin) DeletePlan(ctx context.Context, id string) error { + _, err := k.sdk.OpenMeterProductCatalog.DeletePlan(ctx, id) + if err != nil { + _, archiveErr := k.sdk.OpenMeterProductCatalog.ArchivePlan(ctx, id) + if archiveErr != nil { + return fmt.Errorf("deleting plan %s: %w (archive: %v)", id, err, archiveErr) + } + } + return nil +} + +func nilIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/internal/admin/port.go b/internal/admin/port.go new file mode 100644 index 0000000..460b761 --- /dev/null +++ b/internal/admin/port.go @@ -0,0 +1,16 @@ +package admin + +import "context" + +type OpenMeterAdmin interface { + WaitForHealthy(ctx context.Context) error + ListMeters(ctx context.Context) ([]Meter, error) + CreateMeter(ctx context.Context, input MeterInput) (*Meter, error) + DeleteMeter(ctx context.Context, id string) error + ListFeatures(ctx context.Context) ([]Feature, error) + CreateFeature(ctx context.Context, input FeatureInput) (*Feature, error) + DeleteFeature(ctx context.Context, id string) error + ListPlans(ctx context.Context) ([]Plan, error) + EnsurePlan(ctx context.Context, input PlanInput) (*Plan, error) + DeletePlan(ctx context.Context, id string) error +} diff --git a/internal/admin/prune.go b/internal/admin/prune.go new file mode 100644 index 0000000..df85827 --- /dev/null +++ b/internal/admin/prune.go @@ -0,0 +1,153 @@ +package admin + +import ( + "context" + "fmt" + + "github.com/livepeer/clearinghouse/internal/meters" + "github.com/livepeer/clearinghouse/internal/pricing" +) + +type catalogExpectations struct { + meters map[string]MeterInput + features map[string]string // feature key -> meter key + planKeys map[string]struct{} +} + +func buildCatalogExpectations( + meterCfg *meters.Config, + pricingCfg *pricing.Config, + trialFeatureKey string, +) catalogExpectations { + if trialFeatureKey == "" { + trialFeatureKey = meterCfg.DefaultTrialFeatureKey + } + + meterInputs := make(map[string]MeterInput) + for _, d := range meterCfg.KonnectMeterDefinitions() { + meterInputs[d.Key] = MeterInput{ + Key: d.Key, + Name: d.Name, + Description: d.Description, + EventType: d.EventType, + Aggregation: d.Aggregation, + ValueProperty: d.ValueProperty, + Dimensions: d.Dimensions, + } + } + + planKeys := map[string]struct{}{ + pricingCfg.DefaultPlanKey: {}, + } + + return catalogExpectations{ + meters: meterInputs, + features: map[string]string{ + trialFeatureKey: meterCfg.NetworkFeeUsdMicrosMeter, + pricingCfg.BillableFeatureKey: meterCfg.BillableUsdMicrosMeter, + }, + planKeys: planKeys, + } +} + +func dimensionsMatch(want, have map[string]string) bool { + for k, v := range want { + if have[k] != v { + return false + } + } + return true +} + +func meterMatchesConfig(m Meter, expected MeterInput) bool { + return dimensionsMatch(expected.Dimensions, m.Dimensions) +} + +// PruneCatalog removes meters, features, and plans that are not defined in config, +// and removes configured meters/features whose shape no longer matches config. +func PruneCatalog( + ctx context.Context, + admin OpenMeterAdmin, + meterCfg *meters.Config, + pricingCfg *pricing.Config, + trialFeatureKey string, +) (*PruneResult, error) { + expect := buildCatalogExpectations(meterCfg, pricingCfg, trialFeatureKey) + result := &PruneResult{} + + // Plans first (depend on features). + plans, err := admin.ListPlans(ctx) + if err != nil { + return nil, err + } + for _, p := range plans { + if _, ok := expect.planKeys[p.Key]; ok { + continue + } + if err := admin.DeletePlan(ctx, p.ID); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("plan %s (%s): %v", p.Key, p.ID, err)) + continue + } + result.DeletedPlans = append(result.DeletedPlans, p.Key) + } + + // Features before meters (features reference meters). + allMeters, err := admin.ListMeters(ctx) + if err != nil { + return nil, err + } + meterIDByKey := make(map[string]string, len(allMeters)) + for _, m := range allMeters { + meterIDByKey[m.Key] = m.ID + } + + features, err := admin.ListFeatures(ctx) + if err != nil { + return nil, err + } + for _, f := range features { + expectedMeterKey, configured := expect.features[f.Key] + if !configured { + if err := admin.DeleteFeature(ctx, f.ID); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("feature %s: %v", f.Key, err)) + continue + } + result.DeletedFeatures = append(result.DeletedFeatures, f.Key) + continue + } + expectedMeterID := meterIDByKey[expectedMeterKey] + if expectedMeterID != "" && f.MeterID != expectedMeterID { + if err := admin.DeleteFeature(ctx, f.ID); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("feature %s (wrong meter): %v", f.Key, err)) + continue + } + result.DeletedFeatures = append(result.DeletedFeatures, f.Key) + } + } + + // Meters last. + allMeters, err = admin.ListMeters(ctx) + if err != nil { + return nil, err + } + for _, m := range allMeters { + expected, configured := expect.meters[m.Key] + if !configured { + if err := admin.DeleteMeter(ctx, m.ID); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("meter %s: %v", m.Key, err)) + continue + } + result.DeletedMeters = append(result.DeletedMeters, m.Key) + continue + } + if !meterMatchesConfig(m, expected) { + if err := admin.DeleteMeter(ctx, m.ID); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("meter %s (config mismatch): %v", m.Key, err)) + continue + } + result.DeletedMeters = append(result.DeletedMeters, m.Key) + } + } + + return result, nil +} diff --git a/internal/admin/types.go b/internal/admin/types.go new file mode 100644 index 0000000..222f8f9 --- /dev/null +++ b/internal/admin/types.go @@ -0,0 +1,67 @@ +package admin + +type Meter struct { + ID string + Key string + Name string + Dimensions map[string]string +} + +type MeterInput struct { + Key string + Name string + Description string + EventType string + Aggregation string + ValueProperty string + Dimensions map[string]string +} + +type Feature struct { + ID string + Key string + Name string + MeterID string +} + +type FeatureInput struct { + Key string + Name string + MeterID string +} + +type Plan struct { + ID string + Key string + Name string + Status string +} + +type PlanInput struct { + Key string + Name string + FeatureKey string + FeatureName string + BillableMetterID string + UnitAmount string + IncludedMicros int + Currency string + BillingCadence string +} + +type EnsureResult[T any] struct { + Resource T + Created bool + Warnings []string +} + +type PruneResult struct { + DeletedMeters []string + DeletedFeatures []string + DeletedPlans []string + Warnings []string +} + +type BootstrapOptions struct { + Prune bool +} diff --git a/internal/auth0/provision.go b/internal/auth0/provision.go new file mode 100644 index 0000000..519ddb0 --- /dev/null +++ b/internal/auth0/provision.go @@ -0,0 +1,150 @@ +package auth0 + +import ( + "context" + "fmt" + + "github.com/auth0/go-auth0/v2/management" + auth0client "github.com/auth0/go-auth0/v2/management/client" + "github.com/auth0/go-auth0/v2/management/option" +) + +type ProvisionResult struct { + APIIdentifier string + PublicClientID string + M2MClientID string + M2MClientSecret string + JwksURL string + Issuer string +} + +type ProvisionConfig struct { + Domain string + MgmtClientID string + MgmtClientSecret string + AppName string + APIAudience string +} + +func Provision(ctx context.Context, cfg ProvisionConfig) (*ProvisionResult, error) { + mgmt, err := auth0client.New( + cfg.Domain, + option.WithClientCredentials(ctx, cfg.MgmtClientID, cfg.MgmtClientSecret), + ) + if err != nil { + return nil, fmt.Errorf("creating Auth0 management client: %w", err) + } + + // 1. Create resource server + rsName := fmt.Sprintf("%s Livepeer API", cfg.AppName) + signingAlg := management.SigningAlgorithmEnumRs256 + tokenLifetime := 86400 + skipConsent := true + _, err = mgmt.ResourceServers.Create(ctx, &management.CreateResourceServerRequestContent{ + Name: &rsName, + Identifier: cfg.APIAudience, + SigningAlg: &signingAlg, + TokenLifetime: &tokenLifetime, + SkipConsentForVerifiableFirstPartyClients: &skipConsent, + Scopes: []*management.ResourceServerScope{ + { + Value: "sign:job", + Description: strPtr("Sign payment tickets for Livepeer remote signer"), + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("creating resource server: %w", err) + } + + // 2. Create public client (native, device_code + refresh_token) + publicName := fmt.Sprintf("%s Public", cfg.AppName) + publicDesc := "Public client for SDK, CLI, and device authorization flow" + appTypeNative := management.ClientAppTypeEnumNative + authMethodNone := management.ClientTokenEndpointAuthMethodEnumNone + isFirstParty := true + oidcConformant := true + + publicResp, err := mgmt.Clients.Create(ctx, &management.CreateClientRequestContent{ + Name: publicName, + Description: &publicDesc, + AppType: &appTypeNative, + OidcConformant: &oidcConformant, + IsFirstParty: &isFirstParty, + GrantTypes: []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"}, + TokenEndpointAuthMethod: &authMethodNone, + }) + if err != nil { + return nil, fmt.Errorf("creating public client: %w", err) + } + publicClientID := derefStr(publicResp.ClientID) + if publicClientID == "" { + return nil, fmt.Errorf("Auth0 public client missing client_id") + } + + // 3. Create M2M client (non_interactive, client_credentials) + m2mName := fmt.Sprintf("%s M2M", cfg.AppName) + m2mDesc := "Confidential client for server-side operations" + appTypeM2M := management.ClientAppTypeEnumNonInteractive + authMethodPost := management.ClientTokenEndpointAuthMethodEnumClientSecretPost + + m2mResp, err := mgmt.Clients.Create(ctx, &management.CreateClientRequestContent{ + Name: m2mName, + Description: &m2mDesc, + AppType: &appTypeM2M, + OidcConformant: &oidcConformant, + GrantTypes: []string{"client_credentials"}, + TokenEndpointAuthMethod: &authMethodPost, + }) + if err != nil { + return nil, fmt.Errorf("creating M2M client: %w", err) + } + m2mClientID := derefStr(m2mResp.ClientID) + m2mClientSecret := derefStr(m2mResp.ClientSecret) + if m2mClientID == "" { + return nil, fmt.Errorf("Auth0 M2M client missing client_id") + } + if m2mClientSecret == "" { + return nil, fmt.Errorf("Auth0 M2M client missing client_secret") + } + + // 4. Create client grants + _, err = mgmt.ClientGrants.Create(ctx, &management.CreateClientGrantRequestContent{ + ClientID: &m2mClientID, + Audience: cfg.APIAudience, + Scope: []string{"sign:job"}, + }) + if err != nil { + return nil, fmt.Errorf("creating M2M client grant: %w", err) + } + + _, err = mgmt.ClientGrants.Create(ctx, &management.CreateClientGrantRequestContent{ + ClientID: &publicClientID, + Audience: cfg.APIAudience, + Scope: []string{"sign:job"}, + }) + if err != nil { + return nil, fmt.Errorf("creating public client grant: %w", err) + } + + issuer := fmt.Sprintf("https://%s/", cfg.Domain) + jwksURL := fmt.Sprintf("https://%s/.well-known/jwks.json", cfg.Domain) + + return &ProvisionResult{ + APIIdentifier: cfg.APIAudience, + PublicClientID: publicClientID, + M2MClientID: m2mClientID, + M2MClientSecret: m2mClientSecret, + JwksURL: jwksURL, + Issuer: issuer, + }, nil +} + +func strPtr(s string) *string { return &s } + +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..afbd7d6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,175 @@ +package config + +import ( + "crypto/rand" + "encoding/hex" + "flag" + "fmt" + "os" + "strings" +) + +type BootstrapConfig struct { + // Auth0 + Auth0Domain string + Auth0MgmtClientID string + Auth0MgmtClientSecret string + AppName string + APIAudience string + + // OpenMeter / Konnect + OpenmeterURL string + OpenmeterAPIKey string + TrialFeatureKey string + + // Identity webhook + WebhookSecret string + + // Signer + SignerProxyURL string + SignerPublicURL string + RemoteSignerWebhookURL string + SignerNetwork string + EthRPCURL string + SignerEthAddr string + + // Kafka + KafkaBrokers string + KafkaGatewayTopic string + + // Collector + EthUsdPrice string + + // Output + OutputPath string + SDKConfigOutputPath string + + // Config files + MetersConfigPath string + PricingConfigPath string + + // Skip flags + SkipAuth0 bool + SkipOpenMeter bool + + // Destructive catalog sync + Prune bool +} + +func env(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} + +func envOr(key, fallback string) string { + if v := env(key); v != "" { + return v + } + return fallback +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func Parse(args []string) (*BootstrapConfig, error) { + fs := flag.NewFlagSet("clearinghouse-bootstrap", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + cfg := &BootstrapConfig{} + + fs.StringVar(&cfg.Auth0Domain, "auth0-domain", envOr("AUTH0_DOMAIN", ""), "Auth0 tenant domain") + fs.StringVar(&cfg.Auth0MgmtClientID, "auth0-mgmt-client-id", envOr("AUTH0_MGMT_CLIENT_ID", ""), "Auth0 Management M2M client ID") + fs.StringVar(&cfg.Auth0MgmtClientSecret, "auth0-mgmt-client-secret", envOr("AUTH0_MGMT_CLIENT_SECRET", ""), "Auth0 Management M2M client secret") + fs.StringVar(&cfg.AppName, "app-name", envOr("APP_NAME", "Livepeer Platform"), "Application display name prefix") + fs.StringVar(&cfg.APIAudience, "api-audience", envOr("AUTH0_AUDIENCE", "livepeer"), "API identifier / audience") + fs.StringVar(&cfg.OpenmeterURL, "openmeter-url", envOr("OPENMETER_URL", "https://us.api.konghq.com/v3/openmeter"), "Konnect metering base URL") + fs.StringVar(&cfg.OpenmeterAPIKey, "openmeter-api-key", envOr("OPENMETER_API_KEY", ""), "Konnect PAT (kpat_…)") + fs.StringVar(&cfg.TrialFeatureKey, "trial-feature-key", envOr("OPENMETER_TRIAL_FEATURE_KEY", "network_spend"), "Entitlement feature key for trial") + fs.StringVar(&cfg.WebhookSecret, "webhook-secret", envOr("WEBHOOK_SECRET", ""), "Shared secret for remote_signer_webhook") + fs.StringVar(&cfg.SignerProxyURL, "signer-proxy-url", envOr("SIGNER_PROXY_URL", "https://your-platform.vercel.app/api/signer"), "Vercel signer proxy BFF URL") + fs.StringVar(&cfg.SignerPublicURL, "signer-public-url", envOr("SIGNER_PUBLIC_URL", "https://signer.your-domain.com"), "Public HTTPS URL of VM remote signer") + fs.StringVar(&cfg.RemoteSignerWebhookURL, "remote-signer-webhook-url", envOr("REMOTE_SIGNER_WEBHOOK_URL", "https://your-platform.vercel.app/webhooks/remote-signer"), "Identity webhook (go-livepeer calls this)") + fs.StringVar(&cfg.SignerNetwork, "signer-network", envOr("SIGNER_NETWORK", "arbitrum-one-mainnet"), "go-livepeer -network") + fs.StringVar(&cfg.EthRPCURL, "eth-rpc-url", envOr("ETH_RPC_URL", "https://arb1.arbitrum.io/rpc"), "Ethereum RPC for remote signer") + fs.StringVar(&cfg.SignerEthAddr, "signer-eth-addr", envOr("SIGNER_ETH_ADDR", ""), "Funded signer ETH address") + fs.StringVar(&cfg.KafkaBrokers, "kafka-brokers", envOr("KAFKA_BROKERS", "kafka:9092"), "Kafka bootstrap servers") + fs.StringVar(&cfg.KafkaGatewayTopic, "kafka-gateway-topic", envOr("KAFKA_GATEWAY_TOPIC", "livepeer-gateway-events"), "go-livepeer monitor topic") + fs.StringVar(&cfg.EthUsdPrice, "eth-usd-price", envOr("ETH_USD_PRICE", "3500"), "ETH/USD for collector Wei conversion") + fs.StringVar(&cfg.OutputPath, "output", envOr("BOOTSTRAP_OUTPUT", ".env.livepeer"), "Write combined .env file") + fs.StringVar(&cfg.SDKConfigOutputPath, "sdk-config-output", envOr("SDK_CONFIG_OUTPUT", "sdk-config.json"), "Write builder-sdk config JSON") + fs.StringVar(&cfg.MetersConfigPath, "meters-config", envOr("METERS_CONFIG_PATH", "config/meters.json"), "Meter definition config JSON") + fs.StringVar(&cfg.PricingConfigPath, "pricing-config", envOr("PRICING_CONFIG_PATH", "config/pricing.json"), "Pricing definition config JSON") + fs.BoolVar(&cfg.SkipAuth0, "skip-auth0", false, "Only bootstrap OpenMeter") + fs.BoolVar(&cfg.SkipOpenMeter, "skip-openmeter", false, "Only provision Auth0") + fs.BoolVar(&cfg.Prune, "prune", false, "Destructive: remove Konnect catalog objects not in config") + + if err := fs.Parse(args); err != nil { + return nil, err + } + + if cfg.WebhookSecret == "" { + cfg.WebhookSecret = randomHex(24) + } + + if !cfg.SkipAuth0 { + if cfg.Auth0Domain == "" { + return nil, fmt.Errorf("missing AUTH0_DOMAIN (set in .env or pass --auth0-domain)") + } + if cfg.Auth0MgmtClientID == "" || cfg.Auth0MgmtClientSecret == "" { + return nil, fmt.Errorf("missing Auth0 Management API credentials (AUTH0_MGMT_CLIENT_ID / AUTH0_MGMT_CLIENT_SECRET in .env)") + } + } + + if !cfg.SkipOpenMeter { + if cfg.OpenmeterURL == "" { + return nil, fmt.Errorf("missing OPENMETER_URL (set in .env or pass --openmeter-url)") + } + if cfg.OpenmeterAPIKey == "" { + return nil, fmt.Errorf("missing OPENMETER_API_KEY — Konnect PAT (kpat_…) in .env") + } + } + + return cfg, nil +} + +// PrintUsage prints brief or full help. +func PrintUsage(all bool) { + if all { + printHelpAll() + return + } + printHelpBrief() +} + +func printHelpBrief() { + fmt.Print(`Usage: clearinghouse-bootstrap [options] + +Provision Auth0 + OpenMeter/Konnect and write .env.livepeer + sdk-config.json. +Settings load from .env (see .env.example); flags override env. + +Quick start: + cp .env.example .env + ./clearinghouse-bootstrap + +Options: + --env-file Env file (default: .env if present) + --skip-auth0 Konnect catalog only + --skip-openmeter Auth0 only + --prune Remove catalog objects not in config (destructive) + --output .env output (default: .env.livepeer) + --sdk-config-output JSON output (default: sdk-config.json) + +Run --help-all for config file locations. +`) +} + +func printHelpAll() { + fmt.Print(`Configuration reference + + .env.example Auth0, Konnect, deploy vars, output paths + config/meters.json Meter definitions + config/pricing.json Default plan and pricing +`) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..93c5e39 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,71 @@ +package config + +import "testing" + +const testEmptyEnv = "testdata/empty.env" + +func parseForTest(args ...string) (*BootstrapConfig, error) { + full := append([]string{"--env-file", testEmptyEnv}, args...) + remaining, envFile, explicit, help, err := PreprocessArgs(full) + if err != nil { + return nil, err + } + if help != HelpNone { + return nil, nil + } + if err := LoadEnvFile(envFile, explicit); err != nil { + return nil, err + } + return Parse(remaining) +} + +func TestParseMinimal(t *testing.T) { + cfg, err := parseForTest( + "--auth0-domain", "test.auth0.com", + "--auth0-mgmt-client-id", "cid", + "--auth0-mgmt-client-secret", "csec", + "--openmeter-api-key", "kpat_test", + ) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Auth0Domain != "test.auth0.com" { + t.Errorf("Auth0Domain = %s", cfg.Auth0Domain) + } + if cfg.AppName != "Livepeer Platform" { + t.Errorf("AppName = %s", cfg.AppName) + } + if cfg.APIAudience != "livepeer" { + t.Errorf("APIAudience = %s", cfg.APIAudience) + } + if cfg.WebhookSecret == "" { + t.Error("expected auto-generated webhook secret") + } +} + +func TestParseMissingAuth0(t *testing.T) { + _, err := parseForTest("--openmeter-api-key", "kpat_test") + if err == nil { + t.Fatal("expected error for missing auth0 domain") + } +} + +func TestParseSkipAuth0(t *testing.T) { + cfg, err := parseForTest("--skip-auth0", "--openmeter-api-key", "kpat_test") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !cfg.SkipAuth0 { + t.Error("expected SkipAuth0=true") + } +} + +func TestParseSkipBoth(t *testing.T) { + cfg, err := parseForTest("--skip-auth0", "--skip-openmeter") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !cfg.SkipAuth0 || !cfg.SkipOpenMeter { + t.Error("expected both skip flags") + } +} diff --git a/internal/config/envfile.go b/internal/config/envfile.go new file mode 100644 index 0000000..341cce4 --- /dev/null +++ b/internal/config/envfile.go @@ -0,0 +1,67 @@ +package config + +import ( + "fmt" + "os" + "strings" + + "github.com/joho/godotenv" +) + +// HelpMode selects which help text to print. +type HelpMode int + +const ( + HelpNone HelpMode = iota + HelpBrief + HelpAll +) + +// PreprocessArgs extracts --env-file, --help, and --help-all before flag parsing. +// Remaining args are passed to Parse. The explicit return is true when --env-file was passed. +func PreprocessArgs(args []string) (remaining []string, envFile string, explicit bool, help HelpMode, err error) { + envFile = ".env" + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--help" || arg == "-h": + return nil, "", false, HelpBrief, nil + case arg == "--help-all": + return nil, "", false, HelpAll, nil + case arg == "--env-file": + if i+1 >= len(args) { + return nil, "", false, HelpNone, fmt.Errorf("--env-file requires a path") + } + envFile = args[i+1] + explicit = true + i++ + case strings.HasPrefix(arg, "--env-file="): + envFile = strings.TrimPrefix(arg, "--env-file=") + explicit = true + default: + remaining = append(remaining, arg) + } + } + + return remaining, envFile, explicit, HelpNone, nil +} + +// LoadEnvFile loads KEY=VALUE pairs into the process environment. +// Existing shell variables are not overwritten. When path is the default ".env" +// and the file is missing, loading is skipped silently. +func LoadEnvFile(path string, explicit bool) error { + if path == "" { + return nil + } + if _, err := os.Stat(path); os.IsNotExist(err) { + if explicit { + return fmt.Errorf("env file not found: %s", path) + } + return nil + } + if err := godotenv.Load(path); err != nil { + return fmt.Errorf("loading env file %s: %w", path, err) + } + return nil +} diff --git a/internal/config/envfile_test.go b/internal/config/envfile_test.go new file mode 100644 index 0000000..3b9ab87 --- /dev/null +++ b/internal/config/envfile_test.go @@ -0,0 +1,94 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadEnvFile(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + os.WriteFile(envPath, []byte("OPENMETER_API_KEY=kpat_from_file\n"), 0644) + + t.Setenv("OPENMETER_API_KEY", "kpat_existing") + if err := LoadEnvFile(envPath, true); err != nil { + t.Fatalf("LoadEnvFile: %v", err) + } + if got := os.Getenv("OPENMETER_API_KEY"); got != "kpat_existing" { + t.Errorf("should not override existing env, got %q", got) + } + + os.Unsetenv("OPENMETER_API_KEY") + if err := LoadEnvFile(envPath, true); err != nil { + t.Fatalf("LoadEnvFile: %v", err) + } + if got := os.Getenv("OPENMETER_API_KEY"); got != "kpat_from_file" { + t.Errorf("got %q, want kpat_from_file", got) + } +} + +func TestLoadEnvFileMissingDefault(t *testing.T) { + if err := LoadEnvFile(filepath.Join(t.TempDir(), "missing.env"), false); err != nil { + t.Fatalf("optional missing file should not error: %v", err) + } +} + +func TestLoadEnvFileMissingExplicit(t *testing.T) { + err := LoadEnvFile(filepath.Join(t.TempDir(), "missing.env"), true) + if err == nil { + t.Fatal("expected error for explicit missing env file") + } +} + +func TestPreprocessArgsHelp(t *testing.T) { + _, _, _, help, err := PreprocessArgs([]string{"--help"}) + if err != nil || help != HelpBrief { + t.Fatalf("help=%v err=%v", help, err) + } + _, _, _, help, err = PreprocessArgs([]string{"--help-all"}) + if err != nil || help != HelpAll { + t.Fatalf("help=%v err=%v", help, err) + } +} + +func TestPreprocessArgsEnvFile(t *testing.T) { + remaining, path, explicit, help, err := PreprocessArgs([]string{"--env-file", "custom.env", "--skip-auth0"}) + if err != nil || help != HelpNone { + t.Fatalf("err=%v help=%v", err, help) + } + if path != "custom.env" || !explicit { + t.Fatalf("path=%q explicit=%v", path, explicit) + } + if len(remaining) != 1 || remaining[0] != "--skip-auth0" { + t.Fatalf("remaining=%v", remaining) + } +} + +func TestParseFromEnvFile(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + os.WriteFile(envPath, []byte(`AUTH0_DOMAIN=test.auth0.com +AUTH0_MGMT_CLIENT_ID=cid +AUTH0_MGMT_CLIENT_SECRET=csec +OPENMETER_API_KEY=kpat_test +`), 0644) + + for _, k := range []string{"AUTH0_DOMAIN", "AUTH0_MGMT_CLIENT_ID", "AUTH0_MGMT_CLIENT_SECRET", "OPENMETER_API_KEY"} { + os.Unsetenv(k) + } + if err := LoadEnvFile(envPath, true); err != nil { + t.Fatalf("LoadEnvFile: %v", err) + } + + cfg, err := parseForTest() + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Auth0Domain != "test.auth0.com" { + t.Errorf("Auth0Domain = %s", cfg.Auth0Domain) + } + if cfg.OpenmeterAPIKey != "kpat_test" { + t.Errorf("OpenmeterAPIKey = %s", cfg.OpenmeterAPIKey) + } +} diff --git a/internal/config/testdata/empty.env b/internal/config/testdata/empty.env new file mode 100644 index 0000000..6278be7 --- /dev/null +++ b/internal/config/testdata/empty.env @@ -0,0 +1 @@ +# empty env file for tests diff --git a/internal/meters/meters.go b/internal/meters/meters.go new file mode 100644 index 0000000..1320499 --- /dev/null +++ b/internal/meters/meters.go @@ -0,0 +1,91 @@ +package meters + +import ( + "encoding/json" + "fmt" + "os" +) + +type MeterDef struct { + Key string + Name string + Description string + EventType string + Aggregation string + ValueProperty string + Dimensions map[string]string +} + +type meterSpec struct { + KonnectName string `json:"konnectName"` + KonnectDescription string `json:"konnectDescription"` + ValueProperty string `json:"valueProperty"` +} + +type Config struct { + CreateSignedTicketEventType string `json:"createSignedTicketEventType"` + SignedTicketEventSource string `json:"signedTicketEventSource"` + DefaultTrialFeatureKey string `json:"defaultTrialFeatureKey"` + DefaultBillableFeatureKey string `json:"defaultBillableFeatureKey"` + NetworkFeeUsdMicrosMeter string `json:"networkFeeUsdMicrosMeter"` + BillableUsdMicrosMeter string `json:"billableUsdMicrosMeter"` + SignedTicketCountMeter string `json:"signedTicketCountMeter"` + Dimensions map[string]string `json:"dimensions"` + Meters struct { + NetworkFeeUsdMicros meterSpec `json:"networkFeeUsdMicros"` + BillableUsdMicros meterSpec `json:"billableUsdMicros"` + SignedTicketCount meterSpec `json:"signedTicketCount"` + } `json:"meters"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading meters config: %w", err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing meters config: %w", err) + } + if cfg.CreateSignedTicketEventType == "" { + return nil, fmt.Errorf("invalid meter config: createSignedTicketEventType is required") + } + if cfg.NetworkFeeUsdMicrosMeter == "" || cfg.SignedTicketCountMeter == "" { + return nil, fmt.Errorf("invalid meter config: networkFeeUsdMicrosMeter and signedTicketCountMeter are required") + } + if cfg.BillableUsdMicrosMeter == "" { + return nil, fmt.Errorf("invalid meter config: billableUsdMicrosMeter is required") + } + return &cfg, nil +} + +func (c *Config) KonnectMeterDefinitions() []MeterDef { + return []MeterDef{ + { + Key: c.NetworkFeeUsdMicrosMeter, + Name: c.Meters.NetworkFeeUsdMicros.KonnectName, + Description: c.Meters.NetworkFeeUsdMicros.KonnectDescription, + EventType: c.CreateSignedTicketEventType, + Aggregation: "sum", + ValueProperty: c.Meters.NetworkFeeUsdMicros.ValueProperty, + Dimensions: c.Dimensions, + }, + { + Key: c.BillableUsdMicrosMeter, + Name: c.Meters.BillableUsdMicros.KonnectName, + Description: c.Meters.BillableUsdMicros.KonnectDescription, + EventType: c.CreateSignedTicketEventType, + Aggregation: "sum", + ValueProperty: c.Meters.BillableUsdMicros.ValueProperty, + Dimensions: c.Dimensions, + }, + { + Key: c.SignedTicketCountMeter, + Name: c.Meters.SignedTicketCount.KonnectName, + Description: c.Meters.SignedTicketCount.KonnectDescription, + EventType: c.CreateSignedTicketEventType, + Aggregation: "count", + Dimensions: c.Dimensions, + }, + } +} diff --git a/internal/meters/meters_test.go b/internal/meters/meters_test.go new file mode 100644 index 0000000..7850fce --- /dev/null +++ b/internal/meters/meters_test.go @@ -0,0 +1,57 @@ +package meters + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "meters.json") + os.WriteFile(path, []byte(`{ + "createSignedTicketEventType": "create_signed_ticket", + "signedTicketEventSource": "go-livepeer-remote-signer", + "defaultTrialFeatureKey": "network_spend", + "defaultBillableFeatureKey": "billable_spend", + "networkFeeUsdMicrosMeter": "network_fee_usd_micros", + "billableUsdMicrosMeter": "billable_usd_micros", + "signedTicketCountMeter": "signed_ticket_count", + "dimensions": {"client_id": "$.client_id"}, + "meters": { + "networkFeeUsdMicros": {"konnectName": "NF", "konnectDescription": "desc", "valueProperty": "$.nf"}, + "billableUsdMicros": {"konnectName": "BU", "konnectDescription": "desc", "valueProperty": "$.bu"}, + "signedTicketCount": {"konnectName": "SC", "konnectDescription": "desc"} + } + }`), 0644) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + defs := cfg.KonnectMeterDefinitions() + if len(defs) != 3 { + t.Fatalf("expected 3 meter definitions, got %d", len(defs)) + } + if defs[0].Key != "network_fee_usd_micros" { + t.Errorf("first meter key = %s, want network_fee_usd_micros", defs[0].Key) + } + if defs[0].Aggregation != "sum" { + t.Errorf("first meter aggregation = %s, want sum", defs[0].Aggregation) + } + if defs[2].Aggregation != "count" { + t.Errorf("third meter aggregation = %s, want count", defs[2].Aggregation) + } +} + +func TestLoadMissingFields(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "meters.json") + os.WriteFile(path, []byte(`{"createSignedTicketEventType": ""}`), 0644) + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for missing fields") + } +} diff --git a/internal/output/env.go b/internal/output/env.go new file mode 100644 index 0000000..257f6bd --- /dev/null +++ b/internal/output/env.go @@ -0,0 +1,69 @@ +package output + +import ( + "fmt" + "strings" + + "github.com/livepeer/clearinghouse/internal/auth0" + "github.com/livepeer/clearinghouse/internal/config" +) + +func BuildEnvFile(cfg *config.BootstrapConfig, auth0Result *auth0.ProvisionResult) string { + var b strings.Builder + + b.WriteString("# Generated by clearinghouse-bootstrap\n") + b.WriteString("# VM runtime: deploy/docker-compose.yml | Vercel platform: sdk-config.json\n\n") + + if auth0Result != nil { + b.WriteString("# Auth0\n") + fmt.Fprintf(&b, "AUTH0_DOMAIN=%s\n", cfg.Auth0Domain) + fmt.Fprintf(&b, "AUTH0_ISSUER=%s\n", auth0Result.Issuer) + fmt.Fprintf(&b, "AUTH0_JWKS_URL=%s\n", auth0Result.JwksURL) + fmt.Fprintf(&b, "AUTH0_AUDIENCE=%s\n", auth0Result.APIIdentifier) + fmt.Fprintf(&b, "AUTH0_PUBLIC_CLIENT_ID=%s\n", auth0Result.PublicClientID) + fmt.Fprintf(&b, "AUTH0_M2M_CLIENT_ID=%s\n", auth0Result.M2MClientID) + fmt.Fprintf(&b, "AUTH0_M2M_CLIENT_SECRET=%s\n", auth0Result.M2MClientSecret) + b.WriteString("\n# Identity webhook (Vercel platform — builder-sdk/signer/webhook)\n") + fmt.Fprintf(&b, "JWKS_URL=%s\n", auth0Result.JwksURL) + fmt.Fprintf(&b, "JWT_ISSUER=%s\n", auth0Result.Issuer) + fmt.Fprintf(&b, "JWT_AUDIENCE=%s\n", auth0Result.APIIdentifier) + fmt.Fprintf(&b, "WEBHOOK_SECRET=%s\n", cfg.WebhookSecret) + b.WriteString("CLAIM_CLIENT_ID=azp\n") + b.WriteString("USAGE_SUBJECT_TYPE=auth0_user_id\n\n") + } + + if !cfg.SkipOpenMeter { + b.WriteString("# Konnect Metering & Billing\n") + fmt.Fprintf(&b, "OPENMETER_URL=%s\n", strings.TrimSuffix(cfg.OpenmeterURL, "/")) + if cfg.OpenmeterAPIKey != "" { + fmt.Fprintf(&b, "OPENMETER_API_KEY=%s\n", cfg.OpenmeterAPIKey) + } + fmt.Fprintf(&b, "OPENMETER_TRIAL_FEATURE_KEY=%s\n\n", cfg.TrialFeatureKey) + } + + b.WriteString("# Vercel platform URLs (update after platform deploy)\n") + fmt.Fprintf(&b, "REMOTE_SIGNER_WEBHOOK_URL=%s\n", cfg.RemoteSignerWebhookURL) + fmt.Fprintf(&b, "SIGNER_PROXY_URL=%s\n", cfg.SignerProxyURL) + fmt.Fprintf(&b, "SIGNER_PUBLIC_URL=%s\n\n", cfg.SignerPublicURL) + + b.WriteString("# Kafka (docker compose service name)\n") + fmt.Fprintf(&b, "KAFKA_BROKERS=%s\n", cfg.KafkaBrokers) + fmt.Fprintf(&b, "KAFKA_GATEWAY_TOPIC=%s\n\n", cfg.KafkaGatewayTopic) + + b.WriteString("# OpenMeter Collector (Wei -> USD micros)\n") + fmt.Fprintf(&b, "ETH_USD_PRICE=%s\n\n", cfg.EthUsdPrice) + + b.WriteString("# Remote signer (operator — fund ETH account before stack:up)\n") + fmt.Fprintf(&b, "SIGNER_NETWORK=%s\n", cfg.SignerNetwork) + fmt.Fprintf(&b, "ETH_RPC_URL=%s\n", cfg.EthRPCURL) + if cfg.SignerEthAddr != "" { + fmt.Fprintf(&b, "SIGNER_ETH_ADDR=%s\n", cfg.SignerEthAddr) + } else { + b.WriteString("# SIGNER_ETH_ADDR=0x...\n") + } + + b.WriteString("\n# VM deploy: clearinghouse-bootstrap + docker compose\n") + b.WriteString("# Platform deploy: use sdk-config.json on Vercel (webhook route + signer proxy)\n") + + return b.String() +} diff --git a/internal/output/env_test.go b/internal/output/env_test.go new file mode 100644 index 0000000..59fbcfb --- /dev/null +++ b/internal/output/env_test.go @@ -0,0 +1,83 @@ +package output + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/livepeer/clearinghouse/internal/auth0" + "github.com/livepeer/clearinghouse/internal/config" +) + +func TestBuildEnvFile(t *testing.T) { + cfg := &config.BootstrapConfig{ + Auth0Domain: "test.us.auth0.com", + OpenmeterURL: "https://us.api.konghq.com/v3/openmeter", + OpenmeterAPIKey: "kpat_test123", + TrialFeatureKey: "network_spend", + WebhookSecret: "deadbeef", + SignerProxyURL: "https://platform.vercel.app/api/signer", + SignerPublicURL: "https://signer.example.com", + RemoteSignerWebhookURL: "https://platform.vercel.app/webhooks/remote-signer", + SignerNetwork: "arbitrum-one-mainnet", + EthRPCURL: "https://arb1.arbitrum.io/rpc", + SignerEthAddr: "0x1234", + KafkaBrokers: "kafka:9092", + KafkaGatewayTopic: "livepeer-gateway-events", + EthUsdPrice: "3500", + } + auth0Result := &auth0.ProvisionResult{ + APIIdentifier: "livepeer", + PublicClientID: "pub_123", + M2MClientID: "m2m_456", + M2MClientSecret: "secret_789", + JwksURL: "https://test.us.auth0.com/.well-known/jwks.json", + Issuer: "https://test.us.auth0.com/", + } + + got := BuildEnvFile(cfg, auth0Result) + + golden := filepath.Join("testdata", "env.golden") + if os.Getenv("UPDATE_GOLDEN") == "1" { + os.MkdirAll("testdata", 0755) + os.WriteFile(golden, []byte(got), 0644) + t.Log("Updated golden file") + return + } + + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("Missing golden file %s — run with UPDATE_GOLDEN=1 to create", golden) + } + if got != string(want) { + t.Errorf("env output mismatch.\n--- got ---\n%s\n--- want ---\n%s", got, string(want)) + } +} + +func TestBuildEnvFileSkipAuth0(t *testing.T) { + cfg := &config.BootstrapConfig{ + SkipAuth0: true, + OpenmeterURL: "https://us.api.konghq.com/v3/openmeter", + OpenmeterAPIKey: "kpat_test", + TrialFeatureKey: "network_spend", + WebhookSecret: "abc", + SignerProxyURL: "https://p.example.com/api/signer", + SignerPublicURL: "https://s.example.com", + RemoteSignerWebhookURL: "https://p.example.com/webhooks/remote-signer", + SignerNetwork: "arbitrum-one-mainnet", + EthRPCURL: "https://arb1.arbitrum.io/rpc", + KafkaBrokers: "kafka:9092", + KafkaGatewayTopic: "livepeer-gateway-events", + EthUsdPrice: "3500", + } + + got := BuildEnvFile(cfg, nil) + + if strings.Contains(got, "AUTH0_DOMAIN") { + t.Error("env should not contain Auth0 block when skipped") + } + if !strings.Contains(got, "OPENMETER_URL=") { + t.Error("env should contain OPENMETER_URL") + } +} diff --git a/internal/output/sdkconfig.go b/internal/output/sdkconfig.go new file mode 100644 index 0000000..66a20e8 --- /dev/null +++ b/internal/output/sdkconfig.go @@ -0,0 +1,69 @@ +package output + +import ( + "encoding/json" + "strings" + + "github.com/livepeer/clearinghouse/internal/auth0" + "github.com/livepeer/clearinghouse/internal/config" +) + +type SDKConfig struct { + Auth0 SDKAuth0 `json:"auth0"` + Signer SDKSigner `json:"signer"` + RemoteSigner SDKRemoteSigner `json:"remoteSigner"` + OpenMeter *SDKOpenMeter `json:"openmeter,omitempty"` +} + +type SDKAuth0 struct { + Domain string `json:"domain"` + Issuer string `json:"issuer"` + JwksURL string `json:"jwksUrl"` + ClientID string `json:"clientId"` + Audience string `json:"audience"` +} + +type SDKSigner struct { + ProxyURL string `json:"proxyUrl"` + PublicURL string `json:"publicUrl"` + Audience string `json:"audience"` +} + +type SDKRemoteSigner struct { + WebhookURL string `json:"webhookUrl"` +} + +type SDKOpenMeter struct { + URL string `json:"url"` + TrialFeatureKey string `json:"trialFeatureKey"` +} + +func BuildSDKConfig(cfg *config.BootstrapConfig, auth0Result *auth0.ProvisionResult) ([]byte, error) { + sdkCfg := SDKConfig{ + Auth0: SDKAuth0{ + Domain: cfg.Auth0Domain, + Issuer: auth0Result.Issuer, + JwksURL: auth0Result.JwksURL, + ClientID: auth0Result.PublicClientID, + Audience: auth0Result.APIIdentifier, + }, + Signer: SDKSigner{ + ProxyURL: cfg.SignerProxyURL, + PublicURL: cfg.SignerPublicURL, + Audience: auth0Result.APIIdentifier, + }, + RemoteSigner: SDKRemoteSigner{ + WebhookURL: cfg.RemoteSignerWebhookURL, + }, + } + + if !cfg.SkipOpenMeter { + url := strings.TrimSuffix(cfg.OpenmeterURL, "/") + sdkCfg.OpenMeter = &SDKOpenMeter{ + URL: url, + TrialFeatureKey: cfg.TrialFeatureKey, + } + } + + return json.MarshalIndent(sdkCfg, "", " ") +} diff --git a/internal/output/sdkconfig_test.go b/internal/output/sdkconfig_test.go new file mode 100644 index 0000000..25073f2 --- /dev/null +++ b/internal/output/sdkconfig_test.go @@ -0,0 +1,60 @@ +package output + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/livepeer/clearinghouse/internal/auth0" + "github.com/livepeer/clearinghouse/internal/config" +) + +func TestBuildSDKConfig(t *testing.T) { + cfg := &config.BootstrapConfig{ + Auth0Domain: "test.us.auth0.com", + OpenmeterURL: "https://us.api.konghq.com/v3/openmeter", + TrialFeatureKey: "network_spend", + SignerProxyURL: "https://platform.vercel.app/api/signer", + SignerPublicURL: "https://signer.example.com", + RemoteSignerWebhookURL: "https://platform.vercel.app/webhooks/remote-signer", + } + auth0Result := &auth0.ProvisionResult{ + APIIdentifier: "livepeer", + PublicClientID: "pub_123", + M2MClientID: "m2m_456", + M2MClientSecret: "secret_789", + JwksURL: "https://test.us.auth0.com/.well-known/jwks.json", + Issuer: "https://test.us.auth0.com/", + } + + got, err := BuildSDKConfig(cfg, auth0Result) + if err != nil { + t.Fatalf("BuildSDKConfig: %v", err) + } + + golden := filepath.Join("testdata", "sdkconfig.golden.json") + if os.Getenv("UPDATE_GOLDEN") == "1" { + os.MkdirAll("testdata", 0755) + os.WriteFile(golden, append(got, '\n'), 0644) + t.Log("Updated golden file") + return + } + + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("Missing golden file %s — run with UPDATE_GOLDEN=1 to create", golden) + } + + // Normalize for comparison + var gotObj, wantObj SDKConfig + json.Unmarshal(got, &gotObj) + json.Unmarshal(want, &wantObj) + + gotNorm, _ := json.Marshal(gotObj) + wantNorm, _ := json.Marshal(wantObj) + + if string(gotNorm) != string(wantNorm) { + t.Errorf("sdk-config mismatch.\n--- got ---\n%s\n--- want ---\n%s", string(got), string(want)) + } +} diff --git a/internal/output/testdata/env.golden b/internal/output/testdata/env.golden new file mode 100644 index 0000000..4ca6941 --- /dev/null +++ b/internal/output/testdata/env.golden @@ -0,0 +1,44 @@ +# Generated by clearinghouse-bootstrap +# VM runtime: deploy/docker-compose.yml | Vercel platform: sdk-config.json + +# Auth0 +AUTH0_DOMAIN=test.us.auth0.com +AUTH0_ISSUER=https://test.us.auth0.com/ +AUTH0_JWKS_URL=https://test.us.auth0.com/.well-known/jwks.json +AUTH0_AUDIENCE=livepeer +AUTH0_PUBLIC_CLIENT_ID=pub_123 +AUTH0_M2M_CLIENT_ID=m2m_456 +AUTH0_M2M_CLIENT_SECRET=secret_789 + +# Identity webhook (Vercel platform — builder-sdk/signer/webhook) +JWKS_URL=https://test.us.auth0.com/.well-known/jwks.json +JWT_ISSUER=https://test.us.auth0.com/ +JWT_AUDIENCE=livepeer +WEBHOOK_SECRET=deadbeef +CLAIM_CLIENT_ID=azp +USAGE_SUBJECT_TYPE=auth0_user_id + +# Konnect Metering & Billing +OPENMETER_URL=https://us.api.konghq.com/v3/openmeter +OPENMETER_API_KEY=kpat_test123 +OPENMETER_TRIAL_FEATURE_KEY=network_spend + +# Vercel platform URLs (update after platform deploy) +REMOTE_SIGNER_WEBHOOK_URL=https://platform.vercel.app/webhooks/remote-signer +SIGNER_PROXY_URL=https://platform.vercel.app/api/signer +SIGNER_PUBLIC_URL=https://signer.example.com + +# Kafka (docker compose service name) +KAFKA_BROKERS=kafka:9092 +KAFKA_GATEWAY_TOPIC=livepeer-gateway-events + +# OpenMeter Collector (Wei -> USD micros) +ETH_USD_PRICE=3500 + +# Remote signer (operator — fund ETH account before stack:up) +SIGNER_NETWORK=arbitrum-one-mainnet +ETH_RPC_URL=https://arb1.arbitrum.io/rpc +SIGNER_ETH_ADDR=0x1234 + +# VM deploy: clearinghouse-bootstrap + docker compose +# Platform deploy: use sdk-config.json on Vercel (webhook route + signer proxy) diff --git a/internal/output/testdata/sdkconfig.golden.json b/internal/output/testdata/sdkconfig.golden.json new file mode 100644 index 0000000..30d944e --- /dev/null +++ b/internal/output/testdata/sdkconfig.golden.json @@ -0,0 +1,21 @@ +{ + "auth0": { + "domain": "test.us.auth0.com", + "issuer": "https://test.us.auth0.com/", + "jwksUrl": "https://test.us.auth0.com/.well-known/jwks.json", + "clientId": "pub_123", + "audience": "livepeer" + }, + "signer": { + "proxyUrl": "https://platform.vercel.app/api/signer", + "publicUrl": "https://signer.example.com", + "audience": "livepeer" + }, + "remoteSigner": { + "webhookUrl": "https://platform.vercel.app/webhooks/remote-signer" + }, + "openmeter": { + "url": "https://us.api.konghq.com/v3/openmeter", + "trialFeatureKey": "network_spend" + } +} diff --git a/internal/pricing/pricing.go b/internal/pricing/pricing.go new file mode 100644 index 0000000..33fc386 --- /dev/null +++ b/internal/pricing/pricing.go @@ -0,0 +1,39 @@ +package pricing + +import ( + "encoding/json" + "fmt" + "os" +) + +type Config struct { + DefaultTrialIncludedUsdMicros string `json:"defaultTrialIncludedUsdMicros"` + DefaultPlanKey string `json:"defaultPlanKey"` + BillableFeatureKey string `json:"billableFeatureKey"` + BillableMeterKey string `json:"billableMeterKey"` + UnitPriceUsdPerBillableMicro string `json:"unitPriceUsdPerBillableMicro"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading pricing config: %w", err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing pricing config: %w", err) + } + if cfg.DefaultPlanKey == "" { + return nil, fmt.Errorf("invalid pricing config: defaultPlanKey is required") + } + if cfg.BillableFeatureKey == "" || cfg.BillableMeterKey == "" { + return nil, fmt.Errorf("invalid pricing config: billableFeatureKey and billableMeterKey are required") + } + if cfg.UnitPriceUsdPerBillableMicro == "" { + cfg.UnitPriceUsdPerBillableMicro = "0.000001" + } + if cfg.DefaultTrialIncludedUsdMicros == "" { + cfg.DefaultTrialIncludedUsdMicros = "5000000" + } + return &cfg, nil +} diff --git a/internal/pricing/pricing_test.go b/internal/pricing/pricing_test.go new file mode 100644 index 0000000..7589aa7 --- /dev/null +++ b/internal/pricing/pricing_test.go @@ -0,0 +1,51 @@ +package pricing + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "pricing.json") + os.WriteFile(path, []byte(`{ + "defaultTrialIncludedUsdMicros": "5000000", + "defaultPlanKey": "clearinghouse_default_ppu", + "billableFeatureKey": "billable_spend", + "billableMeterKey": "billable_usd_micros", + "unitPriceUsdPerBillableMicro": "0.000001" + }`), 0644) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.DefaultPlanKey != "clearinghouse_default_ppu" { + t.Errorf("DefaultPlanKey = %s", cfg.DefaultPlanKey) + } + if cfg.UnitPriceUsdPerBillableMicro != "0.000001" { + t.Errorf("UnitPriceUsdPerBillableMicro = %s", cfg.UnitPriceUsdPerBillableMicro) + } +} + +func TestLoadDefaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "pricing.json") + os.WriteFile(path, []byte(`{ + "defaultPlanKey": "test", + "billableFeatureKey": "bf", + "billableMeterKey": "bm" + }`), 0644) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.UnitPriceUsdPerBillableMicro != "0.000001" { + t.Errorf("expected default unit price, got %s", cfg.UnitPriceUsdPerBillableMicro) + } + if cfg.DefaultTrialIncludedUsdMicros != "5000000" { + t.Errorf("expected default trial included, got %s", cfg.DefaultTrialIncludedUsdMicros) + } +} From 484caf480127e71e46d2a41905e8b6febbff70a3 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 17 Jun 2026 18:40:41 -0400 Subject: [PATCH 2/3] refactor: clean up environment configuration and CLI documentation Removed deprecated environment variables from .env.example and updated README for clarity on CLI behavior. Simplified BootstrapConfig structure by eliminating unused fields related to signer and Kafka. Adjusted error messages for missing environment variables to enhance user experience. Updated tests to reflect changes in configuration handling. --- .env.example | 14 +--- README.md | 17 ++-- internal/admin/catalog.go | 6 -- internal/admin/konnect_adapter.go | 13 +-- internal/admin/types.go | 1 - internal/config/config.go | 79 +++++++------------ internal/config/config_test.go | 27 ++++--- internal/config/envfile_test.go | 2 +- internal/output/env.go | 28 +------ internal/output/env_test.go | 47 ++++------- internal/output/sdkconfig.go | 13 ++- internal/output/sdkconfig_test.go | 20 ++--- internal/output/testdata/env.golden | 22 +----- .../output/testdata/sdkconfig.golden.json | 6 +- 14 files changed, 99 insertions(+), 196 deletions(-) diff --git a/.env.example b/.env.example index 2d48138..ae05422 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,6 @@ # cp .env.example .env # # clearinghouse-bootstrap loads .env automatically from the current directory. -# CLI flags override .env values. # --- Auth0 (required unless --skip-auth0) --- AUTH0_DOMAIN=your-org.us.auth0.com @@ -22,13 +21,6 @@ OPENMETER_API_KEY=kpat_your_konnect_personal_access_token # BOOTSTRAP_OUTPUT=.env.livepeer # SDK_CONFIG_OUTPUT=sdk-config.json -# --- Deploy template vars (optional — written into .env.livepeer with defaults) --- -# REMOTE_SIGNER_WEBHOOK_URL=https://your-platform.vercel.app/webhooks/remote-signer -# SIGNER_PROXY_URL=https://your-platform.vercel.app/api/signer -# SIGNER_PUBLIC_URL=https://signer.your-domain.com -# SIGNER_NETWORK=arbitrum-one-mainnet -# ETH_RPC_URL=https://arb1.arbitrum.io/rpc -# SIGNER_ETH_ADDR=0x... -# KAFKA_BROKERS=kafka:9092 -# KAFKA_GATEWAY_TOPIC=livepeer-gateway-events -# ETH_USD_PRICE=3500 +# --- Config paths (optional) --- +# METERS_CONFIG_PATH=config/meters.json +# PRICING_CONFIG_PATH=config/pricing.json diff --git a/README.md b/README.md index 7b7ce4f..a480984 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,8 @@ cp .env.example .env # fill in secrets — see file for Auth0 + Konnect vars ./clearinghouse-bootstrap --skip-openmeter ``` -The CLI loads `.env` from the current directory automatically. Flags override -`.env` values. Run `./clearinghouse-bootstrap --help` for CLI flags; see -`.env.example` for all settings. +The CLI loads `.env` from the current directory automatically. See +`.env.example` for settings; run `./clearinghouse-bootstrap --help` for CLI flags. ## What it does @@ -36,14 +35,15 @@ The CLI loads `.env` from the current directory automatically. Flags override plan with a usage rate card. Uses the official [`Kong/sdk-konnect-go`](https://github.com/Kong/sdk-konnect-go) SDK. -3. **Output** — writes `.env.livepeer` (all env vars for VM deploy / Docker - Compose) and `sdk-config.json` (structured config for Vercel platform deploy - via builder-sdk). +3. **Output** — writes `.env.livepeer` (Auth0 + Konnect runtime vars) and + `sdk-config.json` (structured config for Vercel platform deploy via + builder-sdk). Signer URLs in `sdk-config.json` are placeholders to update + after platform deploy. ## Configuration Copy `.env.example` to `.env` and fill in your secrets. The CLI loads `.env` -automatically (override with `--env-file`). Flags override `.env` values. +automatically (override with `--env-file`). If a required value is missing for the selected mode, the CLI exits with an error before calling any APIs. See `.env.example` for variable names and @@ -55,9 +55,6 @@ features, plans) that are not defined in `config/meters.json` and Prune runs before ensure/create. **This can delete production billing catalog data** — use only when you intend to reconcile the tenant to config. -Deploy template vars (Kafka, signer URLs, ETH RPC) are optional in `.env` — they -get sensible defaults in `.env.livepeer` output. - ## Config files Meter and pricing definitions live in `config/meters.json` and diff --git a/internal/admin/catalog.go b/internal/admin/catalog.go index 96d2d31..1b59a54 100644 --- a/internal/admin/catalog.go +++ b/internal/admin/catalog.go @@ -3,8 +3,6 @@ package admin import ( "context" "fmt" - "math" - "strconv" "github.com/livepeer/clearinghouse/internal/meters" "github.com/livepeer/clearinghouse/internal/pricing" @@ -157,9 +155,6 @@ func BootstrapCatalog( } ensuredFeatures = append(ensuredFeatures, *bfResult) - included, _ := strconv.ParseFloat(pricingCfg.DefaultTrialIncludedUsdMicros, 64) - includedMicros := int(math.Max(0, math.Floor(included))) - result.Meters = ensuredMeters result.Features = ensuredFeatures @@ -183,7 +178,6 @@ func BootstrapCatalog( FeatureName: "Billable spend", BillableMetterID: billableMeter.ID, UnitAmount: pricingCfg.UnitPriceUsdPerBillableMicro, - IncludedMicros: includedMicros, Currency: "USD", BillingCadence: "P1M", }) diff --git a/internal/admin/konnect_adapter.go b/internal/admin/konnect_adapter.go index 00c446c..6b9f118 100644 --- a/internal/admin/konnect_adapter.go +++ b/internal/admin/konnect_adapter.go @@ -166,11 +166,6 @@ func (k *KonnectAdmin) ListPlans(ctx context.Context) ([]Plan, error) { } func (k *KonnectAdmin) EnsurePlan(ctx context.Context, input PlanInput) (*Plan, error) { - includedMicros := input.IncludedMicros - if includedMicros < 0 { - includedMicros = 0 - } - // Resolve feature ID features, err := k.ListFeatures(ctx) if err != nil { @@ -195,6 +190,9 @@ func (k *KonnectAdmin) EnsurePlan(ctx context.Context, input PlanInput) (*Plan, featureID = created.ID } + // Pure pay-per-use rate card: no included units / discounts. Trial credit is + // granted once per customer as an entitlement at provision time, not baked + // into the plan (a plan-level discount recurs every billing period). rateCard := components.BillingRateCard{ Key: input.FeatureKey, Name: "Billable usage", @@ -205,11 +203,6 @@ func (k *KonnectAdmin) EnsurePlan(ctx context.Context, input PlanInput) (*Plan, Amount: input.UnitAmount, }), } - if includedMicros > 0 { - rateCard.Discounts = &components.Discounts{ - Usage: sdkkonnectgo.Pointer(fmt.Sprintf("%d", includedMicros)), - } - } currency := input.Currency if currency == "" { diff --git a/internal/admin/types.go b/internal/admin/types.go index 222f8f9..8880081 100644 --- a/internal/admin/types.go +++ b/internal/admin/types.go @@ -44,7 +44,6 @@ type PlanInput struct { FeatureName string BillableMetterID string UnitAmount string - IncludedMicros int Currency string BillingCadence string } diff --git a/internal/config/config.go b/internal/config/config.go index afbd7d6..70c79bf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,24 +22,9 @@ type BootstrapConfig struct { OpenmeterAPIKey string TrialFeatureKey string - // Identity webhook + // Identity webhook (auto-generated when unset) WebhookSecret string - // Signer - SignerProxyURL string - SignerPublicURL string - RemoteSignerWebhookURL string - SignerNetwork string - EthRPCURL string - SignerEthAddr string - - // Kafka - KafkaBrokers string - KafkaGatewayTopic string - - // Collector - EthUsdPrice string - // Output OutputPath string SDKConfigOutputPath string @@ -73,34 +58,30 @@ func randomHex(n int) string { return hex.EncodeToString(b) } +func loadFromEnv() *BootstrapConfig { + return &BootstrapConfig{ + Auth0Domain: envOr("AUTH0_DOMAIN", ""), + Auth0MgmtClientID: envOr("AUTH0_MGMT_CLIENT_ID", ""), + Auth0MgmtClientSecret: envOr("AUTH0_MGMT_CLIENT_SECRET", ""), + AppName: envOr("APP_NAME", "Livepeer Platform"), + APIAudience: envOr("AUTH0_AUDIENCE", "livepeer"), + OpenmeterURL: envOr("OPENMETER_URL", "https://us.api.konghq.com/v3/openmeter"), + OpenmeterAPIKey: envOr("OPENMETER_API_KEY", ""), + TrialFeatureKey: envOr("OPENMETER_TRIAL_FEATURE_KEY", "network_spend"), + WebhookSecret: envOr("WEBHOOK_SECRET", ""), + OutputPath: envOr("BOOTSTRAP_OUTPUT", ".env.livepeer"), + SDKConfigOutputPath: envOr("SDK_CONFIG_OUTPUT", "sdk-config.json"), + MetersConfigPath: envOr("METERS_CONFIG_PATH", "config/meters.json"), + PricingConfigPath: envOr("PRICING_CONFIG_PATH", "config/pricing.json"), + } +} + func Parse(args []string) (*BootstrapConfig, error) { fs := flag.NewFlagSet("clearinghouse-bootstrap", flag.ContinueOnError) fs.SetOutput(os.Stderr) - cfg := &BootstrapConfig{} - - fs.StringVar(&cfg.Auth0Domain, "auth0-domain", envOr("AUTH0_DOMAIN", ""), "Auth0 tenant domain") - fs.StringVar(&cfg.Auth0MgmtClientID, "auth0-mgmt-client-id", envOr("AUTH0_MGMT_CLIENT_ID", ""), "Auth0 Management M2M client ID") - fs.StringVar(&cfg.Auth0MgmtClientSecret, "auth0-mgmt-client-secret", envOr("AUTH0_MGMT_CLIENT_SECRET", ""), "Auth0 Management M2M client secret") - fs.StringVar(&cfg.AppName, "app-name", envOr("APP_NAME", "Livepeer Platform"), "Application display name prefix") - fs.StringVar(&cfg.APIAudience, "api-audience", envOr("AUTH0_AUDIENCE", "livepeer"), "API identifier / audience") - fs.StringVar(&cfg.OpenmeterURL, "openmeter-url", envOr("OPENMETER_URL", "https://us.api.konghq.com/v3/openmeter"), "Konnect metering base URL") - fs.StringVar(&cfg.OpenmeterAPIKey, "openmeter-api-key", envOr("OPENMETER_API_KEY", ""), "Konnect PAT (kpat_…)") - fs.StringVar(&cfg.TrialFeatureKey, "trial-feature-key", envOr("OPENMETER_TRIAL_FEATURE_KEY", "network_spend"), "Entitlement feature key for trial") - fs.StringVar(&cfg.WebhookSecret, "webhook-secret", envOr("WEBHOOK_SECRET", ""), "Shared secret for remote_signer_webhook") - fs.StringVar(&cfg.SignerProxyURL, "signer-proxy-url", envOr("SIGNER_PROXY_URL", "https://your-platform.vercel.app/api/signer"), "Vercel signer proxy BFF URL") - fs.StringVar(&cfg.SignerPublicURL, "signer-public-url", envOr("SIGNER_PUBLIC_URL", "https://signer.your-domain.com"), "Public HTTPS URL of VM remote signer") - fs.StringVar(&cfg.RemoteSignerWebhookURL, "remote-signer-webhook-url", envOr("REMOTE_SIGNER_WEBHOOK_URL", "https://your-platform.vercel.app/webhooks/remote-signer"), "Identity webhook (go-livepeer calls this)") - fs.StringVar(&cfg.SignerNetwork, "signer-network", envOr("SIGNER_NETWORK", "arbitrum-one-mainnet"), "go-livepeer -network") - fs.StringVar(&cfg.EthRPCURL, "eth-rpc-url", envOr("ETH_RPC_URL", "https://arb1.arbitrum.io/rpc"), "Ethereum RPC for remote signer") - fs.StringVar(&cfg.SignerEthAddr, "signer-eth-addr", envOr("SIGNER_ETH_ADDR", ""), "Funded signer ETH address") - fs.StringVar(&cfg.KafkaBrokers, "kafka-brokers", envOr("KAFKA_BROKERS", "kafka:9092"), "Kafka bootstrap servers") - fs.StringVar(&cfg.KafkaGatewayTopic, "kafka-gateway-topic", envOr("KAFKA_GATEWAY_TOPIC", "livepeer-gateway-events"), "go-livepeer monitor topic") - fs.StringVar(&cfg.EthUsdPrice, "eth-usd-price", envOr("ETH_USD_PRICE", "3500"), "ETH/USD for collector Wei conversion") - fs.StringVar(&cfg.OutputPath, "output", envOr("BOOTSTRAP_OUTPUT", ".env.livepeer"), "Write combined .env file") - fs.StringVar(&cfg.SDKConfigOutputPath, "sdk-config-output", envOr("SDK_CONFIG_OUTPUT", "sdk-config.json"), "Write builder-sdk config JSON") - fs.StringVar(&cfg.MetersConfigPath, "meters-config", envOr("METERS_CONFIG_PATH", "config/meters.json"), "Meter definition config JSON") - fs.StringVar(&cfg.PricingConfigPath, "pricing-config", envOr("PRICING_CONFIG_PATH", "config/pricing.json"), "Pricing definition config JSON") + cfg := loadFromEnv() + fs.BoolVar(&cfg.SkipAuth0, "skip-auth0", false, "Only bootstrap OpenMeter") fs.BoolVar(&cfg.SkipOpenMeter, "skip-openmeter", false, "Only provision Auth0") fs.BoolVar(&cfg.Prune, "prune", false, "Destructive: remove Konnect catalog objects not in config") @@ -115,7 +96,7 @@ func Parse(args []string) (*BootstrapConfig, error) { if !cfg.SkipAuth0 { if cfg.Auth0Domain == "" { - return nil, fmt.Errorf("missing AUTH0_DOMAIN (set in .env or pass --auth0-domain)") + return nil, fmt.Errorf("missing AUTH0_DOMAIN in .env") } if cfg.Auth0MgmtClientID == "" || cfg.Auth0MgmtClientSecret == "" { return nil, fmt.Errorf("missing Auth0 Management API credentials (AUTH0_MGMT_CLIENT_ID / AUTH0_MGMT_CLIENT_SECRET in .env)") @@ -124,7 +105,7 @@ func Parse(args []string) (*BootstrapConfig, error) { if !cfg.SkipOpenMeter { if cfg.OpenmeterURL == "" { - return nil, fmt.Errorf("missing OPENMETER_URL (set in .env or pass --openmeter-url)") + return nil, fmt.Errorf("missing OPENMETER_URL in .env") } if cfg.OpenmeterAPIKey == "" { return nil, fmt.Errorf("missing OPENMETER_API_KEY — Konnect PAT (kpat_…) in .env") @@ -147,19 +128,17 @@ func printHelpBrief() { fmt.Print(`Usage: clearinghouse-bootstrap [options] Provision Auth0 + OpenMeter/Konnect and write .env.livepeer + sdk-config.json. -Settings load from .env (see .env.example); flags override env. +Settings load from .env (see .env.example). Quick start: cp .env.example .env ./clearinghouse-bootstrap Options: - --env-file Env file (default: .env if present) - --skip-auth0 Konnect catalog only - --skip-openmeter Auth0 only - --prune Remove catalog objects not in config (destructive) - --output .env output (default: .env.livepeer) - --sdk-config-output JSON output (default: sdk-config.json) + --env-file Env file (default: .env if present) + --skip-auth0 Konnect catalog only + --skip-openmeter Auth0 only + --prune Remove catalog objects not in config (destructive) Run --help-all for config file locations. `) @@ -168,7 +147,7 @@ Run --help-all for config file locations. func printHelpAll() { fmt.Print(`Configuration reference - .env.example Auth0, Konnect, deploy vars, output paths + .env.example Auth0 and Konnect credentials, output paths config/meters.json Meter definitions config/pricing.json Default plan and pricing `) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 93c5e39..d7dd61e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,7 +4,9 @@ import "testing" const testEmptyEnv = "testdata/empty.env" -func parseForTest(args ...string) (*BootstrapConfig, error) { +func parseForTest(t *testing.T, args ...string) (*BootstrapConfig, error) { + t.Helper() + full := append([]string{"--env-file", testEmptyEnv}, args...) remaining, envFile, explicit, help, err := PreprocessArgs(full) if err != nil { @@ -20,12 +22,12 @@ func parseForTest(args ...string) (*BootstrapConfig, error) { } func TestParseMinimal(t *testing.T) { - cfg, err := parseForTest( - "--auth0-domain", "test.auth0.com", - "--auth0-mgmt-client-id", "cid", - "--auth0-mgmt-client-secret", "csec", - "--openmeter-api-key", "kpat_test", - ) + t.Setenv("AUTH0_DOMAIN", "test.auth0.com") + t.Setenv("AUTH0_MGMT_CLIENT_ID", "cid") + t.Setenv("AUTH0_MGMT_CLIENT_SECRET", "csec") + t.Setenv("OPENMETER_API_KEY", "kpat_test") + + cfg, err := parseForTest(t) if err != nil { t.Fatalf("Parse: %v", err) } @@ -44,14 +46,19 @@ func TestParseMinimal(t *testing.T) { } func TestParseMissingAuth0(t *testing.T) { - _, err := parseForTest("--openmeter-api-key", "kpat_test") + t.Setenv("AUTH0_DOMAIN", "") + t.Setenv("OPENMETER_API_KEY", "kpat_test") + + _, err := parseForTest(t) if err == nil { t.Fatal("expected error for missing auth0 domain") } } func TestParseSkipAuth0(t *testing.T) { - cfg, err := parseForTest("--skip-auth0", "--openmeter-api-key", "kpat_test") + t.Setenv("OPENMETER_API_KEY", "kpat_test") + + cfg, err := parseForTest(t, "--skip-auth0") if err != nil { t.Fatalf("Parse: %v", err) } @@ -61,7 +68,7 @@ func TestParseSkipAuth0(t *testing.T) { } func TestParseSkipBoth(t *testing.T) { - cfg, err := parseForTest("--skip-auth0", "--skip-openmeter") + cfg, err := parseForTest(t, "--skip-auth0", "--skip-openmeter") if err != nil { t.Fatalf("Parse: %v", err) } diff --git a/internal/config/envfile_test.go b/internal/config/envfile_test.go index 3b9ab87..bf09fb4 100644 --- a/internal/config/envfile_test.go +++ b/internal/config/envfile_test.go @@ -81,7 +81,7 @@ OPENMETER_API_KEY=kpat_test t.Fatalf("LoadEnvFile: %v", err) } - cfg, err := parseForTest() + cfg, err := parseForTest(t) if err != nil { t.Fatalf("Parse: %v", err) } diff --git a/internal/output/env.go b/internal/output/env.go index 257f6bd..313f609 100644 --- a/internal/output/env.go +++ b/internal/output/env.go @@ -12,7 +12,7 @@ func BuildEnvFile(cfg *config.BootstrapConfig, auth0Result *auth0.ProvisionResul var b strings.Builder b.WriteString("# Generated by clearinghouse-bootstrap\n") - b.WriteString("# VM runtime: deploy/docker-compose.yml | Vercel platform: sdk-config.json\n\n") + b.WriteString("# Platform deploy: sdk-config.json | VM runtime: see deploy stack\n\n") if auth0Result != nil { b.WriteString("# Auth0\n") @@ -38,32 +38,8 @@ func BuildEnvFile(cfg *config.BootstrapConfig, auth0Result *auth0.ProvisionResul if cfg.OpenmeterAPIKey != "" { fmt.Fprintf(&b, "OPENMETER_API_KEY=%s\n", cfg.OpenmeterAPIKey) } - fmt.Fprintf(&b, "OPENMETER_TRIAL_FEATURE_KEY=%s\n\n", cfg.TrialFeatureKey) + fmt.Fprintf(&b, "OPENMETER_TRIAL_FEATURE_KEY=%s\n", cfg.TrialFeatureKey) } - b.WriteString("# Vercel platform URLs (update after platform deploy)\n") - fmt.Fprintf(&b, "REMOTE_SIGNER_WEBHOOK_URL=%s\n", cfg.RemoteSignerWebhookURL) - fmt.Fprintf(&b, "SIGNER_PROXY_URL=%s\n", cfg.SignerProxyURL) - fmt.Fprintf(&b, "SIGNER_PUBLIC_URL=%s\n\n", cfg.SignerPublicURL) - - b.WriteString("# Kafka (docker compose service name)\n") - fmt.Fprintf(&b, "KAFKA_BROKERS=%s\n", cfg.KafkaBrokers) - fmt.Fprintf(&b, "KAFKA_GATEWAY_TOPIC=%s\n\n", cfg.KafkaGatewayTopic) - - b.WriteString("# OpenMeter Collector (Wei -> USD micros)\n") - fmt.Fprintf(&b, "ETH_USD_PRICE=%s\n\n", cfg.EthUsdPrice) - - b.WriteString("# Remote signer (operator — fund ETH account before stack:up)\n") - fmt.Fprintf(&b, "SIGNER_NETWORK=%s\n", cfg.SignerNetwork) - fmt.Fprintf(&b, "ETH_RPC_URL=%s\n", cfg.EthRPCURL) - if cfg.SignerEthAddr != "" { - fmt.Fprintf(&b, "SIGNER_ETH_ADDR=%s\n", cfg.SignerEthAddr) - } else { - b.WriteString("# SIGNER_ETH_ADDR=0x...\n") - } - - b.WriteString("\n# VM deploy: clearinghouse-bootstrap + docker compose\n") - b.WriteString("# Platform deploy: use sdk-config.json on Vercel (webhook route + signer proxy)\n") - return b.String() } diff --git a/internal/output/env_test.go b/internal/output/env_test.go index 59fbcfb..5b46f1d 100644 --- a/internal/output/env_test.go +++ b/internal/output/env_test.go @@ -12,28 +12,19 @@ import ( func TestBuildEnvFile(t *testing.T) { cfg := &config.BootstrapConfig{ - Auth0Domain: "test.us.auth0.com", - OpenmeterURL: "https://us.api.konghq.com/v3/openmeter", - OpenmeterAPIKey: "kpat_test123", - TrialFeatureKey: "network_spend", - WebhookSecret: "deadbeef", - SignerProxyURL: "https://platform.vercel.app/api/signer", - SignerPublicURL: "https://signer.example.com", - RemoteSignerWebhookURL: "https://platform.vercel.app/webhooks/remote-signer", - SignerNetwork: "arbitrum-one-mainnet", - EthRPCURL: "https://arb1.arbitrum.io/rpc", - SignerEthAddr: "0x1234", - KafkaBrokers: "kafka:9092", - KafkaGatewayTopic: "livepeer-gateway-events", - EthUsdPrice: "3500", + Auth0Domain: "test.us.auth0.com", + OpenmeterURL: "https://us.api.konghq.com/v3/openmeter", + OpenmeterAPIKey: "kpat_test123", + TrialFeatureKey: "network_spend", + WebhookSecret: "deadbeef", } auth0Result := &auth0.ProvisionResult{ - APIIdentifier: "livepeer", - PublicClientID: "pub_123", - M2MClientID: "m2m_456", + APIIdentifier: "livepeer", + PublicClientID: "pub_123", + M2MClientID: "m2m_456", M2MClientSecret: "secret_789", - JwksURL: "https://test.us.auth0.com/.well-known/jwks.json", - Issuer: "https://test.us.auth0.com/", + JwksURL: "https://test.us.auth0.com/.well-known/jwks.json", + Issuer: "https://test.us.auth0.com/", } got := BuildEnvFile(cfg, auth0Result) @@ -57,19 +48,11 @@ func TestBuildEnvFile(t *testing.T) { func TestBuildEnvFileSkipAuth0(t *testing.T) { cfg := &config.BootstrapConfig{ - SkipAuth0: true, - OpenmeterURL: "https://us.api.konghq.com/v3/openmeter", - OpenmeterAPIKey: "kpat_test", - TrialFeatureKey: "network_spend", - WebhookSecret: "abc", - SignerProxyURL: "https://p.example.com/api/signer", - SignerPublicURL: "https://s.example.com", - RemoteSignerWebhookURL: "https://p.example.com/webhooks/remote-signer", - SignerNetwork: "arbitrum-one-mainnet", - EthRPCURL: "https://arb1.arbitrum.io/rpc", - KafkaBrokers: "kafka:9092", - KafkaGatewayTopic: "livepeer-gateway-events", - EthUsdPrice: "3500", + SkipAuth0: true, + OpenmeterURL: "https://us.api.konghq.com/v3/openmeter", + OpenmeterAPIKey: "kpat_test", + TrialFeatureKey: "network_spend", + WebhookSecret: "abc", } got := BuildEnvFile(cfg, nil) diff --git a/internal/output/sdkconfig.go b/internal/output/sdkconfig.go index 66a20e8..6220e83 100644 --- a/internal/output/sdkconfig.go +++ b/internal/output/sdkconfig.go @@ -8,6 +8,13 @@ import ( "github.com/livepeer/clearinghouse/internal/config" ) +// Placeholder platform URLs — update after Vercel deploy (not configured by bootstrap). +const ( + defaultSignerProxyURL = "https://your-platform.vercel.app/api/signer" + defaultSignerPublicURL = "https://signer.your-domain.com" + defaultRemoteSignerWebhookURL = "https://your-platform.vercel.app/webhooks/remote-signer" +) + type SDKConfig struct { Auth0 SDKAuth0 `json:"auth0"` Signer SDKSigner `json:"signer"` @@ -48,12 +55,12 @@ func BuildSDKConfig(cfg *config.BootstrapConfig, auth0Result *auth0.ProvisionRes Audience: auth0Result.APIIdentifier, }, Signer: SDKSigner{ - ProxyURL: cfg.SignerProxyURL, - PublicURL: cfg.SignerPublicURL, + ProxyURL: defaultSignerProxyURL, + PublicURL: defaultSignerPublicURL, Audience: auth0Result.APIIdentifier, }, RemoteSigner: SDKRemoteSigner{ - WebhookURL: cfg.RemoteSignerWebhookURL, + WebhookURL: defaultRemoteSignerWebhookURL, }, } diff --git a/internal/output/sdkconfig_test.go b/internal/output/sdkconfig_test.go index 25073f2..638ce40 100644 --- a/internal/output/sdkconfig_test.go +++ b/internal/output/sdkconfig_test.go @@ -12,20 +12,17 @@ import ( func TestBuildSDKConfig(t *testing.T) { cfg := &config.BootstrapConfig{ - Auth0Domain: "test.us.auth0.com", - OpenmeterURL: "https://us.api.konghq.com/v3/openmeter", - TrialFeatureKey: "network_spend", - SignerProxyURL: "https://platform.vercel.app/api/signer", - SignerPublicURL: "https://signer.example.com", - RemoteSignerWebhookURL: "https://platform.vercel.app/webhooks/remote-signer", + Auth0Domain: "test.us.auth0.com", + OpenmeterURL: "https://us.api.konghq.com/v3/openmeter", + TrialFeatureKey: "network_spend", } auth0Result := &auth0.ProvisionResult{ - APIIdentifier: "livepeer", - PublicClientID: "pub_123", - M2MClientID: "m2m_456", + APIIdentifier: "livepeer", + PublicClientID: "pub_123", + M2MClientID: "m2m_456", M2MClientSecret: "secret_789", - JwksURL: "https://test.us.auth0.com/.well-known/jwks.json", - Issuer: "https://test.us.auth0.com/", + JwksURL: "https://test.us.auth0.com/.well-known/jwks.json", + Issuer: "https://test.us.auth0.com/", } got, err := BuildSDKConfig(cfg, auth0Result) @@ -46,7 +43,6 @@ func TestBuildSDKConfig(t *testing.T) { t.Fatalf("Missing golden file %s — run with UPDATE_GOLDEN=1 to create", golden) } - // Normalize for comparison var gotObj, wantObj SDKConfig json.Unmarshal(got, &gotObj) json.Unmarshal(want, &wantObj) diff --git a/internal/output/testdata/env.golden b/internal/output/testdata/env.golden index 4ca6941..e1a08e9 100644 --- a/internal/output/testdata/env.golden +++ b/internal/output/testdata/env.golden @@ -1,5 +1,5 @@ # Generated by clearinghouse-bootstrap -# VM runtime: deploy/docker-compose.yml | Vercel platform: sdk-config.json +# Platform deploy: sdk-config.json | VM runtime: see deploy stack # Auth0 AUTH0_DOMAIN=test.us.auth0.com @@ -22,23 +22,3 @@ USAGE_SUBJECT_TYPE=auth0_user_id OPENMETER_URL=https://us.api.konghq.com/v3/openmeter OPENMETER_API_KEY=kpat_test123 OPENMETER_TRIAL_FEATURE_KEY=network_spend - -# Vercel platform URLs (update after platform deploy) -REMOTE_SIGNER_WEBHOOK_URL=https://platform.vercel.app/webhooks/remote-signer -SIGNER_PROXY_URL=https://platform.vercel.app/api/signer -SIGNER_PUBLIC_URL=https://signer.example.com - -# Kafka (docker compose service name) -KAFKA_BROKERS=kafka:9092 -KAFKA_GATEWAY_TOPIC=livepeer-gateway-events - -# OpenMeter Collector (Wei -> USD micros) -ETH_USD_PRICE=3500 - -# Remote signer (operator — fund ETH account before stack:up) -SIGNER_NETWORK=arbitrum-one-mainnet -ETH_RPC_URL=https://arb1.arbitrum.io/rpc -SIGNER_ETH_ADDR=0x1234 - -# VM deploy: clearinghouse-bootstrap + docker compose -# Platform deploy: use sdk-config.json on Vercel (webhook route + signer proxy) diff --git a/internal/output/testdata/sdkconfig.golden.json b/internal/output/testdata/sdkconfig.golden.json index 30d944e..f537701 100644 --- a/internal/output/testdata/sdkconfig.golden.json +++ b/internal/output/testdata/sdkconfig.golden.json @@ -7,12 +7,12 @@ "audience": "livepeer" }, "signer": { - "proxyUrl": "https://platform.vercel.app/api/signer", - "publicUrl": "https://signer.example.com", + "proxyUrl": "https://your-platform.vercel.app/api/signer", + "publicUrl": "https://signer.your-domain.com", "audience": "livepeer" }, "remoteSigner": { - "webhookUrl": "https://platform.vercel.app/webhooks/remote-signer" + "webhookUrl": "https://your-platform.vercel.app/webhooks/remote-signer" }, "openmeter": { "url": "https://us.api.konghq.com/v3/openmeter", From e701671a5cf4a3c5dc708387c3a939f54db06443 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 17 Jun 2026 18:51:28 -0400 Subject: [PATCH 3/3] chore: update environment configuration and documentation for Livepeer Clearinghouse Changed the application name and API audience in the .env.example and related configuration files to reflect the new branding as "Livepeer Clearinghouse". Updated README and tests to ensure consistency with the new naming conventions. Minor formatting adjustments made for code clarity. --- .env.example | 4 +-- README.md | 2 +- internal/admin/konnect_adapter.go | 4 +-- internal/admin/prune.go | 8 ++--- internal/auth0/provision.go | 56 +++++++++++++++---------------- internal/config/config.go | 4 +-- internal/config/config_test.go | 4 +-- internal/meters/meters.go | 14 ++++---- 8 files changed, 48 insertions(+), 48 deletions(-) diff --git a/.env.example b/.env.example index ae05422..76fb3d1 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,8 @@ AUTH0_DOMAIN=your-org.us.auth0.com AUTH0_MGMT_CLIENT_ID=your_auth0_management_m2m_client_id AUTH0_MGMT_CLIENT_SECRET=your_auth0_management_m2m_client_secret -APP_NAME=Livepeer Platform -AUTH0_AUDIENCE=livepeer +APP_NAME=Livepeer Clearinghouse +AUTH0_AUDIENCE=livepeer-clearinghouse # --- Konnect Metering & Billing (required unless --skip-openmeter) --- # Docs: https://developer.konghq.com/api/konnect/metering-and-billing/v3/ diff --git a/README.md b/README.md index a480984..46fbc9d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The CLI loads `.env` from the current directory automatically. See ## What it does -1. **Auth0** — creates a resource server (`livepeer`, RS256, `sign:job`), +1. **Auth0** — creates a resource server (`livepeer-clearinghouse`, RS256, `sign:job`), a public client (native, device_code + refresh_token), an M2M client (client_credentials), and two client grants. Uses [`go-auth0/v2`](https://github.com/auth0/go-auth0). diff --git a/internal/admin/konnect_adapter.go b/internal/admin/konnect_adapter.go index 6b9f118..d4a4df2 100644 --- a/internal/admin/konnect_adapter.go +++ b/internal/admin/konnect_adapter.go @@ -44,8 +44,8 @@ func (k *KonnectAdmin) WaitForHealthy(ctx context.Context) error { for i := range maxAttempts { res, err := k.sdk.Meters.ListMeters(ctx, operations.ListMetersRequest{ Page: &components.PagePaginationQuery{ - Number: sdkkonnectgo.Pointer(int64(1)), - Size: sdkkonnectgo.Pointer(int64(1)), + Number: sdkkonnectgo.Pointer(int64(1)), + Size: sdkkonnectgo.Pointer(int64(1)), }, }) if err == nil && res.StatusCode >= 200 && res.StatusCode < 300 { diff --git a/internal/admin/prune.go b/internal/admin/prune.go index df85827..1daf061 100644 --- a/internal/admin/prune.go +++ b/internal/admin/prune.go @@ -9,9 +9,9 @@ import ( ) type catalogExpectations struct { - meters map[string]MeterInput - features map[string]string // feature key -> meter key - planKeys map[string]struct{} + meters map[string]MeterInput + features map[string]string // feature key -> meter key + planKeys map[string]struct{} } func buildCatalogExpectations( @@ -43,7 +43,7 @@ func buildCatalogExpectations( return catalogExpectations{ meters: meterInputs, features: map[string]string{ - trialFeatureKey: meterCfg.NetworkFeeUsdMicrosMeter, + trialFeatureKey: meterCfg.NetworkFeeUsdMicrosMeter, pricingCfg.BillableFeatureKey: meterCfg.BillableUsdMicrosMeter, }, planKeys: planKeys, diff --git a/internal/auth0/provision.go b/internal/auth0/provision.go index 519ddb0..684bf5c 100644 --- a/internal/auth0/provision.go +++ b/internal/auth0/provision.go @@ -10,20 +10,20 @@ import ( ) type ProvisionResult struct { - APIIdentifier string - PublicClientID string - M2MClientID string + APIIdentifier string + PublicClientID string + M2MClientID string M2MClientSecret string - JwksURL string - Issuer string + JwksURL string + Issuer string } type ProvisionConfig struct { - Domain string - MgmtClientID string + Domain string + MgmtClientID string MgmtClientSecret string - AppName string - APIAudience string + AppName string + APIAudience string } func Provision(ctx context.Context, cfg ProvisionConfig) (*ProvisionResult, error) { @@ -41,9 +41,9 @@ func Provision(ctx context.Context, cfg ProvisionConfig) (*ProvisionResult, erro tokenLifetime := 86400 skipConsent := true _, err = mgmt.ResourceServers.Create(ctx, &management.CreateResourceServerRequestContent{ - Name: &rsName, - Identifier: cfg.APIAudience, - SigningAlg: &signingAlg, + Name: &rsName, + Identifier: cfg.APIAudience, + SigningAlg: &signingAlg, TokenLifetime: &tokenLifetime, SkipConsentForVerifiableFirstPartyClients: &skipConsent, Scopes: []*management.ResourceServerScope{ @@ -66,12 +66,12 @@ func Provision(ctx context.Context, cfg ProvisionConfig) (*ProvisionResult, erro oidcConformant := true publicResp, err := mgmt.Clients.Create(ctx, &management.CreateClientRequestContent{ - Name: publicName, - Description: &publicDesc, - AppType: &appTypeNative, - OidcConformant: &oidcConformant, - IsFirstParty: &isFirstParty, - GrantTypes: []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"}, + Name: publicName, + Description: &publicDesc, + AppType: &appTypeNative, + OidcConformant: &oidcConformant, + IsFirstParty: &isFirstParty, + GrantTypes: []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"}, TokenEndpointAuthMethod: &authMethodNone, }) if err != nil { @@ -89,11 +89,11 @@ func Provision(ctx context.Context, cfg ProvisionConfig) (*ProvisionResult, erro authMethodPost := management.ClientTokenEndpointAuthMethodEnumClientSecretPost m2mResp, err := mgmt.Clients.Create(ctx, &management.CreateClientRequestContent{ - Name: m2mName, - Description: &m2mDesc, - AppType: &appTypeM2M, - OidcConformant: &oidcConformant, - GrantTypes: []string{"client_credentials"}, + Name: m2mName, + Description: &m2mDesc, + AppType: &appTypeM2M, + OidcConformant: &oidcConformant, + GrantTypes: []string{"client_credentials"}, TokenEndpointAuthMethod: &authMethodPost, }) if err != nil { @@ -131,12 +131,12 @@ func Provision(ctx context.Context, cfg ProvisionConfig) (*ProvisionResult, erro jwksURL := fmt.Sprintf("https://%s/.well-known/jwks.json", cfg.Domain) return &ProvisionResult{ - APIIdentifier: cfg.APIAudience, - PublicClientID: publicClientID, - M2MClientID: m2mClientID, + APIIdentifier: cfg.APIAudience, + PublicClientID: publicClientID, + M2MClientID: m2mClientID, M2MClientSecret: m2mClientSecret, - JwksURL: jwksURL, - Issuer: issuer, + JwksURL: jwksURL, + Issuer: issuer, }, nil } diff --git a/internal/config/config.go b/internal/config/config.go index 70c79bf..08278c6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -63,8 +63,8 @@ func loadFromEnv() *BootstrapConfig { Auth0Domain: envOr("AUTH0_DOMAIN", ""), Auth0MgmtClientID: envOr("AUTH0_MGMT_CLIENT_ID", ""), Auth0MgmtClientSecret: envOr("AUTH0_MGMT_CLIENT_SECRET", ""), - AppName: envOr("APP_NAME", "Livepeer Platform"), - APIAudience: envOr("AUTH0_AUDIENCE", "livepeer"), + AppName: envOr("APP_NAME", "Livepeer Clearinghouse"), + APIAudience: envOr("AUTH0_AUDIENCE", "livepeer-clearinghouse"), OpenmeterURL: envOr("OPENMETER_URL", "https://us.api.konghq.com/v3/openmeter"), OpenmeterAPIKey: envOr("OPENMETER_API_KEY", ""), TrialFeatureKey: envOr("OPENMETER_TRIAL_FEATURE_KEY", "network_spend"), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d7dd61e..7341ee1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -34,10 +34,10 @@ func TestParseMinimal(t *testing.T) { if cfg.Auth0Domain != "test.auth0.com" { t.Errorf("Auth0Domain = %s", cfg.Auth0Domain) } - if cfg.AppName != "Livepeer Platform" { + if cfg.AppName != "Livepeer Clearinghouse" { t.Errorf("AppName = %s", cfg.AppName) } - if cfg.APIAudience != "livepeer" { + if cfg.APIAudience != "livepeer-clearinghouse" { t.Errorf("APIAudience = %s", cfg.APIAudience) } if cfg.WebhookSecret == "" { diff --git a/internal/meters/meters.go b/internal/meters/meters.go index 1320499..f7d007d 100644 --- a/internal/meters/meters.go +++ b/internal/meters/meters.go @@ -25,13 +25,13 @@ type meterSpec struct { type Config struct { CreateSignedTicketEventType string `json:"createSignedTicketEventType"` SignedTicketEventSource string `json:"signedTicketEventSource"` - DefaultTrialFeatureKey string `json:"defaultTrialFeatureKey"` - DefaultBillableFeatureKey string `json:"defaultBillableFeatureKey"` - NetworkFeeUsdMicrosMeter string `json:"networkFeeUsdMicrosMeter"` - BillableUsdMicrosMeter string `json:"billableUsdMicrosMeter"` - SignedTicketCountMeter string `json:"signedTicketCountMeter"` - Dimensions map[string]string `json:"dimensions"` - Meters struct { + DefaultTrialFeatureKey string `json:"defaultTrialFeatureKey"` + DefaultBillableFeatureKey string `json:"defaultBillableFeatureKey"` + NetworkFeeUsdMicrosMeter string `json:"networkFeeUsdMicrosMeter"` + BillableUsdMicrosMeter string `json:"billableUsdMicrosMeter"` + SignedTicketCountMeter string `json:"signedTicketCountMeter"` + Dimensions map[string]string `json:"dimensions"` + Meters struct { NetworkFeeUsdMicros meterSpec `json:"networkFeeUsdMicros"` BillableUsdMicros meterSpec `json:"billableUsdMicros"` SignedTicketCount meterSpec `json:"signedTicketCount"`