# Access control

**Read this before changing anything that decides who sees what.** More bugs
have come out of this file's subject than any other part of the board, and the
failure mode is silent: a leaked support ticket looks exactly like a working
page.

---

## Three permissions, not one

This is the rule most likely to be broken by someone "simplifying" the access
code. `he_type.access` governs **only the first** of these:

| | Rule | Implemented in |
|---|---|---|
| **Browse** a section | `he_type.access <= your level` — **except types 200+, always public** | `Base.canSeeSection()` |
| **Create** a thread | its own rule set (below) | `Base.canPostIn()` |
| **Read** a thread | the owner, **always, at any level** | `Base.canReadThread()` |
| **Reply** to a thread | can read it, not muted, confirmed, not closed/locked/archived, and — if the thread is marked staff-only — staff | `Base.canReplyTo()` |
| **Edit** a post | author within 30 min, or staff at any time | `Base.canEdit()` |

### The two cases that prove they are different

**Section 91 — Abuse › Re-Activate / Silencing (`access = 4`).**
A level-0 player cannot browse it. They must still be able to file an appeal
there and read the reply, or a suspension becomes unappealable. **181 of that
section's live threads belong to level-0 players.** The legacy board made the
same carve-out, in `f_he_detail.cfm:26`:

```cfml
<cfelseif (h.type LT 200 and application["s_hetype#h.type#_access"] GT session.adminflag)
          and h.userid is not s_userid>
```

Note the trailing `and h.userid is not s_userid` — deny *unless you own it*.

**Sections 200 / 201 — Updates and News (`access = 5`).**
That 5 is **who may post an announcement**. Everyone reads and replies. The
legacy code exempts them explicitly — the same line above begins `h.type LT
200`, and `f_he.cfm` calls types 200+ "publicly viewable" outright.

---

## Browse

```cfml
boolean function canSeeSection(required numeric typeId)
{
    var id = int(arguments.typeId);
    if (id GTE 200) return true;            // announcements are public

    var t = typeInfo(id);
    if (structIsEmpty(t)) return false;     // unknown id → deny
    return me().level GTE t.access;
}
```

Two things to preserve:

- **Unknown section ids fail closed.** An id nobody has classified is not
  something to publish by default. `sectionMap()` gives an unmapped `he_type`
  an access of 255 for the same reason.
- **`he_type` is read from the database, joined, on every request** — never from
  the `application.s_hetype*` cache alone. On a cold scope that lookup missed
  and `access` fell back to 0, which silently published the Guide & Admin
  section to everyone. A permission must never default to "allowed" because a
  cache was cold.

---

## Create

Create rules are **not** derived from browse rules.

**Forum (`hef`)** — browse and create do line up: you should not be able to
start a thread in a section you cannot read. (This is a deliberate tightening;
the legacy `templist` would have let a player pick the staff section.)

With one section on top of that: **Events (106) takes `post.events`, default
3.** Everyone browses it — its `he_type.access` is 0, and raising that would
hide the section from the players the announcements are written for — but only
staff start a thread there. It is the Announcements pattern the Help Center
already uses for 200 / 201, expressed as a capability rather than as an access
level that means the opposite of what it says.

**Help Center (`he`)** — taken from `f_he_new.cfm`:

| Section | Who may create |
|---|---|
| 201 News | level ≥ 9 |
| 200 Updates, 22 Bounty | level ≥ 5 |
| anything else below 100 | **any signed-in player, whatever its browse level** |
| — while deactivated | **only** 91 (appeal), 92 (complain about staff), 10 (payment) |

That last row is the one that matters. A deactivated account keeps exactly three
routes; anything wider would let a silenced player keep talking.

`Base.creatableSections(src)` builds the compose dropdown by asking
`canPostIn()` about each mapped section, so the menu can never offer somewhere
the post will be refused after it has been typed. **Build compose menus from
this, never from the browse list** — doing the latter silently removes the one
route a suspended player has.

---

## Read

```cfml
boolean function canReadThread(required struct thread)
{
    var mine = m["in"] && val(t.userid) EQ m.id;

    if (!mine && !canSeeSection(val(t.type))) return false;
    if (val(t.publicflag) EQ 0 && !mine && !m.staff) return false;
    return true;
}
```

Ownership beats section access **and** `publicflag`. Staff see private threads;
nobody else does.

---

## Reply, and "no player replies"

`canReplyTo()` is the one gate: the reply box, the reply action and the Quote
links all ask it, so a rule added here holds everywhere.

Beyond the usual tests (muted, unconfirmed, deactivated, closed, locked,
archived) a thread can be marked **staff replies only**:

