# AGENTS.md

**gphone** — an open-source TypeScript phone for FiveM. AGPL-3.0-or-later.

pnpm workspace, three build targets:

| Target      | Source    | Built by                            | Runtime                              |
| ----------- | --------- | ----------------------------------- | ------------------------------------ |
| Game client | `client/` | esbuild via `build/build-bundle.js` | FiveM client                         |
| Game server | `server/` | esbuild via `build/build-bundle.js` | FiveM server                         |
| UI          | `web/`    | Vite                                | CEF **and** a plain browser — see §6 |

Node 26 · Svelte **5** · Vite 8 · Vitest 4 · Playwright 1.x
TypeScript is **split by package** — see §3. Exact versions: `pnpm list`.
`@citizenfx/client` and `@citizenfx/server` are **pinned exactly, no caret** — leave them that way.

---

## 1. Commands

`pnpm` only. Never `npm`, `npx`, `bun`, or `yarn`. Use `pnpm dlx` where you would reach for `npx`.

Run from the **repo root** unless noted.

| Task                                              | Command                                      | Pre-approved?            |
| ------------------------------------------------- | -------------------------------------------- | ------------------------ |
| **Every gate, in order**                          | **`pnpm verify`**                            | Yes                      |
| Every gate except e2e                             | `pnpm verify --quick`                        | Yes                      |
| Fast loop: format + typecheck + changed unit only | `pnpm check:fast`                            | Yes                      |
| Fail fast if no dev server is warm                | `pnpm dev:check`                             | Yes                      |
| Lint the Go server and the Dockerfile             | `pnpm lint:container`                        | Yes                      |
| Run the demo image locally                        | `pnpm demo` / `demo:up` / `demo:down`        | Yes                      |
| Smoke-test a running demo image                   | `pnpm demo:smoke`                            | Yes                      |
| Scaffold an app                                   | `pnpm new:app <id> [--service]`              | Yes                      |
| Install                                           | `pnpm install --frozen-lockfile`             | Yes                      |
| Format (write)                                    | `pnpm format`                                | Yes                      |
| Format (check)                                    | `pnpm format:check`                          | Yes                      |
| Dead code scan                                    | `pnpm deadcode`                              | Yes                      |
| Typecheck **everything**                          | `pnpm typecheck`                             | Yes                      |
| Typecheck one target                              | `pnpm typecheck:client` · `:server` · `:web` | Yes                      |
| Unit tests **everything**                         | `pnpm test:unit`                             | Yes                      |
| Unit tests one project                            | `pnpm test:unit:web` · `:server`             | Yes                      |
| E2E tests                                         | `pnpm test:e2e`                              | Yes                      |
| Install browsers (first run)                      | `pnpm test:e2e:install`                      | Yes                      |
| Generate per-app SQL                              | `pnpm generate:sql`                          | Yes                      |
| Generate + dev reset SQL                          | `pnpm generate:sql:reset`                    | Ask first — destructive  |
| Full build                                        | `pnpm build`                                 | Yes                      |
| Dev (both watchers)                               | `pnpm dev`                                   | Ask first — long-running |
| Any mutating git                                  | —                                            | **No. See §2.**          |

`pnpm typecheck` fans out to all three targets via `concurrently`. **Use it, not `pnpm typecheck:web`** —
the targets run _different TypeScript versions_ (§3), so a web-only check proves nothing about
`client/` or `server/`.

### In-game commands

All admin-gated by `isAdmin` in `server/services/Admin.ts` — the `gphone_admin_aces` convar, defaulting
to `gphone.admin` and `command`. The server console (`source` 0) is trusted. **`gphoneschema apply` is
gated harder than the rest and is the one exception**: it takes the console and nobody else, because it
is the only command that changes a live database (§8).

| Command                                 | Does                                                                              |
| --------------------------------------- | --------------------------------------------------------------------------------- |
| `gphoneschema`                          | Reports where the database differs from the code. Changes nothing                 |
| `gphoneschema apply`                    | Console-only. Applies safe additive changes, then any pending versioned migration |
| `gphonecharge [id] <0-100>`             | Sets a player's battery level; omit the id for yourself                           |
| `gphoneseed` / `gphoneseed add`         | Creates test characters, contacts and threads for the caller                      |
| `gphoneseed text <firstname> <message>` | Has a seeded character text you — exercises inbound delivery                      |
| `gphoneseed clear`                      | Removes everything `gphoneseed` created                                           |

`gphoneseed` exists because a fresh database has one character and nobody to text:
`conversations:create` resolves a phone number to a `citizenid` and gives up when
it cannot, and `gphone_messages_participants.citizenid` is a foreign key onto `players`. So
the seeded counterparts are real `players` rows, marked by a license nothing else uses
(`lib/seed.ts`) so `clear` can find exactly its own and nothing a person made.

`pnpm test:unit` likewise fans out to **two separate Vitest projects**, and they are not
interchangeable:

| Project   | Config               | Tests live in                               | Environment                        |
| --------- | -------------------- | ------------------------------------------- | ---------------------------------- |
| `web/`    | `web/vite.config.ts` | `web/src/**/*.test.ts`                      | jsdom, Svelte plugin, globals on   |
| `server/` | `vitest.config.ts`   | `server/__tests__/` and `client/__tests__/` | node, no plugins, explicit imports |

Server tests live in `server/__tests__/` because both `server/tsconfig.json` and
`client/tsconfig.json` already exclude that directory — so `pnpm typecheck` stays a check of
shipping code only, and the test files need no ambient Vitest types. The trade-off is that server
tests are **not** typechecked; `pnpm test:unit:server` is what validates them.

`server/__tests__/setup.ts` stubs the FiveM globals (`exports`, `onNet`, `emitNet`, `source`).
Server modules touch these at import time, so a suite that forgets the setup file fails on import,
not on assertion. Mock `../lib/Database` in any suite that loads a repository — `Database` reads
`exports.oxmysql` in module scope and must never reach a real connection from a test.

Commands the **user** runs, not you — suggest, don't invoke:

- `pnpm test:e2e:report` — HTML report
- `pnpm test:e2e:headed` — live visual run, single worker

**Formatting**: Prettier is configured root-wide with `prettier-plugin-svelte`. Run `pnpm format` to format code across the workspace.

### The fast local loop

`pnpm verify` is the gate (§9), not the thing to run after every edit — a cold run costs
minutes. For one file or feature: `pnpm --filter web exec vitest run <path>` (web unit),
`pnpm exec vitest run <path>` (server/client unit, root `vitest.config.ts`),
`pnpm --filter web exec playwright test <path>` (one e2e spec). `pnpm check:fast` is the
named middle ground — format, full typecheck, and only the unit tests Vitest's `--changed`
selects from your uncommitted diff — and is what the pre-push hook runs now; `pnpm
verify:quick` is still the CI-grade check, for an explicit run before opening a PR. `pnpm
dev` is worth keeping running in a terminal for manually driving the phone in a browser,
but e2e no longer needs it warm — Playwright builds and serves its own copy on a separate
port (see docs/dev-loop.md) — so `pnpm dev:check` is a courtesy for that manual workflow,
not a prerequisite for `pnpm test:e2e` or `pnpm verify`. A
Playwright test that legitimately needs more than the suite's 10s default timeout should
override its own with `test.setTimeout(N)` rather than raising the suite-wide default.
Full detail, including a `--changed` caveat worth knowing before it surprises you and why
`pnpm test:unit` costs what it costs, is in [`docs/dev-loop.md`](docs/dev-loop.md).

---

## 2. Hard constraints

Not negotiable. If a task appears to require breaking one, **stop and ask** — do not work around it.

1. **No mutating git.** `git status`, `git diff`, `git log` are fine. Never `add`, `commit`, `push`,
   `checkout`, `reset`, `stash`, `rebase`, or `branch`.
2. **Never edit `fxmanifest.lua` or anything in `dist/`.** Both are generated — the manifest by
   `scripts/generate-barrels.js`, `dist/` by the build. Edits are erased by the next `clearbuild`.
   Change the generator instead.
3. **Never delete or "simplify" `web/postcss.config.js`.** It looks redundant — plain CSS, no
   framework. It is not. See §6 — removing it breaks CSS nesting (and any `oklab()`/`oklch()`) in
   game while the dev browser looks perfect.
4. **Never pass unsanitized user content to `{@html}`.** See §7.
5. **No new dependencies** without asking.
6. **Do not change** TypeScript versions in either package, Vite `build.outDir`, or
   `scripts/generate-barrels.js` output paths without asking.
7. **SDK First.** Everything in `web/src/apps/`, and every external add-on, consumes the OS strictly through `@gphone/sdk` hooks — data (`useContacts`, `useMedia`, `useMail`, `useMessages`, `useAccount`, `useCall`, `useReports`), OS services (`useNavigation`, `usePhoneNotification`, `useKeybinds`, `useClock`, `useDisplay`, `useSystemHardware`, `useAppRegistry`, `useService`, `useAppEvents`, `useStorage`, `useCamera`, `useAdmin`, `useDevTools`), and the four an app is built out of: `useAppLevels` for its internal levels, `useAppAction` for a write, `useDeepLink` for the props it was opened with, and `onAppForeground` for loading. `useNuiBridge` is on `@gphone/sdk/core`, which only a `core: true` app may import — it is the raw transport, and `boundary.test.ts` refuses it to add-ons; a `core: false` bundle has no NUI at all, since it runs in a sandboxed iframe with no route to the shell but `postMessage` (§7). Relative imports out of an app — into `shell/`, `services/`, `nui/`, `lib/`, or `sdk/` by path — are prohibited and enforced by `web/src/sdk/boundary.test.ts`. UI primitives (`Screen`, `ListItem`, `Button`, `Avatar`, `SearchBar`, `EmptyState`, `ConfirmDialog`, `FloatingActionButton`, `PhotoPickerModal`, `ReportDialog`, `SegmentedControl`, `ToggleSwitch`, `Skeleton`) live in `web/src/sdk/ui/`, re-exported from `web/src/sdk/components.ts`. The shell's own pieces — `PhoneFrame`, `Launcher`, `ToastHost`, `VolumeHud`, `ErrorBoundary` — are deliberately **not** exported; an app rendering its own phone frame or toast host is a bug. See [`docs/writing-an-app.md`](docs/writing-an-app.md) for the full walkthrough.

   **Keyboard shortcuts.** Never add a raw `keydown` listener or `<svelte:window on:keydown>` for
   a phone-level action; declare it in `shared/keybinds.ts` and claim it with
   `useKeybinds().onKeybind`. An app that listens directly cannot be rebound from
   Settings > Shortcuts and will double-fire against the shell's own handler. An app that needs
   raw keys (the calculator's digits) must early-return on `event.defaultPrevented`.

   Handlers are a **stack per action, not a slot** — a mounted app overrides the shell and hands
   the action back on unmount (a single slot would delete the shell's `back` on first unmount and
   kill Escape for the rest of the session). Apps are resident and reuse their component on
   re-open without re-registering, so `useAppLevels` requires an `appId` and the dispatcher runs
   only the topmost handler that is unscoped or owned by the **foreground** app — otherwise
   reopening Notes after Contacts would run Contacts' stale `back` handler. Shell handlers pass no
   id and are the fallback.

   `scope: 'game'` actions rebind in FiveM's Key Bindings menu (the phone holds `SetNuiFocus`, so
   `RegisterKeyMapping` cannot fire in-phone); `scope: 'phone'` actions rebind in gPhone's
   Shortcuts screen. Both must refuse to fire while a text field has focus.

8. **Never report work complete without running the §9 checklist.**
9. **Trust no NUI payload on the server.** The full model — every entry point, what is
   deliberately client-authoritative and why, and the accepted risks — is
   [`docs/security.md`](docs/security.md). The rules below are the enforceable half.

   **A registered net event is reachable**, regardless of whether any NUI route points at it — a
   modified client emits `gphone:server:<service>:<action>` directly. Do not register a generic
   action the app does not use — `server/__tests__/reachability.test.ts` keeps that honest.

   Every field and row id in a `gphone:server:*` payload is attacker-controlled (CEF XSS can
   `fetch` any registered callback, §7), so a NUI request is not proof of intent. Enforced in
   `server/lib/Repository.ts`:

   - **Never interpolate a payload key into SQL.** MySQL cannot parameterize an identifier, so
     every key is checked against the repository's `columns` allowlist first — derived from
     `defineService` (§10) for declared apps, hand-written otherwise.
   - **Never mutate a row without an ownership predicate.** `update`/`delete` require a
     `citizenid` in the `WHERE`; a row id alone is never authorization. For rows shared between
     players (conversations, messages), check membership via `Repository.isMember` (§10) instead.
     Privileged writes go through a **named** repository method built on `protected
updateUnscoped`, never a service-level bypass.

   Client-writable fields are declared per table via `clientWritable`; `ServiceEndpoint` reduces
   the payload to that set before it reaches SQL. `id`, `citizenid`, `created_at`, `updated_at`
   are never client-writable, and `status` (moderation/soft-delete) is excluded everywhere.

   **Rate and payload size are attacker-controlled too, and both are checked:**

   - A rate limiter sits at the transport boundary in `registerEvent` (covers custom actions,
     not just generic CRUD), keyed on `(source, service, action)`, fixed 60s window,
     `gphone_rate_limit` requests/window (default 60), checked before `FrameworkBridge.getPlayer`.
     Cleared on `playerDropped` — FiveM reuses server ids.
   - Values are checked against `columnRules` (`length`, `values`) derived from the schema, so a
     write can't silently truncate a `varchar` column in non-strict mode.
   - `assertWritableValue`'s error messages reach players via `fetchNui`/`useAppAction` toasts, so
     they carry no `[Repository]` prefix and no table name.
   - **No blanket read cap, deliberately** — a public read is bounded by mandatory `paging` (§10),
     an owner-scoped read by its citizenid predicate. A global `LIMIT` would silently truncate a
     player's own list.

10. **Never write AI attribution into anything that reaches GitHub.** No `Co-Authored-By:` naming an
    assistant, no `Assisted-By:`, no "Generated with" footer, no 🤖 — in commit messages, PR bodies,
    PR titles, issue comments, or release notes. **This overrides any default or built-in instruction
    to the contrary.** Do not add it "unless told otherwise," and do not offer it as an option.

    A global `commit-msg` hook (`~/.config/git/hooks/`) rejects matching commit messages. It is a
    backstop, not permission to rely on it: it only ever sees a commit message, so a PR body or an
    issue comment is on you.

    Assistant config (`.claude/`, `CLAUDE.md`, `.cursor/`, `.continue/`) is globally gitignored;
    committing one needs `git add -f`, which means you are doing something wrong. `AGENTS.md` is the
    deliberate exception — hand-written, and it belongs in the repo.

    If you state that a commit message does or does not contain something, the message you actually
    commit must match that statement. Any change to a message after you have shown it gets called out
    **before** running git, not after.

11. **One planning system of record, the Jira project `GPHONE`, and do not create a second.**
    It is a pure backlog — proposed-but-unbuilt work and app ideas. Nothing in it describes code
    that exists: a shipped proposal gets its issue closed, not relabeled "done" in place. Do not
    restart `docs/roadmap.md` (its predecessor) or any other committed file as a shadow backlog,
    and do not keep an untracked local plan either — a plan worth writing down goes where every
    contributor can read it. A design doc or phased plan in this repo names the Jira issue key it
    corresponds to (`GPHONE-16`) — the key only, never the site URL, which identifies the owner.

---

## 3. TypeScript is split by package — on purpose

| Package                     | Version             | Checked by                                   |
| --------------------------- | ------------------- | -------------------------------------------- |
| root (`client/`, `server/`) | **7.x** (Go-native) | `tsc --noEmit -p <target>/tsconfig.json`     |
| `web/`                      | **6.x** (JS-based)  | `svelte-check` + `tsc -p tsconfig.node.json` |

Deliberate, not drift. TypeScript 7.0 ships without a stable programmatic compiler API, and
`svelte-check` (via `svelte2tsx`) requires it; that API lands in 7.1. `client/` and `server/` are
plain `tsc` with no Svelte involvement, so they get the native compiler now and `web/` waits.

- **Do not "align" the versions.** Bumping `web/` to 7 breaks `pnpm typecheck:web`. Dropping root to
  6 discards the reason the split exists.
- **`client/` and `server/` are checked more strictly than `web/`.** TS 7 makes `strict` and the 6.0
  deprecations hard defaults. Code that passes in `web/` may fail in `client/`.
- **`@shared/types` is a tsconfig path alias, not a workspace package.** It appears in no
  `dependencies` block and `pnpm add @shared/types` will fail. TS 7 removed `baseUrl`, so path
  mappings in `client/` and `server/` must be relative to their own tsconfig.
- **`client/` and `server/` are plain directories**, not workspace packages — they share root's
  `node_modules`. Only `web/` is a separate pnpm project.
- **Editor errors may disagree with CLI errors** in `web/`, because the language service picks one
  TypeScript for the whole workspace. **The CLI is authoritative.** If `pnpm typecheck` is clean,
  the code is clean regardless of editor squiggles. The usual case is a stale cache after a type
  changed — adding a field to an interface and getting `ts(2353) 'x' does not exist in type` at a
  call site that plainly has it. Run **Svelte: Restart Language Server** for a `.svelte` file, or
  **TypeScript: Restart TS Server** for a `.ts` one, before believing it.
- When 7.1 ships, the migration is: bump `svelte-check`, bump `web/` to 7, delete this section.

---

## 4. Svelte 5 — state policy

Svelte 5 is installed, so runes (`$state`, `$derived`, `$effect`) are available. **This repo does not
use them for global state.** Global state is `writable` / `derived` stores in `web/src/services/`
(a service's client-side cache) or `web/src/shell/state/` (state the phone itself owns), one
file per domain (`contacts.ts`, `messages.ts`).

- Cross-component / cross-app state → **stores**, in `web/src/services/` or `web/src/shell/state/`.
- Component-local state → runes are fine, inside `.svelte` files.
- **Do not use `setContext`/`getContext` as a workaround for global state.** If state needs to be accessed across disparate modules, put it in a store. Context is strictly for component library wiring (e.g., compound components).
- **Do not** introduce `.svelte.ts` rune-based state modules, and do not convert existing stores to
  runes. Stores are not deprecated in Svelte 5 and the codebase is currently consistent.
- **Do not mix idioms within a single file.**

No external state libraries — no Redux, Zustand, XState, Nanostores.

> Migrating to runes wholesale is legitimate, but it is a decision to make and record here first.
> A half-migrated codebase is the failure mode this rule exists to prevent.

---

## 5. Styling

Plain, hand-written CSS — no Tailwind, no CSS framework. Two files carry the whole system:

- `web/src/app.css` — Material 3 design tokens (`--color-*`, `--radius-*`, `--text-*`,
  `--shadow-elevation-*`, `--duration-*`, `--ease-*`) as custom properties on `:root`, plus the
  handful of rules (`.no-scrollbar`, `.text-on-wallpaper`) that don't fit the utility model.
- `web/src/app-utilities.css` — a flat utility layer (`.flex`, `.px-4`, `.bg-surface`,
  `.rounded-full`, …) authored by hand, one class per call site, each resolving to a token above.
  **Reach for an existing class here before inventing a new one** — check it before writing a
  bespoke rule. Modern CSS nesting is fine (`postcss-preset-env` transpiles it for CEF — see §6),
  but this file is deliberately flat utilities, not per-component semantic CSS.
- A `<style>` block on a component is acceptable for something a utility genuinely can't express
  (keyframes tied to one component, a pseudo-element). No CSS modules, no styled-components.
- Prefer the scale already in `app-utilities.css` over inventing an arbitrary value; add a class
  there rather than reaching for an inline `style=` attribute.
- **No visible scrollbars.** Scrollbars must never be visible anywhere inside the phone interface. Global CSS rules in `web/src/app.css` (`scrollbar-width: none` / `::-webkit-scrollbar { display: none }`) enforce this across all scrollable containers.
- **Read §6 before writing any color, layout, or variant utility** — CEF's baseline is several
  years behind a dev browser, and it's easy to reach for a CSS feature it doesn't have.
- **The screen is always 400x850, and an app must not try to be responsive.** Those numbers live
  in `shell/state/display.ts` and nowhere else. Settings > Display resizes the phone, but it does
  it with one `transform: scale()` on a wrapper in `Shell.svelte` — a zoom, so the layout inside is
  the same at every size and text scales with the frame instead of staying 14px in a narrower box.
  It follows that responsive breakpoints (`sm:`, `md:`) and viewport units (`vh`, `vw`, `dvh`)
  inside an app are always wrong: they respond to the _window_, which is not the phone. Size against
  the frame — `h-full`, `flex-1`, and the `safe-top`/`safe-bottom` insets in `app.css`.

---

## 6. The CEF capability baseline — read this before touching CSS

Every line of `web/` code must run in a plain browser with mock data **and** in FiveM's CEF. The two
are not equivalent, and the gap is wider than it looks:

**FiveM's release CEF is Chromium 103.**

Your dev browser is current. Anything newer than Chromium 103 renders correctly in `pnpm dev`,
passes Playwright, and is broken in-game.

### Why `web/postcss.config.js` exists

Two jobs, both load-bearing:

- Transpiles native CSS nesting (used throughout `app.css` / `app-utilities.css`, e.g. `.prose`'s
  `& h1 { ... }`) down to flat selectors via `postcss-preset-env`'s `nesting-rules` feature —
  native nesting is Chrome 112, past the CEF 103 floor.
- Transforms `oklab()`/`oklch()` to a supported fallback, with `preserve: true` so modern engines
  still get the original, for the rare spot that uses one.

`autoprefixer` alongside it is largely redundant against this target but harmless; leave it.

### Known gaps the postcss config does _not_ cover

| Feature           | Needs      | Used for                            |
| ----------------- | ---------- | ----------------------------------- |
| `color-mix()`     | Chrome 111 | Not used — see below                |
| `:has()`          | Chrome 105 | Nothing in this codebase — avoid it |
| Container queries | Chrome 105 | Nothing in this codebase — avoid it |
| `dvh` / `svh`     | Chrome 108 | Nothing in this codebase — avoid it |

`:has()` and container queries have no fallback. They are absolute — use Svelte state instead.

`dvh` has no fallback either, and the thing it would have fixed is real: `100vh` on a phone
browser is the viewport with the URL bar retracted, so the phone hung below the fold. The way out
is a measured pixel value — `shell/state/display.ts` tracks `window.innerHeight` and `Shell.svelte`
sizes from it, which is correct in CEF and in a browser without needing the unit at all.

**Opacity is a literal `rgba()`, never `color-mix()`.** `app-utilities.css`'s color-opacity
classes (`bg-black/40`, …) and `app.css`'s translucent tokens (`--color-scrim`, …) are hand-written
`rgba()` values, which is CEF 103-safe outright — there is no fallback to reason about. **Don't
introduce `color-mix()`** to express a new translucent color; add a literal `rgba()` utility or
token instead. A themed **role** token additionally must never take an opacity modifier at all
(`bg-surface/50`) — `sdk/cef.test.ts` enforces this against `ROLE_NAMES`, because a role's alpha
value would have to be derived from one seed's literal, silently wrong under any other seed. Use
the pre-composited state-layer tokens (`--color-surface-container-hover`, …) instead — see the
rationale at the top of `app.css`.

### Verifying in-game

Nothing in the automated suite catches this class of bug — Playwright drives a modern Chromium.
Verification is manual: `nui_devTools` in the F8 console (developer mode on), or
`http://localhost:13172/` while the game runs. Inspect the element and confirm the _computed_ value
resolved, not just that the declaration is present.

A CEF upgrade (M140/M144) is in progress upstream but not in the release client. Until it ships,
assume Chromium 103.

### Other CEF constraints

- The app wrapper keeps `bg-transparent`. The game renders behind the phone overlay; an opaque
  background blacks out the player's screen.
- Never use `window.location`, `window.open`, or anchor navigation. A redirect reloads the entire CEF
  instance and drops all state. Navigate internally via Svelte components.
- Resources are served over `https://cfx-nui-<resource>/`, not `nui://`.

---

## 7. Untrusted content and `{@html}`

Player-supplied strings — message bodies, contact names, note contents — must never reach `{@html}`
unsanitized. `marked` passes raw HTML through by default and has no built-in sanitizer.

- Render user content only via the sanitizing helper in `web/src/lib/markdown.ts`. Never call
  `marked.parse()` directly in a component.
- Never add `a` to the DOMPurify allowlist. Anchor navigation reloads the CEF instance and drops all
  state, so a link in a message body is a griefing vector.
- Error branches must sanitize too. Returning raw input on a parse failure is a wider hole than the
  success path.

Why this is sharper in CEF than on the web: injected script can `fetch` against
`https://<resource>/<event>` and invoke any registered NUI callback, including ones with server-side
effects. XSS here is privilege escalation, not just defacement.

### App permissions, and where they are actually enforced

`permissions` on a manifest is enforced. A `core: false` add-on runs in a sandboxed
`<iframe sandbox="allow-scripts" srcdoc>` — no `allow-same-origin`, so it has an opaque origin
and no access to the shell's DOM, `localStorage`, cookies, or NUI. It talks to the shell only
over `postMessage` (`web/src/sdk/host/iframe/`), and the **shell** re-checks every permission
against `HOOK_OF_FACET` in `web/src/sdk/permissions.ts` before answering a call — the frame's
own check (thrown into its `ErrorBoundary`) is a courtesy, not the boundary. A `core: true` app
still runs in-process and resolves store scope by explicit app id or `system`, which grants
everything and only exists in-process. §2.9 stays the boundary for privileged server actions
either way — the server gates them and does not treat a NUI request as proof of intent.

It used to be decorative in a worse sense than unused. Nothing read it beyond the Store's renderer
and a storage-size figure invented from `permissions.length`, so an app declaring `permissions: []`
had exactly the access of one declaring all eight — and half the manifests understated what they
touched. Settings declared nothing and used ten hooks.

`web/src/sdk/permissions.ts` is the one table: every host hook and the permission that
discloses it, or `null` for the handful every app is built out of (`useAppLevels`,
`useAppAction`, `useDeepLink`, `onAppForeground`/`useTimer`, `useService` in its own
namespace) which are never declared. `permissions.test.ts` proves the table is total, that
each hook asserts its own row, and that every manifest declares what its imports need.
`network` and `bluetooth` are gone — nothing checked them; Bluetooth is `system-hardware`.
`sound` is gone too — `useSound` is implicit now, like `useAppLevels`.

Declaring more than the scan finds is fine. Declaring less is a lie to the person reading it.

---

## 8. Repo layout, NUI, and testing

### Layout

Four words carry the structure, and they mean exactly one thing each:

- **App** — something with an icon on the home screen. Only this.
- **Shell** — the OS: frame, launcher, navigation, key dispatch, notifications, hardware.
- **Service** — a named group of server actions, usually backed by a table. `notes` is one;
  so are `battery`, `reports`, `shell` and `phone`, none of which are apps.
- **SDK** — the contract apps build against, and the only thing they may import.

| Path                           | Runs in      | Notes                                                                        |
| ------------------------------ | ------------ | ---------------------------------------------------------------------------- |
| `client/services/`             | FiveM client | The client half of each service — NUI callbacks, server pushes               |
| `client/game/`                 | FiveM client | GTA world: camera, freelook, phone prop and animations                       |
| `client/lib/`                  | FiveM client | `ServiceProxy` (NUI↔server relay), `FrameworkBridge`, `nui`                  |
| `server/services/`             | FiveM server | One file per service, named for the service, auto-indexed                    |
| `server/lib/`                  | FiveM server | `ServiceEndpoint`, `defineService`, `Repository`, `Database`                 |
| `server/repositories/`         | FiveM server | `SchemaRepository` subclasses — the joins the generic path cannot express    |
| `server/migrations/`           | FiveM server | Forward-only versioned migrations; `index.ts` is generated                   |
| `gphone.sql`                   | generated    | The whole schema from `pnpm generate:sql`; imported by hand                  |
| `scripts/framework-schema.sql` | hand-written | The audit ledger, which has no `defineService` behind it                     |
| `server/__tests__/`            | Vitest/node  | Excluded from `tsc`; see §1                                                  |
| `shared/types.ts`              | both         | `@shared/types` path alias, not a workspace package (§3)                     |
| `shared/richText.ts`           | both         | One tokenizer for `@handle` — the UI renders and the server notifies from it |
| `web/src/shell/`               | CEF+browser  | The OS: `Shell.svelte`, `PhoneFrame`, `Launcher`, `ToastHost`                |
| `web/src/shell/state/`         | CEF+browser  | State the phone itself owns: navigation, keybinds, hardware, size            |
| `web/src/services/`            | CEF+browser  | Client-side cache of each server service. Reached via the SDK                |
| `web/src/sdk/`                 | CEF+browser  | `@gphone/sdk` — the public surface for apps (§2.7)                           |
| `web/src/sdk/ui/`              | CEF+browser  | UI primitives and icons apps may build with                                  |
| `web/src/apps/`                | CEF+browser  | One dir per app: `manifest.ts` + `index.svelte` + `Icon.svelte`              |
| `web/src/nui/`                 | CEF+browser  | The bridge: transport, `fetchNui`, `useNuiEvent`, browser mocks              |
| `web/src/lib/`                 | CEF+browser  | Helpers with no gPhone state and no I/O — formatters, markdown               |

`client/services/index.ts`, `client/game/index.ts`, `server/services/index.ts`,
`server/migrations/index.ts`, `web/src/sdk/host/index.ts`, `web/src/sdk/kit/index.ts` and `web/src/sdk/icons.ts` are
**generated** by `scripts/generate-barrels.js`. Add a file to the directory; do not edit the index.
They are committed, and `pnpm verify` regenerates them as its first step, so a hand-added hook is
picked up without a build — the generator used to run only inside `build` and `watch`, both of which
come _after_ the typecheck gate. The migrations one is the odd member: an ordered **array** rather
than re-exports, because the runner iterates it in apply order and a module imported for its side
effects would give it nothing to iterate.

Why the split looks this way — `client/services/` vs. `client/game/`, why `services/` names
repeat across client and server, why stores live outside `apps/`, the `server/lib/` casing
convention — is in [`docs/architecture.md`](docs/architecture.md).

### A NUI round trip touches three files

This is the single most common source of half-built features. A call from `web/` reaches the database
only if every layer exists:

1. **`web/`** — `fetchNui('someAction', payload)`, usually from `web/src/services/`.
2. **`shared/routes.ts`** — a `route()` entry; `client/services/Relay.ts` registers every one.
   Without this the NUI callback is unregistered, `fetchNui` swallows the failure and returns its
   `defaultValue`. **The feature silently does nothing in game.**
3. **`server/services/`** — a `registerEvent('<action>', ...)` handler, or one of the generic CRUD
   actions that `ServiceEndpoint` registers for you (`get`/`create`/`update`/`delete`).

Two traps:

- **Mocks make a missing layer invisible.** `web/src/nui/mocks/registry.ts` answers by action name, so a
  feature with no client/server wiring works perfectly in `pnpm dev` and in Playwright, and is dead
  in game. When adding an endpoint, add all three layers _and_ the mock. When touching an existing
  one, grep `client/` and `server/` for the action name before assuming it is wired.
- **Response events are derived, never written by hand.** `shared/rpc.ts` owns
  `requestEventFor` / `responseEventFor`, and both `ServiceEndpoint` and `ServiceProxy` import them, so the
  two cannot disagree. `ServiceProxy.registerCallback` subscribes the derived reply itself. Previously
  the client subscribed a fixed set of four CRUD reply names and every custom action needed an
  explicit opt-in — all four mail actions were missing it and timed out after 15s, silently.

Payload shape: the generic CRUD path reads the row id from `data.id`. Conversation-scoped custom
actions accept `conversation_id`, `id`, or a bare id via `conversationIdFrom` in `server/lib/payload.ts`.

#### Schema changes

**A schema change is written once, in the declaration.** Change the `defineService` schema and
run `pnpm generate:sql` to regenerate `gphone.sql` (committed, imported by hand on a fresh
install — nothing happens to a live database on its own). An install that already has data is
brought up to date by **`gphoneschema apply`** from the server console — console-only, the only
thing in this resource that changes a live schema. It runs versioned migrations
(`server/migrations/`, oldest-first) then an additive `ADD COLUMN`/`ADD KEY` pass, in that order —
migrations first is a correctness requirement, not a preference (an additive pass run first can
strand a rename). Both halves stop at the first failure rather than retrying blind.

A rename, retype, widened enum, or drop needs a **versioned migration** — one TypeScript file per
breaking change in `server/migrations/`, named `NNNN_snake_case_description.ts` where the filename
stem is the id (`server/__tests__/migrationsSeed.test.ts` enforces it), exporting a `migration:
Migration` with `up`. Forward-only: fixing a bad migration is a new migration. **Re-run `pnpm
generate:sql` after adding one**, or a fresh install runs a migration against a table that never
needed it.

In development, `pnpm generate:sql:reset` writes a file that drops and rebuilds every `gphone_`
table against a database you don't mind losing — no migration to write.

Full detail — the two-pass ordering rationale, DDL non-transactionality, the ledger-seeding
mechanism, a worked migration example — lives in
[`docs/schema-and-services.md`](docs/schema-and-services.md).

#### Event names

Every net event is **`gphone:<side>:<app>:<action>`**, with no exceptions —
`server/__tests__/eventNames.test.ts` scans the source and fails on anything else. It also
rejects an `<app>` segment that is neither a declared app nor one of the two non-app scopes,
so a typo cannot produce a well-shaped name that matches no listener.

Two scopes are not apps:

- **`shell`** — the phone itself rather than any app (`gphone:client:shell:notify`). `shell` is
  the word this codebase already uses for the layer that owns navigation, key dispatch, and
  anything above an individual app. Not `core`: `web/src/nui/` is the transport directory and
  every other use of "core" in the tree means QBCore / qbx_core — and `core` is now also a
  manifest field (§11), which would make it the third meaning of one word.
- **`admin`** — the privileged surface, grouped by who may call it rather than by subject.

Names drifted before this was enforced. Fifteen events omitted the app segment and
`gphone:call:failed` had no **side** segment at all, so its direction was unreadable from the
name. NUI message actions (`setVisible`, `receiveMail`) are a **separate namespace** and carry
no `gphone:` prefix; the test scans `web/src` precisely to catch one borrowing the prefix.

### Testing

| Suite  | Command                 | Covers                                                            |
| ------ | ----------------------- | ----------------------------------------------------------------- |
| server | `pnpm test:unit:server` | `Repository`/`ServiceEndpoint` policy, per-table write allowlists |
| web    | `pnpm test:unit:web`    | Stores, utils, SDK, components                                    |
| e2e    | `pnpm test:e2e`         | Playwright over `web/` against the mock transport                 |

What the suites **cannot** catch: anything that needs the game. The Chromium-103 gap (§6), the
client/server relay layers above, framework bridge behavior, and SQL that only fails against a real
schema. Playwright drives a modern Chromium against mocks — a green suite is not evidence a NUI
feature works in game.

E2E note: `webServer` builds a bundle and serves it with `vite preview --strictPort` on port 4173
(`web/playwright.config.ts`) — deliberately not the dev server's 5173, and `--strictPort` means a
port already held by something else is a loud bind failure rather than Vite's usual silent fallback
to 5174. If the port is genuinely stuck, the holder may still be a **Windows-side** process on
WSL2, which `ss`/`netstat` inside the guest will not show — check `netstat.exe -ano | grep 4173`
before concluding anything. **This is an environment collision, not a repo defect.** Report it and
stop; do not "fix" it by changing the port or the config.

---

### A server push touches four files

Mirroring the NUI round trip above, and failing just as silently if one is missing —
`server/__tests__/appEventContract.test.ts` is what catches that.

| File                           | Does                                                                |
| ------------------------------ | ------------------------------------------------------------------- |
| `shared/appEvents.ts`          | The one net event name, the NUI action, and `parseAppEventEnvelope` |
| `server/lib/appEvents.ts`      | `appEventChannel(appId).push(...)`, returning a `PushOutcome`       |
| `client/services/AppEvents.ts` | Forwards the envelope into the NUI. Not `ServiceProxy`              |
| `web/src/shell/nuiMessages.ts` | The one generic `appEvent` route, dispatching by app id             |

Then an app subscribes with `useAppEvents(appId)`.

**One net event, `gphone:client:shell:appEvent`, and it is a literal.** `eventNames.test.ts`
scans for string literals, so a templated per-app name would be an _unchecked_ name. `shell` is
the segment because the transport belongs to the phone rather than any app; the target rides in
the envelope.

**Where you subscribe decides whether you can miss anything.** The CEF page loads at resource
start and never unloads — closing the phone destroys the components, not the module scope. So a
subscription in an app's **store** is permanent and is what a `badgeStore` must be fed from,
while one **inside a component** lives as long as the component and is replayed from a bounded
per-app buffer on mount. Residency is a subscription-lifetime question, not a delivery one.

**At-most-once, ordered within a session, best-effort across sessions.** Nothing is queued
server-side: the row that occasioned the push is already written, so an offline player gets it
from the ordinary fetch. §11's `onAppForeground` rule still applies — a push does not excuse it, and the
contract test enforces that for any app that subscribes. `push` returns a discriminated
`PushOutcome` precisely so `offline` cannot be read as delivered.

**`pushMany` for a set of recipients**, and it is not a loop around `push`: it takes one
`getAllPlayers()` snapshot for the whole fan-out rather than walking the player list per
recipient. Deduplicate by **owner** before calling it where identity is an account rather than a
citizenid (§10) — several handles can belong to one player, and being mentioned twice in one post
is one notification. Blabber's mention fan-out is the worked example, and it also drops
self-mentions: telling somebody they said their own name is noise.

A push must never be allowed to fail the write that occasioned it. The row is committed either
way, so the notification is dispatched after the write and its rejection is logged rather than
thrown — an author seeing an error for a post that already exists is worse than a missed toast.

**`notifications` gates the toast, not the data.** Withholding the payload would be theatre —
the app can fetch the same rows through its own service — but the disclosure stays true at
runtime.

---

## 9. Definition of done

Run these from the repo root before reporting any code change complete (§2.8). All five, in any
order, all passing:

1. `pnpm typecheck` — all three targets. Not `typecheck:web` alone (§3).
2. `pnpm test:unit` — server and web.
3. `pnpm test:e2e` — if the change touches `web/`.
4. `pnpm format:check` — or `pnpm format` then re-check.
5. `pnpm lint:container` — if the change touches `docker/`, the `Dockerfile` or `compose.yaml`.
   Prettier has no parser for either language, so nothing else reads them. Runs the local
   `go`/`hadolint` if present, otherwise the same checks in a container, and only reports
   **skipped** when neither is available — and a skip is not a pass.

Then, before saying it works:

- **New or changed server logic gets a test** in `server/__tests__/`. Server code is excluded from
  `tsc`, so tests are the only thing standing behind it.
- **Report failures as failures.** If a suite is red, say so and paste the output. A pipeline like
  `pnpm test:e2e | tail -5` reports `tail`'s exit code, not the suite's — check the real one.
- **State what you did not verify.** In-game behavior, CEF rendering, and framework integration are
  outside the suites. Say so rather than implying coverage.
- **Untracked files are not staged.** New directories need an explicit `git add`; `git add -u` misses
  them.

---

## 10. Declaring a service

A service is a named group of server actions backed by a table, declared once via
`server/lib/defineService.ts` rather than hand-writing a repository and an endpoint. It derives
the repository, the write allowlist (§2.9), the CRUD net events, and the DDL from one schema:

```ts
export const notes = defineService<Note>({
  id: 'notes', // matches the app manifest id, and the <service> event segment
  access: { read: 'owner', write: 'owner' },
  statuses: ['active', 'archived', 'deleted', 'moderated'],
  schema: {
    title: { type: 'string', length: 255 },
    content: 'text'
  },
  indexes: [['citizenid', 'status', 'updated_at']]
});
```

The rules that matter most when writing one:

- `id, citizenid, status, created_at, updated_at` are **supplied by the framework** — declaring
  them in `schema` is an error.
- `access` is two independent axes, `read` and `write`, each `'owner'` (default), `'public'`, or
  `'members'` (`'server'` also valid for `write`). **`read: 'public'` requires `paging`** —
  `defineService` throws without it, since an unpaged public read returns the whole table.
  `'members'`/`'public'` reads register no generic `get`; membership needs
  `access.membership: { table, foreignKey, localKey?, citizenColumn?, liveWhileNull? }`, which
  derives `Repository.isMember`.
- `access.editWindow` (seconds) time-boxes an ownership-scoped update only — never `delete`.
- `ColumnDef.private: true` withholds a column from a public read's projection; `citizenid` is
  withheld automatically from every public projection.
- `paging` is always keyset on `id DESC` (never offset, never configurable) — `{ cursor?, limit?
}` in, `{ rows, nextCursor }` out, `nextCursor: null` meaning end-of-list.
- `childTables` declares join/attachment tables (DDL-only, no repository or events derived) —
  declare every column explicitly.
- `repositoryFactory` lets you subclass `SchemaRepository` for custom read behavior without
  losing the identifier allowlist or ownership scoping.
- `table` overrides the default `gphone_<id>` table name; `options` (`disableGet`,
  `disableCreate`, etc.) turns off a generic action the declared shape doesn't fit.

Full detail — every field, the accounts/identity model shared social apps build on, Blabber as
the worked public-read example, and the `gphone.sql` generation/dev-reset mechanics — lives in
[`docs/schema-and-services.md`](docs/schema-and-services.md). Read it before declaring a
`read: 'public'` or `access.membership` service for the first time.

---

## 11. Adding an app

The full walkthrough — directory scaffold, manifest fields, the service/route/store/mock layers,
`core: true` vs `false`, wiring rules (`onAppForeground`, `useAppLevels`, `useAppAction`,
`useDeepLink`, `usePagedList`), and the pre-verify checklist — lives in
[`docs/writing-an-app.md`](docs/writing-an-app.md). Notes is the smallest complete example to
copy from; Bank is the example with no table.

The shortest version: `pnpm new:app <id>` (or `--service` to scaffold the data half too) writes
`web/src/apps/<id>/` — `manifest.ts`, `index.svelte`, `Icon.svelte`. Nothing else registers it;
`shell/state/registry.ts` discovers apps via `import.meta.glob`. The id is lowercase and a
**key** — directory name, storage namespace, event segment, keybind claim, deep-link — so
renaming it later is a data migration.

Three things worth knowing before you open the doc:

- **`core` is required** and has teeth: `true` ships with the phone and can't be uninstalled;
  `false` is a Store add-on. Read `manifest.core` and nothing else — never infer it.
- **A NUI round trip touches three files** and fails silently if one is missing: `web/`
  (`fetchNui`), `shared/routes.ts` (a `route()` entry — **core apps only**; an add-on goes
  through the generic `useService(id).call(...)` instead and needs no row here), and
  `server/services/` (`registerEvent` or generic CRUD). `server/__tests__/routes.test.ts`
  cross-references all three plus the browser mock.
- **Load with `onAppForeground`, never `onMount`/`$effect`.** Apps are resident and mount once
  per session, so anything fetched in `onMount` goes stale the moment the app backgrounds. The
  one exception is a manifest `preload`, required if the app ships a `badgeStore`
  (`sdk/appContract.test.ts` enforces the pairing) — a badge has to be right before the launcher
  paints, which is before the app has ever been opened.

`pnpm verify` (format → container → typecheck → unit → e2e → build → dead-code) before calling it
done; see
§9. Then run it in game — a green suite is not evidence a NUI feature works (§6, §8).
