From a8e147e52ac2335c53e4bfdbc2bd7b336ded929a Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 19 Aug 2026 19:48:35 -0400 Subject: [PATCH] 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. --- AGENTS.md | 50 +++++- docs/case-studies/login-return-url.md | 238 ++++++++++++++++++++++++++ src/bot/AGENTS.md | 69 ++++++++ src/collections/AGENTS.md | 81 +++++++++ src/components/frontend/AGENTS.md | 65 +++++++ src/lib/AGENTS.md | 85 +++++++++ src/utils/AGENTS.md | 57 ++++++ 7 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 docs/case-studies/login-return-url.md create mode 100644 src/bot/AGENTS.md create mode 100644 src/collections/AGENTS.md create mode 100644 src/components/frontend/AGENTS.md create mode 100644 src/lib/AGENTS.md create mode 100644 src/utils/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 22745e0..bd0dc89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` 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=`. 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`: `{ 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. diff --git a/docs/case-studies/login-return-url.md b/docs/case-studies/login-return-url.md new file mode 100644 index 0000000..b6820da --- /dev/null +++ b/docs/case-studies/login-return-url.md @@ -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 ? ( + … {children} … + ) : ( + // ← 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 `` — 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, "href">) { + const pathname = usePathname(); + return ; + } + ``` + +2. **`src/components/frontend/LandingPage.tsx`** — the CTA swaps `` for + ``. 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 ; + } + ``` + +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. diff --git a/src/bot/AGENTS.md b/src/bot/AGENTS.md new file mode 100644 index 0000000..d91ff2a --- /dev/null +++ b/src/bot/AGENTS.md @@ -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/.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 diff --git a/src/collections/AGENTS.md b/src/collections/AGENTS.md new file mode 100644 index 0000000..a212c59 --- /dev/null +++ b/src/collections/AGENTS.md @@ -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 `.ts` here, register in `src/payload.config.ts` | +| Add RBAC permission | `src/permissions/index.ts` (add to group + add check) | +| Modify collection access | `/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 diff --git a/src/components/frontend/AGENTS.md b/src/components/frontend/AGENTS.md new file mode 100644 index 0000000..1e82d4c --- /dev/null +++ b/src/components/frontend/AGENTS.md @@ -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 `` component for grid cells with slots: ``, ``, ``. 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)//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 diff --git a/src/lib/AGENTS.md b/src/lib/AGENTS.md new file mode 100644 index 0000000..cf50ef3 --- /dev/null +++ b/src/lib/AGENTS.md @@ -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) diff --git a/src/utils/AGENTS.md b/src/utils/AGENTS.md new file mode 100644 index 0000000..2c4099b --- /dev/null +++ b/src/utils/AGENTS.md @@ -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)