# 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, 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](go-code.md).

**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`:

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

```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:

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