clusev/rules.md

528 lines
20 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

# Clusev — STRICT RULES
> **Non-negotiable.** Generated from `handoff.md` §4. These are the user's explicit,
> mandatory rules. If any instruction (mine, yours, or a future agent's) conflicts with a
> rule here, **STOP and ask** — do not "work around" it. Keep this file open while coding.
>
> *Stack note (2026-06-11): built on **Laravel 13** (handoff said 12; updated by user decision). The rules below are version-agnostic.*
---
## Quick gate — run through this before every commit
- [ ] **R1/R2** — Page is a **full-page, class-based Livewire component** mapped directly in `routes/web.php`.
- [ ] **R2** — Livewire **class** in `app/Livewire/…` **and** a matching **separate Blade view** in `resources/views/livewire/…`. **No Volt.**
- [ ] **R3** — No hard-coded hex/rgb in markup — only `@theme` token utilities (`bg-surface`, `text-accent`, …).
- [ ] **R4** — No inline `style="…"` **except** a progress bar's `width`.
- [ ] **R5** — Destructive/confirm action runs through a **wire-elements/modal**, never Alpine `confirm()`.
- [ ] **R6** — Files placed exactly per the §5 folder map.
- [ ] **R7** — Verified at **375 / 768 / 1280 px**; touch targets ≥ 44px.
- [ ] **R8** — Every PHP/Composer/Node/artisan command ran **inside the container**.
- [ ] **R9** — UI copy is **German**, **no emoji**; status via color/dots/pills.
- [ ] **R10** — Reused existing `@theme` tokens + Blade components (no ad-hoc colors/widgets).
- [ ] **R11** — Records exposed in URLs are addressed by **UUID**, never the integer PK.
- [ ] **R12** — every touched page loads in a **browser** at **HTTP 200** with **zero console errors** (the **loaded** state of `wire:init` pages, not just the skeleton).
- [ ] **R13** — route **paths + names are English** (`/settings`, not `/einstellungen`); German only in the visible label.
- [ ] **R14** — fonts are **self-hosted locally** (`resources/fonts/*.woff2` bundled by Vite + `@font-face` in `app.css`) — **no Google Fonts / CDN `<link>` or `@import`**.
- [ ] **R15****Codex reviewed the change** (`/codex:review`) and reported **no errors and no security issues** — a task is NOT done until Codex passes.
- [ ] **Secrets**`git status` shows **no** `.env*`, token, key, or home-dir dotfile staged.
---
## R1 — Pages are full-page Livewire components, mapped directly as routes
**Why.** One page = one component = one route. No controller indirection, no orphan Blade views.
Keeps routing, state, and view co-located and predictable.
**Correct**
```php
// routes/web.php
use App\Livewire\Dashboard;
use App\Livewire\Servers\Show;
Route::get('/', Dashboard::class)->name('dashboard');
Route::get('/servers/{server}', Show::class)->name('servers.show');
```
**Forbidden**
```php
// Controller returning a Blade view for a PAGE
Route::get('/', [DashboardController::class, 'index']);
class DashboardController {
public function index() {
return view('dashboard'); // ❌ pages do not belong to controllers
}
}
```
---
## R2 — Class-based Livewire only. Volt is FORBIDDEN.
**Why.** Class + separate view is explicit, testable, and conventional. Volt single-file
components hide logic in the view and are banned for this project.
**Correct**`php artisan make:livewire Servers/Show` (creates class **and** view)
```php
// app/Livewire/Servers/Show.php
namespace App\Livewire\Servers;
use App\Models\Server;
use Livewire\Component;
class Show extends Component
{
public Server $server;
public function render()
{
return view('livewire.servers.show'); // ← separate Blade file
}
}
```
```blade
{{-- resources/views/livewire/servers/show.blade.php --}}
<div>{{ $server->hostname }}</div>
```
**Forbidden**
```blade
{{-- Volt single-file component — NEVER. Do not run `php artisan make:volt`. --}}
<?php
use function Livewire\Volt\{state};
state(['count' => 0]);
?>
<div>{{ $count }}</div>
```
---
## R3 — All colors / design tokens live in `resources/css/app.css` under `@theme`
**Why.** Single source of truth for the visual system (Tailwind v4 CSS-first, no
`tailwind.config.js`). Markup references **tokens**, never raw colors — so a token change
restyles the whole app.
**Correct**
```css
/* resources/css/app.css */
@import "tailwindcss";
@theme {
--color-surface: #0F1318;
--color-accent: #FF6B2C;
--color-online: #35D07F;
--color-line: rgba(255,255,255,.10);
}
```
```blade
<div class="bg-surface border border-line">
<span class="text-accent">CPU</span>
<span class="text-online">online</span>
<div class="bg-accent/10">soft accent tint</div> {{-- opacity modifier, not a separate token --}}
</div>
```
**Forbidden**
```blade
<div class="bg-[#0F1318] border-[#ffffff1a]" style="color:#FF6B2C"> {{-- ❌ raw hex in markup --}}
```
---
## R4 — Inline styles are FORBIDDEN (one exception)
**Why.** Inline styles bypass the token system and Tailwind. The **only** legitimate dynamic
value that can't be a utility class is a progress bar's percentage width.
**Correct** — the ONE allowed inline style
```blade
<div class="h-1.5 w-full rounded-full bg-line">
<div class="h-full rounded-full bg-accent" style="width: {{ $pct }}%"></div>
</div>
```
**Forbidden**
```blade
<div style="margin-top:12px; display:flex; color:#FF6B2C"> {{-- ❌ use utilities: mt-3 flex text-accent --}}
```
---
## R5 — Destructive / confirm actions use a Modal, never an Alpine dialog
**Why.** Destructive actions (delete server, remove SSH key, stop a service) must be
deliberate, styled, and auditable. Use **wire-elements/modal** components — never native
`confirm()` or an `x-data` popup.
**Correct**
```blade
{{-- trigger: open the modal component (exact payload per installed wire-elements/modal version) --}}
<button type="button"
wire:click="$dispatch('openModal', { component: 'modals.confirm-delete', arguments: { server: {{ $server->id }} } })"
class="rounded-md px-3 py-2 text-offline hover:bg-offline/10">
Löschen
</button>
```
```php
// app/Livewire/Modals/ConfirmDelete.php (a real Livewire modal class)
namespace App\Livewire\Modals;
use App\Models\Server;
use LivewireUI\Modal\ModalComponent; // wire-elements/modal base class
class ConfirmDelete extends ModalComponent
{
public Server $server;
public function delete(): void
{
$this->server->delete(); // audit-logged in the domain layer
$this->closeModalWithEvents([/* refresh listeners */]);
}
public function render() { return view('livewire.modals.confirm-delete'); }
}
```
**Forbidden**
```blade
<button x-data @click="if (confirm('Wirklich löschen?')) $wire.delete()">Löschen</button>
{{-- ❌ no native confirm(), no Alpine confirm popups for destructive actions --}}
```
---
## R6 — Clean, conventional folder structure
**Why.** Predictable structure = fast ramp-up, no duplication, no "where does this go?".
Follow the §5 map in `CLAUDE.md` exactly.
**Correct** — component, view, model, SSH wrapper each in their canonical place
```
app/Livewire/Servers/Show.php
resources/views/livewire/servers/show.blade.php
app/Models/Server.php
app/Support/Ssh/SshClient.php
```
**Forbidden**
```
app/Http/Controllers/ServerController.php {{-- ❌ pages aren't controllers (R1) --}}
app/Livewire/show.blade.php {{-- ❌ view in the class namespace --}}
app/ServerSshHelperStuff.php {{-- ❌ unstructured dumping ground --}}
```
---
## R7 — Fully responsive: mobile + tablet + desktop
**Why.** Operators use phones and tablets in the field. Every screen must adapt — not just shrink.
**Correct**
```blade
{{-- KPI grid reflows 1 → 2 → 4; sidebar is drawer on small, fixed on large --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> … </div>
<aside class="hidden lg:block lg:w-[272px]"> … </aside> {{-- desktop sidebar --}}
<button class="lg:hidden min-h-11 min-w-11" wire:click="$toggle('navOpen')">…</button> {{-- ≥44px touch --}}
```
**Forbidden**
```blade
<div class="grid grid-cols-4"> {{-- ❌ desktop-only; breaks on mobile/tablet --}}
<aside class="w-[272px]"> {{-- ❌ always-on sidebar, no drawer --}}
```
Breakpoints to test: **375** (mobile), **768** (tablet), **1280+** (desktop).
---
## R8 — Docker-first, host stays clean
**Why.** The target host has **only Docker** + a sudo user — no PHP/Node/Composer. Everything
runs in containers, in dev and prod. Run all tooling through the `app` (or a throwaway) container.
**Correct**
```bash
docker compose exec app php artisan make:livewire Dashboard
docker compose exec app composer require some/package
docker compose exec app php artisan migrate
```
**Forbidden**
```bash
php artisan migrate # ❌ assumes PHP on the host — there is none
composer install # ❌ host has no Composer
npm run build # ❌ run node inside the vite/app container
```
---
## R9 — UI copy register: terse, operational, no emoji
**Why.** Console/status-page register: terse, operational, factual. Status is communicated
by **color/dots/pills**, never emoji. Technical identifiers stay native. German is the
default language, but every visible string is **localized** via lang files — see **R16**
(no hard-coded copy in views/components/services).
**Correct**
```blade
<x-status-pill status="online">{{ __('common.online') }}</x-status-pill>
<span class="font-mono">nginx.service</span>
<button>{{ __('services.restart') }}</button>
```
**Forbidden**
```blade
<span>🟢 Online ✅</span> {{-- ❌ emoji as status --}}
<button>Dienst neu starten</button> {{-- ❌ hard-coded copy (must be __('…'), R16) --}}
```
Keep native technical tokens as-is: `nginx.service`, `systemd`, `chmod 600`, `SSH`, `2FA`.
---
## R10 — Reuse the existing design system
**Why.** The visual system (tokens + components) is already designed ("Tactical Terminal",
softened orange). Don't reinvent colors or one-off widgets — compose from the shared kit.
**Correct** — use the Blade component kit + tokens
```blade
<x-panel title="Auslastung">
<x-kpi label="CPU" :value="$cpu" unit="%" />
<x-status-dot status="warning" />
</x-panel>
```
**Forbidden**
```blade
<div class="rounded-xl bg-[#12161c] p-4 shadow"> {{-- ❌ ad-hoc panel, raw hex --}}
<div style="color:#ffa500">CPU</div> {{-- ❌ new orange, inline style --}}
</div>
```
---
## R11 — Public URLs use UUIDs, never the numeric primary key
**Why.** Auto-increment IDs in URLs are enumerable and leak record counts/ordering. Anything
addressable in a route is referenced by an unguessable **UUID**; the integer PK stays internal
(relations, performance).
**Correct**
```php
// app/Models/Server.php — keep the bigint id internally, expose a uuid in URLs
use Illuminate\Support\Str;
class Server extends Model
{
protected static function booted(): void
{
static::creating(fn (self $m) => $m->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid'; // {server} in routes resolves by uuid, not the bigint id
}
}
// migration: $table->uuid('uuid')->unique();
```
```php
// routes/web.php — {server} binds by uuid
Route::get('/servers/{server}', \App\Livewire\Servers\Show::class)->name('servers.show');
// → /servers/9f3c0b1a-7e21-... (never /servers/42)
```
**Forbidden**
```php
Route::get('/servers/{id}', ...); // integer PK in the URL — enumerable
route('servers.show', $server->id); // exposes the auto-increment id
```
---
## R12 — Browser-verified: HTTP 200, zero console errors
**Why.** A passing `Livewire::test(...)->assertOk()` only renders ONE state in PHP — it does not
catch client-side JS errors, and for a `wire:init`/lazy page it exercises the **skeleton**, not the
loaded view. Render-time Blade/PHP errors (e.g. `@disabled` on a component → "unexpected endif", a
500) only surface on the real request. A green unit test is **not** "done".
**Rule.** Before declaring any UI change done, load **every affected page in a real browser**
(headless is fine) and confirm, per page: **HTTP 200** (no 500), **zero console errors**, **zero
failed requests**. For lazy pages, verify the **loaded** state (after `wire:init`), not the skeleton.
Never use `@disabled`/`@checked`/`@required` on a Blade **component** — use the bound `:disabled="…"`
attribute (the component attribute bag drops a `false` value correctly).
**Correct** — headless probe over the routes (captures console + HTTP status):
```bash
# mount the script into the image's home dir or require('puppeteer') fails
docker run --rm --network host -v /tmp/probe.js:/home/pptruser/probe.js -w /home/pptruser \
ghcr.io/puppeteer/puppeteer:latest node probe.js
```
```js
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
const r = await page.goto(url, { waitUntil: 'networkidle2' }); // expect r.status() === 200
await new Promise(s => setTimeout(s, 4000)); // let wire:init load, then assert errors == 0
```
**Forbidden**
```text
"Livewire::test rendered OK" → shipping without ever loading the page in a browser.
Testing only the skeleton state of a wire:init page and assuming the loaded state works.
@disabled($cond) on <x-btn …> → injects if/endif into the component tag → 500.
```
---
## R13 — Routes and URL paths are English, always
**Why.** UI copy is German (R9), but code-level identifiers — route paths, route names,
URL segments, query keys — stay **English**, like every other identifier in the codebase.
German belongs in the visible label, never in the `href`.
**Correct**
```php
Route::get('/settings', Settings\Index::class)->name('settings'); // path + name English
```
```blade
<x-nav-item href="/settings">Einstellungen</x-nav-item> {{-- German label, English path --}}
```
**Forbidden**
```php
Route::get('/einstellungen', ...)->name('einstellungen'); // German in the URL/name
Route::get('/dateien', ...); // → use /files
```
---
## R14 — Fonts are self-hosted locally, never loaded from a CDN
**Why.** A self-hosted control plane runs in private/air-gapped networks and must not leak
requests to third parties or break when the internet is unreachable. Every font ships **with the
app** — `.woff2` files in `public/fonts/`, declared via `@font-face` in `resources/css/app.css`.
**No** `<link href="fonts.googleapis.com">`, no `@import url(...)` from a CDN, no `fonts.gstatic.com`.
**Correct**
```css
/* resources/css/app.css — local file in resources/fonts/, bundled + hashed by Vite
(relative url so it resolves on the Vite dev server AND in the prod build) */
@font-face {
font-family: 'Space Grotesk';
font-weight: 300 700;
font-display: swap;
src: url('../fonts/space-grotesk.woff2') format('woff2');
}
```
> Use a **relative** `url('../fonts/…')` (not `/fonts/…`): an absolute path resolves against the
> Vite dev-server origin in dev → 404. Relative lets Vite resolve + emit the asset for both modes.
**Forbidden**
```html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk"> <!-- ❌ CDN -->
```
```css
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch'); /* ❌ CDN import */
```
---
## R15 — Codex must review every change before it is "done"
**Why.** A second, independent reviewer catches what the author misses — logic bugs, regressions,
and especially **security holes** in a control plane that holds the keys to a whole fleet. Self-review
is not enough; an external pass is mandatory before any task is considered complete.
**Rule.** After implementing and self-verifying (R12 etc.), run **`/codex:review`** over the change.
Codex must report **no errors and no security vulnerabilities**. If it flags anything, fix it and
re-run until Codex is clean. Only then is the task done. Reviewing for **security** specifically —
SSH/command injection, credential handling, authz, lock-out safety, CSP — is part of every pass.
**Correct**
```text
implement → self-check (lint + R12 browser) → /codex:review → 0 findings → done → commit
↑__________________│ (loop until clean)
```
**Forbidden**
```text
"tests pass, shipping it" without an independent Codex review.
Marking a task done while Codex still reports an error or a security issue.
```
---
## R16 — Every UI string is localized (German + English)
**Why.** Clusev ships in **two languages, user-selectable** (DE default, EN). There must be
**no hard-coded user-facing copy** anywhere — every visible string (views, Livewire components,
service messages, notifications, validation, modal copy) goes through Laravel localization so a
new string is never English-only or German-only.
**Structure (mandatory).** Translations live in `lang/<code>/<group>.php` returning a nested
array, one **group per feature area** mirroring the view tree, plus a shared `common` group:
```
lang/
de/ common.php auth.php dashboard.php servers.php services.php files.php
audit.php settings.php system.php versions.php modals.php shell.php
en/ (same files, same keys)
```
- Access with `__('group.key')` / `@lang`; interpolate with `:placeholders`
(`__('servers.banned', ['ip' => $ip])`).
- **Keys are identical across `de` and `en`** — every key MUST exist in BOTH. Reuse `common.*`
for shared actions/status (`save`, `cancel`, `online`, …) instead of duplicating.
- Supported locales are declared once in `App\Http\Middleware\SetLocale::SUPPORTED`; the active
locale comes from the user's saved preference → session → `config('app.locale')`.
**Correct**
```blade
<x-btn>{{ __('common.save') }}</x-btn>
<h2>{{ __('servers.detail_title') }}</h2>
```
```php
$this->dispatch('notify', message: __('servers.credential_saved'));
```
**Forbidden**
```blade
<x-btn>Speichern</x-btn> {{-- ❌ hard-coded — add a key to lang/{de,en} --}}
```
```php
$this->error = 'Server nicht gefunden.'; // ❌ use __('common.server_not_found')
```
Adding a string in only one language, or omitting a key from `en`/`de`, is a rule violation.
---
## R17 — Blade: block `@php`; never the literal directive tokens in a comment
Blade's raw-block precompiler matches `@php … @endphp` **non-greedily on the FIRST `@endphp`**.
- **Never** write the literal tokens `@php`/`@endphp` (or `@verbatim`/`@endverbatim`) inside a
Blade **comment or text** in a file that also has a real `@php` block — the matcher takes the
token in your comment as the block end and dumps the remainder as raw text. (This shipped a
garbled `<header>` on every signed-in page.) To mention a directive, break it (`@php`) or word
it ("the php directive").
- Use the **block** `@php … @endphp`, not **inline** `@php(...)`: inline silently mis-compiles
when the expression nests parens/calls (e.g. `@php($x ??= __('a.b'))`) and collides with any
later block in the same file.
- **Verify the rendered DOM, not just HTTP 200** (extends R12): leaked template text and missing
translations both return 200 with no console error. R12 must inspect the actual visible markup
(`<header>`/body) for stray `@`, `{{ }}`, `$var`, or `group.key` literals.
---
## Appendix — Secret hygiene
The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in
`../.env.gitea` (in the parent home dir, untracked) and must later move into the app `.env`.
- `.env*` (incl. `.env.gitea`), keys, and local state are in `.gitignore` — keep it that way.
- Never commit or echo the `GIT_ACCESS_TOKEN`; read it only at push time.
- Before any `git push`, eyeball `git status` and the staged diff for stray secrets.