# Architecture

A map for contributors: the polyglot split, the components, the trust flow, both
grant flows module-by-module, the on-chain gate + cache, and the client registry
seam. For the product-level pitch see `docs/index.md`; for the full design
rationale see `docs/superpowers/specs/2026-07-21-passport-web3-oauth-design.md`
(the "spec"); for a code-level map of each unit see `crates.md`.

## The polyglot split (AD-1)

> On-chain = **Solidity** (the EVM's native language). Everything off-chain =
> **Rust-first**. **TypeScript only at the SDK edge** (the relying-app SDK,
> because web apps are JS/TS). — spec §12, AD-1

This is not a style preference, it is the trust boundary:

- **On-chain = trust + money.** `GrantorRegistry` (Solidity, `contracts/`) is
  the one place that holds value and the one place that says which issuer keys
  are legitimate. It is deliberately small, immutable where possible, and
  reviewed like money-handling code (heavy Foundry invariant/fuzz coverage —
  see `contracts/test/GrantorRegistry.invariant.t.sol`).
- **Off-chain = auth, in Rust.** The issuer (`grantor-issuer`) and the shared
  verify core (`grantor-verify`) are Rust. A login never writes to the chain;
  it only *reads* the registry. AD-4 picked Poem (`poem-openapi`) for the
  issuer's typed HTTP handlers + OpenAPI; AD-5 put JWT verification and the
  on-chain trust check in one shared Rust crate so there is a single source of
  truth for what "a valid, trusted Grantor token" means.
- **TypeScript only at the edge.** The escape hatch for a Rust *contract*
  exists (Arbitrum Stylus, spec AD-1) but is not used by default — the default
  on-chain language stays Solidity. TS shows up only where the runtime forces
  it: browser wallet interaction (`assets/grantor-login.js`, viem +
  WalletConnect) and the future relying-app SDK, which per AD-5 will wrap the
  same `grantor-verify` logic compiled to WASM rather than reimplementing it.

## Components

```
                 reads paid/registered (public RPC, no operator infra)
   ┌───────────────┐ ───────────────────────────────────► ┌──────────────────┐
   │ grantor-         │                                       │ GrantorRegistry    │
   │ issuer (Rust)  │ ◄─────────────────────────────────── │  (Solidity, L2)  │
   │  Poem HTTP     │        isActive / isAgentKey          │  trust anchor +  │
   └───────┬────────┘                                       │  billing         │
           │ mints ES256 JWT                                └──────────────────┘
           ▼                                                       ▲
   end-user / app / agent                                          │ isActive(keyId)
           │                                                       │ (client-side, any RPC)
           ▼ verifies via /jwks + on-chain check                   │
   ┌────────────────────┐                                          │
   │ grantor-verify       │ ─────────────────────────────────────────┘
   │ (Rust; used by the │
   │  issuer directly,  │
   │  → WASM for the    │
   │  TS SDK later)     │
   └────────────────────┘
```

- **`contracts/` — `GrantorRegistry`.** The canonical trust anchor and billing
  engine (`contracts/src/GrantorRegistry.sol`). Maps an issuer `keyId` to a
  tenant, derives tenant `Status` (`Active`/`Grace`/`Inactive`) from
  `periodEnd`/`graceWindow`/`now` (never stored stale — `status()`,
  `GrantorRegistry.sol:95`), and exposes the one view the rest of the system
  depends on: `isActive(bytes32 keyId) → bool` (`GrantorRegistry.sol:105`).
  Also tracks the multiwallet agent-key registry (`isAgentKey`, a public
  mapping) and per-tenant usage caps (apps/issuer keys/agents) and a
  time-boxed free-trial window.
- **`crates/grantor-verify`.** Pure, chain-free Rust: `verify_jwt` (ES256 +
  `exp`/`aud`), `issuer_key_id` (RFC 7638 JWK thumbprint — the exact `[u8; 32]`
  value `GrantorRegistry` stores as `keyId`), the `TrustSource` trait, and
  `verify` (JWT check + thumbprint + trust check, composed). No network, no
  `alloy` dependency — deliberately, so it can later compile to WASM (AD-5) for
  the TS relying-app SDK.
- **`crates/grantor-issuer`.** The OIDC/OAuth2 issuer: holds the ES256 signing
  key, publishes `/jwks`, mints JWTs, and gates every mint on the on-chain
  check. Depends on `grantor-verify` (for `issuer_key_id`, and its unit tests
  round-trip through `verify_jwt`) plus `alloy` (chain reads), `poem` (HTTP),
  and `siwe` (EIP-4361). See `crates.md` for the module breakdown.

## The trust flow

Every mint and every verification reduces to the same question the registry
answers: **is this issuer key registered to a tenant that is paid up?**

1. **Mint side (issuer).** Before minting, `grantor-issuer` calls its
   `TrustGate` (`crates/grantor-issuer/src/gate.rs`), which ultimately reads
   `GrantorRegistry.isActive(keyId)` over RPC. `keyId` is always *this
   issuer's own* key (`AppState.key.key_id`, `crates/grantor-issuer/src/state.rs:21`).
   If the tenant isn't Active/Grace, the issuer refuses to mint
   (`403 {"error":"inactive"}`).
2. **Verify side (relying app).** A relying app fetches `/jwks`
   (`crates/grantor-issuer/src/http/discovery.rs`), verifies the JWT
   signature/claims the standard OIDC way, and — for revocation awareness
   beyond the token TTL — can independently read `isActive(kid)` from any
   public RPC via `grantor-verify::verify` + a `TrustSource` impl. No call to
   any operator-run service is required; the registry is the only shared
   dependency, and it's public.

The `keyId` is the hinge: it is simultaneously the JWKS `kid`
(`token.rs::IssuerKey::key_id_hex`, embedded in the JWT header) and the
on-chain registry key (`issuerKeyToTenant` in `GrantorRegistry.sol:52`),
computed identically on both sides by `issuer_key_id` (RFC 7638 thumbprint).

## Request flow — Grant A (Authorization Code + SIWE, humans)

| Step | HTTP | Module(s) |
|---|---|---|
| 1. App redirects the browser | `GET /authorize` | `http/authorize.rs::authorize` — requires PKCE S256, checks `ClientStore::is_valid_redirect`, renders the HTML page |
| 2. Issuer serves the connect-and-sign page | (response) | `http/page.rs::render` (all 5 reflected query params HTML-escaped) + `assets/grantor-login.js` (viem: builds + signs the EIP-4361 message in-browser) |
| 3. Wallet signs; page posts back | `POST /authorize/callback` | `http/authorize.rs::callback` — parses the SIWE message (`siwe` crate), checks `domain` against `cfg.domain`, verifies the EIP-191 signature *before* burning the SIWE nonce (`AppState::use_nonce`), calls `TrustGate::is_active`, re-checks the redirect allowlist, derives `sub` via `token::pairwise_sub`, stores a one-time `AuthCode` (`state.rs::AuthCode`, ~60s TTL) via `AppState::put_code`, and `302`s to `redirect_uri?code=…&state=…` |
| 4. App exchanges the code | `POST /token` (`grant_type=authorization_code`) | `http/token.rs::token` dispatches to `http/token_code.rs::authorization_code` — takes the code (`AppState::take_code`, single-use + expiry), requires `redirect_uri` to match, verifies PKCE (`base64url(sha256(code_verifier)) == stored challenge`), re-checks `TrustGate::is_active`, mints via `token::IssuerKey::mint` |
| 5. App verifies the JWT | — | `grantor-verify::verify_jwt` / `verify`, against `/jwks` |

## Request flow — Grant C (Client Credentials, agents)

| Step | HTTP | Module(s) |
|---|---|---|
| 1. Agent builds + signs the challenge | (off-band) | Canonical message from `http/assertion.rs::build_message` (`domain`/`tenant`/`nonce`/`issued-at`), signed EIP-191 |
| 2. Agent calls the token endpoint | `POST /token` (`grant_type=client_credentials`) | `http/token.rs::client_credentials` |
| 3. Issuer validates | — | `ClientStore::is_allowed_audience` (audience must be pre-registered) → `http/assertion.rs::verify` (recovers the signer via `login::recover_login_address` *before* burning the nonce — anti-grief; checks the ±120s skew) → `TrustGate::is_agent_key(tenant_id, signer)` (on-chain multiwallet check) → `TrustGate::is_active(issuer key_id)` |
| 4. Issuer mints | — | `sub` = `token::pairwise_sub` of the agent address; `token::IssuerKey::mint` (no `nonce` claim for Grant C) |
| 5. Resource server verifies | — | `grantor-verify`, against `/jwks` |

Both flows pause at exactly one shared checkpoint — `TrustGate::is_active` —
where billing, revocation, and the freeloader-fork defense all live (spec §5).

## The on-chain gate + cache

```
TrustGate (trait, gate.rs)           — object-safe async trait: is_active, is_agent_key
  └─ ChainGate (production impl)     — gate.rs
       ├─ is_active  → CachedIsActive<AlloyReader, SystemClock>   (cache.rs)
       │                    └─ AlloyReader::is_active              (chain.rs)
       └─ is_agent_key → AlloyReader::is_agent_key (uncached)       (chain.rs)
```

- **`AlloyReader`** (`crates/grantor-issuer/src/chain.rs`) wraps an RPC URL +
  registry address and calls `GrantorRegistry` through `alloy`'s `sol!`
  bindings (`isActive(bytes32)`, `isAgentKey(uint256,address)`). It is async by
  nature (an RPC round-trip), which is why it's a distinct type from the
  synchronous `grantor_verify::TrustSource`.
- **`CachedIsActive<S, C>`** (`cache.rs`) wraps `is_active` reads with a
  short-TTL cache and the fail-closed semantics from spec §10: a fresh hit is
  served without an RPC call (bounding revocation latency to `ttl_secs`); on a
  transient RPC failure it serves the last good value while within
  `staleness_secs`; past that bound (or with no cached value at all) it
  fails closed — returns `false`, i.e. "not trusted." `Clock` is injectable
  (`SystemClock` in production) so this is unit-tested without a live chain.
- **`ChainGate`** (`gate.rs`) is the production `TrustGate`: `is_active` goes
  through `CachedIsActive`; `is_agent_key` is read live, uncached, because
  agent-key registration changes at admin-tx frequency, not per-login
  frequency. Both readers share one underlying RPC/registry (`AlloyReader` is
  `Clone`).
- `gate.rs`'s own test (`is_active_fails_closed_when_rpc_is_down`) exercises
  the cold-cache + RPC-down case end-to-end through `ChainGate`.

## The client registry seam (`ClientStore`)

`crates/grantor-issuer/src/clients.rs` defines a second, deliberately
**off-chain** allowlist: which relying-app `client_id → redirect_uris` (Grant
A) and which resource `audience`s (Grant C) this issuer will actually issue
tokens for.

```rust
pub trait ClientStore: Send + Sync {
    fn is_valid_redirect(&self, client_id: &str, redirect_uri: &str) -> bool;
    fn is_allowed_audience(&self, audience: &str) -> bool;
}
```

Why off-chain: `GrantorRegistry.registerApp` only *counts* apps toward a
tier's usage cap (`GrantorRegistry.sol:261`) — it never stores redirect URIs
or audience strings. Exact-match redirect/audience validation is what closes
the open-redirect and audience-spoofing gaps (see
`docs/superpowers/specs/2026-07-22-passport-client-registry-design.md`), and
that's a lookup problem, not a trust/money problem, so there's no reason to
pay gas for it or route it through the chain's block-time latency.

`FileClientStore` is the only implementation today: it loads a TOML file
(`CLIENTS_FILE`) at startup and is **fail-closed** — `main.rs` propagates
`FileClientStore::load`'s error with `?`, so a missing or malformed file
refuses to boot rather than booting with an empty (or worse, permissive)
allowlist. Being behind a trait is the point: a hosted, multi-tenant service
can later swap in a `DbClientStore` without touching any HTTP handler. (Known
current limitation, tracked in the build ledger: the registry is global per
issuer process — correct for today's single-tenant deploy, but it
will need to become tenant-scoped for a future hosted service.)

## See also

- `crates.md` — per-module code map (what/API/depends-on) for every unit named
  above.
- `docs/guide/concepts.md` — the product-facing mental model (same trust flow,
  written for integrators rather than contributors).
- `docs/llms-full.txt` — the full machine reference (endpoints, claims, error
  codes, config).
