soliconfig

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 2, 2026 License: MIT Imports: 10 Imported by: 0

README

soliconfig-go

A thin Go client for the soliconfig REST core, built for machine/service usage — agents, CI pipelines, and servers. It targets the env-scoped agent endpoints (/v1/agent/...) and is the foundation for the soliconfig Terraform provider.

  • Standard library only — no external dependencies.
  • Env-scoped API key (sc_...) auth via Authorization: Bearer.
  • Go 1.22+.

Install

go get github.com/vennyx-org/soliconfig-go

Authentication

Machine usage authenticates with an env-scoped API key (sc_...). The key resolves the target org/project/environment server-side — you never pass the environment in the request. Create a key from the soliconfig dashboard scoped to a single environment with read or admin scope (an encrypt-only key is write-only and is rejected by the read endpoints below).

Config fetch and requireApproval: FetchConfig reads whatever template version is currently published for the environment. Machine flows are expected to run against environments where changes are auto-published (requireApproval = false); if an environment gates publishes behind approval, the client sees the last approved version.

Usage

Fetch evaluated remote config

FetchConfig performs a one-shot, server-evaluated fetch (POST /v1/agent/config/fetch). The server evaluates conditions against the context you pass and returns the resolved values plus the published version.

package main

import (
	"context"
	"fmt"
	"log"

	soliconfig "github.com/vennyx-org/soliconfig-go"
)

