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
26 changes: 26 additions & 0 deletions .cursor/rules/mermaid-github.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
description: GitHub-compatible Mermaid diagrams in markdown docs
globs: docs/**/*.md
alwaysApply: false
---

# Mermaid on GitHub

GitHub uses a strict Mermaid parser. Diagrams that render in VS Code or mermaid.live may still fail on GitHub.

## Sequence diagrams (`sequenceDiagram`)

- **Never use `;` in arrow labels** — GitHub treats `;` as end-of-statement. Use `,`, ` and `, or ` — ` instead.
- **Multi-line labels:** use `<br/>` inside the label, not a real newline.
- **Special characters:** if a label must contain `;`, `:`, or parentheses, wrap the whole label in double quotes: `A->>B: "Parse SIWE; recover (ecrecover)"`.
- **Participant aliases:** keep short; avoid unquoted `:` inside message text when possible.

## Flowcharts (`flowchart`)

- Prefer quoted edge labels for paths, colons, or braces: `A -->|"POST /session {msg}"| B`.
- Avoid raw newlines inside node text; use `<br/>` or split into separate nodes.

## Before committing doc diagrams

1. Grep mermaid blocks for `;` in `->>` / `-->>` lines.
2. Preview on GitHub (PR) or match [GitHub Mermaid docs](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-diagrams#creating-mermaid-diagrams).
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,6 @@ Figment order (later overrides earlier):
All fields must have sensible defaults so the server starts with zero config.
Secrets (signing keys, service-account creds) come from env / a secret manager,
never from committed files.

`APP_SERVER__EXPOSE` selects the HTTP surface: `all` (dev), `public` (wallet +
JWKS), or `internal` (RS `POST /v1/auth/quota/consume`). See `docs/DEPLOYMENT.md`.
15 changes: 15 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
[server]
host = "127.0.0.1"
port = 3001
# Route surface: "all" (dev), "public" (wallet + JWKS), "internal" (RS quota APIs).
expose = "all"

[telemetry]
# "json" for production, "pretty" for local development.
Expand All @@ -26,6 +28,19 @@ default_scopes = ["ai:invoke", "mint:request", "scan:submit", "profile:read"]
refresh_token_ttl_secs = 604800
issue_refresh_tokens = true

[quota]
enabled = true
window_secs = 86400
paid_scopes = ["ai:invoke", "mint:request"]

[quota.limits.new_wallet]
"ai:invoke" = 10
"mint:request" = 2

[quota.limits.established]
"ai:invoke" = 1000
"mint:request" = 50

[rate_limit]
nonce_per_ip_per_minute = 30

Expand Down
86 changes: 73 additions & 13 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ _Version 0.1 — phase-0 wallet-signature session authority_
2. [Design Principles](#2-design-principles)
3. [The Pattern: OAuth2 AS/RS with a SIWE Grant](#3-the-pattern-oauth2-asrs-with-a-siwe-grant)
4. [Deployment Topology](#4-deployment-topology)
- [4.1 Network exposure (public vs internal)](#41-network-exposure-public-vs-internal)
5. [Build vs. Buy](#5-build-vs-buy)
6. [Component Architecture](#6-component-architecture)
7. [Authentication Flow](#7-authentication-flow)
Expand Down Expand Up @@ -180,6 +181,50 @@ hot part (signature verification) wants to be everywhere. B splits them
correctly; C smears sensitive state across every service. `beacon-relay` becomes
the first *consumer*, not the host.

### 4.1 Network exposure (public vs internal)

One logical service, **two ingress surfaces** on Google Cloud (see
[DEPLOYMENT.md](DEPLOYMENT.md) and [adr/0003-public-internal-deployment.md](adr/0003-public-internal-deployment.md)):

```mermaid
flowchart TB
subgraph Public["trust-relay-public — APP_SERVER__EXPOSE=public"]
JWKS["/.well-known/jwks.json"]
SIWE_EP["/v1/auth/nonce · session · refresh · logout"]
QGET["GET /v1/auth/quota"]
end

subgraph Internal["trust-relay-internal — APP_SERVER__EXPOSE=internal"]
QCON["POST /v1/auth/quota/consume"]
end

subgraph Store["Memorystore Redis (private IP)"]
N[nonces]
Q[quota counters]
V[revocation · refresh]
end

Wallet((Nodle app)) --> SIWE_EP
Wallet --> QGET
RS[Resource servers] -->|user Bearer forwarded| QCON
RS -.->|cache JWKS| JWKS
Public --> Store
Internal --> Store
```

| Surface | Cloud Run ingress | Routes | Authenticated identity (today) |
| --- | --- | --- | --- |
| **Public** | `all` / external HTTPS LB | SIWE ceremony, JWKS, logout, optional quota read | Wallet (SIWE) or user JWT |
| **Internal** | `internal` (VPC only) | `POST /v1/auth/quota/consume`, `/healthz` | **User JWT** in `Authorization` (RS not identified) |

Resource servers **must not** call quota consume on the public URL. They call the
internal service inside the VPC and **must forward the user's bearer token** —
the same `Authorization: Bearer <accessToken>` header the client sent to the RS.
trust-relay does not authenticate the RS on this path in phase 0; it verifies the
forwarded user JWT and decrements quota for that wallet (`sub`). Long term, Rust
RSs should use a **`trust-auth`** layer with optional Redis replica reads instead
of per-request HTTP (M3b).

---

## 5. Build vs. Buy
Expand Down Expand Up @@ -242,7 +287,8 @@ Signing keys: from a secret manager / KMS, never committed.
sequenceDiagram
autonumber
participant C as Client (wallet)
participant P as trust-relay (AS)
participant P as trust-relay public
participant I as trust-relay internal
participant R as Redis
participant RS as Resource Server

Expand All @@ -253,7 +299,7 @@ sequenceDiagram
C->>C: Build EIP-4361 message (domain, uri, chainId, nonce, iat, exp)
C->>C: Sign message with wallet key (EOA)
C->>P: POST /v1/auth/session { message, signature }
P->>P: Parse SIWE; recover address (ecrecover)
P->>P: Parse SIWE, recover address via ecrecover
P->>R: validate nonce (exists, unused, unexpired, ip-bound?)
P->>R: mark nonce used
P->>P: optional wallet-heuristic gate for paid scopes
Expand All @@ -266,26 +312,40 @@ sequenceDiagram
RS->>RS: verify signature with cached JWKS public key
RS->>RS: check exp/nbf/iss/aud, required scope, tier
alt sensitive route (/ai/*, /mint/*)
RS->>R: jti not revoked? wallet not blocked? quota remaining?
RS->>I: POST /quota/consume (user Bearer)
I->>R: decrement quota / check revocation
end
RS-->>C: 200 / 401 / 403 / 429

Note over C,P: Renewal without re-signing (optional)
C->>P: POST /v1/auth/session/refresh { refreshToken }
P->>R: validate + rotate refresh token
P-->>C: { accessToken, expiresIn, refreshToken }

Note over C,RS: Paid route (sensitive)
C->>RS: Bearer JWT on /ai/* or /mint/*
RS->>RS: verify JWT (cached JWKS from public)
RS->>I: POST /v1/auth/quota/consume (internal ingress)
I->>R: decrement wallet quota
I-->>RS: remaining or 429
RS-->>C: 200 / 403 / 429
```

`P` = trust-relay **public** deploy; `I` = trust-relay **internal** deploy (same
image, `APP_SERVER__EXPOSE=internal`). Both share Redis.

### Endpoints

| Endpoint | Method | Purpose |
| --- | --- | --- |
| `GET /healthz` | GET | Liveness. |
| `GET /.well-known/jwks.json` | GET | Public verification keys (JWKS). |
| `GET /v1/auth/nonce` | GET | Issue a single-use nonce. Per-IP rate limited. |
| `POST /v1/auth/session` | POST | Verify SIWE message + signature; issue tokens. |
| `POST /v1/auth/session/refresh` | POST | Rotate the bearer without a new signature. |
| `POST /v1/auth/logout` | POST | Revoke the current bearer (`jti`). |
| Endpoint | Method | Exposure | Purpose |
| --- | --- | --- | --- |
| `GET /healthz` | GET | public + internal | Liveness. |
| `GET /.well-known/jwks.json` | GET | public | Public verification keys (JWKS). |
| `GET /v1/auth/nonce` | GET | public | Issue a single-use nonce. Per-IP rate limited. |
| `POST /v1/auth/session` | POST | public | Verify SIWE message + signature; issue tokens. |
| `POST /v1/auth/session/refresh` | POST | public | Rotate the bearer without a new signature. |
| `POST /v1/auth/logout` | POST | public | Revoke the current bearer (`jti`). |
| `GET /v1/auth/quota` | GET | public | Remaining quota buckets for the bearer wallet. |
| `POST /v1/auth/quota/consume` | POST | **internal** | Decrement a paid scope (RS forwards user JWT). |

---

Expand Down Expand Up @@ -423,11 +483,11 @@ sequenceDiagram
B-->>D: attestation_result (signed by beacon-relay)

D->>T: SIWE re-auth or session upgrade with resources: attestation:result
T->>T: Verify result via beacon-relay JWKS; wallet match
T->>T: Verify result via beacon-relay JWKS, wallet match
T-->>D: attested-tier JWT (tier=attested, att.* claims)

D->>B: POST /v2/scan/ble Bearer attested JWT
B->>B: Verify via trust-relay JWKS; attested middleware path
B->>B: Verify via trust-relay JWKS, attested middleware path
```

- **Phase 2 — hardware-backed wallet key.** Wallet key moves to secure enclave;
Expand Down
141 changes: 141 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Trust Relay — Deployment & network exposure

How to split **public** (wallet + JWKS) and **internal** (resource-server quota) surfaces on Google Cloud, and how the binary selects routes.

## Route exposure matrix

| Endpoint | Method | `public` | `internal` | Caller |
| --- | --- | :---: | :---: | --- |
| `/healthz` | GET | yes | yes | Load balancer / Cloud Run probe |
| `/.well-known/jwks.json` | GET | yes | no | All resource servers (cache) |
| `/v1/auth/nonce` | GET | yes | no | Nodle app (wallet) |
| `/v1/auth/session` | POST | yes | no | Nodle app (wallet) |
| `/v1/auth/session/refresh` | POST | yes | no | Nodle app (wallet) |
| `/v1/auth/logout` | POST | yes | no | Nodle app (wallet) |
| `/v1/auth/quota` | GET | yes | no | App (UX) or RS pre-check |
| `/v1/auth/quota/consume` | POST | **no** | yes | **Resource servers only** |

Set `server.expose` via config or `APP_SERVER__EXPOSE`:

| Value | Use case |
| --- | --- |
| `all` | Local dev, integration tests (default in `config/default.toml`) |
| `public` | Cloud Run service with `ingress: all` (internet-facing) |
| `internal` | Cloud Run service with `ingress: internal` (VPC-only) |

## Google Cloud topology (recommended)

Two **Cloud Run** services from the **same container image**, different env and ingress:

```mermaid
flowchart TB
subgraph Internet
APP[Nodle app]
end

subgraph GCP_VPC["VPC"]
subgraph Public_CR["trust-relay-public"]
P_ING["ingress: all"]
P_ROUTES["expose = public"]
end

subgraph Internal_CR["trust-relay-internal"]
I_ING["ingress: internal"]
I_ROUTES["expose = internal"]
end

REDIS[(Memorystore for Redis)]
AI[AI agent RS]
MINT[NFT mint RS]
end

APP -->|SIWE ceremony| Public_CR
AI -->|VPC + user Bearer| Internal_CR
MINT -->|VPC + user Bearer| Internal_CR
Public_CR --> REDIS
Internal_CR --> REDIS
AI -.->|JWKS over HTTPS| Public_CR
MINT -.->|JWKS over HTTPS| Public_CR
```

### Checklist

1. **Memorystore for Redis** — private IP in the VPC; nonces, refresh tokens, revocation, quotas.
2. **Serverless VPC Access** (or Direct VPC egress) on **both** Cloud Run services.
3. **trust-relay-public** — `APP_SERVER__EXPOSE=public`; external HTTPS LB or Cloud Run `ingress: all`.
4. **trust-relay-internal** — `APP_SERVER__EXPOSE=internal`; `ingress: internal` only ([Cloud Run internal ingress](https://cloud.google.com/run/docs/securing/ingress)).
5. **RS** — call internal URL; **forward** the user's `Authorization: Bearer` JWT (see below).
6. **Signing key** — Secret Manager → env `APP_SIGNING__KEY_SEED_B64`; never in the image.
7. **Optional** — Cloud Armor on public service (rate-limit `GET /nonce`); `roles/run.invoker` on internal for RS service accounts.

## Quota consume: who is authenticated?

`POST /v1/auth/quota/consume` does **not** authenticate the resource server today. trust-relay:

1. Verifies the **end-user access JWT** (same as logout).
2. Checks the token's `scope` includes the requested scope.
3. Decrements the wallet counter in Redis (`sub` from the JWT).

The RS is trusted to **reach the internal URL** (network) and to **forward the
user's bearer token**. Private ingress is the phase-0 control; RS service
identity (mTLS / service JWT) is a follow-up.

### RS MUST forward the user `Authorization` header

When the Nodle app calls a resource server (e.g. `POST /ai/...`), it sends
`Authorization: Bearer <accessToken>`. Before serving the route, the RS **MUST**
copy that header onto the internal trust-relay request:

```http
POST /v1/auth/quota/consume HTTP/1.1
Host: trust-relay-internal
Authorization: Bearer <same accessToken the client sent to the RS>
Content-Type: application/json

{"scope":"ai:invoke","amount":1}
```

| Do | Don't |
| --- | --- |
| Forward the client's `Authorization: Bearer` unchanged | Call internal trust-relay with no `Authorization` |
| Use the internal Cloud Run / VPC URL | Expose or call consume on the public URL |
| Verify the JWT locally first (JWKS) | Send an RS service-account or workload-identity token instead of the user JWT |

```mermaid
sequenceDiagram
autonumber
participant C as Nodle app
participant P as trust-relay-public
participant RS as Resource server
participant I as trust-relay-internal
participant R as Redis

C->>P: POST /session (SIWE)
P->>R: init quota buckets
P-->>C: access JWT

C->>RS: Bearer JWT (e.g. /ai/*)
RS->>RS: Verify JWT locally (JWKS from public)
RS->>I: POST /quota/consume<br/>Authorization: user JWT
I->>I: Verify JWT + scope
I->>R: decrement quota
I-->>RS: remaining / 429
RS-->>C: 200 or 429
```

## Local development

```bash
# Everything on one port (default)
APP_SERVER__EXPOSE=all cargo run

# Simulate split deploys (two terminals)
APP_SERVER__PORT=3001 APP_SERVER__EXPOSE=public cargo run
APP_SERVER__PORT=3002 APP_SERVER__EXPOSE=internal cargo run
```

## Future: `trust-auth` crate

Rust resource servers should move quota/revocation checks into a **Tower layer** with optional **Redis replica** reads, avoiding a per-request HTTP hop to trust-relay-internal. The internal service remains the **authoritative writer** until then.

See [adr/0003-public-internal-deployment.md](adr/0003-public-internal-deployment.md) and [ARCHITECTURE.md](ARCHITECTURE.md) §4.1.
12 changes: 9 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ Reading order:
attestation upgrade flow with beacon-relay, revocation authority, Redis
topology, and the implementation roadmap.

2. **[adr/0001-siwe-wallet-session-auth.md](adr/0001-siwe-wallet-session-auth.md)**
2. **[DEPLOYMENT.md](DEPLOYMENT.md)** — public vs internal route surfaces,
`APP_SERVER__EXPOSE`, Google Cloud (two Cloud Run services + Memorystore).

3. **[adr/0001-siwe-wallet-session-auth.md](adr/0001-siwe-wallet-session-auth.md)**
— standalone SIWE session authority; build vs buy; JWT/JWKS pattern.

3. **[adr/0002-attestation-upgrade-and-sole-issuer.md](adr/0002-attestation-upgrade-and-sole-issuer.md)**
4. **[adr/0002-attestation-upgrade-and-sole-issuer.md](adr/0002-attestation-upgrade-and-sole-issuer.md)**
— sole session issuer; beacon-relay attestation results; identity model;
revocation authority; Redis split with beacon-relay.

4. **[TOKEN-SPEC.md](TOKEN-SPEC.md)** — bearer JWT contract, scopes, attested
5. **[adr/0003-public-internal-deployment.md](adr/0003-public-internal-deployment.md)**
— split public/internal ingress on GCP; quota consume on VPC-only surface.

6. **[TOKEN-SPEC.md](TOKEN-SPEC.md)** — bearer JWT contract, scopes, attested
`att.*` claims, attestation-result credential, RS verification checklist.

## Two-service relationship
Expand Down
Loading
Loading