| | Lock | Staff replies only |
|---|---|---|
| Stops | everyone, staff included | players only |
| Means | this conversation is over | this was never a conversation |
| Set by | any mod, any thread | whoever starts the thread, in a section that offers it; any mod afterwards |
| Stored | `forum_thread_meta.locked_at` | `forum_thread_meta.staff_only_at` |

Reading is untouched — the thread stays public and stays in the listing, with a
**Staff replies only** tag on it.

Which sections offer it is `Base.offersReplyLock(src, typeId)`, today the Forum's
Events section alone. It is **not** a `forum_section` column: that table is
placement and presentation, and whether a thread can be silenced is neither. A
second section is one id in that function; the flag, the composer checkbox, the
staff toggle and the gate already work for any thread on either board.

The composer checkbox is `staffonly`, and `createThread()` ignores it for any
section that does not offer it rather than trusting the form. `Mod.staffOnlyReplies()`
refuses outright in those sections, so a hand-built POST cannot silence a
discussion thread.

---

## Visibility in SQL

Two helpers on `Board` turn the above into query fragments. Both are built from
**session state only** — no request value is ever concatenated into them.

```cfml
visibilitySQL(alias)    // "and (x.publicflag = 1 or x.userid = 4711)"
visibleTypeList(src)    // "1,2,100,101,200,201"  — never empty; "-1" matches nothing
```

`visibleTypeList()` calls `canSeeSection()`. So did `shapeIndex()`, eventually —
see the next section.

---

## Never re-implement these inline

Three real bugs, all the same shape: the rule written down in more than one
place, and the copies drifting.

1. **Section 105 listed to signed-out visitors.** `threads()` applied
   `visibilitySQL()` (which filters `publicflag`) but never checked section
   access. Section 104 *looked* safe only because all 89 of its threads happen
   to be `publicflag = 0`; 105 (`access = 1`, `publicflag = 1`) listed in full
   to anyone who guessed the URL. Hiding a door is not locking it.

2. **Announcements vanished from the Help Center index for guests.**
   `shapeIndex()` compared `m.level` to `sec.access` itself and did not know
   about the 200+ exemption, while `canSeeSection()` did.

3. **Profiles hid an author's Announcement posts.** `People` carried private
   copies of `visibleTypeList()` / `visibilitySQL()` with the same blind spot.
   Deleted; it extends `Board`, the originals were already in scope.

All three now go through `canSeeSection()`. If you find yourself writing
`m.level GTE sec.access` anywhere, you are recreating bug 2.

---

## Ownership as the permission

`Board.myTickets()` **deliberately bypasses `canSeeSection()`**. The
`userid = me` predicate *is* the access control — that is not a shortcut, it is
the whole reason the page exists. Nothing may be added to that query that would
narrow it.

`Board.ticketQueue()` is the opposite case: staff-only, and scoped with
`visibleTypeList()` so a Guide sees the queue they can work rather than the
payment and staff-complaint tickets above their level. It also excludes types
200+ — Updates and News are posts staff make, not tickets waiting on staff.

---

## Staff levels

`session.adminflag`, 0–10. These are **defaults** — every one of them can be
moved from the Access Control screen (next section) — and they match what the
code enforced before that screen existed:

| `me.` | Default | May |
|---|---|---|
| `staff` | ≥ 1 | be badged as staff; exempt from flood limits |
| `mod` | ≥ 2 | edit others' posts, lock, move, archive, remove replies, work the queue |
| `senior` | ≥ 3 | pin / unpin |

These mirror the legacy board exactly (`f_he_detail.cfm` gated Edit and Close at
`>= 2`, Sticky at `> 2`), so nobody gains or loses a power in the move.

The **staff directory** (`People.staffDirectory()`) is **not** built from these
levels. It reads `gcc.user_guide`, the list maintained from the Admin panel's
"In-game staff list" screen: `adminflag` → Administrators, `modflag` →
Moderators, `guideflag` → Guides, highest flag wins. **Ownership** is the
hardcoded `STAFF_OWNERS` list in `People.cfc` (302041, 388580), always listed
first and never repeated lower down — deliberately not a flag, so no admin
tickbox can grant it. Being listed is who the game *names* as staff; what a
person can *do* on the board is still `session.adminflag`. Exact levels are never
published: knowing who to ask is useful, knowing the exact rung is an invitation.

---

## Configurable clearance (the Access Control screen)

Every level above is a **default**, not a constant. `?p=acl` in the top bar
("Access") lets an Owner move any of them. The model is lifted from the Admin
panel (`app/Admin/docs/ACCESS-CONTROL.md`) because staff already know it:

1. **`Base.aclRegistry()`** is the single source of truth for capabilities.
   Each has a `label`, a built-in `def` and a `group`.