func main() {
	client := soliconfig.New("sc_your_env_scoped_key")

	cfg, err := client.FetchConfig(context.Background(), soliconfig.FetchConfigParams{
		Context: soliconfig.EvalContext{
			RandomizationID: "user-1234", // required (sticky bucketing id)
			Platform:        "server",
			Country:         "TR",
		},
		TemplateType: soliconfig.TemplateServer, // "client" (default) or "server"
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("published version:", cfg.Version)

	if v, ok := cfg.Get("new_checkout"); ok {
		fmt.Println("new_checkout enabled:", v.Bool())
	}

	// Typed accessors match server-side cast semantics by valueType:
	//   v.Bool()          // "boolean"
	//   v.Int()/v.Float() // "number"
	//   v.JSON(&dst)      // "json"
	//   v.Value           // raw "string"
}
Pull secrets (env values)

PullSecrets fetches the latest env version (GET /v1/agent/env), returning the environment metadata, version, and entries.

secrets, err := client.PullSecrets(context.Background())
if err != nil {
	log.Fatal(err)
}

for name, value := range secrets.Entries {
	if soliconfig.IsEncrypted(value) {
		// ciphertext — see "Secret decryption" below
		continue
	}
	fmt.Printf("%s=%s\n", name, value)
}
Secret decryption

soliconfig stores secret values as ciphertext and by default the server never sees plaintext (server-side decrypt exists only for environments that have opted in by entrusting their private key — see below). PullSecrets returns entry values with an encrypted: prefix (base64 ECIES over secp256k1 + AES-256-GCM, dotenvx wire-compatible). Decryption is a client-side operation requiring the environment's private key and a secp256k1 ECIES implementation.

Because that curve is not in the Go standard library, this stdlib-only client returns entries verbatim and does not decrypt. Use IsEncrypted to detect ciphertext (values without the prefix are already plaintext). To decrypt, feed the ciphertext to a secp256k1 ECIES library, or use the soliconfig CLI (soliconfig pull --decrypt) which handles decryption.

Opt-in server-side decrypt

If you have entrusted the environment's private key to soliconfig's Vault (an explicitly non-zero-knowledge choice), DecryptSecrets returns plaintext values via POST /v1/orgs/{org}/environments/{env}/vault/decrypt. It needs a vault:decrypt scoped key (the read/admin presets carry it) and a Team+ plan.

When the vault has requireApproval = true, the endpoint releases no values: it files an approval request and answers HTTP 202 with an approvalId. Because that is a 2xx status, ignoring it would look like a successful decrypt of an empty secret set — so the SDK returns it as an *ApprovalPendingError instead. After a second admin approves the request, the same caller redeems it (single-use, time-boxed) with DecryptSecretsWithApproval:

values, err := client.DecryptSecrets(ctx, orgID, envID)

var pending *soliconfig.ApprovalPendingError
if errors.As(err, &pending) {
	// ... wait until an admin approves pending.ApprovalID, then:
	values, err = client.DecryptSecretsWithApproval(ctx, orgID, envID, pending.ApprovalID)
}

Use errors.Is(err, soliconfig.ErrApprovalPending) when the id is not needed. The unattended sync surface (GET /v1/eso/secrets) never runs this flow and rejects an approval-gated vault outright with HTTP 409 soliconfig.CodeApprovalRequired, which stays a plain *APIError.

Error handling

Every non-2xx response is returned as an *APIError:

_, err := client.FetchConfig(ctx, params)

var apiErr *soliconfig.APIError
if errors.As(err, &apiErr) {
	switch apiErr.Code {
	case soliconfig.CodeQuotaExceeded: // 429 — monthly fetch quota hit (free plan hard cap)
		// back off / prompt upgrade
	case soliconfig.CodeOverageBlocked: // 429 — unpaid overage debt
		// prompt to settle billing
	case soliconfig.CodeSeatLimit:
		// seat limit reached
	default:
		fmt.Printf("HTTP %d: %s\n", apiErr.StatusCode, apiErr.Message)
	}
}

Code is set for machine-readable entitlement/quota gates; Message carries the human message for generic failures (falling back to the code or HTTP status text).

Configuration

client := soliconfig.New(
	"sc_your_key",
	soliconfig.WithBaseURL("https://api.soliconfig.com"), // default
	soliconfig.WithHTTPClient(customHTTPClient),           // any HTTPDoer
)

License

MIT

Documentation

Overview

Package soliconfig is a thin Go client for the soliconfig REST core, intended for machine/service usage (agents, CI, servers). It is the foundation for the soliconfig Terraform provider.

The client talks to the env-scoped "agent" endpoints (/v1/agent/...) and authenticates with an env-scoped API key (sc_...) via Authorization: Bearer.

Only the standard library is used — there are no external dependencies.

Index

Constants

View Source
const (
	TemplateClient = "client"
	TemplateServer = "server"
)

Template types for a config fetch. When unspecified the server defaults to TemplateClient.

View Source
const (
	CodeQuotaExceeded  = "quota_exceeded"
	CodeOverageBlocked = "overage_blocked"
	CodeSeatLimit      = "seat_limit"
	// CodeApprovalRequired is returned with HTTP 409 by the unattended sync
	// surface (GET /v1/eso/secrets) when the vault requires manual approval.
	// The interactive decrypt endpoint does NOT use it — it answers HTTP 202
	// and is surfaced as *ApprovalPendingError, see ErrApprovalPending.
	CodeApprovalRequired = "approval_required"
)

Machine-readable codes surfaced by entitlement/quota gates.

View Source
const DefaultBaseURL = "https://api.soliconfig.com"

DefaultBaseURL is the production soliconfig API base URL.

View Source
const DefaultUserAgent = "soliconfig-go/0.1.0"

DefaultUserAgent identifies this SDK in outgoing requests. Higher-level integrations built on this SDK (e.g. the Terraform provider) prepend their own identifier via WithUserAgent so the server can apply integration tier gates.

Variables

View Source
var ErrApprovalPending = errors.New("soliconfig: vault decrypt is awaiting approval")

ErrApprovalPending is the sentinel behind every approval-pending failure. Use it with errors.Is when the approval id itself is not needed:

if errors.Is(err, soliconfig.ErrApprovalPending) { /* wait for a human */ }

Functions

func IsEncrypted

func IsEncrypted(value string) bool

IsEncrypted reports whether a value is ECIES ciphertext (i.e. carries the "encrypted:" prefix) rather than plaintext.

Types

type APIError

type APIError struct {
	// StatusCode is the HTTP status code of the response.
	StatusCode int
	// Code is the machine-readable error code (e.g. "quota_exceeded"), if any.
	Code string
	// Message is the human-readable error message.
	Message string
}

APIError is returned for any non-2xx response from the soliconfig API.

The API reports failures in two shapes:

  • Generic HTTP failures carry a human message: {"error": "..."}.
  • Quota/entitlement gates carry a machine code: {"code": "quota_exceeded"} (also "overage_blocked", "seat_limit"), typically with HTTP 429.

Both are captured here: Code holds the machine code (when present) and Message holds the human message (falling back to the code or the HTTP status text). Callers can branch on Code, e.g.:

var apiErr *soliconfig.APIError
if errors.As(err, &apiErr) && apiErr.Code == soliconfig.CodeQuotaExceeded {
	// back off / surface upgrade prompt
}

func (*APIError) Error

func (e *APIError) Error() string

Error implements the error interface.

type ApprovalPendingError added in v0.2.0

type ApprovalPendingError struct {
	// ApprovalID is the id of the pending vault approval request.
	ApprovalID string
	// Status is the server-reported approval status (currently "pending").
	Status string
}

ApprovalPendingError reports that a vault decrypt could not be served because the environment's vault has requireApproval enabled and a human still has to approve the request.

The API answers such a request with HTTP 202 and {"approvalId","status"} — a *success* status carrying no secret values. Returning it as an error is deliberate: a caller that ignored it would otherwise silently proceed with an empty secret set.

ApprovalID identifies the pending (or freshly created) approval request. Once a second admin approves it in the dashboard, the same caller redeems it via DecryptSecretsWithApproval. Match it with errors.As:

var pending *soliconfig.ApprovalPendingError
if errors.As(err, &pending) {
	log.Printf("waiting for approval %s", pending.ApprovalID)
}

func (*ApprovalPendingError) Error added in v0.2.0

func (e *ApprovalPendingError) Error() string

Error implements the error interface.

func (*ApprovalPendingError) Unwrap added in v0.2.0

func (e *ApprovalPendingError) Unwrap() error

Unwrap makes errors.Is(err, ErrApprovalPending) report true.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a soliconfig REST client. Create one with New. It is safe for concurrent use by multiple goroutines.

func New

func New(apiKey string, opts ...Option) *Client

New creates a Client authenticated with the given env-scoped API key (sc_...). Options may override the base URL and HTTP client.

func (*Client) DecryptSecrets

func (c *Client) DecryptSecrets(ctx context.Context, orgID, envID string) (map[string]string, error)

DecryptSecrets fetches the latest env version and returns fully-decrypted, plaintext secret values via the server-side decrypt endpoint POST /v1/orgs/{orgID}/environments/{envID}/vault/decrypt.

Unlike PullSecrets — which returns "encrypted:"-prefixed ciphertext verbatim — this performs the decrypt on the server. It requires a read or admin scoped API key (sc_...); an encrypt-only key is rejected with a 403 *APIError. Entitlement/plan gates surface as a 402 *APIError.

When the environment's vault has requireApproval enabled, the server does not return secrets: it registers an approval request and answers HTTP 202 with {"approvalId","status":"pending"}. That case is returned as an *ApprovalPendingError (which matches errors.Is(err, ErrApprovalPending)) and never as a nil error with an empty map. Once a second admin approves the request, redeem it with DecryptSecretsWithApproval.

The returned map is keyed by secret name with plaintext values.

func (*Client) DecryptSecretsWithApproval added in v0.2.0

func (c *Client) DecryptSecretsWithApproval(ctx context.Context, orgID, envID, approvalID string) (map[string]string, error)

DecryptSecretsWithApproval is DecryptSecrets for the requireApproval flow: it redeems the approval request identified by approvalID (obtained from an *ApprovalPendingError returned by an earlier call).

Approvals are single-use, time-boxed, and may only be redeemed by the same actor that requested them — redeeming with a different API key fails with a 403 *APIError. A request that is still awaiting a decision comes back as an *ApprovalPendingError again; a denied one as 403, an expired one as 410.

An empty approvalID behaves exactly like DecryptSecrets.

func (*Client) FetchConfig

func (c *Client) FetchConfig(ctx context.Context, params FetchConfigParams) (*Config, error)

FetchConfig performs a one-shot, server-evaluated config fetch against POST /v1/agent/config/fetch. The API key must be env-scoped and readable (an encrypt-only key is rejected with an *APIError).

func (*Client) PullSecrets

func (c *Client) PullSecrets(ctx context.Context) (*Secrets, error)

PullSecrets fetches the latest env version via GET /v1/agent/env. The API key must be env-scoped and readable (an encrypt-only, write-only key is rejected with an *APIError). When no version has been pushed yet, Version is 0 and Entries is empty.

See the Secrets docs: entry values may be "encrypted:"-prefixed ciphertext; this client returns them verbatim and does not decrypt.

type Config

type Config struct {
	Version   int                       `json:"version"`
	Evaluated map[string]EvaluatedValue `json:"evaluated"`
}

Config is the result of FetchConfig: the published version and the evaluated parameter map keyed by parameter name.

func (*Config) Get

func (cfg *Config) Get(key string) (EvaluatedValue, bool)

Get returns the evaluated value for key and whether it was present.

type Environment

type Environment struct {
	ID        string `json:"id"`
	Name      string `json:"name"`
	PublicKey string `json:"publicKey"`
}

Environment describes the env-scoped target resolved from the API key, plus its public key (used by producers to encrypt values before push).

type EvalContext

type EvalContext struct {
	// RandomizationID is the sticky bucketing id (required, min length 1).
	RandomizationID string `json:"randomizationId"`
	// CustomSignals are arbitrary string key/value signals for rule matching.
	CustomSignals map[string]string `json:"customSignals,omitempty"`
	// Platform is one of "web", "ios", "android", "server".
	Platform string `json:"platform,omitempty"`
	// Country is an ISO 3166-1 alpha-2 code (2 letters).
	Country string `json:"country,omitempty"`
	// Language is a BCP-47-ish language tag.
	Language string `json:"language,omitempty"`
	// Now is an optional ISO-8601 timestamp override for time-based rules.
	Now string `json:"now,omitempty"`
}

EvalContext is the server-side evaluation context (Firebase Remote Config parity). RandomizationID is required; all other fields are optional.

type EvaluatedValue

type EvaluatedValue struct {
	// Value is the raw string value.
	Value string `json:"value"`
	// ValueType is one of "string", "boolean", "number", "json".
	ValueType string `json:"valueType"`
	// Source is "conditional" or "default".
	Source string `json:"source"`
	// MatchedCondition is the name of the matched condition, when Source is
	// "conditional".
	MatchedCondition string `json:"matchedCondition,omitempty"`
}

EvaluatedValue is a single evaluated parameter. Value is always the raw string representation; ValueType indicates how to interpret it. Use the typed accessors (Bool, Int, Float, JSON) to cast.

func (EvaluatedValue) Bool

func (v EvaluatedValue) Bool() bool

Bool parses the value as a boolean (true when Value == "true"), matching the server-side cast semantics.

func (EvaluatedValue) Float

func (v EvaluatedValue) Float() (float64, error)

Float parses the value as a float.

func (EvaluatedValue) Int

func (v EvaluatedValue) Int() (int64, error)

Int parses the value as an integer.

func (EvaluatedValue) JSON

func (v EvaluatedValue) JSON(out any) error

JSON unmarshals the value (a JSON document) into out.

type FetchConfigParams

type FetchConfigParams struct {
	// Context is the evaluation context. RandomizationID must be set.
	Context EvalContext
	// TemplateType is "client" or "server" (default "client" server-side).
	TemplateType string
}

FetchConfigParams are the inputs to FetchConfig.

type HTTPDoer

type HTTPDoer interface {
	Do(req *http.Request) (*http.Response, error)
}

HTTPDoer is the subset of *http.Client used by the client. It lets callers inject a custom transport (retries, tracing, mocks, ...) via WithHTTPClient.

type Option

type Option func(*Client)

Option configures a Client in New.

func WithBaseURL

func WithBaseURL(baseURL string) Option

WithBaseURL overrides the API base URL (default DefaultBaseURL). A trailing slash is trimmed. Useful for self-hosted deployments or tests.

func WithHTTPClient

func WithHTTPClient(h HTTPDoer) Option

WithHTTPClient sets a custom HTTP client (or any HTTPDoer). When unset a *http.Client with a 30s timeout is used.

func WithUserAgent added in v0.1.1

func WithUserAgent(ua string) Option

WithUserAgent overrides the outgoing User-Agent header (default DefaultUserAgent). Integrations built on this SDK use it to identify their surface — e.g. the Terraform provider sends a "terraform-provider-soliconfig/..." User-Agent so the server can apply the Team+ integration tier gate. An empty value is ignored and the default is retained.

type Secrets

type Secrets struct {
	Environment Environment       `json:"environment"`
	Version     int               `json:"version"`
	Entries     map[string]string `json:"entries"`
}

Secrets is the result of PullSecrets: the environment metadata, the current version, and the entries map.

Entries values are stored server-side as ciphertext and are returned with the "encrypted:" prefix (base64 ECIES over secp256k1 + AES-256-GCM, dotenvx wire-compatible). The server never sees plaintext and there is no server-side decrypt endpoint, so decryption is a client-side concern requiring the environment's private key and a secp256k1 ECIES implementation — which is out of scope for this stdlib-only client. Use IsEncrypted to detect ciphertext; values without the prefix are already plaintext.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL