# CLAUDE.md — GCC Player API

Working notes for the **public, player-facing** API at `app/api/`. Read this
before changing anything under this folder.

This is *not* the Admin panel API (`app/Admin/api/`). Different application,
different auth, different audience: this one is consumed by **players and their
tools**, so every change here is a change to a public contract.

## 1. What this is

A read-only JSON API. A player generates a key on `i.cfm?f=option_api`, sends it
as `X-API-Key`, and gets back game data they are already entitled to see.

```
GET /api/?action=<module>.<method>
X-API-Key: gccapi_...

{ "ok": true,  "data": ..., "meta": {...} }
{ "ok": false, "error": "..." }
```

## 2. Layout

```
app/api/
├── CLAUDE.md              this file
├── Application.cfc        own CFML application; no sessions; onError → 500 envelope
├── index.cfm              the router: key → rate limit → access rule → dispatch
├── components/
│   ├── Base.cfc           envelope, params, auth, rate limit, queries, cache, logging
│   ├── Registry.cfc       THE single declaration point for every endpoint
│   ├── Meta.cfc           meta.endpoints / meta.whoami (self-documenting)
│   ├── Ranks.cfc          ranks.top50 (carries the damage-protection flag)
│   ├── Dsr.cfc            dsr.battles (gated on project 5)
│   ├── Empires.cfc        empires.info / empires.search
│   ├── Feds.cfc           feds.list
│   ├── Chat.cfc           chat.recent — READ ONLY, see §4
│   ├── Market.cfc         market.prices
│   ├── ServerInfo.cfc     server.info
│   ├── Ships.cfc          ships.list
│   ├── Research.cfc       research.list
│   ├── Buildings.cfc      buildings.list
│   ├── Goods.cfc          minerals.list / artifacts.list
│   ├── Planets.cfc        planets.types
│   └── Races.cfc          races.list
├── test.cfm               TEMPORARY browser harness — delete before production
├── docs/                  reference material (start at docs/ARCHITECTURE.md)
└── Skills/                task playbooks (start at Skills/README.md)
```

## 3. The one rule that matters

**`Registry.cfc` is the only place an endpoint is declared.** The router
dispatches from it, the access checks read it, `meta.endpoints` renders from it,
and the in-game docs page (`f_option_api.cfm`) renders from it — from the game
application, not this one.

Adding an endpoint is two steps:

1. add the entry to `Registry.cfc`
2. add the method to the component named in `cfc`, and list that method name in
   that component's **`this.routable`**

Docs, routing, discovery and logging all follow. Nothing else to touch.

## 4. Traps specific to this folder

### `this.routable`, never `this.endpoints`

A property on `this` **shadows a method of the same name**. The allowlist was
originally `this.endpoints`, which made `Meta.endpoints()` unreachable — every
call to `meta.endpoints` died with:

```
Member [endpoints] of component [components.Meta] is not a function
```

It is `this.routable` now. Whatever the allowlist is called is barred from being
a method name, so never rename it to something a handler might want.

### Two allowlists, both required

The registry says which method, and the component has to agree
(`index.cfm` checks `listFindNoCase(handler.routable, spec.method)`). This is
deliberate: inherited helpers on `Base` — `qGame`, `apiOut`, `requireKey` — must
never be reachable over HTTP. A method missing from either list is a 404/500,
not an endpoint.

### Registry must stay data-only

`f_option_api.cfm` instantiates `Registry.cfc` from the **game** application to
render the docs page. No queries, no state, no `Base` inheritance — keep it a
plain component returning structs, or the game side breaks.

### GET only

`index.cfm` rejects anything else with 405. This API never mutates game state.
If a change seems to need a write, it belongs in the game or the Admin panel.

### Reserved scopes

`server`, `local`, `url`, `form`, `request`, `application` are CFML scopes. A
`var server = ...` silently resolves to the scope, not your variable — this has
bitten `Ranks.cfc` and `Dsr.cfc` already. Name locals `slot`, `srv`, `rows`.

