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:
go get github.com/djokodonev/egav-sdk-go@v0.1.0That 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.
module github.com/example/my-consumer
go 1.24.0
toolchain go1.24.13
require github.com/djokodonev/egav-sdk-go v0.1.0A 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.
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:
| Field | Type | Purpose |
|---|---|---|
ServiceURLs | map[string]string | App 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. |
TokenSource | auth.TokenSource | Supplies the bearer for authed calls. May be nil for public-only usage. |
DefaultHeaders | map[string]string | Sent on every request (e.g. X-App-Instance-Guid for instance-scoped reads, X-App-Code). |
HTTPClient | *http.Client | Overrides the built-in client entirely. When nil, one is built from Timeout + InsecureSkipVerify. |
InsecureSkipVerify | bool | Disables TLS verification for local self-signed certs. Never in production. |
Timeout | time.Duration | Per-request timeout. Default 30s. |
MaxRetries | int | Retries 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.
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:
POST /v1/authn/public/oidc/authorize/headless— starts a headless authorization session, returning asession_id. The manager generates a PKCE verifier and sends itsS256challenge here.POST /v1/authn/public/oidc/headless/credentials— submits the email and password against that session, returning an authorizationcode. If the account requires MFA, the manager returns an error — MFA is not handled by this manager.POST /v1/authn/public/oidc/token— exchanges the code plus the PKCE verifier for theaccess_tokenand itsexpires_in.
OIDCConfig is where you wire it:
| Field | Purpose |
|---|---|
AuthNBaseURL | The AuthN base URL, e.g. https://authn-api.your-domain.example:5209. |
ClientID / RedirectURI | Of a registered public OIDC client (from CP config). |
Scope | Defaults to openid profile email. Add offline_access to receive a refresh token; otherwise the manager simply re-logs in on expiry. |
Email / Password | The end-user credentials. |
HTTPClient | Used for the token calls — provide one with InsecureSkipVerify for local self-signed certs. Defaults to http.DefaultClient. |
ExpirySkew | How 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:
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 -> 429Authenticate 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:
| Gate | Error type | HTTPStatus() | Kind() |
|---|---|---|---|
| 1 — auth | *Unauthenticated | 401 | unauthenticated |
| 2 — permission | *PermissionDenied (error_code 4031) | 403 | permission_denied |
| 3 — subscription | *SubscriptionInactive | 402 | subscription_inactive |
| 4 — feature | *FeatureDisabled | 403 | feature_disabled |
| 5 — quota | *QuotaExceeded | 429 | quota_exceeded |
| any — dependency down | *Unavailable | 503 | unavailable |
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.