# Plan 74: AIOS Admin — panel operacyjny nad Directusem (wdrożenie plansz Sesja 03.09)

**Status:** proposal → YOLO-ready po akceptacji plansz · **Data:** 2026-09-03 · **Skala:** M · **SoT produktu:** [`plans/59`](59-aion-web-dev-aios.md) [F21] · **Rdzeń:** [`plans/70`](70-aios-core-cms-astro.md) (P70.0–P70.11 DONE)
**Spec widoków:** [`aios-core/docs/admin-ui-views.md`](../aios-core/docs/admin-ui-views.md) · **Plansze:** `aios-core/design/Sesja 03.09/*.dc.html` (kontrakt: [`BRIEF-SESJA-03.09.md`](../aios-core/design/BRIEF-SESJA-03.09.md)) · **Shell:** [`aionflow-hub/design/AIONLINE-CONTRACT.md`](../aionflow-hub/design/AIONLINE-CONTRACT.md)
**Handoff:** `/yolo-implementuj plan 74` od P74.0 · **Repo docelowe:** `/var/www/aios-core/apps/admin`

**Werdykt jednolinijkowy:** AIOS Admin = aplikacja Astro SSR w monorepo `aios-core` (`apps/admin`, port bazowy `/admin`), która **wywołuje istniejące deep modules** (`ModuleManifest`, `runExitHarness`, `BuildDataPipeline`, `seo-gate`, `check-webmcp-html`, `UsageMeter`/hub `/api/usage`) i pokazuje to, czego Directus nie widzi; Directus zostaje edytorem treści; ten sam IA w `aion` i `standalone`; shell = menu systemu (lewo) + AionLine (prawo, tylko `aion`).

---

## 0. Decyzje architektoniczne (wiążące)

| # | Decyzja | Uzasadnienie | Alternatywa odrzucona |
|---|---|---|---|
| D1 | **Astro 7 SSR** (`@astrojs/node`, `output: 'server'`) w `apps/admin`, islands **Preact** tylko tam, gdzie jest interakcja (toggle, DiffPanel, Playground, wizard) | reguła stacku (Astro day-one AIOS), `scripts/ci-no-nuxt.mjs` odrzuca każdy `package.json` z `nuxt`/`@nuxt/*`; admin musi wejść do paczki `aios eject` | Nuxt + `@aion/ui` (szybki shell, ale łamie guard i eject) |
| D2 | **Shell własny wg AIONLINE-CONTRACT**: `SystemMenu.astro` (244/64) + `AionLine` (56/200) jako island czytający `aion-ui/shell-contract.ts` (kontrakt jest framework-neutralny) | parytet wizualny z hub bez zależności od Nuxt layera | iframe hub AionLine (focus/cookies), kopiowanie `.vue` |
| D3 | **Auth day-one = Directus auth** (`/auth/login`, role `owner` \| `member` = role Directus; `operator` = Directus admin **lub** OIDC Authentik w P74.8) | działa w obu trybach od razu; Authentik = LOCAL-GATED (SSO Repako A3 też) | własna tabela userów |
| D4 | **Backend = endpointy Astro `/admin/api/*`** wołające `@aios/core` + `scripts/*.mjs` jako child process z logiem JSONL w `.data/admin/jobs/` | zero duplikacji logiki gate/harness; log = źródło `EventStream` | osobny serwis Node/Express |
| D5 | **Audit log** = `.data/admin/audit.jsonl` (append-only) + opcjonalnie kolekcja Directus `aios_audit` w P74.9 | działa w `standalone` bez hub | tylko Directus (gorsze w eject) |
| D6 | **Usage w trybie klienta** = nowy endpoint hub `GET /api/usage/tenant` autoryzowany **tenant key** (Bearer, `verifyTenantKey`) — obecny `/api/usage` wymaga `requireOperator` | owner musi widzieć własne zużycie bez roli operatora | proxy przez operatora (dziura RBAC) |
| D7 | **Tokeny** = z planszy `AIOS — 00 System i komponenty` → `apps/admin/src/styles/tokens.css` (jasny `:root` + `[data-theme="operator"]`); nazwy identyczne z hub `tokens.css`; `scripts/design-lint.mjs` = 0 hex poza tokenami | jedno źródło koloru, migracja do hub v2 bez zmian w widokach | dziedziczenie po tokenach marketingu |
| D8 | **Jeden admin per instancja tenanta** (runbook kopiuje `aios-core` per klient); operator widzi wiele tenantów przez **tenant switcher = lista deep-linków** z hub `clients.json`, nie multi-tenant w jednym procesie | eject = jeden katalog; zero cross-tenant w kodzie | centralny `aios.aionflow.pl` |
| D9 | **Commerce** = strona zablokowana z werdyktem, dopóki `profiles` nie zawiera `commerce` | kill-gate Discovery (plan 59) | ukrycie pozycji menu (użytkownik nie wie, czemu nie ma) |

