Skip to content

Mintcore Architecture ​

This guide describes the shipped wiring of internal/mintcore/ — the shared token-minting library used by three deploy targets. It covers platform accessors, load-site construction, WASM constraints, and the patterns that keep the Cloudflare Worker binary within size limits.

Deploy targets ​

Mintcore is shared by three deploy targets. Each target has its own load site (entrypoint) that constructs the verifier and PEM accessor and passes them into NewHandler.

TargetLoad siteVerifierPEM accessor
GCF (Cloud Function)internal/mintNewSTSVerifierNewGCPSecretPEMAccessor
Cloudflare Workercmd/mint-wasm (mintcoreInitMint)NewJWKSVerifierNewHostPEMAccessor
Standalonecmd/mintNewJWKSVerifierNewFilesystemPEMAccessor

A fourth consumer, devmint (used in e2e tests), also constructs NewJWKSVerifier and passes its own PEM accessor via the same NewHandler interface.

Layering ​

internal/mintcore/        shared library — all mint logic
  mintconsts/             compile-time constants (OIDCAudience)
internal/mint/            GCF load site (has its own go.mod)
cmd/mint/                 standalone HTTP server load site (has its own go.mod)
cmd/mint-wasm/            WASM entrypoint for CF Worker (imports mintcore)
internal/dispatch/cf/
  workersrc/              CF Worker TypeScript adapter (I/O only)
internal/dispatch/gcf/
  mintsrc/                embedded copies for GCF deployment (.embed files)

internal/mintcore/ contains all token-minting logic: request parsing, OIDC verification, claims validation, authorization (org, workflow-ref, repos scope), GitHub App token creation, bot-role lookup (POST /v1/bot-role), and status endpoints. It compiles to both native (!js) and WASM (js) targets. It is a nested module with a local-only replace; packages reachable from pkg/behaviourtest must not import it (including mintconsts). See Go Code.

Entrypoints (internal/mint, cmd/mint, cmd/mint-wasm) are thin. They construct the appropriate OIDCVerifier + PEMAccessor for their platform, call NewHandler(pemAccessor, verifier), and serve HTTP. No mint logic lives in entrypoints.

The CF Worker TypeScript adapter (workersrc/src/index.ts) handles I/O only — Worker secrets, host fetch, Fetch Request/Response mapping. It calls mintcoreInitMint and mintcoreHandleFetch registered on globalThis by the Go WASM bridge in cmd/mint-wasm.

Platform accessors ​

Two package-internal accessor functions abstract platform I/O inside mintcore:

mintEnv(key string) string ​

Reads environment/configuration values.

Build tagImplementationFile
!js (native)os.Getenv(key)env.go
js (WASM)JS callback registered via RegisterEnvenv_js.go

NewHandler reads all configuration variables (ROLE_APP_IDS, ALLOWED_WORKFLOW_FILES, PER_REPO_WIF_REPOS, WORKFLOW_HOST_REPOS, CUSTOM_ROLE_PERMISSIONS, ALLOWED_ROLES) via mintEnv at construction time.

mintHTTP(req *http.Request) (*http.Response, error) ​

Executes outbound HTTP requests (GitHub API calls).

Build tagImplementationFile
!js (native)Cached *http.Client with 30s timeouthttp_client.go
js (WASM)JS fetch callback registered via RegisterHTTPhttp_client_js.go

mintHTTP(req) is called at request time inside handler and verifier files (github.go, jwks_verifier.go, sts_verifier.go, gcp_pem.go, tracing.go). It is never called during construction.

Registration on WASM ​

The CF Worker calls RegisterEnv and RegisterHTTP once during mintcoreInitMint — before NewHandler reads configuration. Only cmd/mint-wasm/main.go calls these functions; native entrypoints do not need to (and cannot — the functions are behind //go:build js).

Who may call RegisterEnv / RegisterHTTP ​

Only the WASM entrypoint (cmd/mint-wasm). These are one-shot registration functions, not something load sites or tests should call. Tests use t.Setenv for environment variables and SetMintHTTPForTest(t, fake) for HTTP overrides.

Load-site construction ​

Each entrypoint builds the verifier and PEM accessor appropriate for its platform, then passes them into NewHandler:

go
// cmd/mint (standalone)
verifier, _ := mintcore.NewJWKSVerifier(mintcore.JWKSVerifierConfig{
    IssuerURL: "https://token.actions.githubusercontent.com",
})
pemAccessor, _ := mintcore.NewFilesystemPEMAccessor(os.Getenv("PEM_DIR"))
handler, _ := mintcore.NewHandler(pemAccessor, verifier)
go
// internal/mint (GCF)
verifier, _ := mintcore.NewSTSVerifier(mintcore.STSVerifierConfig{
    GCPProjectNum:      gcpProjectNum,
    WIFPoolName:        wifPoolName,
    DefaultWIFProvider: defaultWIFProvider,
    PerRepoWIFRepos:    perRepoWIFRepos,
})
pemAccessor := mintcore.NewGCPSecretPEMAccessor(gcpProjectNum)
handler, _ := mintcore.NewHandler(pemAccessor, verifier)
go
// cmd/mint-wasm (CF Worker)
mintcore.RegisterEnv(getEnvFn)
mintcore.RegisterHTTP(fetchFn)
pemAccessor, _ := mintcore.NewHostPEMAccessor(pemFn)
verifier, _ := mintcore.NewJWKSVerifier(mintcore.JWKSVerifierConfig{
    IssuerURL: "https://token.actions.githubusercontent.com",
})
handler, _ := mintcore.NewHandler(pemAccessor, verifier)

Key properties:

  • No getEnv or HTTPDoer parameters on NewHandler. The handler reads config via mintEnv and makes HTTP calls via mintHTTP internally.
  • No factories passed into NewHandler. The handler receives constructed interfaces, not factory functions.
  • Verifier configs use plain data (strings, maps, booleans). The OIDC audience is the compile-time constant mintconsts.OIDCAudience, applied inside verifier constructors — load sites do not pass it.

WASM-unsafe patterns ​

These patterns increase the WASM binary size or cause deadlocks in the Worker runtime. Do not use them in internal/mintcore/.

Closures and function values in config structs ​

go
// BAD: closure captures http.Client's entire dependency graph
type VerifierConfig struct {
    DoHTTP func(*http.Request) (*http.Response, error)
}

Closure capture pulls the entire dependency graph of the captured variables into the WASM binary. A func(string) string for env lookups or a func(*http.Request) (*http.Response, error) for HTTP inflates the binary even if the function body is trivial, because the compiler must include all transitively reachable types.

mintEnv / mintHTTP inside verifier constructors ​

go
// BAD: constructor-time env/HTTP pulls dependencies into the call
// graph at init, and on WASM the JS callbacks may not be registered yet
func NewJWKSVerifier() (*JWKSVerifier, error) {
    issuer := mintEnv("OIDC_ISSUER")  // constructor-time env read
    resp, _ := mintHTTP(req)           // constructor-time HTTP call
    // ...
}

Verifier constructors run during mintcoreInitMint. Using mintHTTP in a constructor tries to make HTTP calls before the event loop is available, risking deadlocks. Using mintEnv in a constructor is less dangerous but couples construction to environment state rather than explicit config.

The shipped pattern: constructors take plain config structs → mintHTTP(req) is called at request time in verifier and handler methods.

WASM-safe patterns ​

These are the patterns used in shipped code. Follow them when modifying mintcore.

Register* once at bootstrap ​

On WASM, the CF Worker calls RegisterEnv(fn) and RegisterHTTP(fn) once during mintcoreInitMint, before constructing the handler. Native platforms do not call these — mintEnv delegates to os.Getenv and mintHTTP uses a cached *http.Client automatically via build tags.

Constructors take plain data ​

Verifier configs contain only strings, maps, and booleans — no function values, no interfaces, no closures:

go
type JWKSVerifierConfig struct {
    IssuerURL string
}

type STSVerifierConfig struct {
    GCPProjectNum      string
    WIFPoolName        string
    DefaultWIFProvider string
    PerRepoWIFRepos    map[string]bool
    // optional fields...
}

Request-time mintHTTP(req) ​

HTTP calls happen at request time, not construction time. Files that call mintHTTP include:

  • github.go — GitHub App installation token creation
  • bot_role.go — GitHub App lookup and installation verification for POST /v1/bot-role
  • jwks_verifier.go — JWKS key fetching
  • sts_verifier.go — GCP STS token exchange
  • gcp_pem.go — GCP Secret Manager access (AccessGCPSecret is the one exception to request-time calls: the native-only GCF load site calls it once in init() to read the collector headers)
  • tracing.go — OTLP span export (end of request, bounded timeout)

This is intentional: on WASM, the JS event loop must be free for Promises to settle. Constructor-time HTTP would deadlock.

Handler reads config via mintEnv ​

NewHandler reads ROLE_APP_IDS, PER_REPO_WIF_REPOS, and other configuration variables once via mintEnv at construction time. This works because RegisterEnv has already been called by the time NewHandler runs — the pattern is: register → construct → serve.

PEM remains injected ​

PEM access uses the PEMAccessor interface at the NewHandler boundary.

PEM implementationPlatformStorage
GCPSecretPEMAccessorGCFGCP Secret Manager
FilesystemPEMAccessorStandaloneLocal directory (PEM_DIR)
HostPEMAccessorCF Worker (WASM)Worker secrets via JS callback

Each load site constructs the appropriate accessor and passes it to NewHandler. Tests can pass any PEMAccessor implementation.

Interfaces vs accessors ​

Mintcore uses two patterns for dependency injection:

Package-internal accessors (mintEnv, mintHTTP) for production environment lookups and HTTP — these are implementation details of mintcore, not part of its public API. They exist because every file in mintcore needs env/HTTP access, and threading interfaces through every function signature would be impractical. Tests override them with t.Setenv and SetMintHTTPForTest(t, fake).

Interfaces (OIDCVerifier, PEMAccessor) at the NewHandler boundary — these are the public contract between load sites and mintcore. Load sites choose which implementation to construct; mintcore does not know or care which platform it is running on.

go
type OIDCVerifier interface {
    Verify(ctx context.Context, rawToken string) (*Claims, error)
}

type PEMAccessor interface {
    AccessPEM(ctx context.Context, role string) ([]byte, error)
}

Tracing ​

tracing.go implements mint request tracing without the OpenTelemetry Go SDK, whose dependency graph would inflate the WASM binary. It provides W3C trace-context extraction, span recording, and OTLP/HTTP JSON export through mintHTTP, so it works unchanged on every target.

  • Load-site wiring: load sites build a *Tracer with NewTracer(TracingConfig{...}) (plain data only) and call handler.SetTracer(t) before serving. A load site that routes some requests outside Handler (the standalone fallback proxy) instead wraps its whole handler chain with t.Wrap(next), which starts the server span; Handler reuses it. Code outside mintcore forwards the trace with TraceHeaders(ctx) and records failures with RecordSpanError(ctx, stage, err). NewTracer returns nil when the endpoint is empty, and a nil tracer is a no-op. cmd/mint reads OTEL_EXPORTER_OTLP_TRACES_ENDPOINT / OTEL_EXPORTER_OTLP_ENDPOINT (resolved with ResolveOTLPTracesEndpoint) and collector headers from MINT_OTLP_HEADERS_FILE (parsed with ParseOTLPHeaders). cmd/mint-wasm calls NewStampedTracer with the endpoint and deployment stamped into main.otlpTracesEndpoint / main.traceDeployment via -ldflags (traceLDFlags in internal/dispatch/cf) and the Worker secret named by main.otlpHeadersSecret (default MINT_OTLP_HEADERS; durable deploys stamp a unique per-deploy name), read through the getEnv callback. A bad configuration is logged and leaves tracing off rather than failing mintcoreInitMint. Header values are never stamped. The GCF load site (internal/mint) calls NewStampedTracer from buildTracer with the endpoint and deployment stamped into trace_consts.go (writeTraceConstsGoToZip in internal/dispatch/gcf). When otlpHeadersSecret is stamped, it reads the collector headers once in init() from that Secret Manager secret (fullsend-mint-otlp-headers-<random>, a new ID per deploy so a revision's endpoint and headers can never be mismatched) through AccessGCPSecret, with a 5s timeout. An unreadable secret or a bad configuration is logged and leaves tracing off; it never fails init().
  • Adding spans: call startSpan(ctx, name, stage) and use the returned context for downstream calls. Call recordError(err) and then end(). Spans are nil-safe, so call sites need no enabled checks. The stage becomes fullsend.mint.failure_stage on the server span when that span is the first to record an error.
  • Never record secrets: do not put tokens, JWTs, PEM bytes, collector headers, or raw upstream response bodies in span attributes. Error text passes through safeErrorMessage, which bounds and redacts it, but do not rely on that redaction as the primary control.
  • Fail-open: export has a bounded timeout (TracingConfig.ExportTimeout, default 2s). By default it runs synchronously at the end of ServeHTTP, so serverless runtimes do not drop spans after the response; the net/http server holds the buffered response until it finishes. Long-lived load sites (the standalone mint) set TracingConfig.AsyncExport so export runs in a background goroutine that does not delay the response, and call Tracer.Shutdown(ctx) after the server stops to drain it. Never let an export error affect the response.
  • No redirects: export marks its request with withNoRedirect. The native mintHTTP honors it. The js mintHTTP passes it as the fifth noRedirect argument of the host fetch callback, and the Worker adapter then fetches with redirect: "manual".
  • Bounded fetch: for noRedirect requests, the js mintHTTP also passes the remaining context deadline as a sixth timeoutMs argument. The Worker adapter aborts the underlying fetch at that deadline (never later than its 20 s host-fetch budget), so a stalled collector cannot keep fetches and payloads alive after the exporter releases its export slot. The adapter discards the collector's response body unread.

Embed sync and make wasm-build ​

Embedded copies for GCF ​

Mintcore files are embedded for Cloud Function deployment at internal/dispatch/gcf/mintsrc/mintcore/*.embed. When changing any file in internal/mintcore/, sync it to the corresponding .embed file. The lint-mint-embed-sync pre-commit hook enforces this.

When adding a new file to internal/mintcore/:

  1. Create the .embed copy in internal/dispatch/gcf/mintsrc/mintcore/.
  2. If the file is included in the GCF bundle (no build tag, or //go:build !js), add it to embeddedMintFiles in internal/dispatch/gcf/provisioner.go and to the go:embed directive.
  3. If the file is NOT included in the GCF bundle (Worker-only //go:build js files, or standalone-mint-only files), add it to gcfSkip in TestEmbeddedMintSource_MatchesOriginal in provisioner_test.go.

Current gcfSkip entries: env_js.go, fetch_js.go, http_client_js.go, pem_js.go (all //go:build js), and file_pem.go (standalone-mint-only, //go:build !js).

Build metadata stamping ​

The mint Cloud Function receives version and commit metadata via deploy-time source stamping, not runtime environment variables. The provisioner writes mintcore/version.go into the function source zip at bundle time (writeVersionGoToZip in internal/dispatch/gcf/provisioner.go) with version and commit values baked in. The same technique stamps GitHub status-auth config into mintcore/status_consts.go, and the tracing endpoint, deployment identity and headers secret ID into the function package's trace_consts.go (internal/mint/trace_consts.go on disk, embedded as mintsrc/trace_consts.go.embed, with tracing disabled). On-disk copies of these generated files are skipped during bundling so stale source cannot overwrite the stamped values. Collector header values are never stamped: they live in Secret Manager.

The Cloudflare Worker follows the same principle: wasmLDFlags in internal/dispatch/cf/provisioner.go stamps mintcore.Version, mintcore.Commit, and status-auth config into the WASM binary via -ldflags at compile time rather than injecting runtime env vars. traceLDFlags adds the tracing endpoint and deployment identity the same way. A traces endpoint always forces a rebuild of a pre-staged mintcore.wasm, because a staged binary does not carry the stamp.

This keeps metadata in lockstep with the deployed code. Follow this pattern for any future build metadata the function needs — never use environment variables for values that must stay in lockstep with the deployed source.

WASM binary size gate ​

The compiled WASM binary must stay within Cloudflare Workers size limits. Run make wasm-build after any change to internal/mintcore/ or cmd/mint-wasm/ to verify:

TierGzip limitMakefile behavior
Workers Free3 MBWarning
Workers Paid10 MBHard fail

Keep the WASM dependency graph minimal. The Go WASM compiler includes the transitive closure of all referenced packages — small-looking changes can cause large binary size increases. Avoid importing heavy packages (net/http, crypto/x509, cloud SDKs) in files that are WASM-compiled. Use build tags (//go:build js / //go:build !js) to isolate platform-specific implementations.