Skip to main content

Documentation

Go client SDK

Consume the platform from Go — headless OIDC auth, a retrying JSON client that resolves named services, and the five access gates.

The Go client SDK — consume the platform, headless

egav-sdk-go is the platform client for Go: a program that already exists and needs to call SynaptaGrid — a CLI, a worker, a backend in another stack, an integration test — reaches for it. It is four things and no more: a headless OIDC token manager that logs in against AuthN and keeps the access token fresh, an authenticated JSON client that resolves a named service through a URL map and retries transient failures, typed BaseResponse / ErrorResponse envelopes so a call decodes into a Go struct or a typed error, and the five access gates a server-side app applies to protect its own endpoints.

It deliberately omits the app-building runtime. There is no app factory, no tenant middleware, no outbox, no scoped CRUD — that machinery lives in the app SDKs (Python egav-app-sdk and TypeScript @egav/app-sdk), and this package is for programs that consume the platform rather than build a tenant-native app on it. The SDKs overview covers the whole family; this page is the Go-specific reference.

The SDK's own package doc states the split: it is "a consumer SDK (Tier 1–2): auth + typed REST access + wire envelopes. It deliberately does not include the app-building runtime."

Install and distribution status

The module path is github.com/djokodonev/egav-sdk-go (Go 1.24+), with three importable subpackages: auth, envelope, and gates. The root package imports as egavsdk.

The module is published and tagged, so a plain go get resolves it through the public module proxy:

bash
go get github.com/djokodonev/egav-sdk-go@v0.1.0

That adds the require line to your go.mod and the checksums to go.sum; no replace directive and no GOPRIVATE setting are needed. Pin a tag rather than a branch so a rebuild reproduces the same bytes.

go.modgo
module github.com/example/my-consumer

go 1.24.0

toolchain go1.24.13

require github.com/djokodonev/egav-sdk-go v0.1.0

A worked example — log in, build a client, make a typed call

Three steps compose an authenticated call: construct a TokenSource (here the headless OIDC manager), construct a Client with the service URL map and that token source, and call the generic Get[T] / Post[T] helper with the app code and path. The client injects the bearer, retries transient failures, and decodes the platform envelope into *envelope.BaseResponse[T]; a >= 400 comes back as a typed *envelope.APIError you branch on.

main.gogo
package main

import (
	"context"
	"errors"
	"log"
	"net/http"

	egavsdk "github.com/djokodonev/egav-sdk-go"
	"github.com/djokodonev/egav-sdk-go/auth"
	"github.com/djokodonev/egav-sdk-go/envelope"
)

func main() {
	ctx := context.Background()

	// 1. Headless OIDC login. ClientID / RedirectURI come from a registered
	// public OIDC client in CP config; Email / Password from the environment.
	tm, err := auth.NewOIDCHeadlessManager(auth.OIDCConfig{
		AuthNBaseURL: "https://authn-api.your-domain.example:5209",
		ClientID:     "public_web_67a894b8cb0e",
		RedirectURI:  "https://your-domain.example:3200/callback",
		Email:        "user@example.com",
		Password:     "s3cret",
	})
	if err != nil {
		log.Fatal(err)
	}

	// 2. The authenticated, retrying client. ServiceURLs is the client-side
	// equivalent of CP's resolve_service — one map, no hardcoded URLs.
	c, err := egavsdk.New(egavsdk.Config{
		ServiceURLs: map[string]string{
			"control-plane": "https://cp-api.your-domain.example:5207",
			"authz":         "https://authz-api.your-domain.example:5206",
		},
		TokenSource:        tm,
		InsecureSkipVerify: true, // local self-signed certs only — never in prod
		DefaultHeaders:     map[string]string{"X-App-Instance-Guid": "inst-abc"},
	})
	if err != nil {
		log.Fatal(err)
	}

	// 3. A typed GET. T is your response shape; the envelope is decoded for you.
	type Me struct {
		UserGuid string `json:"user_guid"`
		OrgGuid  string `json:"org_guid"`
	}
	res, err := egavsdk.Get[Me](ctx, c, "control-plane", "/v1/control-plane/portal/me")
	if err != nil {
		var apiErr *envelope.APIError
		if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized {
			log.Fatal("token rejected")
		}
		log.Fatal(err)
	}
	log.Printf("me: %+v", res.Data)
}

The full response schema of /portal/me — and every other way to read the current user, their organization, and other members — is documented in Users & organizations.

The authenticated client — service resolution, retries, envelopes

egavsdk.New(Config) validates config and returns a *Client you build once and reuse for the process; it is safe for concurrent use. Config carries seven fields:

FieldTypePurpose
ServiceURLsmap[string]stringApp code → base URL. Required. The client-side resolve_service: one map, one source of truth, no URLs scattered through call sites. An unknown app code returns an error rather than guessing.
TokenSourceauth.TokenSourceSupplies the bearer for authed calls. May be nil for public-only usage.
DefaultHeadersmap[string]stringSent on every request (e.g. X-App-Instance-Guid for instance-scoped reads, X-App-Code).
HTTPClient*http.ClientOverrides the built-in client entirely. When nil, one is built from Timeout + InsecureSkipVerify.
InsecureSkipVerifyboolDisables TLS verification for local self-signed certs. Never in production.
Timeouttime.DurationPer-request timeout. Default 30s.
MaxRetriesintRetries on transient failures. Default 2 (3 attempts total).

The retry contract is deliberate and worth stating plainly: the client retries connection errors, timeouts, and 5xx with exponential backoff (starting at 200ms, doubling per attempt), and it never retries a 4xx — a client error is your bug, not a transient blip, so it is returned immediately for you to surface. When retries are exhausted on a 5xx, the last response is handed back so Decode can turn it into a typed *APIError. Backoff respects the context: a cancelled ctx aborts the wait.

Two request surfaces sit under the typed helpers. Client.Do issues an authenticated request and injects the TokenSource bearer; Client.DoRaw takes explicit per-request headers and does not inject the token source — it exists precisely so a caller can forward an end-user bearer to one endpoint while presenting a service x-api-key to another. Both share the same base-URL resolution, retry/backoff, and DefaultHeaders; headers passed to DoRaw override DefaultHeaders on conflict. The generic Get[T] / Post[T] wrap Do and decode; reach for DoRaw only when you must control auth per call (the gates do exactly this).

The wire envelopes live in the envelope package and mirror the platform's egav_base shapes (Quality Rule 5.1). BaseResponse[T] carries Message, Success, Status, a *T Data, and an optional *Meta pagination block (Total / Page / PerPage / TotalPages). Any >= 400 decodes into *APIError, which exposes both the HTTP StatusCode and the platform's machine-readable ErrorCode, so you can branch on either. request_payload is deliberately absent from error responses server-side (Rule 5.2) and is never expected here.

decode.gogo
res, err := egavsdk.Post[Project](ctx, c, "my-saas", "/v1/my-saas/portal/projects", body)
if err != nil {
	var apiErr *envelope.APIError
	if errors.As(err, &apiErr) {
		// apiErr.StatusCode is the HTTP status; apiErr.ErrorCode is the platform code.
		log.Printf("api error: %v", apiErr)
		return
	}
	return // transport / decode failure
}
// res.Data is *Project; res.Meta is *envelope.Meta on list responses.

The headless OIDC token manager

The platform has no simple ROPC endpoint, so portal login is the three-step OIDC headless flow with PKCE, and the SDK hides all of it behind the auth.TokenSource interface — a single method, Token(ctx) (string, error). NewOIDCHeadlessManager returns an *OIDCHeadlessTokenManager that performs the flow on first use and caches the access token until just before it expires, re-running the flow when the cache is empty or near expiry. It is safe for concurrent use.

The three steps it runs internally:

  1. POST /v1/authn/public/oidc/authorize/headless — starts a headless authorization session, returning a session_id. The manager generates a PKCE verifier and sends its S256 challenge here.
  2. POST /v1/authn/public/oidc/headless/credentials — submits the email and password against that session, returning an authorization code. If the account requires MFA, the manager returns an error — MFA is not handled by this manager.
  3. POST /v1/authn/public/oidc/token — exchanges the code plus the PKCE verifier for the access_token and its expires_in.

OIDCConfig is where you wire it:

FieldPurpose
AuthNBaseURLThe AuthN base URL, e.g. https://authn-api.your-domain.example:5209.
ClientID / RedirectURIOf a registered public OIDC client (from CP config).
ScopeDefaults to openid profile email. Add offline_access to receive a refresh token; otherwise the manager simply re-logs in on expiry.
Email / PasswordThe end-user credentials.
HTTPClientUsed for the token calls — provide one with InsecureSkipVerify for local self-signed certs. Defaults to http.DefaultClient.
ExpirySkewHow long before expiry to proactively re-login. Default 30s.

