Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Detailed guidance lives in `docs/contributing/` and topic-specific guides under
| File | When to read |
|------|-------------|
| [Go Code](docs/contributing/go-code.md) | Changing Go code under `cmd/` or `internal/` — covers mint sync, coverage, vet, e2e tests, concurrency testing, suite-timeout policy, WASM binary size constraints, and preferring `go run` for the CLI |
| [Mintcore Architecture](docs/contributing/mintcore.md) | Changing `internal/mintcore/`, `cmd/mint-wasm/`, `cmd/mint/`, or `internal/mint/` — covers platform accessors, load-site construction, and WASM-safe wiring |
| [Behaviour Testing](docs/guides/dev/behaviour-testing.md) | Modifying behaviour test repo provisioning, fork handling, or workflow dispatch — covers forge API constraints (`auto_init`, fork name derivation, Actions readiness, CI timeout budgeting) |
| [Workflow Contracts](docs/contributing/workflow-contracts.md) | Changing GHA reusable workflows — covers dispatch sync, secret/input threading across installation-mode chains, and review rules |
| [Shell Scripting](docs/contributing/shell-scripting.md) | Writing or reviewing shell scripts — covers `gh api --paginate` pitfalls, jq patterns, and stdout contamination in command substitution |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ Identity is not the same as trust. An agent's identity lets it authenticate to e
- Cross-org mint authorization: workflows may request tokens for a different org via optional `target_org` when the target org installs the role App and sets `FULLSEND_FOREIGN_<role>_REPOS` ([ADR 0060](ADRs/0060-cross-org-mint-authorization-via-org-variables.md)). Repo-level `FULLSEND_FOREIGN_<role>_REPOS` variables enable per-repo foreign grants (scoped to the specific target repo) and intra-org cross-repo access for per-repo callers, with disjoint authorization boundaries from org-level grants — repo-level for repo-scoped requests, org-level for installation-wide requests ([ADR 0083](ADRs/0083-repo-level-foreign-allow-list.md)).
- Mint `repos` scope: foreign mints with `repos: ["*"]` require an org-level FOREIGN grant; foreign mints with specific repos require per-repo FOREIGN grants on each requested repo (org-level grants are not consulted for repo-scoped requests). Per-repo callers (repo in `PER_REPO_WIF_REPOS`) must list exactly the requesting repository unless authorized by repo-level FOREIGN grants for other repos. Per-org callers (org in `ALLOWED_ORGS`, repo not in `PER_REPO_WIF_REPOS`) get org-mode shapes: `.fullsend` callers may use any non-empty validated list; other callers may use `[.fullsend]` or `{self,.fullsend}`. Same-org installation-wide tokens are denied ([ADR 0077](ADRs/0077-mint-repos-scope-hardening.md), simplified in [ADR 0078](ADRs/0078-simplified-mint-authorization-policy.md)).
- Workflow-host allow-list: `WORKFLOW_HOST_REPOS` controls which repos may host workflows calling the mint for per-repo and public-mode callers (default: `fullsend-ai/fullsend`). Per-org callers hard-wire to `{org}/.fullsend` and upstream. Public mode is not special-cased — it uses the same per-repo validation path with `WORKFLOW_HOST_REPOS` and the basename allowlist. This separates caller enrollment from workflow-host trust ([ADR 0082](ADRs/0082-workflow-host-allow-list.md)).
- Standalone mint deployment: `cmd/mint/` provides a self-contained HTTP server that uses direct JWKS verification and filesystem PEM storage instead of GCP infrastructure. It shares the `internal/mintcore/` library with the GCF mint and adds support for custom role permissions and a fallback proxy to an upstream mint. Custom role permissions live in mintcore (not `cmd/mint/`) so that `RolePermissionsFor`, `HasRole`, and `CreateInstallationToken` return a unified view without callers needing to distinguish built-in from custom roles. The GCF mint never calls `RegisterCustomRolePermissions`, so the code is inert there. See the [standalone mint guide](guides/infrastructure/standalone-mint.md).
- Standalone mint deployment: `cmd/mint/` provides a self-contained HTTP server that uses direct JWKS verification and filesystem PEM storage instead of GCP infrastructure. It shares the `internal/mintcore/` library with the GCF mint and adds support for custom role permissions and a fallback proxy to an upstream mint. Custom role permissions live in mintcore (not `cmd/mint/`) so that `RolePermissionsFor`, `HasRole`, and `CreateInstallationToken` return a unified view without callers needing to distinguish built-in from custom roles. The GCF mint never calls `RegisterCustomRolePermissions`, so the code is inert there. See the [standalone mint guide](guides/infrastructure/standalone-mint.md). For mintcore internals (platform accessors, load-site construction, WASM constraints), see the [mintcore contributor guide](contributing/mintcore.md).
- Hosted public community mint: steady-state deployment on Cloudflare Workers (JWKS + WAF + single ops console), with interim GCP Cloud Function acceptable until the Worker port is production-ready. Trust policy (`ALLOWED_ORGS=*`, upstream-only workflow provenance) is in [ADR 0059](ADRs/0059-public-mint-mode-with-wildcard-allowlists.md); deployment, edge security, monitoring, and phasing are in [ADR 0068](ADRs/0068-public-community-mint-architecture.md). Enrollment is installing the shared Apps—no per-org mint env registration ([#1145](https://github.com/fullsend-ai/fullsend/issues/1145)).
- Named privilege levels: each role defines ordered named levels (`read`, `write`), where each level's permissions are a superset of preceding levels. `read` for built-in roles is derived by downgrading `*:write` permissions to their `read` counterparts. The mint API accepts an optional `level` field (default `read`); omitting it produces narrower tokens than the current behavior. `write` is defined as the current max permission set for each built-in role. `CUSTOM_ROLE_PERMISSIONS` auto-detects a multi-level JSON shape alongside the existing flat format, with mixed format supported per role. The harness `privilege_levels` flag maps run-stages to levels; omitting it defaults to `write`, preserving backward compatibility for existing harness configurations ([ADR 0073](ADRs/0073-named-mint-privilege-levels.md)).

Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/go-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The `internal/mintcore/` module is shared between the mint and devmint. Its file
**When adding a new file to `internal/mintcore/`:**
1. **Create the `.embed` copy:** Place it in `internal/dispatch/gcf/mintsrc/mintcore/` (required for all files — `lint-mint-embed-sync` enforces this).
2. **Register in `embeddedMintFiles`:** If the file will be included in the GCF bundle — either no build tag (e.g., `config.go`) or `//go:build !js` (e.g., `sts_verifier.go`, `gcp_pem.go`, `wif.go`) — add it to `embeddedMintFiles` in `internal/dispatch/gcf/provisioner.go` and to the `go:embed` directive.
3. **Add to `gcfSkip`:** If the file should NOT be in the GCF bundle — Worker-only files (`//go:build js`) or standalone-mint-only files — add it to the `gcfSkip` map in `TestEmbeddedMintSource_MatchesOriginal` in `provisioner_test.go` instead of `embeddedMintFiles`. The three current entries are `fetch_js.go` and `pem_js.go` (Worker-only, `//go:build js`) and `file_pem.go` (standalone-mint-only, `//go:build !js`).
3. **Add to `gcfSkip`:** If the file should NOT be in the GCF bundle — Worker-only files (`//go:build js`) or standalone-mint-only files — add it to the `gcfSkip` map in `TestEmbeddedMintSource_MatchesOriginal` in `provisioner_test.go` instead of `embeddedMintFiles`. The five current entries are `env_js.go`, `fetch_js.go`, `http_client_js.go`, and `pem_js.go` (Worker-only, `//go:build js`) and `file_pem.go` (standalone-mint-only, `//go:build !js`).

**Dispatch workflows:** See [Workflow Contracts](workflow-contracts.md) for dispatch sync rules, secret/input threading across installation-mode chains, and review instructions.

Expand Down
323 changes: 323 additions & 0 deletions docs/contributing/mintcore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
# 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)
Comment thread
ifireball marked this conversation as resolved.
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.

**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_ORGS`, `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`). 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

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`, `ALLOWED_ORGS`, 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)
}
```

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

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