Skip to content

About

Go SDK for the saas accounts API: generated Connect client + gateway-bound facade. Consumed by solutions; never vendored.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

saas-sdk-go

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.v1 and friends), generated from codefly-dev/module-saas-starter at the ref recorded below.

  • accounts/ — a thin, gateway-bound facade with syntactic sugar over the generated stubs. It hides the connect.Request/Response envelope and the raw procedure strings, so a solution handler writes plain protos:

    resp, err := accounts.New(gw).Audit().QueryAuditLog(ctx, &accounts.QueryAuditLogRequest{PageSize: 20})

    gw is any value exposing BaseURL() string and HTTPClient() *http.Client — which github.com/codefly-dev/solution-runtime-go.Gateway already satisfies. The runtime stays accounts-agnostic and takes no dependency on this SDK.

    Datasources() is the same facade over DatasourceService: the host's source connection, operation and account-link contract, message for message. It covers ListSources, GetSource, AddSource, ListSourceOperations, DeclareSourceOperations, InvokeSourceOperation, LookupInvokeSourceOperation, BeginDatasourceAccountLink, CompleteDatasourceAccountLink and ListMyDatasourceAccountLinks, and every message, enum, enum value and oneof wrapper they reach is re-exported from accounts under 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 ErrorInfo intact. datasource/ below is the ergonomic layer over the same service, and its Invoke and Lookup are the effect-safe way to call an operation: they mint the effect ID, keep it on every outcome and classify an unknown one. InvokeSourceOperation sends exactly the request it is given. Both facades read responses through the same client.

  • datasource/ — the same facade over DatasourceService, 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

    Invoke marshals a JSON object (a map, struct with JSON tags, or json.RawMessage) to the host's input_json string. The response's output_json is validated as one JSON object and returned as json.RawMessage, preserving its number precision and text. Invocation payloads never pass through protobuf Struct; declaration schemas still use the host's Struct fields. 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 *InputError before dispatch. A deadline already expired before dispatch (including an earlier parent deadline) returns *InputError with no Result and zero RPC calls. An explicitly canceled context returns context.Canceled with no result. Once a call is attempted, even an error returns a non-nil Result containing that ID in Receipt.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_json asks the host to replay its receipt. Persist the encoded input with the ID and reuse it as json.RawMessage: rebuilding an equivalent object with a different field order can be refused. Every valid explicit Invoke reaches 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 committed Output without invoking the provider. The shared SDK signature keeps orgID and sourceID, but the wire request sends only effect_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 returns ErrEffectNotFound and a receipt carrying the same ID with status NOT_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.Output always comes from the saved receipt.output_json evidence; Result.Output comes from the top-level output_json. Both fields must contain one JSON object on a committed response. If either is missing or malformed, *DatasourceError retains 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 yields ErrOutcomeUnknown. An UNKNOWN receipt does not require valid output.

    Re-invoking a retained effect after its declaration is removed returns *OperationDeclarationRemoved (errors.Is(err, ErrOperationDeclarationRemoved)). Its ReceiptStatus and Receipt.Status carry the host's receipt_status: exact committed becomes COMMITTED; unknown, missing or unrecognized metadata becomes UNKNOWN. The outcome and Result.Receipt retain the original effect ID. This is distinct from a refusal and from OutcomeUnknown, 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 with errors.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 returns ErrOutcomeUnknown with the original ID. The Connect decoder identifies RPC errors; decoded errors retain their code/reason mappings. Check errors.Is(err, datasource.ErrOutcomeUnknown) and errors.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.As can inspect both *datasource.OutcomeUnknown (including its receipt) and the wrapped *connect.Error; errors.Is also 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 return InputError before 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 to https://example.com could 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,
    }})

    ListOperations includes each operation's description and host-computed Digest; declarations ignore a caller-supplied digest. An absent or EFFECT_UNSPECIFIED host effect returns *InputError with an empty pointer and the message host returned an unspecified effect; an unknown future numeric effect returns *DatasourceError with 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 only x-codefly-effect-id to 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 reinvoke
    InvalidArgument *InputError, with Pointer from the first BadRequest.FieldViolation.field
    ResourceExhausted + DATASOURCE_RATE_LIMITED *RateLimited, with ResetAt from reset_at or RetryInfo
    FailedPrecondition + SOURCE_OPERATION_OUTCOME_UNKNOWN receipt plus ErrOutcomeUnknown (errors.Is); the mutation may have happened
    FailedPrecondition + SOURCE_OPERATION_DECLARATION_REMOVED receipt plus *OperationDeclarationRemoved / ErrOperationDeclarationRemoved; recover the original ID with Lookup
    FailedPrecondition + SOURCE_PROVIDER_REFUSED *ProviderRefused, with Status from ErrorInfo.metadata["provider_status"] when known
    FailedPrecondition + SOURCE_EFFECT_REUSED ErrEffectReused (errors.Is)
    Other FailedPrecondition *OperationRefused, with the structured Reason or an empty string
    Non-Connect HTTP error after Invoke/Lookup dispatch (4xx or 5xx) receipt plus ErrOutcomeUnknown; retain the original ID
    Ambiguous Invoke / unknown receipt receipt plus ErrOutcomeUnknown (errors.Is)
    Valid COMMITTED receipt with invalid output JSON receipt plus *DatasourceError; commit evidence is retained

    SOURCE_OPERATION_OUTCOME_UNKNOWN is an effect outcome only on Invoke and Lookup. On Declare/List it is OperationRefused carrying 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 to ErrNotPermitted through 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 br are rejected before reading the body or inferring an HTTP status: Declare/List return *DatasourceError with no receipt, while Invoke/Lookup return ErrOutcomeUnknown with 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.Pointer is empty when no field violation is supplied; otherwise the JSON Pointer is preserved verbatim. ProviderRefused.Status is zero when metadata is missing or is not a three-digit ASCII decimal HTTP status from 100–599. Status metadata is read only for SOURCE_PROVIDER_REFUSED; it cannot turn an unknown outcome into a provider refusal. The host's receipt guard also emits detail-free precondition refusals, represented as OperationRefused with 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, OperationDeclarationRemoved and OutcomeUnknown.

    This call surface uses the four host RPCs from module-saas-starter#1052. SOURCE.txt records the released host tag v0.0.103 used to regenerate the bindings. This regeneration does not publish an SDK release. The host's runtime-scheduled PruneSourceOperationReceipts and LookupPruneSourceOperationReceipts are 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 opaque string. 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 Parent handed 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 only github.com/codefly-dev/core/workcontext.Decode whether it is structurally a sealed capability, and attaches it under core's wool.WorkContextHeader. A mint answering anything else (the pre-cutover JSON payload among them) is ErrInvalidCapability; a Parent core 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.txt now 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 use Client.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's Credentials.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 rest endpoint): POST /modules/_work-context, /modules/_operation-context and /modules/_source-operation-context, where the module presents its pod's projected ServiceAccount token (audience accounts) 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 the Mint* procedures of ModuleCapabilitiesService at the gateway edge;
    • accounts' authority gRPC 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,
    })

    New refuses a missing internal token, gateway base URL or an incomplete authority seam. Leaving Authority zero builds a mint-only client; authority calls then return ErrNoAuthority. It refuses to send the internal token or the projected token in plaintext to anything but localhost or a loopback IP unless AllowInsecureHTTP asserts 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 file CODEFLY__WORKLOAD_TOKEN_FILE names when that is set, and the pod mount, /var/run/secrets/codefly/token, otherwise. A native codefly 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 declares module-identity and does not declare workload-identity. A workload-identity service gets its token's path as TOKEN_FILE in its workload-identity configuration instead, so for it the helper falls back to the pod path, which does not exist under a native run; it passes ProjectedTokenFile(path) with that configured path. A consumer with any other projection does the same; ProjectedTokenFile has no default. The host requires audience accounts and 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 with ErrInvalidCredentials before sending. ProjectedTokenFile also bounds the file read to 64 KiB. Every mint sends exactly one Authorization: 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.Gateway names that endpoint.

    The REST bodies match the v0.0.103 gateway:

    Mint path JSON fields sent
    /modules/_work-context prefix, optional organization_id
    /modules/_operation-context prefix, binding, optional organization_id
    /modules/_source-operation-context prefix, source_id

    Set Credentials.OrganizationID for 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; New refuses any other form with ErrInvalidCredentials at 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) from ModuleWorkContext, and ErrPermissionDenied (HTTP 403) from MintModuleOperationContext. 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.

    ExchangeRequest presents authority one of two ways, and exactly one — both, or neither, is ErrInvalidExchange before anything is sent. Parent is 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. DelegationID is a reference to a host-owned, revocable source delegation, as MintSourceOperationContext reported 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 with ErrPermissionDenied. 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 a system:<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 in SOURCE.txt acknowledges 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.

  • 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-aware Field[M, T] access over a generated protobuf settings message, plus a JSONCodec that 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. usersettings below is the product's own generated field catalog (not shipped by this SDK); it is built once on top of these settings.Field helpers:

      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 of module-compose. A module declares its settings contributions and gets Go / TypeScript / proto catalogs from catalog.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). The workcontext/ package that used to live here — a JWKS verifier over the pre-cutover encoding, with HTTP / Connect / gRPC middleware and RequireScope — was removed in this release; see CHANGELOG.md for what replaces each symbol.

Versioning

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.

Regenerating gen/

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/.

Consuming

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/.

Exact executable artifact consent

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.

Current installation identity

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.

Verifying a Work Context presented to you, at full strength

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.ErrNoSeal and ErrNoBinding are core's own sentinels, which its verifier turns into a refusal of the capability.
  • ErrUnreadableAuthorityAnswer is 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.
  • ErrNoApprovalGrant is an approval the issuer never recorded.
  • A host refusal arrives with its connect code (connect.CodeOf): CodeFailedPrecondition means the issuer decided and the authority has changed — mint again, do not retry — while CodeUnavailable means 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.

Gateway authority transport and audit declarations

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.

About

Go SDK for the saas accounts API: generated Connect client + gateway-bound facade. Consumed by solutions; never vendored.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages