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.
| Target | Load site | Verifier | PEM accessor |
|---|---|---|---|
| GCF (Cloud Function) | internal/mint | NewSTSVerifier | NewGCPSecretPEMAccessor |
| Cloudflare Worker | cmd/mint-wasm (mintcoreInitMint) | NewJWKSVerifier | NewHostPEMAccessor |
| Standalone | cmd/mint | NewJWKSVerifier | NewFilesystemPEMAccessor |
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 tag | Implementation | File |
|---|---|---|
!js (native) | os.Getenv(key) | env.go |
js (WASM) | JS callback registered via RegisterEnv | env_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 tag | Implementation | File |
|---|---|---|
!js (native) | Cached *http.Client with 30s timeout | http_client.go |
js (WASM) | JS fetch callback registered via RegisterHTTP | http_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:
// 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)// 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)// 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
getEnvorHTTPDoerparameters onNewHandler. The handler reads config viamintEnvand makes HTTP calls viamintHTTPinternally. - 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
// 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
// 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:
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 creationbot_role.go— GitHub App lookup and installation verification forPOST /v1/bot-rolejwks_verifier.go— JWKS key fetchingsts_verifier.go— GCP STS token exchangegcp_pem.go— GCP Secret Manager access (AccessGCPSecretis the one exception to request-time calls: the native-only GCF load site calls it once ininit()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 implementation | Platform | Storage |
|---|---|---|
GCPSecretPEMAccessor | GCF | GCP Secret Manager |
FilesystemPEMAccessor | Standalone | Local directory (PEM_DIR) |
HostPEMAccessor | CF 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.
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
*TracerwithNewTracer(TracingConfig{...})(plain data only) and callhandler.SetTracer(t)before serving. A load site that routes some requests outsideHandler(the standalone fallback proxy) instead wraps its whole handler chain witht.Wrap(next), which starts the server span;Handlerreuses it. Code outsidemintcoreforwards the trace withTraceHeaders(ctx)and records failures withRecordSpanError(ctx, stage, err).NewTracerreturnsnilwhen the endpoint is empty, and a nil tracer is a no-op.cmd/mintreadsOTEL_EXPORTER_OTLP_TRACES_ENDPOINT/OTEL_EXPORTER_OTLP_ENDPOINT(resolved withResolveOTLPTracesEndpoint) and collector headers fromMINT_OTLP_HEADERS_FILE(parsed withParseOTLPHeaders).cmd/mint-wasmcallsNewStampedTracerwith the endpoint and deployment stamped intomain.otlpTracesEndpoint/main.traceDeploymentvia-ldflags(traceLDFlagsininternal/dispatch/cf) and the Worker secret named bymain.otlpHeadersSecret(defaultMINT_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 failingmintcoreInitMint. Header values are never stamped. The GCF load site (internal/mint) callsNewStampedTracerfrombuildTracerwith the endpoint and deployment stamped intotrace_consts.go(writeTraceConstsGoToZipininternal/dispatch/gcf). WhenotlpHeadersSecretis stamped, it reads the collector headers once ininit()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) throughAccessGCPSecret, with a 5s timeout. An unreadable secret or a bad configuration is logged and leaves tracing off; it never failsinit(). - Adding spans: call
startSpan(ctx, name, stage)and use the returned context for downstream calls. CallrecordError(err)and thenend(). Spans are nil-safe, so call sites need no enabled checks. Thestagebecomesfullsend.mint.failure_stageon 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 ofServeHTTP, 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) setTracingConfig.AsyncExportso export runs in a background goroutine that does not delay the response, and callTracer.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 nativemintHTTPhonors it. ThejsmintHTTPpasses it as the fifthnoRedirectargument of the host fetch callback, and the Worker adapter then fetches withredirect: "manual". - Bounded fetch: for
noRedirectrequests, thejsmintHTTPalso passes the remaining context deadline as a sixthtimeoutMsargument. 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/:
- Create the
.embedcopy ininternal/dispatch/gcf/mintsrc/mintcore/. - If the file is included in the GCF bundle (no build tag, or
//go:build !js), add it toembeddedMintFilesininternal/dispatch/gcf/provisioner.goand to thego:embeddirective. - If the file is NOT included in the GCF bundle (Worker-only
//go:build jsfiles, or standalone-mint-only files), add it togcfSkipinTestEmbeddedMintSource_MatchesOriginalinprovisioner_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:
| Tier | Gzip limit | Makefile behavior |
|---|---|---|
| Workers Free | 3 MB | Warning |
| Workers Paid | 10 MB | Hard 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.