---

## 1. Architektura

```mermaid
flowchart LR
  subgraph admin[apps/admin — Astro SSR :3031 base /admin]
    Shell[SystemMenu + AionLine island]
    Views[12 widoków .astro]
    Api[/admin/api/* endpoints/]
    Jobs[JobRunner → .data/admin/jobs/*.jsonl]
  end
  Views --> Api
  Api --> Core["@aios/core: ModuleManifest · runExitHarness · BuildDataPipeline · NetworkPort · UsageMeter"]
  Api --> Scripts["scripts/*.mjs: seo-gate · check-webmcp-html · exit-harness · aios-eject · seed-from-blueprint"]
  Api --> Directus[(Directus :8155 SDK)]
  Api -. aion only .-> Hub["hub.aionflow.pl /api/usage/tenant · /api/tenants/verify · clients.json members"]
  Api --> PM2[pm2 restart / smoke]
  Shell -. aion only .-> HubShell["hub BFF: products · events · presence (shell-contract)"]
```

Struktura katalogów:

```text
apps/admin/
  astro.config.mjs            # output:'server', adapter node, base:'/admin', site z env
  package.json                # @aios/admin; deps: astro, @astrojs/node, @astrojs/preact, @directus/sdk, @aios/core
  src/
    styles/tokens.css         # D7 — jedyny hex
    styles/admin.css          # shell, komponenty (klasy jak na planszach: .status-tile .gate-table .conceal …)
    layouts/AdminLayout.astro # shell: SystemMenu · topbar · <slot> · AionLine (jeśli mode=aion)
    components/               # StatusTile GateTable ConcealField ModeBadge KillGateLock DiffPanel EventStream EmptyChecklist Skeleton BlockedBanner StatCard ChartBars Tabs Drawer
    islands/                  # Preact: AionLine, ManifestEditor, DiffPanel, Playground, EjectWizard, ConcealField
    lib/                      # auth.ts (Directus session), rbac.ts, jobs.ts, audit.ts, hub.ts, directus.ts, manifest.ts
    pages/
      index.astro             # Puls
      moduly.astro tresc.astro seo.astro obrazy.astro webmcp.astro tracking.astro usage.astro build.astro exit.astro commerce.astro ustawienia.astro
      login.astro
      api/…                   # health, manifest (GET/PUT), gate (POST run/GET last), harness, build, deploy/restart, smoke, usage, webmcp/call, tracking/config, tracking/events, images/queue, audit, jobs/[id]/stream (SSE)
scripts/design-lint.mjs       # hex/uppercase/letter-spacing/opacity guard na apps/admin/src/**
```

RBAC (matryca `lib/rbac.ts`):

| Zasób | operator | owner | member |
|---|---|---|---|
| Puls, Treść, SEO (read) | ✓ | ✓ | ✓ |
| Moduły zapis, Chaos seed, Tor B/C, DIRECTUS_* | ✓ | – | – |
| Tracking IDs, Usage, Build/Deploy, Ustawienia (read) | ✓ | ✓ | – |
| Eject wizard | ✓ | ✓ | – |
| Rotacja `AION_TENANT_KEY` | ✓ | ✓ (własny) | – |

---

## 2. Fazy realizacji (P74) — każda z verify×3

Kolejność wg priorytetu makiet (§5 spec): Puls → Moduły → SEO → Usage → Exit → reszta.

### P74.0 — Akceptacja plansz + tokeny + design-lint (S)
- Wejście: 12 plików `design/Sesja 03.09/*.dc.html` (00 System + 11 widoków; Commerce jako artboard w Ustawieniach).
- Kroki: przegląd plansz (operator), decyzja o wartościach tokenów → `apps/admin/src/styles/tokens.css`; `scripts/design-lint.mjs` (kryteria BRIEF §7) podpięty jako `npm run design:lint`.
- Verify: `node scripts/design-lint.mjs design/Sesja\ 03.09` → 0 naruszeń na 12 plikach ×3; `tokens.css` ma dokładnie 20 nazw z kontraktu; `--accent-op` ≠ `--warn` (test w lincie).
- Blocker: akceptacja wizualna plansz przez właściciela = **GATE** (bez niej P74.1 jedzie na tokenach z planszy 00 jako proposal).