Credentials never leak into errors: a failed token call surfaces only the HTTP status and short server text, never the email or password. When you already hold a token — forwarded from an inbound request, say — use auth.StaticTokenSource{AccessToken: ...} instead of the manager; it satisfies the same TokenSource interface.

The five gates in Go

The gates package is the Go port of the platform's five access gates — the same order, same fail-closed contract, and same two-credential trust model that the Python egav-app-sdk decorators and egav-cp-client feature gates enforce. A server-side app applies them to protect one of its own endpoints. Construct a *Gates once with gates.New(Config{Client, CPAPIKey, AuthZAPIKey}) and reuse it; it holds no per-request state.

The methods, in the order a handler applies them:

gates.gogo
func (g *Gates) Authenticate(ctx context.Context, userBearer string) (Identity, error)                                    // 1 -> 401 / 503
func (g *Gates) RequirePermission(ctx context.Context, id Identity, permissionCode string, resourceType ...string) error  // 2 -> 403 / 503
func (g *Gates) Entitlements(ctx context.Context, orgGuid string) (Entitlements, error)                                   //   one CP fetch feeding gates 3-5
func (g *Gates) RequireActiveSubscription(ent Entitlements) error                                                         // 3 -> 402
func (g *Gates) RequireFeature(ent Entitlements, code string) error                                                       // 4 -> 403
func (g *Gates) EnforceQuota(ent Entitlements, code string, current int, cost ...int) (int, error)                        // 5 -> 429

Authenticate is gate 1: it validates the inbound end-user bearer against CP's portal/me and returns the resolved Identity{UserGuid, OrgGuid}. RequirePermission is gate 2: it asks AuthZ whether that identity may perform the action (the substring after the last . in permissionCode) against a resourceType (default app). The remaining three gates are pure functions over one Entitlements value — a single CP fetch of the org's effective features that you make once per request with Entitlements(ctx, orgGuid), then pass to RequireActiveSubscription, RequireFeature, and EnforceQuota. EnforceQuota returns the resolved limit when current + cost (cost defaults to 1) is within it.

Every gate fails closed: an unreachable or unusable CP/AuthZ raises *Unavailable (503) rather than default-allowing. And the trust model is not shortcut — two credentials, correctly separated. The end-user bearer is sent only to gate 1 (portal/me); the app's service x-api-key is sent for gates 2–5, whose /internal/* endpoints are service-to-service. The user bearer is never sent to an /internal/* route, and the app key is never sent to a /portal/* route.

Each failure is a distinct, typed error implementing the GateError interface, which exposes HTTPStatus() and a stable machine-readable Kind() — so a handler maps a denied gate straight to a response without inspecting internals:

GateError typeHTTPStatus()Kind()
1 — auth*Unauthenticated401unauthenticated
2 — permission*PermissionDenied (error_code 4031)403permission_denied
3 — subscription*SubscriptionInactive402subscription_inactive
4 — feature*FeatureDisabled403feature_disabled
5 — quota*QuotaExceeded429quota_exceeded
any — dependency down*Unavailable503unavailable
handler.gogo
g, _ := gates.New(gates.Config{Client: c, CPAPIKey: cpKey, AuthZAPIKey: authzKey})

id, err := g.Authenticate(ctx, bearer)                 // gate 1: end-user bearer
if err != nil {
	var ge gates.GateError
	if errors.As(err, &ge) {
		http.Error(w, err.Error(), ge.HTTPStatus())    // 401 or 503
	}
	return
}
if err := g.RequirePermission(ctx, id, "project.create"); err != nil { /* 403 / 503 */ }

ent, err := g.Entitlements(ctx, id.OrgGuid)             // one CP fetch (x-api-key)
if err != nil { /* 503, fail closed */ }
if err := g.RequireActiveSubscription(ent); err != nil { /* 402 */ }
if err := g.RequireFeature(ent, "projects"); err != nil { /* 403 */ }
if _, err := g.EnforceQuota(ent, "max_projects", currentCount); err != nil { /* 429 */ }

The failed-gate status contract is stable across every client SDK: 401 auth, 402 subscription lapsed, 403 permission or feature denied, 429 quota exceeded, 503 dependency unreachable. See Auth & the five gates for the request-time gate model and SDKs overview for the shared five-gates concept and the cross-language package/version table.