1
0
Fork 0

docs: add AGENTS.md subdirectories and login case study

Add domain-specific AGENTS.md files for collections, components, lib,
utils, and bot. Add login-return-url case study documenting the
return-URL flow design decisions and debugging lessons learned.
This commit is contained in:
Jason Fraley 2026-08-19 19:48:35 -04:00
parent fb74e6b0c2
commit a8e147e52a
7 changed files with 642 additions and 3 deletions

View file

@ -1,5 +1,12 @@
# AGENTS.md — Polaris Task Force
> **Sub-AGENTS.md files** (read these for domain-specific context):
> - `src/components/frontend/AGENTS.md` — Frontend component patterns, server/client split, Item primitive
> - `src/collections/AGENTS.md` — Payload collection map, RBAC, hooks, relationship graph
> - `src/lib/AGENTS.md` — Shared business logic, domain services, dependency graph
> - `src/utils/AGENTS.md` — Access control layers, event log, utilities
> - `src/bot/AGENTS.md` — Discord bot architecture, commands, services
## What this is
Next.js 16 + Payload CMS 3.88.0 app for an Arma 3 unit. PostgreSQL database via `@payloadcms/db-postgres` + Drizzle. Tailwind CSS v4 (no config file — CSS-based). shadcn/ui (new-york style, `lucide` icons). Dark-themed frontend.
@ -32,7 +39,7 @@ bun run generate:importmap # regenerates payload admin importMap
npx tsc --noEmit 2>&1 | grep -E "error TS" | grep -v "\.next/"
```
The typecheck should now pass clean (the two pre-existing errors in `hasLogisticsQualification.ts` and `payload-generated-schema.ts` were resolved by the Payload 3.88.0 upgrade). Any error is yours.
Known pre-existing errors (not yours, don't widen scope to fix them): `src/collections/users/Users.ts:57`, `src/tools/seed/backfillProfiles.ts:68`, `src/tools/seed/seedProfiles.ts:19` — all the same Payload profiles-create overload mismatch. Any **other** error introduced by your changes is yours.
## Test details
@ -47,6 +54,27 @@ The typecheck should now pass clean (the two pre-existing errors in `hasLogistic
- If the user says **no**, do **not** start a dev server and do **not** attempt to verify via E2E or Playwright — the user will run the app and test themselves, then report back.
- Stale dev processes: killing the process is not always enough; remove `.next/dev/devserver.lock` before restarting.
## Agent debugging SOP (read before fixing any bug)
These rules were extracted from a real multi-failure debugging session — the full story, including every wrong turn, is in `docs/case-studies/login-return-url.md`. Follow them literally; each one exists because skipping it produced a wrong fix.
1. **Trace the real render path before writing any fix.** State in your reply: which layout wraps the failing route, what each conditional renders, and which component actually owns the navigation or mutation you're changing. If a layout conditionally replaces `{children}` (the `(frontend)` guest gate renders `<LandingPage />` instead of the page), then code inside those children — including `redirect()` calls — is **dead code for that branch** and never runs.
2. **Treat every framework API as a hypothesis.** Before calling a header, hook, or helper, confirm it exists and returns what you expect — against the running app or current docs. An observed default value (e.g. `?next=%2F` when you expected `%2Fflappy`) means the data source was **empty** and your fallback leaked through — not that the data got mangled.
3. **Two failed fixes = your mental model is wrong.** Do not add a third fallback layer on top of a failing approach. Stop editing, re-read the flow, find the wrong assumption.
4. **Ask "which component actually knows this fact?"** The current URL is known client-side (`usePathname()`), not in server layouts. Attach data where it is known, at the point the navigation happens — not where it is merely convenient to compute.
5. **Match producer and consumer.** If you emit a query param (`next`), confirm the consumer reads that exact name (`returnTo`). Mismatches fail silently.
6. **Verify end-to-end before reporting done.** curl the failing route as a guest, follow the redirect, hit the API, check the authed route (a copy-pasteable matrix is in the case study). "Should work" is not verification.
7. **Restate before acting.** For non-trivial changes, output: assumptions → plan → verification command. Then implement.
## Next.js 16 hard rules
Break these and you get runtime errors or silently dead code:
- **`searchParams` and `params` props are Promises** in pages/layouts. `await` them before any property access. Error if violated: ``Route used `searchParams.x`. `searchParams` is a Promise and must be unwrapped with `await` or `React.use()```.
- **Server components (layouts/pages) cannot mutate cookies.** `cookies()` from `next/headers` is read-only there; calling `.set()` throws `Cookies can only be modified in a Server Action or Route Handler`. Writing cookies is only legal in Server Actions and Route Handlers.
- **`headers.get("x-invoke-path")` does not reliably contain the current pathname** in layouts. Never build redirect logic on it. Reliable sources of the current path: `usePathname()` (client components) and the request object (middleware / Route Handlers).
- **`redirect()` narrows poorly across control flow.** After an `if (!user) redirect(...)` early exit, TypeScript may still see `user` as nullable when the narrowing crosses a closure boundary — keep an explicit truthy branch around later `user` usage.
- **Sanitize user-controllable redirect targets** (open-redirect guard): accept only values starting with `/` and reject `//` (protocol-relative URLs). Working example: `safeReturnTo` in `src/app/login/page.tsx`.
## Generated files — never edit manually
@ -228,8 +256,9 @@ A Discord bot living in `src/bot/`, run as a standalone long-running process via
Username-based login (no email login). Users log in via Payload admin with `username` only.
- `src/app/(frontend)/layout.tsx` is the gate: guests render `LandingPage` (no app shell); authed users get sidebar + `GameTickRealtime`.
- `/login` (`src/app/login/page.tsx` + `src/components/frontend/auth/LoginForm.tsx`) POSTs `{ username, password }` to `/api/users/login`, then `router.push("/")` + `router.refresh()`. The page redirects already-authed users to `/`.
- `src/app/(frontend)/layout.tsx` is the gate: guests render `LandingPage` (no app shell); authed users get sidebar + `GameTickRealtime`. Because the gate replaces `{children}` for guests, page-level `if (!user) redirect(...)` blocks under `(frontend)` are **unreachable dead code for guests** — they only serve as type-guards for the authed render path.
- `/login` (`src/app/login/page.tsx` + `src/components/frontend/auth/LoginForm.tsx`) POSTs `{ username, password }` to `/api/users/login`, then `router.push(returnTo ?? "/")` + `router.refresh()`.
- **Return-URL flow**: the `LandingPage` login CTA is `LoginLink` (`src/components/frontend/auth/LoginLink.tsx` — a client component using `usePathname()`), which links to `/login?returnTo=<current path>`. The login page awaits `searchParams`, sanitizes `returnTo` via `safeReturnTo` (must start with `/`, must not start with `//` — open-redirect guard), and passes it to `LoginForm`. Already-authed users hitting `/login?returnTo=X` are redirected straight to `X`. The path is attached client-side because server components cannot reliably know the current path (see "Next.js 16 hard rules"). Full design story: `docs/case-studies/login-return-url.md`.
- Logout lives in `NavUser` (sidebar) → `/api/users/logout`.
- Tip: a corrupted `payload-token` cookie causes an infinite login loop (`Unexpected end of JSON input` on `/api/users/me`) — clear cookies/use incognito. Dev user `dev` / `Test123`.
@ -264,6 +293,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
## Gotchas
- Next.js 16 framework traps (each one caused a real failed fix — see "Next.js 16 hard rules" above): `searchParams`/`params` are Promises and must be awaited; `cookies().set()` throws outside Server Actions/Route Handlers; `x-invoke-path` does not carry the real pathname in layouts.
- `.env.example` shows MongoDB URI but the app uses PostgreSQL — trust `DATABASE_URI` format in `.env.test` as the real reference.
- `bun run build` passes `--max-old-space-size=8000` — the build is memory-intensive.
- The `devturbo` script uses Turbopack; `dev` and `devsafe` use webpack. These are different bundlers with different behavior.
@ -271,3 +301,17 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
- Payload admin layout and importMap are auto-generated — do not edit by hand.
- `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility.
- `bun run db push` fails with `must be owner of table spatial_ref_sys` (a PostGIS table owned by the DB superuser) — drizzle-kit push does a full-schema diff and trips on it. Workaround: apply the needed `ALTER TABLE` directly (via node + `pg` reading `DATABASE_URI` from `.env`) or start the dev server, whose Payload `push: true` path may skip the offending table.
## Server Actions convention
Every `actions.ts` file follows the same pattern (10+ files use it):
1. `"use server"` directive at top
2. `import config from "@payload-config"` + `const payload = await getPayload({ config })`
3. Local `authenticate()` helper — dynamic import of `next/headers`, calls `payload.auth()` and `headers()`
4. Return type `ActionResult<T>`: `{ success: boolean; error?: string; data?: T }`
5. Permission check: `const { user } = await authenticate()` then `await hasPermission(payload, user, "domain:action")`
6. Mutation via `payload.create` / `payload.update` / `payload.delete`
7. Event emission: `await emitGameEvent(payload, { type: EventTypes.xxx, message, ... })`
8. Error handling: `catch (e) { return { success: false, error: e instanceof Error ? e.message : "Unknown error" } }`
The `authenticate()` function is **duplicated in every file** — not extracted to a shared helper. If you add a new server action, copy the pattern from an existing one (e.g., `src/app/(frontend)/logistics/banking/actions.ts`). Do NOT attempt to extract it to a shared helper unless the entire codebase migrates at once.

View file

@ -0,0 +1,238 @@
# Case study: preserving the return URL through login
How a "remember where I was going" feature was implemented (and mis-implemented three times
before that) in this repo. Written for agents — especially smaller local models — as a pattern
to copy and a set of anti-patterns to recognize early. Every wrong turn below was really taken;
the point of writing it down is that each failure had a *signal* that said "stop, your model is
wrong" long before anyone listened to it.
The distilled rules live in `AGENTS.md` → "Agent debugging SOP" and "Next.js 16 hard rules".
This document is the evidence behind them.
## The task
Unauthenticated users who navigate to a protected page (e.g. `/flappy`) should, after logging
in, land back on that page instead of the dashboard.
## The symptom (user report #1)
> I just logged out, went to `/flappy`, found the landing page and was redirected to `/login`
> (no url params!), logged in, and was brought to the dashboard instead of back to Flappy.
## The failure arc
### Attempt 1 — redirect from the page: never runs
**What was done:** the login page and `LoginForm` were taught to read a `returnTo` query
param, and the protected pages (`flappy`, `helpdesk`) got guest blocks like:
```ts
if (!user) {
const pathname = headers.get("x-invoke-path") || "/flappy";
redirect(`/login?next=${encodeURIComponent(pathname)}`);
}
```
**Why it seemed reasonable:** pages own their auth checks; `redirect()` is the canonical
Next.js way to bounce unauthenticated users.
**What actually happened:** no query param appeared at all. The user landed on `/login` bare
and went to `/` after login.
**Why it failed (two independent reasons):**
1. **The page never rendered.** `src/app/(frontend)/layout.tsx` is the real gate, and for
guests it *replaces* `{children}`:
```tsx
{user ? (
<SidebarProvider>… {children} …</SidebarProvider>
) : (
<LandingPage /> // ← guests never reach {children}
)}
```
Every `if (!user) redirect(...)` inside a page under `(frontend)` is **dead code for
guests**. The `redirect()` never executes because the page component never renders.
2. **Producer/consumer mismatch.** The pages emitted `?next=…`; the login page read
`searchParams.returnTo`. Even if the redirect had fired, the param would have been ignored.
Query-param mismatches fail silently — there is no error, just nothing.
**The signal that was missed:** the user said "I found the landing page" — the landing page
rendering *at all* proves the page body (and its redirect) never ran. That was the clue.
### Attempt 2 — move the redirect into the layout: `?next=%2F`
**What was done:** the same `x-invoke-path` redirect logic was moved into the layout, firing
for every guest.
**What actually happened (user report #2):**
> I did see `?next=` appear, but it was actually `?next=%2F` despite expecting `?next=/flappy`.
**Why it failed:** `headers.get("x-invoke-path")` does **not** reliably contain the current
pathname in Next.js 16 layouts — here it returned `/` (or nothing, hitting the `|| "/"`
fallback). Read the symptom the right way: `%2F` is not a mangled `/flappy`; it is the
**default value leaking through**. When you observe a default instead of your data, the data
source was empty. Mangling was never on the table.
**Bonus failure:** this also silently changed product behavior — guests lost the landing page
entirely and got an instant redirect instead. The task never asked for that.
### Attempt 3 — store the path in a cookie: runtime error
**What was done:** since the layout couldn't put the path in the URL, it tried to stash it in
a cookie: `cookies().set("loginRedirect", pathname, …)` in the layout body, then
`redirect("/login")`.
**What actually happened (user report #3):**
```
Error: Cookies can only be modified in a Server Action or Route Handler.
at RootLayout (src/app/(frontend)/layout.tsx:68:16)
```
**Why it failed:** in Next.js App Router, server components (layouts/pages) **cannot mutate
cookies**. `cookies()` from `next/headers` is read-only there. Writes are only legal in Server
Actions and Route Handlers. The framework error message is precise and correct — read it
literally instead of routing around it.
### Attempt 4 — referer fallback: complexity accretion
**What was done:** with `x-invoke-path` proven useless, a `referer`-header fallback was added
on top of the cookie approach (and then a second, duplicated copy of the same fallback block).
**Why it failed:** `referer` is empty on direct navigation (typing a URL), and when present it
points at the *previous* page, not the requested one. It cannot answer this question by
construction. This attempt also demonstrates the worst failure pattern of the whole session:
**when a fix fails, adding a fallback on top of it preserves the wrong assumption and adds
code.** By now there were three mechanisms layered (header → cookie → referer), none of which
could work, and duplicated code on top.
**This is the point where the correct move was:** stop editing. Two-plus failed fixes means
the mental model is wrong, not the implementation.
## The turn: trace the render path
Re-reading the flow instead of patching it produced the key facts:
1. Guests never reach page code — the layout's `LandingPage` branch is the whole guest
experience. So the path can only be captured **where the guest actually is**: inside the
landing page.
2. The landing page's CTA was a plain `<Link href="/login">` — a **static** link with no
knowledge of where the user is standing. *That* is the navigation point the whole feature
hangs on, and it is the thing that should carry the path.
3. Server components cannot reliably know the current path (`x-invoke-path` unreliable, no
request URL in layouts). The current path **is** reliably known by `usePathname()` in
client components — exactly at the point where the user clicks "log in".
Rule of thumb that falls out: **attach data where the fact is known, at the point the
navigation happens — not where it is convenient to compute.**
## The fix (4 small changes)
1. **`src/components/frontend/auth/LoginLink.tsx`** (new, ~8 lines) — a client component that
knows the current path and builds the link:
```tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ComponentProps } from "react";
export function LoginLink(props: Omit<ComponentProps<typeof Link>, "href">) {
const pathname = usePathname();
return <Link {...props} href={`/login?returnTo=${encodeURIComponent(pathname)}`} />;
}
```
2. **`src/components/frontend/LandingPage.tsx`** — the CTA swaps `<Link href="/login">` for
`<LoginLink>`. A guest at `/flappy` now gets `href="/login?returnTo=%2Fflappy"`.
3. **`src/app/login/page.tsx`** — awaits `searchParams` (Next 16: it's a Promise), sanitizes
the target against open redirects, sends already-authed users straight to their target:
```tsx
function safeReturnTo(value: string | undefined): string | undefined {
if (!value?.startsWith("/") || value.startsWith("//")) return undefined;
return value;
}
export default async function LoginPage({ searchParams }: {
searchParams: Promise<{ returnTo?: string }>;
}) {
const { returnTo } = await searchParams;
const target = safeReturnTo(returnTo);
// …
if (user) redirect(target ?? "/");
return <LoginForm returnTo={target} />;
}
```
4. **`src/components/frontend/auth/LoginForm.tsx`** — after a successful login POST,
`router.push(returnTo ?? "/")`. (Also: dropped `returnTo` from the POST body — the API
never read it; the redirect is purely client-side.)
And **reverted** the layout to its original guest flow, plus removed the dead page-level
redirect blocks' reliance on `x-invoke-path`. Net result: less code than the failing versions.
## The verification matrix
Run against a live dev server with the dev user (`dev` / `Test123`). Copy-pasteable:
```bash
# 1. Guest hits the protected route — landing page renders, CTA carries the path
curl -s http://localhost:3000/flappy | grep -o 'href="/login?returnTo=[^"]*"'
# expect: href="/login?returnTo=%2Fflappy"
# 2. Login page passes returnTo through to the form
curl -s "http://localhost:3000/login?returnTo=%2Fflappy" | grep -o '%2Fflappy'
# expect: %2Fflappy (present in the RSC payload)
# 3. Login API sets the auth cookie
curl -s -X POST http://localhost:3000/api/users/login \
-H 'content-type: application/json' \
-d '{"username":"dev","password":"Test123"}' -c /tmp/cookies.txt -o /dev/null -w "%{http_code}\n"
# expect: 200
# 4. Authed user reaches the protected page
curl -s -b /tmp/cookies.txt http://localhost:3000/flappy -o /dev/null -w "%{http_code}\n"
# expect: 200
# 5. Authed user hitting /login?returnTo=... bounces straight to the target
curl -s -b /tmp/cookies.txt -o /dev/null \
-w "%{http_code} -> %{redirect_url}\n" "http://localhost:3000/login?returnTo=%2Fflappy"
# expect: 307 -> http://localhost:3000/flappy
```
All five passed against the running dev server before the work was reported done. Note the
dev-server subtlety encountered along the way: a spawned `bun run dev` detected an
already-running server, exited, and the curls actually hit the *existing* hot-reloaded server —
which is fine (that's the surface the user sees), but know which process you're testing
against. Check the dev log for "Another next dev server is already running" and its PID.
## Signals you are on the wrong path (recognize these early)
- **A default value shows up instead of your data** (`?next=%2F`). The data source is empty.
Find out why it's empty — don't post-process the value.
- **The component you're editing never renders** for the scenario you're fixing (guests and
`{children}` replacement). Verify by asking what the user *saw* — if they saw the landing
page, page code didn't run.
- **A framework error message names a restriction** ("Cookies can only be modified in a Server
Action or Route Handler"). It is stating a rule, not a bug. Restructure to comply; don't
fight it.
- **You're adding a second or third fallback.** Each fallback is an admission the previous
model was wrong — while keeping it. Stop and re-derive instead.
- **The fix changes behavior the task never asked about** (landing page disappears). Scope
creep during a bug fix is a sign the approach is wrong, not a bonus.
## The rules (mirror of the AGENTS.md SOP)
1. Trace the real render path before writing any fix.
2. Treat every framework API as a hypothesis; verify it.
3. Two failed fixes = wrong mental model. Stop, re-read, find the wrong assumption.
4. Attach data where the fact is known (`usePathname()` client-side), at the point of
navigation.
5. Match producer and consumer (`next` vs `returnTo` fails silently).
6. Verify end-to-end (curl matrix above) before reporting done.
7. Restate before acting: assumptions → plan → verification command.

69
src/bot/AGENTS.md Normal file
View file

@ -0,0 +1,69 @@
# AGENTS.md — Discord Bot
> **Parent**: `../../AGENTS.md` — env vars (`DISCORD_TOKEN`, `DISCORD_GUILD_ID`), deployment, Payload config.
## Overview
Standalone long-running process (`bun run bot`). Imports `@payload-config` directly, shares PostgreSQL with web app. Under active development.
## Structure
```
bot/
index.ts # Entry point
config.ts # Env validation (fail-fast on missing required vars)
commands/
index.ts # Command registry (global vs guild scope)
ping.ts # /ping (global, DM-usable)
signup.ts # /signup (DM-only, creates Payload user with temp password)
link.ts # /link (global, links discordId to existing user)
announce.ts # /announce (guild-only, staff only)
events/
interactionCreate.ts # Routes ptf-att: RSVP button interactions
services/
index.ts # Service registry
missionEmbeds.ts # Attendance embed lifecycle + reconcile loop (poll tick)
notificationBridge.ts # Poll user-notifications → Discord DMs
signup.ts # Signup service logic
lib/
roles.ts # isStaff check
resolve.ts # discordId ↔ Payload user lookups
```
## Command registration scope
- **Global** (DM-usable): `signup`, `link`, `ping`
- **Guild-only**: `announce` (guild-scoped commands never appear in DMs)
## Feature flow: signup/link
`/signup` is DM-only. Creates Payload user with `username` = `discordUsername` = caller's Discord username. Ephemeral reply carries temp password (guaranteed delivery path); `interaction.user.send()` is best-effort persistent copy. `/link` works in servers and DMs — matches `discordUsername` → sets `discordId`.
## Feature flow: attendance
Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). Web UI: `MissionAttendance` component.
## Feature flow: notifications
`notificationBridge` polls `user-notifications` (cursor = last seen id; cap 5 DMs/tick). DMs opted-in users (`preferences.discord.enabled`, not in `mutedTypes`).
## Where to look
| Task | Path |
|------|------|
| Add new slash command | `bot/commands/<name>.ts` + register in `bot/commands/index.ts` |
| Add button interaction | `bot/events/interactionCreate.ts` |
| Modify embed lifecycle | `bot/services/missionEmbeds.ts` |
| Change DM bridging | `bot/services/notificationBridge.ts` |
| User lookup patterns | `bot/lib/resolve.ts` |
## Anti-patterns
- **NEVER** run bot and web app with separate database connections without connection pooling — they share PostgreSQL
- **NEVER** register guild-only commands if the command needs to work in DMs (signup, link, ping must be global)
- **NEVER** assume DM delivery succeeded — `/signup` uses ephemeral reply as primary delivery path
- **NEVER** modify `discordId` directly in Payload admin — use the `/link` command or the resolve helper

81
src/collections/AGENTS.md Normal file
View file

@ -0,0 +1,81 @@
# AGENTS.md — Payload Collections
> **Parent**: `../../AGENTS.md` — commands, RBAC basics, Payload CMS config.
## Overview
36 collections across 12 domain groups. Access control defined in `src/permissions/index.ts` (616 lines, 100+ permissions across 25 groups). RBAC check: `hasPermission(payload, user, "collection:action")`.
## Structure
```
collections/
Media.ts # Generic media upload
Shims.ts # Global: CSS/JS shims (admin-only)
users/ 9 files # Users (auth), Ranks, Profiles, Awards,
# Qualifications, Assignments, Experience,
# Roles (dynamic RBAC), UserNotifications
intelligence/ 5 files # Missions, MissionAttendances, Campaigns,
# Factions, Technologies
logistics/ 5 files # Structures, Resources, Assets, Vehicles, Shipments
banking/ 3 files # BankAccounts, BankTransactions, LedgerEntries
market/ 2 files # MarketListings, MarketNegotiations
locker/ 2 files # LockerStorages, Loadouts
game/ 6 files # GameRules (global), GameStructures, GameVehicles,
# GameNpcs, GameHardResources, GameEventLogs
server/ 2 files # MissionFiles, ModLists
world/ 2 files # Maps, NarrativeEvents (React Flow editor)
tickets/ 1 file # Tickets (Lexical rich text)
```
## Key relationships
```
Users ──┬── Profiles ──┬── Qualifications
│ ├── Awards
│ ├── Assignments ── Ranks
│ └── Experience
├── UserNotifications
└── BankAccounts ── BankTransactions ── LedgerEntries
GameStructures ── Structures (template) ── Resources/Assets/Vehicles
GameVehicles ── Vehicles (template)
GameNpcs ── MarketListings ── MarketNegotiations
MissionAttendances ── Missions ── Campaigns
```
## Access control pattern
Collections define `access` at field + document level using helpers from `src/utils/access-control/`:
- `isRole(role)` — single role check
- `hasRoles(roles[])` — any-of role check
- `hasPermission(payload, user, "collection:action")` — full RBAC (cached 30s)
- `hasLogisticsQualification()` / `hasIntelligenceQualification()` — queries Profiles
Admin group access: `developer` only for destructive operations, `admin` for read/write.
## Hooks with side effects
- `Structures` `beforeChange`: emits `structure:resize`
- `GameStructures` `afterChange`: emits storage edit events
- `Users` `afterChange`: maintains profile sync
- `BankTransactions` `beforeValidate`: auto-generates `transactionNumber`
- `MarketNegotiations`: patience meter enforcement
## Where to look
| Task | Path |
|------|------|
| Add a new collection | Create `<Name>.ts` here, register in `src/payload.config.ts` |
| Add RBAC permission | `src/permissions/index.ts` (add to group + add check) |
| Modify collection access | `<collection>/access.ts` or inline in collection file |
| Add field hook | Inline in collection definition (beforeChange/afterChange) |
| Relationship graph | See key relationships above; Payload manages FK constraints |
## Anti-patterns
- **NEVER** edit `src/payload-types.ts` or `src/payload-generated-schema.ts` — run `bun run generate:types` instead
- **NEVER** add `access` functions that call `payload.auth()` without handling the null user case
- **NEVER** use `payload.create` in `afterChange` hooks without checking for infinite loops
- **NEVER** mix `overrideAccess: true` without a permission check — always gate behind RBAC first

View file

@ -0,0 +1,65 @@
# AGENTS.md — Frontend Components
> **Parent**: `../../AGENTS.md` — commands, auth flow, Next.js 16 rules, deployment.
## Overview
100+ client components organized by domain. The `(frontend)` layout renders guests `LandingPage` (no app shell); authed users get `AppSidebar` + `SiteHeader` + `CommandPalette`.
## Structure
```
components/frontend/
auth/ 2 files LoginForm, LoginLink (returnTo via usePathname)
account/ 4 files Profile, password, preferences, Discord link
banking/ 9 files Account cards, ledger, deposit/withdraw/transfer dialogs
blocks/ 6 files AppSidebar, NavCore, NavUser, XPDisplay
dashboard/ 6 files ProfileSummary, QuickStats, MissionBriefing, RecentEvents
flappy/ 4 files Canvas game, leaderboard, sounds
helpdesk/ 6 files Ticket list/detail, create dialog, timeline
intelligence/ 13 files Mission cards/attendance/comms, campaign/faction cards
locker/ 12 files Grid, equipment editor, loadouts, wardrobe
logistics/ 8 files Shipment cards/actions, toast notifications
market/ 10 files Listing cards, negotiation flow, NPC chat, patience meter
notifications/ 2 files Bell (polling), inbox page
realtime/ 1 file GameTickRealtime (SSE -> router.refresh)
roster/ 1 file Org chart view
storage/ 14 files Structure grid, storage dialogs, event ledger
```
## Conventions
### Server → Client data flow
Pages are server components that fetch via `getPayload()`, then pass data as props to client components. Client components receive typed props and never call Payload directly.
### Item compound component
`storage/` uses a compound `<Item>` component for grid cells with slots: `<Item.Slot name="icon">`, `<Item.Slot name="stats">`, `<Item.Menu>`. Attachments render as stacked badges on the grid cell; popover shows full detail on hover.
### SSE consumers
- `GameTickRealtime` mounts in `(frontend)/layout.tsx` for all authed users.
- `ShipmentToasts` mounts only for logistics-qualified users.
- Both use `useGameTick()` hook (custom `ptf:game-tick` event dispatch).
### Nested dialogs
Market negotiation uses `MakeOfferDialog` nested inside `ListingCard` dialog. Loadout editor uses `EquipmentEditorDialog` nested inside locker grid. When a parent dialog unmounts (e.g., sold listing), the child stays visible for 3.5s minimum via `useNow()` hook for readability before refresh.
### Route anomalies
`logistics/vehicles/VehiclesList.tsx` is a client component placed directly in the route directory (not under `components/frontend/`). This is the only route with an inline component file.
## Where to look
| Task | Path |
|------|------|
| Add a new page | `src/app/(frontend)/<domain>/page.tsx` + create client component here |
| Add nav entry | `src/components/frontend/blocks/NavCore.tsx` |
| Modify auth gate | `src/app/(frontend)/layout.tsx` (conditional renders `LandingPage` or shell) |
| Add SSE consumer | `src/hooks/useGameTick.ts` + mount in layout |
| NPC dialogue/chatter | `src/lib/market/npcDialogue.ts` (pure, client-safe import) |
| Keyboard shortcuts | `src/components/command-palette/` |
## Anti-patterns
- **NEVER** call `getPayload()` in client components — fetch in server page, pass as props
- **NEVER** add `useRouter().refresh()` in SSE consumers without a guard — use the `useGameTick()` hook
- **NEVER** use shadcn defaults for dark theme — the admin panel's theme handles styling
- **NEVER** place server-side logic (hooks, Payload calls) in `"use client"` files

85
src/lib/AGENTS.md Normal file
View file

@ -0,0 +1,85 @@
# AGENTS.md — Shared Business Logic
> **Parent**: `../../AGENTS.md` — Payload config, server actions pattern, env vars.
## Overview
23 files across 8 domain subdirectories. Contains pure business logic and Payload-dependent service modules. Server actions in route directories delegate here; these modules hold the actual domain rules.
## Structure
```
lib/
utils.ts # cn() class merger (Tailwind)
storageRules.ts # Storage validation (prohibited → whitelist → per-item cap)
shipping.ts # Fuel cost, transit time, vehicle effective speed
distance.ts # Haversine distance calculation
logistics.ts # Shared logistics helpers
versionInfo.ts # Build version display
attendance/ # Mission attendance service (single write path)
banking/ # Transaction engine, account creation, formatting
locker/ # Grid logic, placement validation, loadouts
market/ # Buy/sell, NPC negotiation, dialogue, vendor resolution
notifications/ # User notification helper + muteable types
realtime/ # In-process SSE bus (single-instance only)
tickets/ # Ticket vocabulary, Lexical helpers, staff resolution
```
## Dependency graph
```
Server actions ──┬── storageRules.ts
├── lib/banking/index.ts ──┬── format.ts
│ └── ui.ts
├── lib/market/index.ts ──┬── negotiations.ts
│ ├── npcs.ts
│ ├── npcDialogue.ts (pure, client-safe)
│ └── chatBubbles.ts (pure, client-safe)
├── lib/locker/index.ts ── search.ts
├── lib/attendance/index.ts
├── lib/notifications/index.ts
└── lib/tickets/
game-tick script ── shipping.ts, distance.ts, storageRules.ts
market-tick script ── lib/market/index.ts (autoPrice, autoQuantity, npc vendor logic)
```
## Pure vs Payload-dependent
- **Pure modules** (no Payload import, safe for client + server): `npcDialogue.ts`, `chatBubbles.ts`, `ticketMeta.ts`, `distance.ts`, `storageRules.ts` (logic only)
- **Payload-dependent** (import `@payload-config`, server-only): everything else
## Key modules
### `storageRules.ts`
Enforcement order: **prohibited → whitelist → per-item cap**. Functions: `checkStorageDeposit`, `isResourceProhibited`, `isResourceWhitelisted`, `getResourceStorageCap`, `storageViolationMessage`. Used in structure actions, shipment creation, and arrival processing.
### `banking/index.ts`
`applyTransaction()` — single source of truth for balance math. Validates accounts, creates transaction + ledger entries, updates balances. `ensurePersonalAccount()` — dedup find-or-create. `getMainCurrencyId()` — resolves Game Rules main currency.
### `market/index.ts`
`buyListing()` — validates, debits buyer, credits locker, marks sold. Supports partial buys via `quantity` parameter. `creditLockerQuantity()` — merges stackables or fills empty grid spots; throws if no space.
### `market/negotiations.ts`
NPC vendor pricing engine: stance starts at asking price, only moves down. Offers ≥ stance accepted; within 2% with 85% chance; well-below draw concession (30% of gap). Patience meter increments per round, closes at cap.
### `realtime/bus.ts`
Module-level subscriber Set. SSE endpoint `GET /api/realtime` subscribes; game tick POST `/api/game-tick/notify` broadcasts. **Single-instance only** — will not work across multiple server processes.
## Where to look
| Task | Path |
|------|------|
| Add storage rule logic | `storageRules.ts` (pure functions) |
| Modify transaction flow | `lib/banking/index.ts` — `applyTransaction()` |
| Change NPC pricing | `lib/market/negotiations.ts` — NPC_ACCEPT constants |
| Add notification type | `lib/notifications/notificationTypes.ts` |
| Add pure client+server logic | Verify no Payload import; place in appropriate domain dir |
## Anti-patterns
- **NEVER** import `@payload-config` in files under `market/npcDialogue.ts` or `market/chatBubbles.ts` — they must stay client-safe
- **NEVER** duplicate business logic in server actions — always delegate to `lib/{domain}/`
- **NEVER** use module-level state in `realtime/bus.ts` for cross-instance scenarios — use external pub/sub (Redis) instead
- **NEVER** mutate `storageRules.ts` enforcement order without updating all 3 call sites (structure actions, shipment creation, arrival processing)

57
src/utils/AGENTS.md Normal file
View file

@ -0,0 +1,57 @@
# AGENTS.md — Utilities & Access Control
> **Parent**: `../../AGENTS.md` — RBAC overview, permission groups, event system.
## Overview
10 files across 3 subdirectories. Core cross-cutting concerns: three-layer RBAC, fire-and-forget event logging, XP resolution.
## Structure
```
utils/
access-control/ 6 files Permission checking, role gates, qualification queries
event-log/ 3 files Event emitter, type constants, formatting
xp/ 1 file Level resolver
```
## Access control layers
Three independent access mechanisms, each used in different contexts:
### 1. `hasPermission(payload, user, "domain:action")`
Full RBAC check against `src/permissions/index.ts` (100+ permissions). Superuser bypass. Cached 30s via `loadUserPermissions`. Used in server actions and collection access functions.
### 2. `isRole(role)` / `hasRoles(roles[])`
Lightweight role checks. Used for quick conditional rendering (e.g., `isRole("admin")` for admin-only UI). No Payload call — reads from the user object directly.
### 3. `hasLogisticsQualification()` / `hasIntelligenceQualification()`
Queries `Profiles.progression.qualifications` for specific qualification strings (case-insensitive). Admin/developer always pass. Used to gate logistics-only and intelligence-only UI sections.
## Event log system
### Emitter: `emitGameEvent(payload, { type, message, ... })`
Fire-and-forget create on `game-event-logs`. Always sets `system: true`. Silent error catch (no throw). Always called AFTER successful mutation in server actions.
### Event types: `EventTypes` constants (95 types across 12 categories)
Defined in `eventTypes.ts`. Use these constants for TypeScript narrowing on the `type` field. Categories: `mission:*`, `finance:*`, `market:*`, `structure:*`, `logistics:*`, `bank:*`, `notification:*`, `locker:*`, `xp:*`, `ticket:*`, `attendance:*`, `system:*`.
### Display: `formatType(type)` — human-readable label for event types.
## Where to look
| Task | Path |
|------|------|
| Add new RBAC permission | `src/permissions/index.ts` + use in `hasPermission` calls |
| Add qualification gate | `access-control/hasLogisticsQualification.ts` (or create similar) |
| Add event type constant | `event-log/eventTypes.ts` (add to `EventTypes` object) |
| Emit event from action | `import { emitGameEvent } from "@/utils/event-log/emit"` |
| Check qualification in layout | Use `hasLogisticsQualification(payload, user)` |
| Modify XP calculation | `xp/resolveLevel.ts` |
## Anti-patterns
- **NEVER** throw in `emitGameEvent` — it's fire-and-forget by design
- **NEVER** use `isRole` for permission-sensitive operations — use `hasPermission` instead
- **NEVER** hardcode qualification strings — use the constants in the qualification collection
- **NEVER** add event types without adding to `EventTypes` constants (breaks TypeScript narrowing)