### P74.1 — Scaffold `apps/admin` + shell + auth Directus (M)
- `npm create astro` → `apps/admin`, adapter node standalone, `base: '/admin'`, Preact.
- `AdminLayout.astro` = SystemMenu (12 pozycji, Commerce z kłódką, stopka ModeBadge + build status) + topbar (tytuł, okruszki, ⌘K stub) + `AionLine` island (renderuje się tylko gdy `modules.mode === 'aion'`; dane z hub BFF w P74.8, do tego czasu produkty z `clients.json`/statycznej listy z etykietą UNVERIFIED).
- `lib/auth.ts`: login przez Directus `/auth/login`, cookie httpOnly, mapowanie roli Directus → `operator|owner|member`; middleware `src/middleware.ts` → redirect `/admin/login`.
- Komponenty bazowe: StatusTile, ModeBadge, Skeleton, BlockedBanner, EmptyChecklist, Tabs.
- Verify: `npm run build -w @aios/admin` PASS; `curl -I :3031/admin/` → 302 → `/admin/login`; po loginie 200 + `<nav class="system-menu">` z 12 pozycjami; `npm run ci:no-nuxt` PASS; `npm run design:lint` PASS.

### P74.2 — Puls (M)
- `/admin/api/health`: Directus `/server/health`, ostatni build (z `.data/admin/jobs`), gate (ostatni wynik `seo-gate`), bridge (`check-webmcp-html` na `apps/starter/dist`), harness (`runExitHarness`) — każdy z `checkedAt` i `evidence` (komenda/URL) → tooltip.
- Usage 30 dni (`lib/hub.ts` → `/api/usage/tenant`, standalone → lokalny `usage_log`), blockery agregowane z gate'ów z deep-linkami, EventStream z audit + jobs, szybkie akcje (`Rebuild`, `Uruchom gate`, `Otwórz Directus`).
- Stany: empty (checklist runbooka §1–§8), degraded (Directus down → baner + reszta read-only), mobile (390) dla Puls.
- Verify: `curl /admin/api/health` → 5 kafli z `status∈{ok,warn,fail,unknown}` i `checkedAt` ×3; Lighthouse a11y ≥ 95 na `/admin/`; brak koloru statusu bez ikony+tekstu (test DOM: każdy `.status-tile` ma `svg` i tekst).

### P74.3 — Moduły (M)
- `GET/PUT /admin/api/manifest` na `ModuleManifest` (walidacja: `standalone` ⇒ `tenant_id: null`; `aios-commerce` ⇒ wymaga `profiles ⊇ ['commerce']` — 422 z powodem kill-gate).
- Island `ManifestEditor` + `DiffPanel` (before/after JSON), `Zapisz + rebuild` (job), `Zasil z blueprintu Chaos` (operator; `mergeBlueprintSeed` podgląd → merge).
- Verify: PUT z `aios-commerce` bez profilu → 422 `kill-gate` ×3; PUT poprawny → `modules.json` zmieniony + wpis audit + job rebuild w kolejce; `git diff --stat modules.json` po rollbacku = 0.

### P74.4 — SEO + Treść (M)
- `POST /admin/api/gate/run` → `scripts/seo-gate.mjs` jako job (SSE log); `GET /admin/api/gate/last` → GateTable z deep-linkami do Directus (`/admin/content/articles/<id>`).
- Surface preview: `SeoSurface` z `@aios/seo` dla wybranego artykułu → SERP-card + drzewo JSON-LD; sitemap z `sitemap-urls`; llms.txt podgląd (notka „nie jest ranking gate”); hreflang „deferred (PL-only)”.
- Treść: tabele pages/articles/authors/redirects/globals przez `@directus/sdk` z nakładką gate + in/out z grafu linków (`BuildDataPipeline` → link graph), filtr „tylko z blockerami”, osierocone; redirects z testem kolizji tras; globals → JSON-LD preview.
- Verify: gate na fixtures → liczba blockerów == wynik `npm run seo-gate` ×3; każdy wiersz z blockerem ma `href` do Directus; redirect kolidujący z trasą Astro oznaczony `warn`.

