---
name: admin-api-endpoint
description: Add or change an Admin panel API endpoint — router registration, this.endpoints allowlist, ACL guard, query binding, audit logging, and the response envelope. Use when writing or modifying anything in app/Admin/api/components/*.cfc.
---

# Adding an Admin panel API endpoint

Backend work lives in `app/Admin/api/components/<Module>.cfc`, all extending
`Base.cfc`. Read `docs/CONVENTIONS.md` before writing CFML here.

## Checklist

1. Add the method to the CFC.
2. **Add its name to `this.endpoints`** — otherwise the router returns 404.
3. Guard it: `requireSection("<acl.section>")`, plus `requirePost()` for
   mutations.
4. Bind every dynamic value as a named query parameter.
5. Exit through `apiOut(...)` / `apiError(...)`.
6. Audit it (`auditLog` for mutations, `auditView` for reads).
7. Verify with the `admin-verify` skill, including the ACL gate.

If the module itself is new, also register it in `api/index.cfm`'s `moduleMap`.

## The shape

```cfc
void function resourcesSave() {
    requireSection("players.resources.edit");
    requirePost();

    var uid = pInt("uid", 0);
    var reason = pTrim("reason", "", 200);
    if (uid <= 0) apiError(400, "uid required");

    var u = qGame("SELECT id, nic FROM `user` WHERE id = :uid", { uid: [ uid, "integer" ] });
    if (u.recordcount == 0) apiError(404, "Player not found");

    // …do the work…

    logChange("player.resourceEdit", uid, toString(u.nic), "Resources", 0, fields, reason);
    apiOut({ "saved": true });
}
```

## Rules that bite if ignored

### `this.endpoints` is the security boundary

Handlers inherit `qGame`, `auditLog`, `playerPm`, and dozens more from
`Base.cfc`. The allowlist is the only thing stopping those being called over
HTTP. Add on create, remove on delete.

### Never name a parameter `action`

The router owns `url.action` and deletes it before dispatch. A filter parameter
called `action` will always be empty. `Audit.search` uses **`actionFilter`** for
exactly this reason.

### Parameter access

`p()` reads the JSON body first, then `url`. Use the typed helpers —
`pInt(name, def)`, `pTrim(name, def, maxLen)`, `csvIntList(raw)` — so a hostile
or absent value can't reach SQL. Always pass a `maxLen` to `pTrim` that matches
the destination column.

### Query binding

```cfc
var r = qGame("SELECT id FROM `user` WHERE nic = :nic AND server = :srv",
              { nic: [ username, "varchar" ], srv: [ srv, "integer" ] });
```

Only inline server-owned values: constants, already-int values, or a list run
through `csvIntList()`. For a dynamic `IN (…)` build placeholders explicitly
with `new Query()` and `addParam` — never string-concatenate user input.

### Reserved scope names

`server`, `session`, `request`, `application`, `url`, `form` are scopes and
cannot be local variable names. `var server = pInt("server", 4)` produces a
baffling *"can't compare Complex Object Type Struct with a numeric value"*. Use
`srv`.

### `##` escaping

Inside a CFML string, `##` is a literal `#`. Unbalanced `#` is a **parse error**
— the whole file stops compiling. When it gets unreadable, concatenate:

```cfc
"…filed as a pending report (##" & abuseId & ") — action it in the UC panel."
```

### Exit discipline

`apiOut`/`apiError` abort, but still `return;` after an early `apiOut` in a
branch. It documents the flow and survives future refactors.

### Truncate to the column

Every string bound into a game table must be `left(...)` to that column's width.
Widths are easy to get wrong from memory — check the schema. When a value plus a
wrapper must fit, size the input limit for the *combined* length or widen the
column with a migration (see `docs/DEVELOPMENT.md`).

## Shaping a response by clearance

Prefer **omitting** sensitive data over sending it and hiding it client-side:

```cfc
var canFaction = canSection("federations.faction.view");
if (canFaction) row["faction_name"] = raceName(row.faction);
else { structDelete(row, "faction"); row["faction_name"] = ""; }

apiOut({ "fed": row, "perms": { "factionView": canFaction, "factionEdit": canSection("federations.faction.edit") } });
```

The `perms` struct is the contract the view reads to gate its UI. Name its keys
after the capability, not the level.

## Tiered (basic/full) endpoints

Let anyone with the basic section in, then decide content by the full section,
and record which tier was served:

```cfc
var canFull  = canSection("events");
var canBasic = canSection("events.basic");
if (!canFull && !canBasic) requireSection("events.basic");   // throws the right 403
// …filter the query by canFull…
auditView(canFull ? "events.watch" : "events.basicWatch", "system", "global", "Global event watch");
```

## Audit

Mutations: `auditLog(action, targetType, targetId, targetLabel, detail)` **after**
the write succeeds. Reads: `auditView(...)` after the fetch, before `apiOut`.
Field edits: `logChange(...)`. Full guidance and the action catalogue are in
`docs/AUDIT-LOGGING.md` and the `admin-audit-logging` skill.

## Verify

Follow the `admin-verify` skill. At minimum: happy path, the ACL gate from both
sides, and each error branch you wrote (400 / 404). Clean up any rows the test
created.