### Damage protection is `planetlost >= 3`, never `user.protection`

`user.protection` records when protection **started**, not when it ends, and
`s_attack_refresh.cfm` is what ends it — by zeroing `planetlost` once that
timestamp has aged past the server's DP window. So a `protection > now()` test
is false for every empire genuinely under protection, and true only for
vacation-mode rows, which carry a far-future sentinel. `Empires.cfc` shipped
with exactly that bug; `dpFlag()` in `Ranks.cfc` carries the full explanation.

Both DP branches of `s_com_attack_req.cfm` and the in-game Intel page agree on
`planetlost >= 3`, so that is what "protected" means. Publish the boolean only —
a remaining time would let a caller queue an attack for the second it lifts.

### This API is read-only, and `chat.recent` is the one that will be tested

`index.cfm` is GET-only, so a write path cannot be reached even by accident. It
still matters that nobody adds a chat *post* endpoint here: the API cannot
establish that a key-holder is at the keyboard, so it would be a spam relay with
an audit trail naming the wrong player. Posting stays in `i_chatb.cfm`, which
has the flood control and the session.

`chat.Posted` (added 30 Aug 2026) has two traps, both handled in `Chat.cfc`:

- **It is written by the database clock, not the game's**, being a MySQL
  `TIMESTAMP DEFAULT current_timestamp()`. It is the only datetime here that is
  not America/New_York on disk, and feeding it to `timestampFields()` publishes
  times wrong by the offset. Take the instant from `UNIX_TIMESTAMP()`, which is
  correct whatever either clock is set to, and derive everything from that.
- **Most rows carry the backfill stamp, not a post time.** The `ALTER` stamped
  all 64,002 pre-existing rows with one instant. Those report `null`. The stamp
  differs per environment (live `11:00:50`, dev `14:59:54`), so `isBackfilled()`
  also derives it from the oldest row and needs no config on a new database.

### Errors are deliberately opaque

`Application.cfc:onError` logs the real message to `gcc_player_api.log` and
returns a flat `{"ok":false,"error":"Internal error"}`. When debugging a 500,
**read the log** — the response will never tell you anything:

```bash
docker compose -f dev/docker-compose.yml --env-file dev/.env exec -T app \
  sh -c 'tail -20 /opt/lucee/server/lucee-server/context/logs/gcc_player_api.log'
```

## 5. Any SQL file names its database

Schema changes live in `app/Admin/sql/`. **Every `.sql` file starts with a
`USE`** (after the header comment, before the first statement) or fully
qualifies every object it touches. Never rely on the invoker passing the right
database on the command line.

`05_api.sql` does both — it spans `gcc.user` and `gcc_log.api_log`, so it fully
qualifies everything *and* carries `USE gcc;` so a later unqualified addition
lands somewhere deliberate. See
[`docs/CONVENTIONS.md`](docs/CONVENTIONS.md#sql-migrations-declare-their-database).

## 6. Before you commit

- Drive the real endpoint over HTTP with a real key — see `Skills/api-verify`.
- Confirm `meta.endpoints` still lists everything and every action returns 200.
- Confirm a request with **no** key returns 401.
- `git status --short app/api` — no harnesses, no scratch files.

## 7. Open items

- **`test.cfm` must be deleted before production.** It is a browser harness that
  posts arbitrary keys; it has no place on a live server.
- `dev/db/05_api.sql` (the `user.api_key` columns and `gcc_log.api_log` table) is
  **gitignored**, so it has to be applied to production by hand.

## 8. Related

- `app/Admin/` — the staff panel. Same architectural ideas, entirely separate
  application and auth. Its docs are the template these follow.
- `app/f_option_api.cfm` — the in-game key management + documentation page.
- `docs/` here for reference, `Skills/` here for procedures.