### P74.5 — Usage & Quota + hub endpoint (S→M)
- **hub:** `server/api/usage/tenant.get.ts` — Bearer tenant key → `verifyTenantKey` → `queryUsage({tenant, group})` + `checkQuota` (bez `requireOperator`); test: cudzy klucz → 401.
- admin: wykres 30 dni (div-y), grupowanie `day|service|provider`, tabela, quota + prognoza końca miesiąca (`spent/dayOfMonth*daysInMonth`), alert ≥ 80 %; standalone → `usage_log` Directus / sink file, sekcja quota ukryta.
- Verify: `curl -H "Authorization: Bearer $AION_TENANT_KEY" hub/api/usage/tenant?tenant=aion-marketing&group=day` 200 ×3; cudzy klucz 401 ×3; suma słupków == `total.units` z API.

### P74.6 — Exit / Eject + Build & Deploy (M)
- Exit: `runExitHarness` na `apps/starter/dist` → PASS / tabela `file · match`; wizard 6 kroków (runbook §6) z weryfikacją per krok (`mode`, brak `AION_TENANT_KEY`, `UsageMeter` sink lokalny, klucze BYOK obecne, harness PASS, brak hub URL); `Wygeneruj ZIP` → `scripts/aios-eject.mjs` jako job, aktywne tylko po PASS.
- Build & Deploy: job `npm run build` rozbity na kroki `data → seo-gate → astro build → pagefind` (stepper, czas, status, log mono); Deploy: PM2 proces/port z `ecosystem`/env, `Restart` (operator/owner), smoke `/`, `/api/webmcp`, `sitemap.xml`; badge CI: `ci:no-nuxt`, `ci:no-hardcode-analytics`, `check:webmcp-html`.
- Verify: harness na dist z wstrzykniętym `https://hub.aionflow.pl` → FAIL z 1 wierszem ×3; po usunięciu → PASS; `Wygeneruj ZIP` disabled przy FAIL (test DOM); job build kończy `exit 0` i smoke 3×200.

### P74.7 — WebMCP · Tracking · Obrazy (M)
- WebMCP: packs toggle (commerce = KillGateLock), registry z `webmcp-registry.json` (inputSchema rozwijane), badge „embedded R-tier payload”, bridge check, `/api/webmcp` status + ostatnie POST-y (log z endpointu startera → `.data/admin/webmcp.jsonl`), Playground (POST tylko tier R).
- Tracking: singleton `tracking_config` przez SDK (walidacja `G-XXXX`, pixel 15–16 cyfr), ConcealField dla `ga4_api_secret`, `meta_capi_token`; EventStream `/api/track` (log JSONL ze startera); baner `ci:no-hardcode-analytics`; Consent Mode v2 karta (default deny).
- Obrazy: kolejka ingestu z `@aios/images` (manifest wariantów, hash, tier), karty A/B/C (B/C toggle tylko operator), status kluczy stock (`PEXELS/PIXABAY/UNSPLASH` obecny/brak, nigdy wartość), edytor sidecar alt/caption/creator/copyright/keywords.
- Verify: Playground `site-nav` R-tier → JSON z `tools[]` ×3; W-tier → 403; `ga4_api_secret` nie występuje w HTML odpowiedzi (`rg` na dump strony = 0); `ci:no-hardcode-analytics` PASS.

### P74.8 — AionLine LIVE + Authentik (aion) — LOCAL-GATED (S)
- AionLine island czyta hub BFF (produkty, inbox, presence) w kształcie `shell-contract.ts`; w `standalone` komponent nie renderuje się i **nie ma importu** hosta hub (ExitHarness na `apps/admin/dist` PASS).
- OIDC Authentik dla roli operator (klient id od admina — nie hardcodować) → **BLOCKED** do czasu wydania credentials; fallback D3.
- Verify: `runExitHarness({dir:'apps/admin/dist'})` przy `mode=standalone` PASS ×3; przy `mode=aion` AionLine renderuje ≥ 1 produkt z hub.

### P74.9 — Ustawienia · Commerce · deploy pilota (S)
- Ustawienia: tożsamość (`tenant_id`, `sites[]`, `kb_slug`, `discoverySlug`), klucze (`AION_TENANT_KEY` ConcealField + rotacja przez hub `/api/tenants/*`), członkowie z `clients.json.members` (read w kliencie), projekty, wskaźnik `DESIGN.md`.
- Commerce: strona zablokowana z werdyktem; z `profiles:['commerce']` → link Medusa admin, pack, Stripe status.
- Deploy pilota: PM2 `aios-admin-aion-marketing` (port z `ss -ltn` — proponowany 3031), nginx `location /admin/` w `dev.aion-marketing.pl` → `:3031`, `X-Robots-Tag: noindex`; audit do Directus `aios_audit` (opcjonalnie).
- Verify: `curl -I https://dev.aion-marketing.pl/admin/` → 302 login ×3; login owner → Puls 200; `pm2 jlist` status online; `npm test` w monorepo PASS.

