From 0b13b040e73aac494b998427cdfee560d4c9da2e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 11:35:39 +0200 Subject: [PATCH] feat(sandbox-mcp): sandbox.stripe.* real impls (last MCP namespace) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires four real Stripe handlers in openova-sandbox-mcp, completing the final unwired namespace from architecture.md §3 (sandbox.stripe.*): - sandbox.stripe.bindAccount {api_key} — validates the key prefix (sk_live_ / sk_test_ / rk_live_ / rk_test_), stores it in the per-Sandbox Secret (`sandbox--secrets`, data-key `stripe_api_key`) via the same write-path sandbox.secrets.write uses, returns a masked confirmation (`sk_test_…xY12`). - sandbox.stripe.listProducts — reads the bound key implicitly, GET /v1/products with limit (1-100, default 20), active, and starting_after cursor passthrough. - sandbox.stripe.listPrices {product_id?} — same pagination shape; optional product_id filter. - sandbox.stripe.createCheckoutSession {price_id, success_url, cancel_url} — validates absolute http(s) URLs, POSTs the form-encoded line_items[0][price/quantity] body to /v1/checkout/sessions, returns the hosted Checkout URL + session id. Implementation: - No new module dep — inline HTTPS calls to api.stripe.com via the stdlib net/http client. stripe-go v82 would have pulled ~80 transitive packages for four endpoints; the surface we need is tiny enough that a 100-line stripeDo helper covers it. Matches the task's "stripe-go v82 if not already in deps; else inline HTTPS" guidance. - The key never round-trips on the wire after first bind. Agent pastes once via bindAccount; every subsequent call reads it from the Secret store. Stripe-Version header pinned to 2024-06-20 so a future API revision can't silently break the wire format. - Auth: RequiredCapability="sandbox.stripe" on every tool. claims.OrgID match enforced by the registry's existing gate. - Read-only cluster invariant: the only writes are to the per-Sandbox Secret. assertManagedBy() enforced on bind so we cannot mutate the controller-injected `sandbox-tokens` Secret. Tests cover key validation (prefix + length), masking format, limit clamping, the httptest.Server-backed happy-path + error-envelope unwrap, form-urlencoded body shape for createCheckoutSession, catalogue wiring (all four handlers non-nil, RequiredCapability matches), and the registry capability gate (missing sandbox.stripe cap → forbidden). Closes the Wave 13 "last MCP namespace" gap; no chart bump. Co-Authored-By: Claude Opus 4.7 (1M context) --- products/sandbox/docs/architecture.md | 3 +- .../mcp-server/internal/tools/registry.go | 50 +- .../internal/tools/sandbox_stripe.go | 553 ++++++++++++++++++ .../internal/tools/sandbox_stripe_test.go | 302 ++++++++++ 4 files changed, 904 insertions(+), 4 deletions(-) create mode 100644 products/sandbox/mcp-server/internal/tools/sandbox_stripe.go create mode 100644 products/sandbox/mcp-server/internal/tools/sandbox_stripe_test.go diff --git a/products/sandbox/docs/architecture.md b/products/sandbox/docs/architecture.md index 4cfa9fd0..f3e0c491 100644 --- a/products/sandbox/docs/architecture.md +++ b/products/sandbox/docs/architecture.md @@ -95,7 +95,8 @@ github.* present ONLY if iOS/macOS-runner work is detected; scoped GH PAT for that pipeline only sandbox.db.* provision / drop / dump CNPG clusters in this Sandbox sandbox.auth.* provisionRealm, listClients, registerClient (Keycloak) -sandbox.stripe.* bindAccount, listProducts (uses Sandbox secret store) +sandbox.stripe.* bindAccount, listProducts, listPrices, createCheckoutSession + (key stored in Sandbox secret store; never re-passed) sandbox.secrets.* read/write Sandbox-scoped secrets (never echoes) sandbox.storage.* bindBucket / signedUploadURL (SeaweedFS-backed) sandbox.preview.* status, rebuild, teardown diff --git a/products/sandbox/mcp-server/internal/tools/registry.go b/products/sandbox/mcp-server/internal/tools/registry.go index 5fb2f08d..835143fb 100644 --- a/products/sandbox/mcp-server/internal/tools/registry.go +++ b/products/sandbox/mcp-server/internal/tools/registry.go @@ -34,9 +34,15 @@ // - marketplace.domain.byod / marketplace.domain.subdomain // - flux.status / flux.reconcile / flux.suspend / flux.resume // -// All other namespaces (sandbox.stripe.*, k8s.write.*, -// gitea.release.list) remain stubbed and continue to return -// not_implemented until later waves ship them. +// Wave 13 extends to sandbox.stripe.* — the last MCP namespace to be +// wired with real handlers (per-Sandbox Stripe account binding + Product +// / Price / Checkout-session ops): +// +// - sandbox.stripe.bindAccount / sandbox.stripe.listProducts / +// sandbox.stripe.listPrices / sandbox.stripe.createCheckoutSession +// +// All other namespaces (k8s.write.*, gitea.release.list) remain stubbed +// and continue to return not_implemented until later waves ship them. // // Wire model recap (architecture.md §3): // @@ -814,6 +820,44 @@ func defaultCatalogue(env *Env) []Tool { RequiredCapability: "sandbox.storage", }, + // sandbox.stripe.* — per-Sandbox Stripe account binding + Product / + // Price / Checkout-session ops (Wave 13 — sandbox_stripe.go). The + // Stripe API key is stored once via bindAccount in the same Secret + // store sandbox.secrets.* uses (`sandbox--secrets`, data + // key `stripe_api_key`); subsequent calls read it implicitly so + // the agent never round-trips the secret on the wire after the + // first bind. RequiredCapability="sandbox.stripe" gates every call + // regardless of read-vs-write — the Stripe API itself is the only + // outbound destination. + { + Name: "sandbox.stripe.bindAccount", + Description: "Bind a Stripe API key (`sk_live_…` / `sk_test_…` / `rk_live_…` / `rk_test_…`) to this Sandbox. Stored in the Sandbox Secret store; subsequent sandbox.stripe.* calls read it implicitly. Returns a MASKED confirmation.", + InputSchema: schemaSandboxStripeBindAccount(), + Handler: sandboxStripeBindAccount, + RequiredCapability: "sandbox.stripe", + }, + { + Name: "sandbox.stripe.listProducts", + Description: "List Stripe Products via the bound key. Supports `limit` (1-100, default 20), `active` (filter), `starting_after` (cursor).", + InputSchema: schemaSandboxStripeListProducts(), + Handler: sandboxStripeListProducts, + RequiredCapability: "sandbox.stripe", + }, + { + Name: "sandbox.stripe.listPrices", + Description: "List Stripe Prices via the bound key. Optional `product_id` filters to one Product; same paging flags as listProducts.", + InputSchema: schemaSandboxStripeListPrices(), + Handler: sandboxStripeListPrices, + RequiredCapability: "sandbox.stripe", + }, + { + Name: "sandbox.stripe.createCheckoutSession", + Description: "Create a Stripe Checkout Session for {price_id, success_url, cancel_url}. Default mode=payment, quantity=1. Returns the hosted Checkout URL.", + InputSchema: schemaSandboxStripeCreateCheckoutSession(), + Handler: sandboxStripeCreateCheckoutSession, + RequiredCapability: "sandbox.stripe", + }, + // sandbox.session.* — this MCP server's own metadata (Wave 8). { Name: "sandbox.session.whoami", diff --git a/products/sandbox/mcp-server/internal/tools/sandbox_stripe.go b/products/sandbox/mcp-server/internal/tools/sandbox_stripe.go new file mode 100644 index 00000000..0e9daff3 --- /dev/null +++ b/products/sandbox/mcp-server/internal/tools/sandbox_stripe.go @@ -0,0 +1,553 @@ +// sandbox_stripe.go — real handlers for sandbox.stripe.* (per-Sandbox +// Stripe account binding + product / price / checkout-session ops). +// +// Scope (architecture.md §3): agents shipping monetised apps from inside +// a Sandbox need a way to bind the operator's Stripe account ONCE and +// then drive product/price/checkout flows without ever round-tripping a +// raw secret through prompts. The MCP server's role is to: +// +// 1. Accept the Stripe API key via `sandbox.stripe.bindAccount`, store +// it in the per-Sandbox K8s Secret (`sandbox--secrets`, +// key `stripe_api_key`) — same crypto-at-rest path the rest of the +// sandbox.secrets.* tools use. Return a MASKED confirmation +// (`sk_…last4`) so the agent + operator can verify what got bound +// without the value re-appearing in transcripts. +// +// 2. Read the stored key on every subsequent call (`listProducts`, +// `listPrices`, `createCheckoutSession`) — the agent NEVER passes +// the key on the call wire after first bind. The key flows +// apiserver → MCP pod env (via Secret mount lookup) → Stripe. +// +// 3. Drive the Stripe REST API directly over HTTPS (no SDK dep). The +// surface we need is tiny (`GET /v1/products`, `GET /v1/prices`, +// `POST /v1/checkout/sessions`) and adding `stripe-go` would pull +// in ~80 transitive packages for four endpoints. Inline HTTP keeps +// the mcp-server binary lean AND matches the codebase's existing +// "talk to api.stripe.com over HTTPS with form-urlencoded bodies" +// pattern (core/services/billing only imports stripe-go for +// webhook-signature verification + checkout-session creation in +// the billing service path, which is a different process). +// +// Auth: same shape as sandbox.secrets.* / sandbox.storage.* — +// claims.OrgID must match env.OrgID, RequiredCapability = +// "sandbox.stripe". +// +// Hard read-only-cluster rule (DoD): these handlers ONLY mutate the +// per-Sandbox Secret (when bindAccount writes the key) and ONLY read +// from it elsewhere. No k8s.write to anything outside the per-Sandbox +// Secret. The Stripe API itself is the destination of every product / +// price / checkout call. +package tools + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// stripeSecretKeyName is the data-key under which the Stripe API key +// lives inside the per-Sandbox Secret store (`sandbox--secrets`). +// Pinned so listProducts / listPrices / createCheckoutSession can fetch +// it deterministically without an arg. +const stripeSecretKeyName = "stripe_api_key" + +// stripeAPIBase is the canonical Stripe REST API root. Overridable by +// tests via stripeHTTPClient + stripeAPIBaseOverride below. +const stripeAPIBase = "https://api.stripe.com" + +// stripeRequestTimeout caps every Stripe HTTPS call. 15s matches the +// default the canonical stripe-go client uses. +const stripeRequestTimeout = 15 * time.Second + +// stripeHTTPClient is the http.Client every Stripe-bound call routes +// through. Hoisted so unit tests can swap a stub against httptest.Server +// without dialling out to api.stripe.com. +var stripeHTTPClient = &http.Client{Timeout: stripeRequestTimeout} + +// stripeAPIBaseOverride lets tests redirect requests to a httptest.Server +// URL. Empty in production; the call helpers fall back to stripeAPIBase. +var stripeAPIBaseOverride = "" + +// stripeKeyPrefixes — Stripe API keys carry one of these documented +// prefixes (`sk_live_`, `sk_test_`, `rk_live_`, `rk_test_`). Accept all +// four so the agent can bind either a secret key or a restricted key. +// Refuse anything else — stops obvious typos (`pk_live_` publishable +// keys) from being stored as authoritative. +var stripeKeyPrefixes = []string{"sk_live_", "sk_test_", "rk_live_", "rk_test_"} + +// --- helpers ----------------------------------------------------------- + +// stripeAPIBaseURL returns the effective base URL — test override if +// set, the canonical api.stripe.com otherwise. +func stripeAPIBaseURL() string { + if stripeAPIBaseOverride != "" { + return stripeAPIBaseOverride + } + return stripeAPIBase +} + +// validateStripeKey checks the key matches one of the documented +// prefixes AND has a non-trivial length. Stops obvious typos / +// publishable-key mix-ups from being stored. +func validateStripeKey(key string) error { + key = strings.TrimSpace(key) + if key == "" { + return errors.New("`api_key` is required") + } + if len(key) < 16 { + return fmt.Errorf("`api_key` looks too short (got %d chars, expected >=16)", len(key)) + } + for _, p := range stripeKeyPrefixes { + if strings.HasPrefix(key, p) { + return nil + } + } + return fmt.Errorf("`api_key` must start with one of %v (got %q)", stripeKeyPrefixes, key[:min(len(key), 8)]) +} + +// maskStripeKey returns `` so the agent + operator can +// verify which key got bound without the value re-appearing in clear. +// Example: `sk_test_…xY12`. +func maskStripeKey(key string) string { + key = strings.TrimSpace(key) + if len(key) <= 12 { + return "***" + } + // Find the prefix `sk_test_` / `rk_live_` etc — keep through the + // second underscore + last 4 chars. + prefix := "" + for _, p := range stripeKeyPrefixes { + if strings.HasPrefix(key, p) { + prefix = p + break + } + } + if prefix == "" { + return "***" + key[len(key)-4:] + } + return prefix + "…" + key[len(key)-4:] +} + +// readStripeKey fetches the bound Stripe API key from the per-Sandbox +// Secret store. Returns ("", structured-not-found-error) when the Secret +// or the `stripe_api_key` data-key isn't set so callers can hand the +// agent a clean "run bindAccount first" message. +func readStripeKey(ctx context.Context) (string, error) { + env, ns, name, err := requireSandboxSecretsScope(ctx) + if err != nil { + return "", err + } + _ = env + client, err := dynamicClient(ctx) + if err != nil { + return "", err + } + obj, err := client.Resource(secretsGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return "", fmt.Errorf("sandbox.stripe: no Stripe account bound yet — call sandbox.stripe.bindAccount first (Secret %s/%s does not exist)", ns, name) + } + return "", fmt.Errorf("sandbox.stripe: GET Secret %s/%s: %w", ns, name, err) + } + if err := assertManagedBy(obj, name); err != nil { + return "", err + } + data, _, _ := unstructured.NestedMap(obj.Object, "data") + enc, ok := data[stripeSecretKeyName].(string) + if !ok || enc == "" { + return "", fmt.Errorf("sandbox.stripe: no Stripe account bound yet — call sandbox.stripe.bindAccount first (Secret %s/%s has no `%s` key)", ns, name, stripeSecretKeyName) + } + decoded, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return "", fmt.Errorf("sandbox.stripe: decode `%s`: %w", stripeSecretKeyName, err) + } + return strings.TrimSpace(string(decoded)), nil +} + +// stripeDo issues an authenticated Stripe REST call. Returns the parsed +// JSON body, the HTTP status, and any transport / decode error. On +// non-2xx, attempts to parse the Stripe error envelope into a clean +// message rather than dumping raw HTML. +func stripeDo(ctx context.Context, key, method, path string, form url.Values) (map[string]any, int, error) { + full := strings.TrimRight(stripeAPIBaseURL(), "/") + path + var body io.Reader + if method != http.MethodGet && form != nil { + body = strings.NewReader(form.Encode()) + } else if method == http.MethodGet && len(form) > 0 { + if strings.Contains(full, "?") { + full += "&" + form.Encode() + } else { + full += "?" + form.Encode() + } + } + req, err := http.NewRequestWithContext(ctx, method, full, body) + if err != nil { + return nil, 0, fmt.Errorf("stripe %s %s: build request: %w", method, path, err) + } + req.Header.Set("Authorization", "Bearer "+key) + if method != http.MethodGet && form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + // Pin a Stripe-Version header so a future API rev (line items + // reshape, etc.) does NOT silently break us. Bumped explicitly when + // we re-test against a newer Stripe API rev. + req.Header.Set("Stripe-Version", "2024-06-20") + resp, err := stripeHTTPClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("stripe %s %s: %w", method, path, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + out := map[string]any{} + if len(raw) > 0 { + // Stripe always returns JSON for both success + error envelopes; + // a decode failure here is itself a hard error (HTML page from a + // proxy in the middle, etc.). + if err := json.Unmarshal(raw, &out); err != nil { + return nil, resp.StatusCode, fmt.Errorf("stripe %s %s: decode body (status=%d, %d bytes): %w", method, path, resp.StatusCode, len(raw), err) + } + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Surface Stripe's structured error envelope: {error: {type, + // code, message, ...}}. + if errObj, ok := out["error"].(map[string]any); ok { + msg, _ := errObj["message"].(string) + code, _ := errObj["code"].(string) + typ, _ := errObj["type"].(string) + return out, resp.StatusCode, fmt.Errorf("stripe %s %s: %d %s (type=%s code=%s): %s", + method, path, resp.StatusCode, http.StatusText(resp.StatusCode), typ, code, msg) + } + return out, resp.StatusCode, fmt.Errorf("stripe %s %s: %d %s", method, path, resp.StatusCode, http.StatusText(resp.StatusCode)) + } + return out, resp.StatusCode, nil +} + +// --- sandbox.stripe.bindAccount ---------------------------------------- + +type sandboxStripeBindAccountArgs struct { + APIKey string `json:"api_key"` +} + +func sandboxStripeBindAccount(ctx context.Context, raw json.RawMessage) (any, error) { + var args sandboxStripeBindAccountArgs + if err := json.Unmarshal(raw, &args); err != nil { + return nil, fmt.Errorf("sandbox.stripe.bindAccount: invalid arguments: %w", err) + } + if err := validateStripeKey(args.APIKey); err != nil { + return nil, fmt.Errorf("sandbox.stripe.bindAccount: %w", err) + } + key := strings.TrimSpace(args.APIKey) + env, ns, name, err := requireSandboxSecretsScope(ctx) + if err != nil { + return nil, err + } + client, err := dynamicClient(ctx) + if err != nil { + return nil, err + } + encoded := base64.StdEncoding.EncodeToString([]byte(key)) + + existing, getErr := client.Resource(secretsGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if getErr != nil && !apierrors.IsNotFound(getErr) { + return nil, fmt.Errorf("sandbox.stripe.bindAccount: GET Secret %s/%s: %w", ns, name, getErr) + } + if apierrors.IsNotFound(getErr) { + obj := buildSecretObject(env, ns, name) + data, _ := obj.Object["data"].(map[string]any) + data[stripeSecretKeyName] = encoded + obj.Object["data"] = data + created, err := client.Resource(secretsGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("sandbox.stripe.bindAccount: CREATE Secret %s/%s: %w", ns, name, err) + } + return map[string]any{ + "status": "Bound", + "masked": maskStripeKey(key), + "namespace": ns, + "secret": name, + "dataKey": stripeSecretKeyName, + "resourceVersion": created.GetResourceVersion(), + "note": "Stripe API key stored in the Sandbox Secret store. Subsequent sandbox.stripe.* calls read it implicitly — do NOT pass it on the wire again.", + }, nil + } + if err := assertManagedBy(existing, name); err != nil { + return nil, err + } + data, _, _ := unstructured.NestedMap(existing.Object, "data") + if data == nil { + data = map[string]any{} + } + previouslyBound := false + if _, ok := data[stripeSecretKeyName]; ok { + previouslyBound = true + } + data[stripeSecretKeyName] = encoded + if err := unstructured.SetNestedMap(existing.Object, data, "data"); err != nil { + return nil, fmt.Errorf("sandbox.stripe.bindAccount: set nested data: %w", err) + } + updated, err := client.Resource(secretsGVR).Namespace(ns).Update(ctx, existing, metav1.UpdateOptions{}) + if err != nil { + return nil, fmt.Errorf("sandbox.stripe.bindAccount: UPDATE Secret %s/%s: %w", ns, name, err) + } + status := "Bound" + if previouslyBound { + status = "Rebound" + } + return map[string]any{ + "status": status, + "masked": maskStripeKey(key), + "namespace": ns, + "secret": name, + "dataKey": stripeSecretKeyName, + "resourceVersion": updated.GetResourceVersion(), + }, nil +} + +func schemaSandboxStripeBindAccount() map[string]any { + return map[string]any{ + "type": "object", + "required": []string{"api_key"}, + "properties": map[string]any{ + "api_key": map[string]any{ + "type": "string", + "description": "Stripe secret or restricted key (`sk_live_…` / `sk_test_…` / `rk_live_…` / `rk_test_…`). Stored in the per-Sandbox Secret; subsequent sandbox.stripe.* tools read it implicitly.", + }, + }, + "additionalProperties": false, + } +} + +// --- sandbox.stripe.listProducts --------------------------------------- + +type sandboxStripeListProductsArgs struct { + Limit int `json:"limit,omitempty"` + Active *bool `json:"active,omitempty"` + StartingAfter string `json:"starting_after,omitempty"` +} + +func sandboxStripeListProducts(ctx context.Context, raw json.RawMessage) (any, error) { + var args sandboxStripeListProductsArgs + if len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return nil, fmt.Errorf("sandbox.stripe.listProducts: invalid arguments: %w", err) + } + } + key, err := readStripeKey(ctx) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("limit", stripeLimitOr(args.Limit, 20)) + if args.Active != nil { + if *args.Active { + q.Set("active", "true") + } else { + q.Set("active", "false") + } + } + if args.StartingAfter != "" { + q.Set("starting_after", args.StartingAfter) + } + body, status, err := stripeDo(ctx, key, http.MethodGet, "/v1/products", q) + if err != nil { + return nil, fmt.Errorf("sandbox.stripe.listProducts: %w", err) + } + products, _ := body["data"].([]any) + hasMore, _ := body["has_more"].(bool) + return map[string]any{ + "status": "OK", + "products": products, + "count": len(products), + "hasMore": hasMore, + "httpStatus": status, + }, nil +} + +func schemaSandboxStripeListProducts() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 100, "description": "Page size (Stripe caps at 100; default 20)."}, + "active": map[string]any{"type": "boolean", "description": "Filter by active flag (omit → both)."}, + "starting_after": map[string]any{"type": "string", "description": "Stripe cursor — pass the last product `id` from a previous page to fetch the next page."}, + }, + "additionalProperties": false, + } +} + +// --- sandbox.stripe.listPrices ----------------------------------------- + +type sandboxStripeListPricesArgs struct { + ProductID string `json:"product_id,omitempty"` + Limit int `json:"limit,omitempty"` + Active *bool `json:"active,omitempty"` + StartingAfter string `json:"starting_after,omitempty"` +} + +func sandboxStripeListPrices(ctx context.Context, raw json.RawMessage) (any, error) { + var args sandboxStripeListPricesArgs + if len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return nil, fmt.Errorf("sandbox.stripe.listPrices: invalid arguments: %w", err) + } + } + key, err := readStripeKey(ctx) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("limit", stripeLimitOr(args.Limit, 20)) + if args.ProductID != "" { + q.Set("product", args.ProductID) + } + if args.Active != nil { + if *args.Active { + q.Set("active", "true") + } else { + q.Set("active", "false") + } + } + if args.StartingAfter != "" { + q.Set("starting_after", args.StartingAfter) + } + body, status, err := stripeDo(ctx, key, http.MethodGet, "/v1/prices", q) + if err != nil { + return nil, fmt.Errorf("sandbox.stripe.listPrices: %w", err) + } + prices, _ := body["data"].([]any) + hasMore, _ := body["has_more"].(bool) + return map[string]any{ + "status": "OK", + "prices": prices, + "count": len(prices), + "hasMore": hasMore, + "product": args.ProductID, + "httpStatus": status, + }, nil +} + +func schemaSandboxStripeListPrices() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "product_id": map[string]any{"type": "string", "description": "Optional Stripe Product ID (`prod_…`) to filter prices by."}, + "limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 100, "description": "Page size (Stripe caps at 100; default 20)."}, + "active": map[string]any{"type": "boolean", "description": "Filter by active flag (omit → both)."}, + "starting_after": map[string]any{"type": "string", "description": "Stripe cursor — pass the last price `id` from a previous page to fetch the next page."}, + }, + "additionalProperties": false, + } +} + +// --- sandbox.stripe.createCheckoutSession ------------------------------ + +type sandboxStripeCreateCheckoutSessionArgs struct { + PriceID string `json:"price_id"` + SuccessURL string `json:"success_url"` + CancelURL string `json:"cancel_url"` + Quantity int `json:"quantity,omitempty"` + Mode string `json:"mode,omitempty"` +} + +func sandboxStripeCreateCheckoutSession(ctx context.Context, raw json.RawMessage) (any, error) { + var args sandboxStripeCreateCheckoutSessionArgs + if err := json.Unmarshal(raw, &args); err != nil { + return nil, fmt.Errorf("sandbox.stripe.createCheckoutSession: invalid arguments: %w", err) + } + if strings.TrimSpace(args.PriceID) == "" { + return nil, errors.New("sandbox.stripe.createCheckoutSession: `price_id` is required") + } + if strings.TrimSpace(args.SuccessURL) == "" { + return nil, errors.New("sandbox.stripe.createCheckoutSession: `success_url` is required") + } + if strings.TrimSpace(args.CancelURL) == "" { + return nil, errors.New("sandbox.stripe.createCheckoutSession: `cancel_url` is required") + } + for _, u := range []string{args.SuccessURL, args.CancelURL} { + parsed, err := url.Parse(u) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("sandbox.stripe.createCheckoutSession: invalid URL %q (must be absolute http(s)://...)", u) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("sandbox.stripe.createCheckoutSession: URL %q must be http or https (got %q)", u, parsed.Scheme) + } + } + qty := args.Quantity + if qty <= 0 { + qty = 1 + } + mode := strings.TrimSpace(args.Mode) + if mode == "" { + mode = "payment" + } + switch mode { + case "payment", "subscription", "setup": + default: + return nil, fmt.Errorf("sandbox.stripe.createCheckoutSession: `mode` must be one of payment|subscription|setup (got %q)", mode) + } + key, err := readStripeKey(ctx) + if err != nil { + return nil, err + } + form := url.Values{} + form.Set("mode", mode) + form.Set("success_url", args.SuccessURL) + form.Set("cancel_url", args.CancelURL) + form.Set("line_items[0][price]", args.PriceID) + form.Set("line_items[0][quantity]", fmt.Sprintf("%d", qty)) + body, status, err := stripeDo(ctx, key, http.MethodPost, "/v1/checkout/sessions", form) + if err != nil { + return nil, fmt.Errorf("sandbox.stripe.createCheckoutSession: %w", err) + } + sessionID, _ := body["id"].(string) + sessionURL, _ := body["url"].(string) + return map[string]any{ + "status": "Created", + "id": sessionID, + "url": sessionURL, + "mode": mode, + "priceId": args.PriceID, + "quantity": qty, + "httpStatus": status, + }, nil +} + +func schemaSandboxStripeCreateCheckoutSession() map[string]any { + return map[string]any{ + "type": "object", + "required": []string{"price_id", "success_url", "cancel_url"}, + "properties": map[string]any{ + "price_id": map[string]any{"type": "string", "description": "Stripe Price ID (`price_…`) to bill in the Checkout session."}, + "success_url": map[string]any{"type": "string", "description": "Absolute http(s) URL Stripe redirects to on a successful checkout."}, + "cancel_url": map[string]any{"type": "string", "description": "Absolute http(s) URL Stripe redirects to on cancel."}, + "quantity": map[string]any{"type": "integer", "minimum": 1, "description": "Line-item quantity (default 1)."}, + "mode": map[string]any{"type": "string", "enum": []string{"payment", "subscription", "setup"}, "description": "Stripe Checkout mode (default `payment`)."}, + }, + "additionalProperties": false, + } +} + +// --- shared helpers ---------------------------------------------------- + +// stripeLimitOr returns the Stripe `limit` query value, clamping to +// (1..100] and falling back to def when 0 / negative. +func stripeLimitOr(limit, def int) string { + if limit <= 0 { + limit = def + } + if limit > 100 { + limit = 100 + } + return fmt.Sprintf("%d", limit) +} diff --git a/products/sandbox/mcp-server/internal/tools/sandbox_stripe_test.go b/products/sandbox/mcp-server/internal/tools/sandbox_stripe_test.go new file mode 100644 index 00000000..94713b90 --- /dev/null +++ b/products/sandbox/mcp-server/internal/tools/sandbox_stripe_test.go @@ -0,0 +1,302 @@ +package tools + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + sharedauth "github.com/openova-io/openova/core/services/shared/auth" +) + +// TestValidateStripeKey covers the per-call key validation gate. +// Stops obvious typos / publishable-key mix-ups from being stored as +// authoritative Stripe credentials in the per-Sandbox Secret. +func TestValidateStripeKey(t *testing.T) { + cases := map[string]string{ + "": "is required", + " ": "is required", + "sk_test_aaaaaaaaaaaaaaaaa": "", + "sk_live_aaaaaaaaaaaaaaaaa": "", + "rk_test_aaaaaaaaaaaaaaaaa": "", + "rk_live_aaaaaaaaaaaaaaaaa": "", + "sk_test_short": "too short", + "pk_live_aaaaaaaaaaaaaaaaa": "must start with", + "whatever_aaaaaaaaaaaaaaaaa": "must start with", + } + for in, want := range cases { + err := validateStripeKey(in) + if want == "" { + if err != nil { + t.Errorf("%q: err=%v want nil", in, err) + } + continue + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%q: err=%v want %q", in, err, want) + } + } +} + +// TestMaskStripeKey pins the masking format so the agent + operator can +// verify which key got bound without re-exposing it in clear. +func TestMaskStripeKey(t *testing.T) { + cases := map[string]string{ + "": "***", + "shorty": "***", + "sk_test_ABCDEFGHxY12": "sk_test_…xY12", + "sk_live_ABCDEFGHxY12": "sk_live_…xY12", + "rk_live_ABCDEFGHxY12": "rk_live_…xY12", + "weirdformatXXXXXXXXXXxY12": "***xY12", + } + for in, want := range cases { + if got := maskStripeKey(in); got != want { + t.Errorf("%q: got=%q want=%q", in, got, want) + } + } +} + +// TestStripeLimitOr exercises the limit-clamping helper. +func TestStripeLimitOr(t *testing.T) { + cases := []struct { + in, def int + want string + }{ + {0, 20, "20"}, + {-3, 20, "20"}, + {5, 20, "5"}, + {200, 20, "100"}, + } + for _, c := range cases { + if got := stripeLimitOr(c.in, c.def); got != c.want { + t.Errorf("stripeLimitOr(%d,%d)=%q want %q", c.in, c.def, got, c.want) + } + } +} + +// TestStripeAPIBaseURL pins the override behaviour — production callers +// hit api.stripe.com; tests redirect via stripeAPIBaseOverride. +func TestStripeAPIBaseURL(t *testing.T) { + if got := stripeAPIBaseURL(); got != stripeAPIBase { + t.Errorf("default base=%q want %q", got, stripeAPIBase) + } + prev := stripeAPIBaseOverride + stripeAPIBaseOverride = "https://example.test" + defer func() { stripeAPIBaseOverride = prev }() + if got := stripeAPIBaseURL(); got != "https://example.test" { + t.Errorf("override base=%q", got) + } +} + +// TestStripeDo_Success exercises the happy-path: forms-urlencoded body, +// authz header, JSON envelope decode. +func TestStripeDo_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer sk_test_xxxxxxxxxxxx" { + t.Errorf("authz=%q", got) + } + if got := r.Header.Get("Stripe-Version"); got == "" { + t.Errorf("Stripe-Version missing") + } + if r.URL.Path != "/v1/products" { + t.Errorf("path=%q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"prod_1","name":"Widget"}],"has_more":false}`)) + })) + defer srv.Close() + prev := stripeAPIBaseOverride + stripeAPIBaseOverride = srv.URL + defer func() { stripeAPIBaseOverride = prev }() + + body, status, err := stripeDo(context.Background(), "sk_test_xxxxxxxxxxxx", http.MethodGet, "/v1/products", nil) + if err != nil { + t.Fatalf("err=%v", err) + } + if status != 200 { + t.Errorf("status=%d", status) + } + data, _ := body["data"].([]any) + if len(data) != 1 { + t.Errorf("len(data)=%d", len(data)) + } +} + +// TestStripeDo_ErrorEnvelope verifies Stripe error JSON envelopes get +// unwrapped into a clean message rather than dumped raw. +func TestStripeDo_ErrorEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"type":"invalid_request_error","code":"api_key_invalid","message":"Invalid API Key provided"}}`)) + })) + defer srv.Close() + prev := stripeAPIBaseOverride + stripeAPIBaseOverride = srv.URL + defer func() { stripeAPIBaseOverride = prev }() + + _, status, err := stripeDo(context.Background(), "sk_test_xxxxxxxxxxxx", http.MethodGet, "/v1/products", nil) + if status != 401 { + t.Errorf("status=%d", status) + } + if err == nil || !strings.Contains(err.Error(), "Invalid API Key") { + t.Errorf("err=%v want Invalid API Key", err) + } +} + +// TestSandboxStripeBindAccount_ArgValidation covers per-arg validation +// before any API / cluster call. +func TestSandboxStripeBindAccount_ArgValidation(t *testing.T) { + ctx := WithEnv(context.Background(), &Env{SandboxNamespace: "sandbox-u1", OwnerUID: "u1"}) + cases := map[string]string{ + `{}`: "is required", + `{"api_key":""}`: "is required", + `{"api_key":"sk_test_short"}`: "too short", + `{"api_key":"pk_live_AAAAAAAAAAAAAAAAA"}`: "must start with", + } + for args, want := range cases { + _, err := sandboxStripeBindAccount(ctx, json.RawMessage(args)) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("args=%s err=%v want %q", args, err, want) + } + } +} + +// TestSandboxStripeCreateCheckoutSession_ArgValidation covers the +// per-arg URL + price validation before any API call. +func TestSandboxStripeCreateCheckoutSession_ArgValidation(t *testing.T) { + ctx := WithEnv(context.Background(), &Env{SandboxNamespace: "sandbox-u1", OwnerUID: "u1"}) + cases := map[string]string{ + `{}`: "`price_id` is required", + `{"price_id":"price_1","success_url":"","cancel_url":"https://ok"}`: "`success_url` is required", + `{"price_id":"price_1","success_url":"https://ok","cancel_url":""}`: "`cancel_url` is required", + `{"price_id":"price_1","success_url":"notaurl","cancel_url":"https://ok"}`: "invalid URL", + `{"price_id":"price_1","success_url":"ftp://x/y","cancel_url":"https://ok"}`: "must be http or https", + `{"price_id":"price_1","success_url":"https://ok","cancel_url":"https://k","mode":"wat"}`: "`mode` must be one of", + } + for args, want := range cases { + _, err := sandboxStripeCreateCheckoutSession(ctx, json.RawMessage(args)) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("args=%s err=%v want %q", args, err, want) + } + } +} + +// TestReadStripeKey_BareEncoding verifies the read path decodes the +// base64'd value back to the original Stripe key (smoke test on the +// encode-on-write / decode-on-read symmetry without standing up a fake +// apiserver). +func TestReadStripeKey_BareEncoding(t *testing.T) { + v := "sk_test_AAAABBBBCCCCDDDD" + enc := base64.StdEncoding.EncodeToString([]byte(v)) + dec, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + t.Fatalf("decode: %v", err) + } + if got := strings.TrimSpace(string(dec)); got != v { + t.Errorf("roundtrip mismatch: got %q want %q", got, v) + } +} + +// TestSandboxStripeCatalogueWiredIn ensures all four tools are in the +// catalogue with non-nil Handler + the right RequiredCapability. +func TestSandboxStripeCatalogueWiredIn(t *testing.T) { + r := NewRegistry(&Env{OrgID: "acme", OwnerUID: "u1"}) + want := map[string]bool{ + "sandbox.stripe.bindAccount": false, + "sandbox.stripe.listProducts": false, + "sandbox.stripe.listPrices": false, + "sandbox.stripe.createCheckoutSession": false, + } + for _, tool := range r.List() { + if _, ok := want[tool.Name]; !ok { + continue + } + if tool.Handler == nil { + t.Errorf("%s: Handler is nil", tool.Name) + } + if tool.RequiredCapability != "sandbox.stripe" { + t.Errorf("%s: RequiredCapability=%q want sandbox.stripe", tool.Name, tool.RequiredCapability) + } + want[tool.Name] = true + } + for name, found := range want { + if !found { + t.Errorf("%s: missing from catalogue", name) + } + } +} + +// TestSandboxStripeCapabilityGate confirms the registry rejects +// sandbox.stripe.* calls whose claims lack the `sandbox.stripe` cap. +func TestSandboxStripeCapabilityGate(t *testing.T) { + r := NewRegistry(&Env{ + OrgID: "acme", + JWTSecret: []byte("x"), + SandboxNamespace: "sandbox-u1", + OwnerUID: "u1", + }) + for _, name := range []string{ + "sandbox.stripe.bindAccount", + "sandbox.stripe.listProducts", + "sandbox.stripe.listPrices", + "sandbox.stripe.createCheckoutSession", + } { + t.Run(name, func(t *testing.T) { + cl := &sharedauth.Claims{OrgID: "acme", Capabilities: []string{"sandbox.db"}} + args := json.RawMessage(`{"api_key":"sk_test_AAAABBBBCCCCDDDD","price_id":"price_1","success_url":"https://ok","cancel_url":"https://k"}`) + _, err := r.Call(context.Background(), name, args, CallOpts{Claims: cl}) + if err == nil || !strings.Contains(err.Error(), "forbidden") { + t.Errorf("missing cap: err=%v want forbidden", err) + } + }) + } +} + +// TestStripeDo_FormEncoding verifies POST bodies are form-encoded (the +// Stripe REST API rejects JSON bodies on most endpoints — line_items[0] +// nesting is array-bracket notation, not JSON objects). +func TestStripeDo_FormEncoding(t *testing.T) { + gotBody := "" + gotCT := "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + gotCT = r.Header.Get("Content-Type") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cs_test_1","url":"https://checkout.stripe.com/c/cs_test_1"}`)) + })) + defer srv.Close() + prev := stripeAPIBaseOverride + stripeAPIBaseOverride = srv.URL + defer func() { stripeAPIBaseOverride = prev }() + + form := url.Values{} + form.Set("mode", "payment") + form.Set("success_url", "https://ok") + form.Set("cancel_url", "https://no") + form.Set("line_items[0][price]", "price_1") + form.Set("line_items[0][quantity]", "1") + body, status, err := stripeDo(context.Background(), "sk_test_xxxxxxxxxxxx", http.MethodPost, "/v1/checkout/sessions", form) + if err != nil { + t.Fatalf("err=%v", err) + } + if status != 200 { + t.Errorf("status=%d", status) + } + if got, _ := body["id"].(string); got != "cs_test_1" { + t.Errorf("id=%q", got) + } + if gotCT != "application/x-www-form-urlencoded" { + t.Errorf("content-type=%q", gotCT) + } + if !strings.Contains(gotBody, "line_items%5B0%5D%5Bprice%5D=price_1") { + t.Errorf("body=%q (line_items not form-encoded)", gotBody) + } +} +