2. **`gcc.forum_acl`** stores overrides (`acl_key`, `min_level`). **An empty
   table means the board behaves exactly as it always has** — which is what
   makes the feature safe to ship, and why setting a row back to its default
   DELETES it rather than storing the same number.
3. **`aclCap(key)`** resolves a capability, **`aclLevel(key, def)`** anything
   else. Overrides are cached in application scope for **60 seconds**;
   `aclCacheBust()` runs on save, so a change is live at once.
4. **Board sections** live in the same table as `section.<he_type id>`, with
   `he_type.access` as the default. A failed read of `forum_acl` yields an
   empty struct, so every gate falls back to its default rather than to zero.
5. **The screen is pinned to level 10** in code (`ACL_ADMIN_LEVEL`) and is not
   itself listed, so nobody can lock everyone out of the fix.

### The registry

| Key | Default | Capability |
|---|---|---|
| `staff` | 1 | Count as staff: staff badge, the ticket queue, no flood limit |
| `mod` | 2 | Moderate: edit others' posts, lock, move, archive, remove replies, action reports |
| `senior` | 3 | Pin and unpin threads |
| `avatars` | 4 | Take down another player's avatar |
| `post.updates` | 5 | Post in Updates & Changes and Bounty |
| `post.news` | 9 | Post in News & Announcements |
| `post.events` | 3 | Start a thread in the Forum's Events section |
| `section.<id>` | `he_type.access` | Browse that section |

### he_type is still the default, not a second answer

`he_type.access` states what a section's access **is out of the box**;
`forum_acl` records where it has since been **moved to**. Both are read through
`aclLevel()`, so `canSeeSection()` and `sectionMap()` cannot drift — that was
bug 1 above, in a new costume. Section ids 200+ are not listed: announcements
are public to read by rule, so a browse level on them would be a control that
does nothing. Who may *post* one is `post.news` / `post.updates`.

**The legacy board does not know about this.** `hef.cfm` reads `he_type`
directly, so a section made stricter here is still at its old level there. That
is deliberate: the alternative is writing to `he_type`, and legacy rows are
never altered. Until `hef.cfm` is retired, treat an override as a rule for the
new board only.

### The ladder

Levels are the same number the Admin panel uses (`user.adminflag` drives both),
so the names match — with three deliberate differences, because players read
this board and nobody but staff reaches the panel:

| Level | Name | | Level | Name |
|---|---|---|---|---|
| 0 | **Player** (panel: "Guide") | | 6 | Administrator |
| 1 | **Guide** (panel: "Senior Guide") | | 7 | Lead Administrator |
| 2 | Moderator | | 8 | Head Administrator |
| 3 | Senior Moderator | | 9 | Super Administrator |
| 4 | Lead Moderator | | 10 | **Owner** (not in the panel's ladder) |
| 5 | Junior Administrator | | | |

0 is Player because everyone reading the forum has a level and nearly all of
them are 0; the panel can call 0 "Guide" because only staff hold an account
there at all. `Base.levelNames()` is the one copy — do not restate it in a view.

### Adding a capability

1. Add it to `aclRegistry()` with a label, default and group.
2. Gate the code with `aclCap("your.key")` — never a bare number.
3. That is all. The screen builds itself from the registry.

Every change is written to `forum_modlog` with action `acl`, so a level that
moved is attributable afterwards.

---

## Other gates

- **Muted** — `server.<server_type>_mute<userid>`, written by the in-game
  silence tool. Blocks all posting. If `application.server_type` is undefined
  the flag cannot be read at all; treated as "not muted" so a cold scope does
  not block the whole board, and the other checks still stand.
- **Unconfirmed email** (`session.confirmflag = 0`) — cannot post or reply.
- **Deactivated** (`session.activeflag = 0`) — cannot reply except in section
  91, and can create only in 91 / 92 / 10.
- **Archive sections** (`forum_section.postable = 0`) — readable, not postable.
  That is how The Archive stays browsable but frozen.

---

## Verifying a change

Never eyeball this. `Skills/forum-verify` has the harness; the shape is:

```cfml
function asUser(uid, lvl, active) {
    session.userid = uid; session.adminflag = lvl; session.activeflag = active;
    session.confirmflag = 1;
    // request-scoped caches MUST be cleared between simulated viewers or the
    // next level passes on the previous one's section map
    for (k in listToArray(structKeyList(request)))
        if (left(k,5) EQ "forum") structDelete(request, k);
    return new components.People();
}
```

Assert **both** directions every time: that the right people get in *and* that
the wrong people are refused. Bug 1 above passed a "staff can see it" test.