---

## 3. Kryteria akceptacji całości

- [ ] 12 widoków zgodnych z planszami Sesja 03.09 (walidacja wizualna operator + `design:lint` = 0)
- [ ] Ten sam IA w `aion` i `standalone`; w `standalone` zero requestów do `*.aionflow.pl` (harness PASS na `apps/admin/dist`)
- [ ] Każdy status w UI ma ikonę + tekst + `LIVE hh:mm | UNVERIFIED`; zielony tylko z `evidence`
- [ ] Sekrety nigdy w HTML/JSON odpowiedzi (test `rg` na dump wszystkich stron)
- [ ] RBAC: 3 role × 12 widoków = test macierzy (403 na zakazanych akcjach)
- [ ] `npm run ci:no-nuxt`, `ci:no-hardcode-analytics`, `check:webmcp-html`, `test` — PASS
- [ ] Eject ZIP zawiera `apps/admin` i uruchamia się bez hub

## 4. Ryzyka / pre-mortem

1. **Plansze zaakceptowane, tokeny inne niż hub v2** → dwa systemy kolorów; checkpoint P74.0: nazwy identyczne, wartości migrują do hub przy akceptacji v2.
2. **Job runner odpala `npm run build` na produkcji równolegle z deployem** → lock file `.data/admin/jobs/.lock`, jeden job naraz, timeout 15 min.
3. **Owner rotuje tenant key i traci usage** → rotacja przez hub z okresem łaski (2 klucze aktywne 24 h) — wymaga zmiany w `usage-store`; do decyzji w P74.9.
4. **Directus auth cookie vs base `/admin`** → cookie `Path=/admin`, `SameSite=Lax`; test w P74.1.
5. **Preact islands rosną w SPA** → budżet JS ≤ 60 KB gz per widok (Lighthouse CI w P74.2).
6. **AionLine w standalone „przypadkiem” zaciąga hub** → import dynamiczny za `mode`, harness na `apps/admin/dist` w CI (P74.8).

## 5. Exclusion report / dissent

- **Nuxt + `@aion/ui`** odrzucone (D1) — dissent: szybszy parytet shellu; kontra: guard `ci:no-nuxt`, eject, reguła stacku.
- **Claude Design (claude.ai/design) jako generator** plansz — pominięte w tej sesji: plansze wygenerowane lokalnie modelem Fable wg tego samego kontraktu `.dc.html` (BRIEF §1). Pipeline `design-work` hub pozostaje dostępny do re-generacji.
- **Centralny multi-tenant admin** (`aios.aionflow.pl`) odrzucony (D8) — operator = deep-linki + tenant switcher.
- **Edytor tokenów hub w adminie** — poza zakresem (spec §2.12: tylko wskaźnik na `DESIGN.md`).
- Authentik OIDC, hub BFF dla AionLine, Meta CAPI EMQ — **LOCAL-GATED**, nie „partial done”.

## 6. Status YOLO

- 2026-09-03 04:05: **12/12 plansz Sesja 03.09 wygenerowane** (4 subagenty Fable × 3 pliki), `npm run design:lint` = 12/12 czystych, tokeny ujednolicone do planszy `00`, smoke renderu Aion Browser OK. Indeks, tokeny i decyzje: [`aios-core/design/README.md`](../aios-core/design/README.md).
- `scripts/design-lint.mjs` + `npm run design:lint` — DONE (część P74.0).
- P74.0 pozostałe: akceptacja wizualna właściciela (GATE) + decyzja o ciemnym `--bg` (#15171b vs hub #1c1a17) → `apps/admin/src/styles/tokens.css`.
- 2026-09-03 09:51: właściciel odrzucił paletę Sesji 03.09 i zażądał dwóch trybów UI + ekranu powitalnego → [`plans/75`](75-aios-admin-tryby-podstawowy-pelny.md). Paleta i P74.0 przechodzą przez **P75.0**; P75.3–P75.5 wymagają P74.1.
