The Go SDK for the saas accounts API — a versioned, published client that
solutions depend on instead of regenerating and vendoring their own gen/ tree.
The SDK surfaces are:
-
gen/— the generated Connect + protobuf bindings for the accounts public proto (saas.accounts.v1and friends), generated fromcodefly-dev/module-saas-starterat the ref recorded below. -
accounts/— a thin, gateway-bound facade with syntactic sugar over the generated stubs. It hides theconnect.Request/Responseenvelope and the raw procedure strings, so a solution handler writes plain protos:resp, err := accounts.New(gw).Audit().QueryAuditLog(ctx, &accounts.QueryAuditLogRequest{PageSize: 20})
gwis any value exposingBaseURL() stringandHTTPClient() *http.Client— whichgithub.com/codefly-dev/solution-runtime-go.Gatewayalready satisfies. The runtime stays accounts-agnostic and takes no dependency on this SDK.Datasources()is the same facade overDatasourceService: the host's source connection, operation and account-link contract, message for message. It coversListSources,GetSource,AddSource,ListSourceOperations,DeclareSourceOperations,InvokeSourceOperation,LookupInvokeSourceOperation,BeginDatasourceAccountLink,CompleteDatasourceAccountLinkandListMyDatasourceAccountLinks, and every message, enum, enum value and oneof wrapper they reach is re-exported fromaccountsunder its generated name:added, err := accounts.New(gw).Datasources().AddSource(ctx, &accounts.AddSourceRequest{ OrgId: org, Provider: accounts.DatasourceProvider_DATASOURCE_PROVIDER_API, Config: &accounts.AddSourceRequest_Api{Api: &accounts.ApiDatasourceConfig{ BaseUrl: "https://api.example.com", CredentialKind: accounts.ApiCredentialKind_API_CREDENTIAL_KIND_OAUTH2, Oauth2: &accounts.ApiOAuth2Config{ Grant: accounts.ApiOAuth2Config_GRANT_AUTHORIZATION_CODE, TokenUrl: "https://api.example.com/oauth/token", ClientId: clientID, }, }}, Boundary: &accounts.AddSourceRequest_CollectionLabel{CollectionLabel: "invoices"}, Credential: clientSecret, })
It is the raw contract: a refusal is the host's Connect error, code, message and
ErrorInfointact.datasource/below is the ergonomic layer over the same service, and itsInvokeandLookupare the effect-safe way to call an operation: they mint the effect ID, keep it on every outcome and classify an unknown one.InvokeSourceOperationsends exactly the request it is given. Both facades read responses through the same client. -
datasource/— the same facade overDatasourceService, so a solution connects a GitHub datasource collection in a few lines:ds := datasource.New(gw) src, err := ds.AddGitHubSource(ctx, datasource.GitHubSource{ OrgID: org, Repo: "codefly-dev/module-saas-starter", Paths: []string{"docs"}, Collection: "handbook", FileExtensions: []string{".md"}, AccessToken: token, }) _, err = ds.Sync(ctx, org, src.GetId())
The call half uses that same gateway and client to invoke a declared operation of a connected API as the signed-in person:
result, err := ds.Invoke(ctx, orgID, sourceID, "list_invoices", map[string]any{"limit": 20}, datasource.WithDeadline(time.Now().Add(10*time.Second))) if errors.Is(err, datasource.ErrOutcomeUnknown) { // Preserve result.Receipt.EffectID. Use Lookup with a live context; // an unknown outcome does not mean the effect failed. return err } if err != nil { return err } output := result.Output // json.RawMessage receipt := result.Receipt
Invokemarshals a JSON object (a map, struct with JSON tags, orjson.RawMessage) to the host'sinput_jsonstring. The response'soutput_jsonis validated as one JSON object and returned asjson.RawMessage, preserving its number precision and text. Invocation payloads never pass through protobufStruct; declaration schemas still use the host'sStructfields.WithDeadline(time.Time)bounds only this call; an earlier context deadline still wins.WithEffectID(id)supplies an effect ID of 1–128 UTF-8 bytes without control bytes or surrounding spaces (which HTTP would trim); interior spaces and Unicode are supported; when omitted, the SDK mints a UUIDv7. Invalid IDs return*InputErrorbefore dispatch. A deadline already expired before dispatch (including an earlier parent deadline) returns*InputErrorwith noResultand zero RPC calls. An explicitly canceled context returnscontext.Canceledwith no result. Once a call is attempted, even an error returns a non-nilResultcontaining that ID inReceipt.EffectID. Persist a caller-owned ID before invoking a mutation if it must survive a process crash.Repeating the same effect ID and byte-identical
input_jsonasks the host to replay its receipt. Persist the encoded input with the ID and reuse it asjson.RawMessage: rebuilding an equivalent object with a different field order can be refused. Every valid explicitInvokereaches the host: the SDK has no retry loop, deduplication or receipt cache. Do not change the ID to recover an uncertain mutation.Lookup(ctx, orgID, sourceID, effectID)returns the receipt and its committedOutputwithout invoking the provider. The shared SDK signature keepsorgIDandsourceID, but the wire request sends onlyeffect_id: the host derives the tenant from the gateway's Work Context and recovers the source from the stored effect binding. These two arguments cannot select or override lookup authority. A lookup miss returnsErrEffectNotFoundand a receipt carrying the same ID with statusNOT_ATTEMPTED; an explicit reinvoke with that ID is safe, and the SDK never invokes automatically. NotFound means the host has no attempt marker. Once an attempt exists, Lookup recovers the saved receipt independently of the current declaration: committed receipts survive its removal or replacement, and unresolved or expired receipts remain unknown. The caller's current source read authority still applies; revoked access refuses.Receipt.Outputalways comes from the savedreceipt.output_jsonevidence;Result.Outputcomes from the top-leveloutput_json. Both fields must contain one JSON object on a committed response. If either is missing or malformed,*DatasourceErrorretains the COMMITTED receipt, effect ID, commit time and provider status (and any valid receipt output). Bad output does not erase a commit or authorize another effect. Missing/mismatched receipt identity, unsupported status or invalid commit time instead yieldsErrOutcomeUnknown. An UNKNOWN receipt does not require valid output.Re-invoking a retained effect after its declaration is removed returns
*OperationDeclarationRemoved(errors.Is(err, ErrOperationDeclarationRemoved)). ItsReceiptStatusandReceipt.Statuscarry the host'sreceipt_status: exactcommittedbecomesCOMMITTED;unknown, missing or unrecognized metadata becomesUNKNOWN. The outcome andResult.Receiptretain the original effect ID. This is distinct from a refusal and fromOutcomeUnknown, even when its receipt status is unknown. No output, commit time or provider status is inferred from the error. Recover with Lookup, never with a fresh ID. A committed status here means the saved output must be retrieved through Lookup.An unknown receipt is returned alongside
ErrOutcomeUnknown; check it witherrors.Is. Unknown is an outcome, not evidence of failure or permission to repeat an effect. Invoke also reports transport loss or deadline expiry after dispatch conservatively as unknown, since it cannot prove the operation was read-only. Every non-Connect HTTP error after Invoke or Lookup dispatch, including 4xx, 429 and 500, likewise returnsErrOutcomeUnknownwith the original ID. The Connect decoder identifies RPC errors; decoded errors retain their code/reason mappings. Checkerrors.Is(err, datasource.ErrOutcomeUnknown)anderrors.Is(err, datasource.ErrOperationDeclarationRemoved)before transport-code retry logic: the wrapped Connect error remains available for diagnostics, but its code does not establish whether the effect happened. An unavailable lookup also returns an unknown receipt: it cannot establish what happened to the effect. Use a fresh bounded context to look up an effect after the original context has expired.For logging and metrics,
errors.Ascan inspect both*datasource.OutcomeUnknown(including its receipt) and the wrapped*connect.Error;errors.Isalso preserves an underlying context error. The SDK never retries either error.DeclareOperations(ctx, orgID, sourceID, []datasource.Operation)replaces the source's declaration set atomically under the host's administrator check. Both schemas must be supplied as JSON objects; omitted schemas returnInputErrorbefore dispatch. Explicit{}is accepted for host admission.ListOperations(ctx, orgID, sourceID)reads the set and represents absent schemas as{}, so its operations can be re-declared. For example, an API connected tohttps://example.comcould declare:err := ds.DeclareOperations(ctx, orgID, sourceID, []datasource.Operation{{ Name: "list_invoices", Method: "GET", Path: "/invoices", Query: []string{"limit"}, Description: "List available invoices", Input: json.RawMessage(`{"type":"object","properties":{"limit":{"type":"integer"}},"additionalProperties":false}`), Output: json.RawMessage(`{"type":"object"}`), Effect: datasource.EffectReadOnly, MaxOutputBytes: 65536, }})
ListOperationsincludes each operation's description and host-computedDigest; declarations ignore a caller-supplied digest. An absent orEFFECT_UNSPECIFIEDhost effect returns*InputErrorwith an empty pointer and the messagehost returned an unspecified effect; an unknown future numeric effect returns*DatasourceErrorwith no receipt. Neither returns a partial list. The host admits the schemas, route, effect and output cap. Invocation never accepts a provider URL or credential. The gateway's HTTP client carries the person's Work Context unchanged; this facade adds onlyx-codefly-effect-idto the normal Connect request, matching the request's effect ID. It never mints, replaces or sets a Work Context header.Host response Go result PermissionDenied ErrNotPermitted(errors.Is)Invoke NotFound ErrUnknownOperation(errors.Is)Lookup NotFound receipt plus ErrEffectNotFound(errors.Is); keep the ID for an explicit reinvokeInvalidArgument *InputError, withPointerfrom the firstBadRequest.FieldViolation.fieldResourceExhausted + DATASOURCE_RATE_LIMITED *RateLimited, withResetAtfromreset_atorRetryInfoFailedPrecondition + SOURCE_OPERATION_OUTCOME_UNKNOWN receipt plus ErrOutcomeUnknown(errors.Is); the mutation may have happenedFailedPrecondition + SOURCE_OPERATION_DECLARATION_REMOVED receipt plus *OperationDeclarationRemoved/ErrOperationDeclarationRemoved; recover the original ID with LookupFailedPrecondition + SOURCE_PROVIDER_REFUSED *ProviderRefused, withStatusfromErrorInfo.metadata["provider_status"]when knownFailedPrecondition + SOURCE_EFFECT_REUSED ErrEffectReused(errors.Is)Other FailedPrecondition *OperationRefused, with the structuredReasonor an empty stringNon-Connect HTTP error after Invoke/Lookup dispatch (4xx or 5xx) receipt plus ErrOutcomeUnknown; retain the original IDAmbiguous Invoke / unknown receipt receipt plus ErrOutcomeUnknown(errors.Is)Valid COMMITTED receipt with invalid output JSON receipt plus *DatasourceError; commit evidence is retainedSOURCE_OPERATION_OUTCOME_UNKNOWNis an effect outcome only on Invoke and Lookup. On Declare/List it isOperationRefusedcarrying that reason. Null, unknown or undecodable protobuf error details are skipped; valid neighboring details still determine the outcome. For Declare/List, non-Connect HTTP 403 with a supported encoding maps toErrNotPermittedthrough Connect's HTTP fallback; on Invoke/Lookup the same non-Connect response remains unknown.Connect JSON errors accept a case-insensitive UTF-8 charset parameter, case-insensitive gzip encoding and a leading UTF-8 BOM. A response adapter normalizes those encodings before Connect decodes the envelope; it forwards the request unchanged through the gateway's existing HTTP client. Empty, identity and gzip encodings are accepted case-insensitively. Unsupported encodings such as
brare rejected before reading the body or inferring an HTTP status: Declare/List return*DatasourceErrorwith no receipt, while Invoke/Lookup returnErrOutcomeUnknownwith the original effect ID. The diagnostic cause is retained and no retry occurs. Other charsets and media types remain non-Connect. Timestamp strings follow the protobuf JSON profile (years 0001–9999, at most nine fractional digits).The recorded host ref emits structured input pointers and provider HTTP statuses.
InputError.Pointeris empty when no field violation is supplied; otherwise the JSON Pointer is preserved verbatim.ProviderRefused.Statusis zero when metadata is missing or is not a three-digit ASCII decimal HTTP status from 100–599. Status metadata is read only forSOURCE_PROVIDER_REFUSED; it cannot turn an unknown outcome into a provider refusal. The host's receipt guard also emits detail-free precondition refusals, represented asOperationRefusedwith an empty reason. A precondition refusal does not establish the outcome of an earlier attempt under that ID.Other errors retain their Connect identity. Host diagnostic messages are preserved, including credential-looking text: the host is the disclosure guard. The SDK neither redacts messages nor treats their prose as status metadata. These names mirror the Python facade's
invoke,lookup,declare_operations,list_operations,Operation,Result,Receipt,NotPermitted,UnknownOperation,EffectNotFound,EffectReused,DatasourceError,InputError,RateLimited,ProviderRefused,OperationRefused,OperationDeclarationRemovedandOutcomeUnknown.This call surface uses the four host RPCs from module-saas-starter#1052.
SOURCE.txtrecords the released host tagv0.0.103used to regenerate the bindings. This regeneration does not publish an SDK release. The host's runtime-scheduledPruneSourceOperationReceiptsandLookupPruneSourceOperationReceiptsare included in the generated bindings only; this caller facade does not expose receipt retention operations. -
moduleauthority/— the module-principal side of installed operation authority. A long-running module supplies a source for its pod's projected ServiceAccount token; the client rereads it before every mint and refreshes the module's short-lived Work Context, attaches it to the generic SaaS exchange, and returns the exchanged child as an opaquestring. The signed-in person's retained parent and the exchanged child remain request-local.Every Work Context this package carries — the module's own, an operation or source context, the exchanged child, a
Parenthanded in — is an opaque string in core's encoding. The client is its holder and sender, never its verifier: before caching or sending one it asks onlygithub.com/codefly-dev/core/workcontext.Decodewhether it is structurally a sealed capability, and attaches it under core'swool.WorkContextHeader. A mint answering anything else (the pre-cutover JSON payload among them) isErrInvalidCapability; aParentcore does not decode is refused as invalid input (ErrInvalidExchange,ErrInvalidArtifact,ErrInvalidInstallation) before anything is sent. This needs a host whose accounts mints core-format Work Contexts (module-saas-starter#1093); the former v0.0.94 source mints the pre-cutover format, which this client refuses.SOURCE.txtnow records v0.0.103.Client.ModuleWorkContext(ctx)returns a cached token, sharing refreshes between concurrent callers. Consumers that already own their expiry and refresh controller useClient.MintModuleWorkContext(ctx)instead:issued, err := authority.MintModuleWorkContext(ctx) if err != nil { return err } // issued.Token is opaque; issued.ExpiresAt is the broker's reported expiry.
This returns
ModuleContext{Token string, ExpiresAt time.Time}and mints once per call. It neither reads nor updates the cached token, coalesces concurrent calls, nor retries failures. Both methods use the same broker request, bounded response reader, expiry parser and refusal mapping. The projected credential is reread for each mint; the organization remains the client'sCredentials.OrganizationID, so keep a client per organization. The returned expiry comes from the broker, never from decoded token claims. It does not prove authority or override a verifier's lifetime limit; Core owns authority validation, and consumers own their refresh/deadline policy.It talks to the two seams the host serves a composed module, and takes both explicitly — the consumer passes what Codefly resolved, nothing is defaulted:
- the gateway's module broker (REST on the auth-gateway's
restendpoint):POST /modules/_work-context,/modules/_operation-contextand/modules/_source-operation-context, where the module presents its pod's projected ServiceAccount token (audienceaccounts) as the bearer; the host reviews it and mints only for the principal's declared workload running the build its authority approves. The host never serves theMint*procedures ofModuleCapabilitiesServiceat the gateway edge; - accounts'
authoritygRPC endpoint (saas/accounts/authority), where the module calls, with its module Work Context, the procedures the host exports to composed modules (ExchangeDelegatedOperationAudience).
authority, err := moduleauthority.New(moduleauthority.Seams{ Gateway: gw, // BaseURL() + HTTPClient() Authority: moduleauthority.Authority{Address: authorityHostPort}, // TLS: nil = h2c InternalToken: internalToken, // Only from the consumer's own configured assertion that every // in-cluster hop is carried by a mutually authenticated mesh. AllowInsecureHTTP: meshProtected, }, moduleauthority.Credentials{ Prefix: modulePrefix, // Read before every mint: the kubelet rotates the projection. Token: moduleauthority.WorkloadTokenFile(), // Required for a shared module; a tenant-pinned module may omit it. OrganizationID: organizationID, }) if err != nil { return err // ErrInvalidSeams / ErrInvalidCredentials: fail closed at startup } defer authority.Close() child, err := authority.ExchangeOperation(ctx, moduleauthority.ExchangeRequest{ BindingID: installedBindingID, Parent: retainedParent, Lookup: false, })
Newrefuses a missing internal token, gateway base URL or an incomplete authority seam. LeavingAuthorityzero builds a mint-only client; authority calls then returnErrNoAuthority. It refuses to send the internal token or the projected token in plaintext to anything butlocalhostor a loopback IP unlessAllowInsecureHTTPasserts the hop is mesh-protected. SaaS owns the installed binding's audience, scopes, lifetime, verification, and audit record.WorkloadTokenFile()is where Codefly projected the token for a module-identity service: the fileCODEFLY__WORKLOAD_TOKEN_FILEnames when that is set, and the pod mount,/var/run/secrets/codefly/token, otherwise. A nativecodefly run(CLI v0.1.187 and later) stands in for the cell and projects each such service's token to a file of its own, since a process cannot read the pod mount, but it sets that variable only for a service that declaresmodule-identityand does not declareworkload-identity. Aworkload-identityservice gets its token's path asTOKEN_FILEin itsworkload-identityconfiguration instead, so for it the helper falls back to the pod path, which does not exist under a native run; it passesProjectedTokenFile(path)with that configured path. A consumer with any other projection does the same;ProjectedTokenFilehas no default. The host requires audienceaccountsand a token lifetime of at most 600 seconds, and verifies the pod, workload and approved build. The SDK rereads the source for every actual mint (including a module Work Context refresh), trims whitespace, and rejects an empty token or one larger than 64 KiB withErrInvalidCredentialsbefore sending.ProjectedTokenFilealso bounds the file read to 64 KiB. Every mint sends exactly oneAuthorization: Bearer <token>header alongside the internal perimeter token; the removed module-secret credential has no fallback. Since host v0.0.103 the gateway serves the three mints on its workload endpoint alone and holds the internal token on the two operation mints only: the work-context mint's perimeter is that endpoint, and its credential the projected token.Seams.Gatewaynames that endpoint.The REST bodies match the v0.0.103 gateway:
Mint path JSON fields sent /modules/_work-contextprefix, optionalorganization_id/modules/_operation-contextprefix,binding, optionalorganization_id/modules/_source-operation-contextprefix,source_idSet
Credentials.OrganizationIDfor a shared module and use a separate client per organization, since each client caches its own module Work Context. A tenant-pinned module may omit the selector or name its own tenant. The host checks the module envelope and live installation; selecting an organization grants no authority. The selector must be a canonical UUID (lowercase, hyphenated), as accounts requires;Newrefuses any other form withErrInvalidCredentialsat construction, whichever mints the client will make, rather than letting the first work or operation mint fail as an untyped 400. A client that only mints source operation contexts leaves it empty. A missing or wrong organization — a shared module that names none, or a tenant-pinned module that names another — is refused as unproven credentials:ErrInvalidCredentials(HTTP 401) fromModuleWorkContext, andErrPermissionDenied(HTTP 403) fromMintModuleOperationContext. The host does not say that the organization was the cause. Source mints ignore this selector and derive their organization from the source's delegation. Deployment and identity requirements are in MODULE_INSTALLATION.md and Establishing what a caller is running.ExchangeRequestpresents authority one of two ways, and exactly one — both, or neither, isErrInvalidExchangebefore anything is sent.Parentis a live capability the caller holds, as the encoded string it arrived as; the child is attenuated against it and expires with it, so work that outlives the parent cannot be authorized this way at all.DelegationIDis a reference to a host-owned, revocable source delegation, asMintSourceOperationContextreported it:child, err := authority.ExchangeOperation(ctx, moduleauthority.ExchangeRequest{ BindingID: installedBindingID, DelegationID: delegationID, // no parent, and none needs to still be valid })
No capability is presented on that arm and none is held. The host re-checks that the delegation is live, the source exists, the person still holds the role and the binding is unchanged, then mints that call's child. So it is not bounded by any token's remaining life and may be called again after the previous child has expired — which is the whole of the renewal path for work longer than a Work Context. A revocation takes effect on the next exchange. The reference is an identifier, never a credential: on its own it authorizes nothing, and the caller's own module Work Context still authenticates the call.
Background work with no person present mints its own operation context instead. It carries exactly the binding's
headless_scopes; a binding that declares none is refused withErrPermissionDenied. It lives about a minute and is never cached, so mint one per call or short batch:op, err := authority.MintModuleOperationContext(ctx, installedBindingID) // op.Token goes in x-codefly-work-context on the call to op.Audience.
EmitAuditEvent(ctx, AuditEvent)sends a registered event through the same module-authenticated authority seam. The host checks the module's grant, tenant, actor, solution and registered field schema. The event's actor may be the module's own principal or asystem:<process>label; a subject actor must belong to the tenant. An empty tenant requires the host's cross-tenant grant.// Persist the complete event and key in the producer's outbox before sending. event := moduleauthority.AuditEvent{ Tenant: tenantID, EventType: "example.changed", Actor: "system:worker", Solution: "example", EntryID: entryID, IdempotencyKey: outboxEventID, Fields: map[string]any{"count": float64(3)}, } if err := authority.EmitAuditEvent(ctx, event); err != nil { return err // Keep the outbox entry and its original key for recovery. } // The host acknowledged the event; the producer may now settle its outbox row.
The host deduplicates by
(tenant, event_type, idempotency_key). Persist and reuse the complete original event and key across retries and restarts; never reuse a key for another intent. The contract inSOURCE.txtacknowledges a duplicate key without comparing its payload. An empty key disables deduplication, and some registered types require a key. Field values use the protobuf Struct representation, so numbers are represented as doubles.A nil error acknowledges the host's durable audit write or an existing key, not completion of outbound webhook delivery. After a timeout or transport error the write may already have committed. The SDK does not own an outbox, store receipts, or automatically retry an uncertain effect; only rejected module authentication is refreshed and retried once. Host error codes remain available through
connect.CodeOf(err). Receipt lookup and runtime-boundary attestation await an integrated host contract and are not exposed here. - the gateway's module broker (REST on the auth-gateway's
-
settings/— the schema-agnostic typed-settings library every module and product depends on instead of vendoring a copy. It has two parts:-
runtime (
settings) — presence-awareField[M, T]access over a generated protobuf settings message, plus aJSONCodecthat is the only boundary between typed settings and their sparse ProtoJSON storage. Product code works with typed fields and never traverses protobuf parents or JSON keys.usersettingsbelow is the product's own generated field catalog (not shipped by this SDK); it is built once on top of thesesettings.Fieldhelpers:theme, err := usersettings.Fields.Appearance.Theme.Get(document) // default when absent err = usersettings.Fields.Email.Product.Set(document, false) // explicit false stays present
-
renderers (
settings/catalog) — the reusable half ofmodule-compose. A module declares its settings contributions and gets Go / TypeScript / proto catalogs fromcatalog.RenderGo/RenderTypeScript/RenderProto, instead of re-implementing the render functions in-repo.
The runtime imports no generated schema, so it is byte-for-byte reusable across products; product-specific fields belong in the product proto and its typed field catalog, never here.
-
-
Verifying a Work Context you receive is not this SDK's job. The callee side lives in
github.com/codefly-dev/sdk-go/workcontext, which re-exports core's verifier (NewVerifier,HeaderName,FromHeaders, the key-set helpers). Theworkcontext/package that used to live here — a JWKS verifier over the pre-cutover encoding, with HTTP / Connect / gRPC middleware andRequireScope— was removed in this release; seeCHANGELOG.mdfor what replaces each symbol.
This SDK's release version tracks the saas-starter module version
(module/module.package.codefly.yaml), recorded in SOURCE.txt alongside the
proto ref gen/ was generated from.
The proto source of truth is module-saas-starter/module/services/accounts/proto.
To refresh from a released tag (in a checkout of that repo):
git fetch origin tag v0.0.103
git checkout --detach v0.0.103
cd module/services/accounts/proto
buf generate --template <this-repo>/buf.gen.yaml -o <this-repo>buf.gen.yaml sets managed.override.go_package_prefix = github.com/codefly-dev/saas-sdk-go/gen, so the descriptors are generated with
the correct module path. Never hand-edit gen/ or sed the module path — the
protobuf file descriptors embed length-prefixed package strings and a text
rewrite corrupts them (panics at init()). Always regenerate.
The template generates the complete host-owned saas/ schema tree. Its
vendored codefly/ import closure is compiled for Runnable annotations, with
imports resolved to github.com/codefly-dev/core/generated/go; Core owns
those Go descriptors. Emitting a second copy in this SDK would conflict at
protobuf initialization. The SDK requires Core at b81d92e6feda
(v0.19.6-0.20261018051341-b81d92e6feda), the Core the v0.0.103 gateway
requires: it supplies the Runnable package, and its SealSource has no
approved build. No tagged Core carries that yet; v0.19.5 still requires
SealSource.ApprovedBuild, so it cannot build this SDK, and the floor moves to
the first Core tag that contains b81d92e6feda. The SDK need not adopt the
host's entire dependency generation. Verify the compatible dependency floor
with SDK and consumer builds when refreshing it.
The generation plugin versions remain pinned.
Record the release tag in SOURCE.txt; CI checks out that tag through
refs/tags/ and verifies that regeneration leaves no diff. Before a host
release, an exact PR-head commit may be used for development. The same ref is
the host whose real auth-gateway handlers moduleauthority must mint through
in CI (conformance/hostgateway/run.sh <host checkout>), so moving it moves
the broker contract too.
Regeneration and matching SDK releases are owned by module-saas-starter and
this repository. Until release automation covers both, follow the
refresh-generated-api procedure in .claude/skills/.
import "github.com/codefly-dev/saas-sdk-go/accounts"Every message a facade method takes or returns, and everything reachable
through it, is re-exported by the facade package; a consumer never imports
gen/.
moduleauthority.Client exposes ApproveExecutableArtifact,
AuthorizeExecutableArtifact, and RevokeExecutableArtifact on the resolved
accounts authority endpoint, using the ordinary cached module credential plus
an independently signed human parent. The first is an explicit administrator
activation action; the second only checks existing exact consent. Never approve
as a fallback on a refused run. Neither operation dispatches work or moves an
active pointer.
ArtifactRequest names the installation, installed policy ID and
ArtifactIdentity: its schema, source, complete canonical subject bytes,
ordered exact contract references and an int64 expected revision. The host
validates its installed MODULE_PRINCIPALS[<module>].artifact_policies ceiling.
The SDK preserves subject bytes and int64 values exactly, bounds the request,
and refuses mismatched or malformed authorization receipts. The module owns
canonical identity extraction, including every script/runtime and content pin;
the host does not interpret configuration or fabricate publisher attestation.
Consent belongs to the tenant and survives administrator turnover. The host
rechecks current caller authority, installation, source, policy and explicit
revocation on each run. Revocation addresses the approval UUID from the returned
host/approved-artifacts/<uuid> Ref, even if the policy was disabled. Revoked
exact identities are terminal; a newly selected content version or activation
revision needs explicit new consent. These Refs are not tool or artifact-access
credentials.
moduleauthority.Client.GetCurrentInstallation(ctx, CurrentInstallationRequest)
reads the host's current active, non-revoked installation identity. Supply the
original verified parent token and InstallationID. The client presents its own
module Work Context independently on the resolved authority endpoint. It never
forwards a viewer bearer, selects a tenant/source, or mints broader parent scopes.
The result is CurrentInstallation{InstallationID, TenantID, TargetID, BindingID}.
The SDK requires the exact requested ID, valid tenant/target UUIDs and a
nonempty binding identity. This replaces the removed SolutionIdentifier
field: the host returns the immutable target and its delivered binding,
rather than a reusable route alias. The consumer must compare TenantID
to its independently verified request tenant and use the target/binding
identities for authority decisions. A successful observation is not executable
consent, a grant or durable liveness proof. Re-read for each new request.
The host preserves its organization-member metadata-read rule: no new permission or binding is required. It checks current parent authority and delegation, authenticated module/audience/tenant alignment, current membership, and exact active record identity. A refusal never invokes a bearer-based fallback.
A module that RECEIVES a Work Context is its callee, and a callee verifies with
sdk-go/workcontext's PinnedVerifier — core's verifier, with its
configuration settled. That verifier needs three live sources only the issuer
holds, and this SDK is how you reach them:
sources, err := client.WorkContextSources(moduleauthority.WorkContextSourceSettings{
Capability: presented, // the capability this module is verifying
})
// Handle err before using sources.
verifier, err := workcontext.NewVerifier(workcontext.VerifierSettings{
Issuer: issuer, Audience: audience,
Seals: sources, Revisions: sources, Grants: sources,
Replay: replayStore, // yours, and durable+shared across replicas
}, keySet)Without them a module can only run core's forwarding-hop Inspector, which
checks the encoding, signature, issuer, audience, window and shape and nothing
live — so a capability whose installation was revoked, whose operation binding
was withdrawn or whose principal's epoch has moved stays accepted until it
expires. A change of approved builds revokes through the principal's epoch and
the installation's revision; core's verifier asks for no build.
The capability is the only required setting, and it is the whole authorization: the host authenticates it, requires it to be addressed to your module (its registration prefix, or the route alias its delivered presence declares) and to the organization your credential is bound to, and answers only about the identifiers it names. There is no tenant, audience or owner to supply. Build one per presented capability; a module verifying a stream of messages that carry the same capability keeps one.
What the answers mean:
corework.ErrNoSealandErrNoBindingare core's own sentinels, which its verifier turns into a refusal of the capability.ErrUnreadableAuthorityAnsweris a host answer this client will not read — a state it does not understand, another arm, or an answer about another principal, installation or binding. It is a refusal, never a permissive default.ErrNoApprovalGrantis an approval the issuer never recorded.- A host refusal arrives with its connect code (
connect.CodeOf):CodeFailedPreconditionmeans the issuer decided and the authority has changed — mint again, do not retry — whileCodeUnavailablemeans the lookup failed and a retry may succeed.
Positive answers are cached for at most AuthorityCacheBound (2 seconds), which
is a ceiling: a cached answer delays a revocation by exactly its bound, so a
larger one is refused rather than clamped, and CacheEntries: -1 turns caching
off. Refusals are never cached. One verification costs four or more round trips
(the revision, the seal, an epoch for the owner and for each actor hop, a
binding), so decide the cadence for a stream deliberately rather than
discovering it from a latency graph.
This module verifies nothing: it holds no key, reads no signature and decides
nothing about a capability. The decision is core's, through
sdk-go/workcontext.
moduleauthority.NewGatewayAuthority(seams, credentials) explicitly selects
the host's binary-protobuf Connect authority bridge at
<gateway BaseURL>/modules/_authority. Leave Seams.Authority zero when using
this constructor. It refuses any simultaneous direct Authority configuration,
applies the same credential and TLS/mesh checks as New, blocks redirects,
and does not fall back to Accounts. The selected host must serve this bridge;
a generated client alone
does not establish that a deployed host supports it or any particular procedure.
The existing New constructor retains the direct authority transport and its
mint-only mode. Audit declarations work with the existing direct authority
endpoint; they do not require the gateway bridge.
Client.DeclareAuditEventTypes presents the module's event schemas through the
selected authority seam. Ownership and compatibility remain host decisions.
Public declaration types and enum constants are re-exported by moduleauthority.
The SDK supplies Credentials.Prefix, uses the same cached module Work Context
as other authority calls, and preserves every field, enum value, visibility and
PII flag. It does not create a second registry or infer a namespace from a prefix.
After constructing authority with the resolved seams and projected credentials
as above, declare schemas during startup and propagate any refusal:
err = authority.DeclareAuditEventTypes(ctx, []*moduleauthority.ModuleAuditEventTypeDeclaration{
{
Type: "example.item.changed", // namespace bound to this module by the operator
Description: "An item changed",
Visibility: moduleauthority.ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_TENANT,
Fields: []*moduleauthority.ModuleAuditFieldDeclaration{
{Name: "item_id", Kind: moduleauthority.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UUID},
{Name: "contact", Kind: moduleauthority.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_STRING, Pii: true},
},
},
})
if err != nil {
return err
}The host admits only operator-bound namespaces. Re-declaring the identical schema
is idempotent; fields and enum values may grow, while removal, retyping and changes
to visibility are refused. A rejected module capability (Unauthenticated) is
refreshed and retried once. Other authority refusals retain their Connect codes
and details (connect.CodeOf(err) / errors.As) and are not retried. A mint-only
client's declaration fails with ErrNoAuthority before any network request. A
missing bridge fails without switching transports.
Broker non-200 responses return *moduleauthority.BrokerError, discoverable
with errors.As. It contains only StatusCode and Classification
(BrokerErrorRefused for 401/403, BrokerErrorNotEstablished for 503,
BrokerErrorOther otherwise), with a fixed message per class. Response bodies
and headers are excluded entirely; classification never depends on reading a
body. The 401, 403 and 503 sentinels remain ErrInvalidCredentials,
ErrPermissionDenied (or ErrDelegationInvalid for source mints), and
ErrExecutionUnestablished. A source mint's 412 is ErrDelegationMissing: the
source has no active delegation to this module, and a person must connect or
reconnect it — minting again will not help. The gateway answers 412 there only
for a missing delegation, so the status alone decides it. Body-based delegation
distinctions are gone: source 403s all return ErrDelegationInvalid, whether
the delegation was revoked or is otherwise unusable.
A module work-context or operation-context mint answers 412 when the module is
proven and the host holds nothing to mint against — admitted but installed
nowhere among them. That is ErrNoHostStateToMintFor, never a delegation
sentinel; the module can serve its install endpoint while waiting.
BrokerError.Path and BrokerError.Reason have been removed.
Transport and response read failures return *moduleauthority.BrokerTransportError
with a fixed message and a Category: BrokerTransportTimeout,
BrokerTransportConnectionRefused, BrokerTransportConnectionReset,
BrokerTransportTLS, or BrokerTransportOther. It retains no underlying
error and has no Unwrap method. Callers use errors.As and the category for retry decisions, and check
their context's Err() for cancellation. For example, a caller may retry a 502,
503, timeout, refused connection or connection reset, while surfacing a 401/403
denial. The SDK does not automatically retry broker failures. Malformed initial
HTTP headers provide no usable status and produce a transport error; malformed bodies cannot
change a successfully parsed non-200 status into a transport failure.