# Architecture

How a request flows through the Player API, and the contracts each layer relies
on.

```
client  ──►  GET /api/?action=<module>.<method>
             X-API-Key: gccapi_...
                  │
                  ▼
             Application.cfc     own CFML app, no sessions, onError → JSON 500
             index.cfm           router
                  │  1. GET only                     (405 otherwise)
                  │  2. parse action=module.method    (400 otherwise)
                  │  3. Registry.get(action)          (404 otherwise)
                  │  4. requireKey()                  (401 otherwise)
                  │  5. enforceRateLimit()            (429 otherwise)
                  │  6. requireAccess(spec.requires)  (403 otherwise)
                  │  7. handler.routable must list the method (500 otherwise)
                  ▼
             components/<Module>.cfc   extends Base
                  │
                  ▼
             gcc / gcc_log             game data, read-only
```

Order matters: the endpoint must exist before we ask who is calling, and the key
must be valid before we spend a rate-limit slot on it.

---

## 1. `Application.cfc`

A **separate CFML application** from the game and from the Admin panel
(`this.name = "GCCPlayerAPI-<version>"`). Consequences worth knowing:

- **No sessions.** Every request authenticates from scratch by key. There is no
  login, no cookie, nothing to fixate.
- **Its own application scope.** The response cache and rate-limit counters live
  here and are invisible to the game. They also vanish on an app restart, which
  is fine for both.
- **`onDebug` is a no-op**, so Lucee's debug output can never contaminate a JSON
  body.
- **`onError` swallows detail.** The real message goes to the
  `gcc_player_api` log; the caller gets `{"ok":false,"error":"Internal error"}`.
  This is intentional — internals are not a public contract — but it means the
  log is the only place to debug a 500.

## 2. `index.cfm` — the router

Instantiates one `Base` (as `guard`) and one `Registry`, then walks the numbered
steps above. Two details that are easy to miss:

**It deletes `url.action` after parsing.** A handler reading `p("action")` gets
its own parameter, never the route it was dispatched from.

**It enforces the second allowlist.** Even after the registry names a method,
`listFindNoCase(handler.routable, spec.method)` must pass. See
[CONVENTIONS.md](CONVENTIONS.md#the-two-allowlists) for why there are two.

Reaching the end of the file is itself an error: handlers are expected to exit
through `apiOut`/`apiError`, so the last line raises
`"Handler produced no response"`.

## 3. `Registry.cfc` — the declaration point

Data only. One struct per action:

| Field | Meaning |
|---|---|
| `cfc` | component under `components/` that owns the method |
| `method` | method name — also its key in that component's `this.routable` |
| `summary` | one line, shown in the docs |
| `detail` | optional paragraph for the docs |
| `requires` | `""` = any valid key; `"project:<n>"` = caller must own project *n*, finished |
| `cache` | seconds a response may be served from cache; `0` = never |
| `params` | array of `{ name, type, required, default, desc }` |

Because the registry is the single source, **three things update themselves**
when you add an entry: routing, `meta.endpoints`, and the in-game documentation
page. `f_option_api.cfm` instantiates this component directly from the *game*
application — which is why it must never query, hold state, or extend `Base`.

## 4. `Base.cfc` — the shared layer

Everything a handler needs, and nothing routable.

- **Envelope** — `apiOut(data, meta)`, `apiError(status, message)`. Both end the
  request.
- **Params** — `p`, `pInt`, `pTrim`, `pRange`. Always go through these; they
  bound and coerce.
- **Server resolution** — `resolveServer()` maps `TB`/`RT` to slots 3/4 and
  defaults to the caller's own server. Callers never send raw slot numbers.
- **Auth** — `requireKey()` resolves the key to `request.apiUser`; then
  `callerId()`, `callerNic()`, `callerServer()`.
- **Access rules** — `requireAccess(rule)` interprets `spec.requires`.
  Unknown rule kinds **fail closed**.
- **Rate limit** — `enforceRateLimit()`, 120 requests/minute per key, counted in
  application scope.
- **Queries** — `qGame` / `qLog`, both bound-parameter only, plus `qRows`.
- **Cache** — `cached(key, ttl, producer)` and `cacheMeta(ttl, count)` to
  describe it in the response.
- **Logging** — `logAccess(status)` writes every call to `gcc_log.api_log`;
  `safeParams()` strips the key first.

## 5. Response shape

Success:

```json
{
  "ok": true,
  "data": [ ... ],
  "meta": { "count": 7, "cached": true, "generated": "...",
            "refresh_secs": 300, "cache_age_secs": 0 }
}
```

Failure:

```json
{ "ok": false, "error": "Unknown endpoint: foo.bar. Call action=meta.endpoints for the list." }
```

`meta` comes from `cacheMeta()`. A caller can use `refresh_secs` and
`cache_age_secs` to avoid polling faster than the data can change.

## 6. What this API deliberately does not do

- **No writes.** GET only, 405 on anything else.
- **No sessions or cookies.**
- **No data a player could not already see in-game.** The access rules mirror
  game entitlements (DSR needs the DSR project), they do not invent new ones.
