Skip to content

Anton petrenko solution - #1

Closed
AntoniPetrenko wants to merge 20 commits into
testfrom
anton-petrenko-solution
Closed

AntoniPetrenko wants to merge 20 commits into
testfrom
anton-petrenko-solution

Conversation

@AntoniPetrenko

@AntoniPetrenko AntoniPetrenko commented Jul 7, 2026 •

Copy link
Copy Markdown
Owner

Автор

Anton Petrenko

Проєкт

mytv — самохостований пульт для Samsung TV, який працює на локальній
мережі: бек-енд на Fastify 5 + TypeScript автоматично знаходить телевізори
через UPnP, оголошує себе як mytv.local через mDNS і керує ТВ через
Samsung Smart View WebSocket API (порт 8001); фронт-енд — React 19 + Vite
SPA (двоекранний: список пристроїв + пульт), обслуговується самим бек-ендом
з того самого origin. Розгортається на Orange Pi одним скриптом. Без хмари,
без авторизації, без інтернету на runtime.

Відео-демо (1–2 хв)

Video: https://youtu.be/zzvwq1cn6cA

Які практики Agentic Engineering застосовано

Контекст-інженерія (статичний vs динамічний):

  • AGENTS.md у корені репо — house rules, які завантажуються на початку
    кожної сесії: security-правила (пейрінг-токен ніколи не покидає бек-енд),
    правила фронт-енду (тільки Orbit DS через @ds/, ніяких hard-coded
    палітр), single-origin у проді, cross-cutting rules по кожному пакету.
  • Плюс CLAUDE.md, DESIGN.md, docs/product-brief.md,
    docs/requirements.md, docs/capabilities.md, LOOP.md — разом
    складають статичний контекст, який агент читає перед будь-якою
    роботою.
  • Динамічний контекст: docs/current-state.md — session log, куди
    агент префіксує запис на початку кожної нової сесії (правило в
    AGENTS.md → "Session log"). Він транспортує стан між незалежними
    сесіями агента.
  • Скіли завантажуються ліниво за тригерами: frontend-design-check
    тільки при редагуванні front-end/**, samsung-ip-control-protocol
    тільки для TV-I/O коду, orbit-tv-remote-design тільки коли треба
    генерувати UI. Це тримає контекстне вікно чистим.

Loop engineering (цикли замість покрокового промптингу):

  • LOOP.md описує чіткий per-cycle алгоритм: select change → openspec
    status → openspec instructions apply → implement → verify → review →
    archive → auto-commit → stop and wait for human. Один слеш-виклик
    /next-change = один повний цикл, не більше.
  • Свідомо залишив рівно один ручний тригер у флоу — /next-change
    (моя власна slash-команда, .claude/commands/next-change.md).
    Це
    human-in-the-loop checkpoint, який зберігає такий послідовний потік
    на кожній capability:
    1. Я запускаю openspec-propose (або сам пишу) і створюю
      proposal.md + design.md + spec.md + tasks.md для наступної
      capability — чітко фіксую why, what, how і критерії
      приймання.
    2. Я перевіряю ці артефакти вручну — це той момент, коли ще можна
      передумати про scope без коштовних rollback-ів.
    3. Я викликаю /next-change <capability-name>, і далі loop уже
      виконує алгоритм з LOOP.md автоматично: імплементує таски по
      tasks.md, крутить build+test gate, спавнить свіжий code-reviewer
      суб-агент, на CHANGES_REQUESTED повертається до fix + re-verify +
      re-review, і зупиняється тільки коли всі критерії з LOOP.md
      виконані
      (green build, green tests, PASS review, archive +
      synced specs, clean commit).
    4. Після зупинки — я знову у циклі: перевіряю результат і вирішую
      чи запускати наступну capability.
  • Один слеш-виклик /next-change = один повний cycle. Автоматично
    наступну capability воно не бере; це дає мені точку контролю між
    ітераціями.
  • Що це реально дало в цій сесії: доставлено C10, C11, C12 без
    того, щоб я вручну керував кожним кроком — але з тим, що я свідомо
    контролюю куди йде наступний cycle. C11 і C12 обидва ловилися
    reviewer-ом на першій ітерації і виправлялися в межах того ж
    /next-change виклику без мого втручання.

Maker ≠ checker / суб-агенти:

  • Після кожної імплементації спавниться окремий code-reviewer
    суб-агент
    зі свіжим контекстом; йому передається тільки назва
    зміни
    — жодного резюме моєї роботи, щоб він не був anchored до
    моїх раціоналізацій. Reviewer read-only (тільки Read/Grep/Glob/
    Bash), тому review і fix — окремі кроки.
  • Реальні verdicts у цій сесії:
    • C10 → PASS з першої спроби.
    • C11 → CHANGES_REQUESTED (unit виставляв XDG_CONFIG_HOME, що
      десинхронізувало реальний шлях tokens.json з тим, що казали
      design/spec/README; плюс невідома CSS-зміна їхала разом зі змінами).
      Виправлено → повторний review → PASS.
    • C12 → CHANGES_REQUESTED (build.sh при tsc/vite failure не називав
      очікуваний артефакт; dry-run banner не згадував split). Виправлено
      → повторний review → PASS.

Верифікація:

  • Автоматична: npm run back:build (tsc), npm run front:build (Vite),
    npm run back:test (node --test, 107 тестів), npm run front:test
    (vitest, 44 тести). Всі мають бути зелені перед архівацією.
  • Правило з AGENTS.md: task marked [x] без passing test run — це
    не done.
    Якщо в tasks.md є "add test", тест повинен існувати і
    пройти.
  • Live end-to-end: bash scripts/build.sh на dev-машині, ручний деплой
    на реальний Orange Pi (Armbian), де на живому діагностували
    203/EXEC → 200/CHDIR → masked service → network-online.target
    дедлок. Кожна проблема породжувала конкретне рішення (setcap, world-
    traversable home, unmask + enable).
  • Bats-core тести написані; коли bats не був встановлений, всі
    assertions програвалися вручну в plain bash проти sourced-функцій
    — і це чесно позначено в tasks.md.

Специфікації наперед (SDD):

  • Використано OpenSpec для кожної capability: proposal.md (why +
    what) → design.md (how, decisions з альтернативами і відхиленими
    варіантами, non-goals, risks, open questions) → specs/**/spec.md
    (requirements з SHALL + #### Scenario: з WHEN/THEN) → tasks.md
    (чекбокси, які тікаються по ходу impl).
  • Для модифікації існуючої capability — delta spec з
    ## MODIFIED Requirements і ## ADDED Requirements (використано для
    C11 при доставці C12).
  • Requirement IDs (FR-*, NFR-*, BC-*) у docs/requirements.md
    трасуються з кожного proposal — жодна імплементація без свого FR.

Інструменти та MCP:

  • Claude Code CLI (Opus 4.7) як primary агент.
  • OpenSpec CLI + скіли: openspec-propose, openspec-apply-change,
    openspec-archive-change, openspec-sync-specs.
  • Кастомні скіли проекту: frontend-design-check,
    orbit-tv-remote-design, samsung-ip-control-protocol, next-change,
    code-review, verify.
  • Суб-агенти: code-reviewer (fresh-context review),
    Explore (read-only search), general-purpose.
  • Стандартний toolchain: git, gh CLI, npm, TypeScript, Fastify, React,
    Vite, vitest, node --test, systemd, bats-core.

Що вирішував я, а що — агент:

  • Я вирішував: продуктовий scope (Samsung TV remote для LAN, target
    Orange Pi), архітектурні межі (single-origin у проді, no-cloud,
    no-auth), поділ на capabilities C1–C12 і їх залежності, дизайн-система
    (neumorphism, Orbit DS), non-goals ("no .deb package", "no cross-arch
    native modules"), точку "де закінчується моє ручне рев'ю і починається
    автономний loop" — тобто момент виклику /next-change після того, як я
    перевірив proposal/design/spec/tasks вручну
    , live-decisions при
    деплої на Pi ("split build з install", "окремий user на Pi",
    "npm ci --omit=dev а не bundled node_modules").
  • Агент вирішував (у межах, які я задав через артефакти): конкретну
    декомпозицію на tasks у межах змін, вибір шаблонів помилок, назви
    функцій, структуру bats-тестів, формулювання spec-сценаріїв, точну
    послідовність кроків у скриптах, тексти session-log записів. Плюс —
    всередині одного /next-change циклу сам вирішував, коли фікс на
    CHANGES_REQUESTED достатній, щоб пройти повторне рев'ю. Деколи
    піднімав питання назад через AskUserQuestion (наприклад, про
    runtime-deps на Pi: npm ci vs. bundled node_modules) — це моменти,
    де він свідомо повертав контроль мені всередині циклу.

(Опційно) Посилання на код

https://github.com/AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP
(бранч anton-petrenko-solution)


Чекліст

  • Вказано справжнє імʼя
  • Додано посилання на відео-демо (1–2 хв)
  • Описано застосовані практики Agentic Engineering
  • Результат робочий і доведений до кінця

Summary by CodeRabbit

  • New Features
    • Додано веб-інтерфейс для керування TV (пошук у мережі, підключення, клавіші, гучність/mute, inputs, запуск браузера).
    • Додано WebSocket live-оновлення списку пристроїв і стану сесії.
    • Додано візуальний індикатор стану з’єднання та дружні сповіщення про помилки (toast/ErrorBoundary).
  • Bug Fixes
    • Захищено від витоків чутливих токенів у логах та payload’ах; уніфіковано JSON-помилки без raw протокольних кодів.
  • Documentation
    • Оновлено інструкції запуску/деплою та системні вимоги для продакшн-застосунку.

AntonPetrenkoSirinSoftware and others added 19 commits July 4, 2026 12:55
Single-origin Fastify 5 substrate for mytv: /api HTTP routes, /ws
WebSocket mount, and SPA hosting with index.html fallback, all on one
port. Structured Pino logging strips AccessToken/authorization at any
nesting depth via a recursive formatter (pino's own redact only
supports single-level wildcards). Uniform JSON error envelope
(HttpError + errorHandler) never leaks stacks. Front-end gets a
relative-path apiClient plus a Vite dev proxy for /api and /ws.

- back-end: app.ts factory, logger.ts, errors.ts, plugins/static-spa.ts,
  routes/health.ts, new src/index.ts entrypoint (replaces server.ts stub)
- back-end tests (6, via npm run back:test): health envelope, SPA
  fallback, 404 envelope, redactor (top-level + nested AccessToken),
  WebSocket ping/pong
- front-end: src/api/client.ts, vite.config.ts dev proxy, temporary
  health-check smoke test on App mount
- docs: back-end/README.md, .env.example SERVE_SPA, current-state.md
- fixed a real bug found during implementation: Fastify's loggerInstance
  option bypasses its default req/res serializers, so raw circular
  request objects reached the log formatter and crashed on the first
  request; switched to passing plain logger options instead and made
  the redaction formatter cycle-safe and plain-object-only
- dropped pino-pretty's in-process transport (hung via worker threads
  in this sandbox) in favor of piping back:dev through pino-pretty

Reviewed by the code-reviewer subagent (fresh context): PASS, with
three low-severity non-blocking findings noted in current-state.md for
future follow-up. Archived via opsx:archive; specs synced to
openspec/specs/platform-foundation/spec.md.

Co-Authored-By: Claude <noreply@anthropic.com>
- back-end/src/mdns.ts: startMdns(port) publishes _http._tcp on mytv.local
  (TXT path=/) via bonjour-service; 5s os.networkInterfaces() poll re-
  advertises on address-set delta; EADDRINUSE keeps HTTP up and flips
  handle to state:"error" with an Avahi/mDNSResponder hint.
- back-end/src/app.ts: onReady starts mDNS, onClose stops it with a
  ~500ms grace period so the goodbye packet leaves. Handle exposed via
  app.mdns.
- back-end/src/routes/health.ts: GET /api/health returns mdns state,
  hostname, address, and optional error for browser-tab diagnosis.
- Tests (11 pass): unit + integration coverage of the advertising path,
  the interface-set change path (stubbed os.networkInterfaces +
  triggerInterfaceCheck), no-op on unchanged addresses, and the
  degraded-not-crashed EADDRINUSE path. Existing app/websocket tests
  updated to inject a stub client / disable mDNS so tests stay hermetic.
- Docs: back-end/README.md gains an mDNS section (env vars, dns-sd /
  avahi-browse repro, port-5353 conflict guidance).
- Delta spec synced to openspec/specs/mdns-advertisement/spec.md and the
  change archived under openspec/changes/archive/2026-07-04-mdns-
  advertisement/.

Co-Authored-By: Claude <noreply@anthropic.com>
Follow-up to mdns-advertisement (C2):

- mdns.ts: pass `probe: false` on publish. bonjour-service's default
  probe path prints `console.log(new Error(...))` outside our Pino
  stream on any duplicate response and silently stops the service
  without notifying us, leaving handle.state stuck at "advertising"
  while nothing is broadcast. Design.md scopes the MVP to one mytv
  per LAN, so straight-announce is fine and overwrites stale cached
  records from previous dev sessions.
- index.ts: SIGINT/SIGTERM now route into app.close() so Fastify's
  onClose hook fires and mDNS gets its ~500ms grace period to emit
  a goodbye packet before the process exits. This closes the
  "advertisement withdraws on shutdown" spec scenario for real
  signal-driven shutdowns and is also the platform-foundation
  reviewer follow-up flagged before upnp-tv-discovery lands.

Verified: 11/11 tests still pass, live SIGTERM against
`node dist/index.js` logs `received shutdown signal; closing server`
and exits with code 0; dns-sd -B shows mytv without the earlier
"Service name is already in use" console noise.

Co-Authored-By: Claude <noreply@anthropic.com>
Aligns the back-end default port with `http://mytv.local/`
(`FR-HOSTING-02`) so operators no longer have to override `PORT=80`
on every deploy while the docs already print the low-port URL.

- back-end/src/index.ts + back-end/src/app.ts: fallback literal
  flipped from `?? 3000` to `?? 80` in both call sites (server bind
  and mDNS SRV advertisement stay in sync).
- openspec/specs/platform-foundation/spec.md: "Single-origin Fastify
  server" now pins the default port at 80, cites FR-HOSTING-02, and
  gains two scenarios (`Default port is 80`, `PORT env var overrides
  the default`).
- back-end/README.md: env-var table shows `PORT` default `80`,
  "Running on port 80 (Orange Pi)" renamed to "Running on the default
  port 80 (Linux)" (setcap now unlocks the default, not an opt-in),
  and a new "Development on macOS / non-privileged shells" section
  documents `PORT=3000` as the standard opt-out. `.env.example` kept
  at `PORT=3000` with a header comment clarifying it's the dev
  override, not the app default.
- docs/current-state.md: session-log entry prepended per AGENTS.md.
- Verification: `npm run back:build` clean, `npm run back:test` 11/11.
  Live smoke: `PORT` unset -> bound `:80`, `curl /api/health` ->
  200; `PORT=3000` -> bound `:3000`, both paths advertised the
  matching SRV port.
- Independent code-reviewer verdict: PASS.

Change archived at
openspec/changes/archive/2026-07-04-default-port-80/.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds the first back-end capability that talks to real TVs on the LAN.
Continuous SSDP-based discovery (30 s M-SEARCH + async NOTIFY), UDN-keyed
registry, and a `/api/devices` + WebSocket `devices` topic that the
device-list UI will consume in the next capability (C4).

- back-end/src/discovery/ssdp.ts: SSDP transport combining node-ssdp
  Client (M-SEARCH responses on an ephemeral port) with a raw dgram
  socket bound to 239.255.255.250:1900 for asynchronous NOTIFY. Both
  paths emit a unified `hit` event. Design-intent-preserving: node-ssdp
  Client alone binds an ephemeral port and cannot receive multicast
  NOTIFYs.
- back-end/src/discovery/description.ts: undici + 3 s AbortController
  timeout, fast-xml-parser, Samsung filter (manufacturer OR model-prefix
  regex). `isSamsungTv` is a pure predicate — logger-free by design.
- back-end/src/discovery/registry.ts: Map<UDN, Device>. Upsert emits
  added/updated; markOfflineOlderThan flips status and emits offline
  once per transition. `removed` on the event union but not emitted —
  MVP keeps offline devices in the registry per FR-DISCOVERY-04.
- back-end/src/discovery/index.ts: orchestrator. p-queue concurrency 5,
  30 s M-SEARCH timer + 30 s offline sweep (60 s threshold), initial
  M-SEARCH fired immediately for the "within 2 s of app.listen()"
  scenario. IP/port derived from LOCATION URL, not XML.
- back-end/src/routes/devices.ts: GET /api/devices -> registry.snapshot().
- back-end/src/ws/broker.ts: subscribes to registry events, fans out
  { topic: "devices", event, device } to WS clients; sends a snapshot
  frame on every new connection so late joiners get the full state
  without a separate HTTP round-trip.
- back-end/src/app.ts: `discovery?: DiscoveryAppOptions` mirrors the
  existing `mdns?:` block. Decorates the Fastify scope with
  app.discovery = { registry, broker, handle } so routes and tests
  reach the registry without a module singleton. DISCOVERY_ENABLED=0
  env opt-out for test/dev environments where port 1900 is contested.
- Tests: 4 new test files (description, registry, discovery
  integration). Integration test uses a StubSsdpTransport +
  fixture-mapped stubFetchDescription so no real socket is touched;
  covers the Samsung filter, WebSocket snapshot + delta flow, and the
  no-AccessToken assertion.
- Pre-existing fix: back-end/package.json test script used an unquoted
  '**' glob that npm/sh did not expand recursively, so
  `npm run back:test` was silently skipping the four new discovery
  test files (was running 12 of 23). Quoted the glob so the full suite
  runs under the standard invocation.
- Updated pre-existing back-end/src/websocket.test.ts to pass
  `discovery: { enabled: false }` — the new /ws contract sends a
  snapshot on connect, which the ping/pong test doesn't care about.

Verification: `npm run back:build` clean; `npm run back:test` 23/23 green
(11 previous + 12 new). No live-LAN repro run — no Samsung TV on hand —
so the fixture-driven `discovery.test.ts` integration test is the
documented manual gate.

Independent code-reviewer verdict: PASS. Three informational findings
noted (Device.port is the UPnP description port not the JSON-RPC control
port — worth flagging for tv-connection-lifecycle; ST choice per
design.md D2; unbounded p-queue depth as a future hardening) — all
non-blocking and appropriate for later capabilities.

Change archived at openspec/changes/archive/2026-07-04-upnp-tv-discovery/;
main spec synced to openspec/specs/upnp-tv-discovery/spec.md.

Co-Authored-By: Claude <noreply@anthropic.com>
First user-visible SPA screen. Swaps the SAMPLE_DEVICES stub in App.tsx
for a live subscription to the back-end registry, built entirely from
Orbit DS primitives + tokens.

- front-end/src/data/types.ts: Device shape matches the wire contract
  emitted by upnp-tv-discovery. No AccessToken field on the type — SPA
  can't accidentally read one.
- front-end/src/data/useDevices.ts: WebSocket-first hook that hydrates
  from `{ topic: "devices", event: "snapshot" }`, applies added /
  updated / removed / offline deltas in place, falls back to
  GET /api/devices if no snapshot arrives within 500 ms, and reconnects
  with exponential backoff (1 s → 30 s cap) on WS close. A late WS
  snapshot always wins over a REST hydration. `createWebSocket` and
  `fetchDevices` are injectable so tests don't touch a real socket.
- front-end/src/screens/DeviceListScreen.tsx: overline "LOCAL NETWORK",
  H1 "Your TVs", one DeviceCard per device (model prop only forwarded
  when non-null so `model: null` doesn't leak "null" into the DOM),
  disabled primary "Add a TV" button (holds the one-accent-per-screen
  slot without giving the user a broken button — manual-add FR is still
  a docs/capabilities.md gap), and a Card variant="inset" empty state
  with the tv_off Material Symbols glyph and the copy from the spec.
- front-end/src/App.tsx: shrunk to a thin composer — useDevices() into
  DeviceListScreen, RemoteScreen unchanged with its stubs. Removed the
  temporary apiClient.get('/api/health') smoke effect; the app now
  consumes apiClient for real through the hook.
- front-end/src/ds.d.ts: added Card shim so the empty state compiles.

Test infrastructure wired up (previously `npm run front:test` was
undefined — a gate failure per AGENTS.md): added vitest + happy-dom +
@testing-library/react, vite.config.ts uses vitest/config's defineConfig
and declares `test.environment = 'happy-dom'` + `globals: true`.

- useDevices.test.ts (4 tests): WS snapshot → added → offline flow,
  REST fallback when snapshot is slow, late-WS snapshot overriding a
  REST hydration, `close` flipping the `offline` flag.
- DeviceListScreen.test.tsx (3 tests): empty state renders the copy +
  tv_off glyph, two devices render two cards, `model: null` leaks no
  "null"/"undefined" text.

Verification: `npm run front:build` clean (204 KB / 64 KB gzip bundle);
`npm run front:test` 7/7 green; `npm run back:test` still 23/23 green.
Live end-to-end repro against real hardware — implementer's LAN has a
Samsung UE40KU6000 at 20.20.20.144. `PORT=3000 npm run back:dev` +
`VITE_BACK_PORT=3000 npm run front:dev`; curl of the Vite dev server
returned the SPA HTML and its /api/devices proxy returned the real TV
row end-to-end. Dark mode + neumorphic-surface eyeball deferred to the
user (no graphical browser in this CLI session — no dark-mode fork was
introduced, so the DS `[data-theme="dark"]` inheritance should work
unchanged).

Independent code-reviewer verdict: PASS. Three low-severity findings
noted, all non-blocking: (1) empty state renders during initial loading
before the first snapshot (loading flag exposed but not consumed by
DeviceListScreen), (2) DS DeviceCard shows a stray leading middot when
model is undefined — DS-level cosmetic issue, (3) DeviceListScreen uses
inline style objects for layout dimensions consistent with the pre-
existing RemoteScreen pattern.

Change archived at openspec/changes/archive/2026-07-04-device-list-ui/;
main spec synced to openspec/specs/device-list-ui/spec.md.

Co-Authored-By: Claude <noreply@anthropic.com>
The pivot capability: per-TV IP Control session state machine keyed by
UDN. Every downstream command capability (C6/C7/C8) plugs into this.

Back-end (new under back-end/src/tv/):
- types.ts: SessionState internal union + ClientSessionState (collapses
  Reconnecting -> Connecting for client visibility) with a single
  toClientSessionState() converter.
- errors.ts: TvError class + mapRpcErrorCode (-32601 -> TvNotSupported,
  -32602 -> TvInvalidOp, -32000..-32099 -> TvFailed) + mapTransportError
  (any socket / TLS / timeout -> TvNotReachable). Fixed HTTP status per
  domain code. Raw -32xxx codes never appear in HTTP bodies.
- token-store.ts: XDG-aware path, atomic write via .tmp sibling + chmod
  0600 + rename so no reader ever sees a world-readable copy.
- jsonrpc.ts: thin JSON-RPC 2.0 client on undici.Pool per TV origin
  (keep-alive 30 s idle / 300 s max, pipelining 1, rejectUnauthorized
  false — Samsung TVs self-sign on the LAN). Every request/response
  logs rpcId for correlation; AccessToken stripped by the redactor.
  Injectable JsonRpcTransport interface so tests never touch a socket.
- session.ts: per-TV actor. One transport + one p-queue (concurrency 1
  — spec forbids batching) + state machine per design D1. tryOpen()
  verifies the transport with a heartbeat call before flipping to
  Connected so a broken TV can't infinite-loop the reconnect budget.
  Backoff [200, 500, 1000, 2000, 5000] ms, cap 5 -> Disconnected.
  Heartbeat = getSystemInfo every 15 s.
- manager.ts: Map<UDN, Session>. Primes deviceIndex from
  registry.snapshot() + subscribes to added/updated/offline/removed.
  offline -> markOffline (Offline); updated on Offline UDN ->
  markBackOnline (Disconnected, no auto-reconnect per spec).

HTTP + WebSocket:
- routes/sessions.ts: GET/POST /api/devices/:udn/{session,connect,
  disconnect}. Reconnecting never leaves the back-end.
- ws/broker.ts extended: 'session' event on the devices topic; the
  snapshot payload now carries per-device session state so a client
  reconnecting mid-session sees state without a separate HTTP call.
- app.ts wired via sessions?: options; SESSIONS_ENABLED=0 env opt-out.

Front-end:
- data/useDeviceSession.ts: subscribes to devices/session events for a
  UDN + one-off GET on mount. All deps injectable.
- App.tsx RemoteScreen: useEffect connect() on mount; back-button
  disconnect() before navigation; Badge reflects session via
  sessionToBadgeStatus (Connected -> online, Connecting -> connecting,
  else offline); command controls disabled while state !== 'Connected'
  (native disabled prop on IconButton, opacity + pointer-events wrapper
  on DPad/Slider/AppShortcut since the DS has no disabled prop there).

Tests (npm run back:test: 40/40; npm run front:test: 7/7):
- errors.test.ts: raw code mapping table + HTTP status matrix.
- token-store.test.ts: round-trip through 0600, missing-file case.
- session.test.ts (5 tests): pair-then-connect, preloaded-token skip
  pairing, heartbeat-fail -> Reconnecting -> Connected on recovery,
  cap-reached -> Disconnected, markOffline <-> markBackOnline.
- logger.test.ts extended: AccessToken redacted inside a JSON-RPC
  params payload; rpcId + method survive for correlation.
- session-integration.test.ts: full app via createApp with stub SSDP +
  stub JSON-RPC transport. GET /session before connect, POST /connect
  drives Connected, WS receives Connecting + Connected (Reconnecting
  MUST NOT leak), heartbeat-fail-and-stay-broken exhausts cap into
  Disconnected, no HTTP response body carries AccessToken.

Verification: builds clean, tests green. Live smoke against real
Samsung UE40KU6000 walked Disconnected -> Connecting -> tries pairing
-> transport timeout at the UPnP description port (9197, not the
JSON-RPC control port 1515/1516) -> mapped to TvNotReachable ->
Disconnected. HTTP response carried only { state: "Disconnected" }; no
raw code leaked; the "MVP pairing" log line and rpcId correlation both
present. Reaching real Connected on a stock TV is blocked on (a) the
port derivation upstream in upnp-tv-discovery (documented gap from the
previous cycle's review) and (b) the getAccessToken pairing method
being protocol-speculative — pre-provision via MYTV_TOKEN_<UDN> env
override for now.

Independent code-reviewer verdict: PASS. Two low-severity edge-case
races on session.ts around user connect()/disconnect() during
Reconnecting, and one already-documented upstream port issue. All spec
scenarios covered.

Change archived at
openspec/changes/archive/2026-07-04-tv-connection-lifecycle/;
main spec synced to openspec/specs/tv-connection-lifecycle/spec.md.

Co-Authored-By: Claude <noreply@anthropic.com>
tv-connection-lifecycle (C5) shipped assuming Samsung's hotel-TV IP
Control protocol (JSON-RPC 2.0 over HTTPS 1515/1516). Live-hardware
smoke against a consumer Samsung UE40KU6000 showed that protocol
doesn't apply to consumer Tizen TVs (2016+) — they speak the Samsung
Smart View WebSocket API on port 8001 instead. This replaces the
transport and pairing flow while keeping the state machine, HTTP/WS
surface, and domain error union from C5 intact.

- back-end/src/tv/jsonrpc.ts rewritten to a ws.WebSocket client with
  connectHandshake() (name-based pairing, on-screen TV accept) and
  ping() (heartbeat) added to the transport interface
- mapWsError added to errors.ts per the domain error mapping table
  (close code 1002 -> TvFailed, unauthorized -> TvNotSupported, etc.)
- manager.ts: hardcoded SMART_VIEW_PORT = 8001 with a
  MYTV_CONTROL_PORT_<UDN> override; never trusts the UPnP description
  port for the control connection
- session.ts: tryOpen()/heartbeat rewritten around pairing + ping;
  handshakeTimeoutMs/pingTimeoutMs now configurable via SessionOptions
- logger.ts: redactor extended to strip `token` (any depth) and
  `token=` query fragments from logged URLs
- discovery: SSDP pre-fetch Samsung-signature filter (shouldFetch) and
  IP-based registry dedup so one physical TV shows as one device row

Bugs found and fixed along the way:
- shouldFetch treated empty-string usn/st as a non-empty (whitespace)
  haystack, skipping the intended "no signal -> fall through" path
- ping() registered a 'pong' listener before calling socket.ping(),
  which throws synchronously on an already-closed socket, leaking the
  listener and its timeout timer on every such call
- mapWsError's close-code branch didn't special-case 1002 (protocol
  error -> TvFailed) as the spec's mapping table requires, mapping it
  to TvNotReachable instead (found by the code-reviewer subagent)

Tests: discovery/index.test.ts and tv/jsonrpc.test.ts are new (real
ws.WebSocketServer, not hand-rolled stubs, per design.md's test
strategy); session.test.ts and session-integration.test.ts rewritten
around the new transport. 66 back-end + 7 front-end tests pass.

Docs synced to the pivot: back-end/README.md, docs/capabilities.md,
AGENTS.md (samsung-ip-control-protocol re-scoped as historical-only;
samsung-smart-view-protocol stub pointer added).

Reviewed by the code-reviewer subagent (fresh context): PASS after
fixing the mapWsError finding above. Not done: live-hardware repro
against a real TV (task 5.2) — no physical hardware reachable from
this environment; the fake-TV WebSocketServer integration test is the
documented stand-in per the change's own escape hatch (task 5.3).
Archived via opsx:archive; specs synced to
openspec/specs/{tv-connection-lifecycle,upnp-tv-discovery}/spec.md.

Co-Authored-By: Claude <noreply@anthropic.com>
- Front-end: apiClient.post no longer sends Content-Type:
  application/json when there is no request body — the /connect
  and /disconnect calls are body-less and Fastify's built-in JSON
  parser rejected the combination with 400.
- Back-end: override the JSON content-type parser to treat an empty
  payload as `undefined` so any client (correctly or not) sending
  the header without a body is accepted instead of 400'd.

Co-Authored-By: Claude <noreply@anthropic.com>
- POST /api/devices/:udn/key: enumerated SamsungKeyCode body,
  dispatches an ms.remote.control Smart View WebSocket frame
  through the per-TV session queue owned by C5. 204 on success,
  400 on unknown key (Fastify enum schema), 409
  SessionNotConnected before the session reaches Connected.
- back-end/src/tv/keys.ts: 9-key MVP union (arrows + ENTER +
  RETURN + HOME + MENU + POWER) and keyControlParams(key) that
  wraps a key in the Smart View { Cmd:'Click', DataOfCmd, Option,
  TypeOfRemote } envelope from smart-view-ws-transport D1.
- errorHandler now maps TvError to its domain HTTP envelope so a
  socket dropping between the state check and the queued call
  surfaces as e.g. TvNotReachable/502, not code:"internal".
- Front-end: useSendKey(udn) hook + RemoteScreen extracted into
  its own file. D-pad, back/home/menu, and power buttons are
  wired to real key sends and rendered disabled unless the
  session state is Connected. DS DPad gained a `disabled` prop
  that propagates to every underlying IconButton (extended per
  frontend-design-check; no inline styles on the primitive).
- Tests: 3 keyControlParams unit tests + 5 route integration
  tests (204, 400, 409, rapid-fire ordering, TvError-to-domain
  envelope). 2 RemoteScreen component tests (Connecting disables
  every command control; Connected D-pad up posts KEY_UP). All
  73→74 back-end and 7→9 front-end tests pass.
- Spec-drift fix caught pre-implementation: change proposal
  predated the smart-view-ws-transport pivot and referenced the
  hotel-TV JSON-RPC `remoteKeyControl` method + Postman
  collection. design.md D2, tasks.md 1.1/1.2, and spec.md were
  rewritten to point at the archived smart-view-ws-transport
  design as the authoritative envelope source, per AGENTS.md.

Task 5.2 (real-hardware repro) is deferred — no LAN/TV reachable
in this environment. Stub-driven integration suite stands in;
follow-up flagged in docs/current-state.md.

Co-Authored-By: Claude <noreply@anthropic.com>
- POST /api/devices/:udn/volume/delta: Fastify schema validates
  the body as an integer in [-25, 25], non-zero (rejects with
  code:"validation" via the errorHandler's `error.validation`
  branch). Dispatches abs(delta) KEY_VOLUP/KEY_VOLDOWN frames as
  Smart View ms.remote.control envelopes through the per-TV FIFO
  queue owned by tv-connection-lifecycle.
- POST /api/devices/:udn/mute: enqueues one KEY_MUTE frame, flips
  the server's optimistic muted tracker, emits a `changed` event
  that the WebSocket broker fans out as
  { topic:"devices", event:"volume", udn, level, muted }.
- GET /api/devices/:udn/volume: always 200 with { level:null,
  muted }. Read-only — does not allocate a tracker entry for
  unknown UDNs (reviewer-flagged memory footgun avoided).
- back-end/src/tv/volume.ts: per-UDN optimistic tracker + module
  handling delta / toggleMute / snapshot / `changed` event.
  Wires to the SessionManager `state` event so Connected fires an
  initial snapshot and Disconnected/Offline clears the tracker.
- back-end/src/tv/keys.ts: extended SamsungKeyCode union with
  KEY_VOLUP/KEY_VOLDOWN/KEY_MUTE. C6 SAMSUNG_KEY_CODES array
  intentionally unchanged so /api/devices/:udn/key still rejects
  volume keys per C6's own Non-Goals boundary.
- WebSocket broker gained a `volume` event and per-UDN volume in
  the connection snapshot (mirroring the C5 `session` pattern).
- Front-end useVolume(udn) hook: WS subscription + initial GET,
  optimistic mute toggle with rollback on failure, no setLevel
  (level is unknowable over Smart View).
- RemoteScreen wired: slider drag commit fires delta from
  position diff; mute button drives useVolume.toggleMute.
- DS Slider gained onCommit + disabled props (extended per
  frontend-design-check; drag-end handling stays in the
  primitive, not inlined in the app).
- Tests: 8 volume module unit tests + 7 route integration tests
  (GET default, GET on unknown UDN doesn't allocate, delta 3,
  delta 0/100 → 400 validation, delta while Disconnected → 409,
  mute + WS event, mute while Disconnected → 409). 5 new
  RemoteScreen component tests. 88→90 back-end, 9→14 front-end,
  all passing.

Spec-drift caught pre-implementation: change proposal was
written pre-Smart-View-pivot and referenced Samsung's hotel-TV
JSON-RPC directVolumeControl for absolute set + level read.
Smart View WebSocket is remote-key-only — no absolute-set, no
level read, no mute-state read. Rewrote design.md / spec.md /
tasks.md to descope: relative delta + mute only; level is
always null; muted is optimistic. UPnP RenderingControl or
SmartThings would be a separate future capability if actual
reads/absolute-set matter.

Task 5.2 (real-hardware repro) and 5.3 (light/dark eyeball) are
honestly deferred — no LAN/TV reachable. Stub-driven integration
suite stands in; follow-up flagged in docs/current-state.md.

Co-Authored-By: Claude <noreply@anthropic.com>
- GET /api/devices/:udn/inputs: returns the static
  INPUT_CATALOGUE regardless of session state. No cache, no
  refresh — Smart View WebSocket cannot enumerate the TV's real
  inputs and the catalogue is a constant per install.
- POST /api/devices/:udn/input: Fastify enum schema validates
  { key } against INPUT_KEYS (unknown → 400 validation via the
  errorHandler `error.validation` branch, same technique C7
  landed). Cross-capability keys like KEY_UP or KEY_VOLUP are
  rejected too — /input only accepts SamsungInputKey. On
  Connected + valid, enqueues one ms.remote.control frame with
  the chosen key through the C5 per-TV FIFO. On non-Connected,
  409 SessionNotConnected. Emits a `switched` event that the
  broker fans out as { topic:"devices", event:"input", udn, key }.
- back-end/src/tv/inputs.ts: SamsungInputKey (subset of
  SamsungKeyCode), INPUT_CATALOGUE static constant (KEY_SOURCE +
  KEY_HDMI1..4 + KEY_HDMI + KEY_TV + KEY_AV1 + KEY_COMPONENT1),
  createInputsModule(sessionManager) returning { list, switch,
  on/off }.
- back-end/src/tv/keys.ts: extended SamsungKeyCode with the
  input keys. SAMSUNG_KEY_CODES (C6 route) and VOLUME_KEY_CODES
  (C7 route) unchanged — capability boundaries preserved.
- WebSocket broker gained an `input` event; broker/app wiring
  mirrors C7's volume wiring.
- Front-end: useInputs(udn) hook returning { inputs, setInput }.
  No activeId. Initial GET /inputs on mount (catches
  back/front catalogue drift).
- InputsModal composed from DS Modal + rows of a new DS ListRow
  primitive. RemoteScreen gains an IconButton icon="input" next
  to Menu; useEffect auto-closes the modal when session state
  leaves Connected.
- DS ListRow.{jsx,d.ts} (new): neomorphic row primitive per
  frontend-design-check. Added a ListRow shim to
  front-end/src/ds.d.ts — the DS's own .d.ts files only export
  props interfaces, so the shim declares ComponentType so tsc
  resolves the import.
- Tests: 3 catalogue unit tests + 5 route integration tests
  (GET returns catalogue with no activeId, POST while
  Disconnected → 409, POST after connect sends the KEY_HDMI1
  frame + emits WS input event, unknown key → 400 validation,
  cross-capability KEY_UP → 400 validation). 3 InputsModal
  component tests + 1 new RemoteScreen integration test
  (opening the modal then flipping state to Connecting
  auto-closes). 90→98 back-end, 14→18 front-end, all passing.

Spec-drift caught pre-implementation: change proposal was
written pre-Smart-View-pivot and required Samsung's hotel-TV
JSON-RPC directSourceControl for listing available inputs,
reading active input, and setting a specific input. Smart View
WebSocket is remote-key-only — none of that is exposed.
Rewrote design.md / spec.md / tasks.md to descope: static
curated catalogue, no active-input marker, no refresh
endpoint. UPnP AVTransport or SmartThings would be a separate
future capability if actual reads / real listing matter.

Task 5.2 (real-hardware repro tapping each input) and 5.3
(dark-mode eyeball) are honestly deferred — no LAN/TV
reachable. Stub-driven integration suite covers the wire.

Phase 3 is complete. Only C9 error-surfacing remains.

Co-Authored-By: Claude <noreply@anthropic.com>
- DS Toast primitive: docs/orbit-tv-remote-design-system/
  components/feedback/Toast.{jsx,d.ts,prompt.md}. Neomorphic
  raised surface with tone-tinted left band (uses the same
  --connecting / --offline / --online tokens Badge uses),
  Material Symbols glyph, close IconButton. Tokens throughout;
  no hex.
- useToast context + ToastHost: front-end/src/ui/useToast.ts
  (React.createElement provider, reducer with push / dismiss /
  pause / resume, 5s auto-dismiss timer, 500ms dedup, cap 3
  with oldest-dropped overflow, injectable now() + nextId() for
  deterministic tests) and ToastHost.tsx (fixed bottom-right
  stack, pointer-events wrangling so the container doesn't
  swallow app clicks). main.tsx wraps App in
  ToastProvider → ErrorBoundary → App + ToastHost.
- messageFor mapper: front-end/src/errors/messages.ts. Pure
  function mapping every ApiError.code to { tone, message } per
  design.md D3. One place to change copy; unit-testable.
- Hook wiring: useSendKey, useVolume (delta + toggleMute),
  useInputs.setInput, useDeviceSession.connect catch ApiError,
  push(messageFor(err)), console.warn with correlationId, and
  re-throw. useDeviceSession.disconnect deliberately does NOT
  toast (documented — noisy UX during back-navigation).
- ErrorBoundary: front-end/src/ui/ErrorBoundary.tsx. Class-
  based backstop. Fallback composed from DS Card + Material
  Symbols error_outline + "Try again" Button that reloads.
- useWsHealth + WsHealthChip: dedicated /ws connection with
  exponential-backoff reconnect (1s doubling to 30s cap);
  transitions to 'offline' after 5 failed attempts. Chip in
  both DeviceListScreen and RemoteScreen headers.
- Tests: messages.test.ts (9 tests including the "no raw code"
  sweep across every code), ToastHost.test.tsx (4 tests: push
  renders, cap 3, dedup, hover pause + resume with remaining
  budget), useSendKey.test.tsx (1 end-to-end integration test
  through the real ToastProvider). 18→32 front-end tests.
- Reviewer-driven fixes in-cycle: (1) useWsHealth off-by-one
  (attempts > giveUpAfter → >=). (2) Toast.jsx +
  ErrorBoundary.tsx referenced --text-body-md (which doesn't
  exist in DS typography.css — real tokens are --text-body,
  --text-body-sm, --text-body-lg); fixed to --text-body. Same
  bug also present in ListRow.jsx from C8 — fixed
  transparently. (3) Reworded the "Hover pauses timer" spec
  scenario to match the "resume with remaining budget"
  semantics the test already codifies. (4) Updated design.md
  D5 to describe the dedicated-WS strategy the code actually
  implements.
- DESIGN.md: added Toast + ListRow rows to the Component
  surface table.

Task 5.5 (manual QA unplug-the-TV) is honestly deferred — no
LAN/TV reachable. Stub-driven ToastHost + useSendKey tests
cover the equivalent wire paths. Also deferred: task 2.3's
narrow-viewport pin-to-bottom-edge media query for the toast
stack (DS's inline-style pattern doesn't take @media; needs a
follow-up to add either a scoped <style> inside Toast.jsx or a
CSS class in the DS styles.css). No spec scenario depends on
it.

MVP backlog is now empty. All nine capabilities C1–C9 shipped.

Co-Authored-By: Claude <noreply@anthropic.com>
- Introduce `RotaryKnob` DS primitive (`docs/orbit-tv-remote-design-system/components/controls/RotaryKnob.{jsx,d.ts,prompt.md}`): raised neumorphic housing + inset centre well hosting a caller-supplied action, `--accent` rim indicator following the pointer, focus-visible outline; per-detent (15°) `onStep('up' | 'down')` fired from PointerEvent capture + keyboard (Arrow/Page keys).
- Wire it into `RemoteScreen`: mute `IconButton` moves into the knob's centre slot, `sendDelta(±1)` fires per detent, drops `sliderPosition` / `lastCommittedRef` / `useVolume().level` reads entirely. Back-end wire contract untouched.
- Sync `openspec/specs/volume-control/spec.md`: replaces the slider requirement with the rotary-knob requirement and its 12 scenarios; archives change under `openspec/changes/archive/2026-07-06-volume-rotary-knob/`.
- Update DS manifest, hand-patch the `_ds_bundle.js` (no in-repo bundler script) with a placeholder sourceHash, refresh the controls card + UI-kit RemoteScreen demo, and add the knob to `DESIGN.md` + DS readme.
- Rewrite `RemoteScreen.test.tsx` around the knob: CW/CCW/45° rotation, sub-detent no-op, ArrowUp/PageUp/keyboard-gated stepping, mute passthrough. `npm run front:build`, `npm run back:build`, `npm run front:test` (38/38), `npm run back:test` (98/98) all green.
- Manual UI + DS-SPA eyeball (tasks 5.4/5.5) deliberately left unchecked — need a paired Samsung TV on the LAN and a human at the browser; flagged in `docs/current-state.md` for the next session.

Co-Authored-By: Claude <noreply@anthropic.com>
First post-MVP capability. Opens a user-supplied URL in the TV's built-in
Tizen browser via a new Samsung Smart View WebSocket envelope family
(`ms.channel.emit` → `ed.apps.launch`), consuming C5's session queue and
C9's toast pattern verbatim.

- Requirements: add FR-BROWSER-01/02/03 in docs/requirements.md; add C10
  row + Phase 5 blurb in docs/capabilities.md.
- Back-end: new `tv/browser.ts` (`launchBrowserParams`) and
  `routes/browser.ts` (POST /api/devices/:udn/browser) with Fastify JSON
  schema + WHATWG URL parse + http/https-only scheme guard; 409
  SessionNotConnected mirrors C6/C7/C8. Wired under /api in app.ts.
- Front-end: remove the four placeholder `AppShortcut` tiles on
  RemoteScreen; render an inline DS `Input` + primary `Button` "Open" in
  the same aria-disabled wrapper; new `useBrowserLaunch` hook that maps
  failures onto the shared C9 toast.
- DS: additive `disabled` prop on existing `Input` primitive (mirrors the
  C7 Slider precedent); no new DS component.
- Tests: back-end unit (`launchBrowserParams` envelope) + integration
  (valid launch, four validation rejections, 409 SessionNotConnected)
  and front-end component tests for the URL row (AppShortcuts absent,
  disabled/enabled matrix, launch-on-click clears input). back:test
  107/107, front:test 44/44.
- Specs: sync `openspec/specs/tv-browser-launch/spec.md` and archive
  change under `openspec/changes/archive/2026-07-06-tv-browser-launch/`.
- Deferred: tasks 7.1/7.2/7.3 (real-TV verification + dark-mode eyeball)
  need a human session with an actual TV on the LAN — flagged in
  docs/current-state.md.

Co-Authored-By: Claude <noreply@anthropic.com>
First Phase-6 (deployment) capability. Ships one bash script that builds
both packages, grants port-80 capability, writes and enables a systemd
unit that runs the back-end as a dedicated `mytv` system user, and
starts it. Idempotent — same script is also the update path.

- Requirements: add FR-DEPLOY-01/02/03/04 in docs/requirements.md; add
  C11 row + Phase 6 blurb in docs/capabilities.md.
- Scripts (new `scripts/` directory):
  - `install.sh`: check_prereqs (refuses non-Linux / missing tools /
    non-root), resolve_paths, install_deps, build, grant_port_capability
    (setcap cap_net_bind_service), create_service_user (idempotent),
    write_unit (cmp against existing, daemon-reload only if changed),
    enable_and_start (poll is-active up to 10s), print_status. Supports
    `--dry-run` for previewing mutating commands.
  - `uninstall.sh`: disable + remove unit; deliberately keeps the mytv
    user + /var/lib/mytv so re-install preserves the on-disk token store.
  - `mytv.service.tmpl`: Type=simple, User=mytv, Restart=on-failure,
    After=network-online.target, HOME=/var/lib/mytv (XDG_CONFIG_HOME
    deliberately NOT set so token-store.ts falls back to $HOME/.mytv).
  - `install.test.bats`: bats-core smoke tests covering darwin refusal,
    systemctl-missing prereq, resolve_paths invariants, render_unit
    substitution + byte-stability, and dry-run behaviour.
- Root package.json: `deploy:install` and `deploy:uninstall` proxies.
- back-end/README.md: new "Production install (Orange Pi)" section
  pointing at `sudo ./scripts/install.sh` (+ `--dry-run` preview and
  troubleshooting note for setcap loss after `apt upgrade`).
- Verification: bash -n clean on both scripts; back:build, front:build,
  back:test (107/107), front:test (44/44) all pass. darwin refusal
  verified manually. bats itself not installed in the agent env; all 7
  bats assertions were replayed as plain-bash checks and passed.
- Deferred: on-Pi verification (tasks.md §7.1–7.4) — no Orange Pi in the
  agent environment; `bats scripts/install.test.bats` (§5.3) — bats-core
  not installed and the classifier declined `brew install`. Both are
  honestly disclosed in tasks.md and docs/current-state.md.
- Specs: sync `openspec/specs/systemd-installer/spec.md` and archive
  change under `openspec/changes/archive/2026-07-06-systemd-installer/`.

Co-Authored-By: Claude <noreply@anthropic.com>
Follow-up to C11, driven by real Orange Pi deploy pain (dev toolchain
required on target, no build-once/ship-dist path, error output tangled
with npm output). Splits the deploy story along a clean seam: build.sh
produces dists (no root, macOS or Linux); install.sh consumes them
(systemd on Linux). Two-step recipe.

- Requirements: add FR-BUILD-01 (build script), FR-DEPLOY-05 (install
  refuses missing dists), FR-DEPLOY-06 (npm ci --omit=dev). Reword
  FR-DEPLOY-01 so build ownership sits with build.sh, not install.sh.
- Capabilities: add C12 deploy-build (Phase 6) and update C11's one-liner
  and covers list.
- New scripts/build.sh: no-root, no-systemd, no OS gate. check_prereqs
  refuses without node; build steps die with the expected artefact path
  named on tsc/vite failure; final verify_artifacts asserts both dists
  non-empty. Prints single-host and split-host next-step recipes.
- scripts/install.sh: delete install_deps + build. Add verify_artifacts
  (fails before any mutating step with the missing path + a build.sh
  pointer) and install_prod_deps (npm ci --omit=dev --prefix back-end/).
  New main() pipeline. --help + dry-run banner updated to name the
  split.
- scripts/install.test.bats: add 6 tests — build.sh no-node refusal,
  verify_artifacts back-end/front-end/both-present cases, and back:build
  / front:build failures each naming the expected artefact path.
- Root package.json: `deploy:build` proxy alongside deploy:install /
  uninstall.
- back-end/README.md: two-step "Production install (Orange Pi)" recipe
  plus a "Build once, ship dist" subsection with the rsync command.
- Verification: bash -n clean on all three scripts; live build.sh e2e
  on this laptop regenerated both dists; back:build, front:build,
  back:test (107/107), front:test (44/44) all pass. All 8 bats
  assertions (existing + 6 new) replayed manually in plain bash since
  bats-core isn't installed and the auto-mode classifier still declines
  brew installs.
- Specs: sync new deploy-build spec, apply MODIFIED + ADDED requirements
  to systemd-installer spec, archive change under
  openspec/changes/archive/2026-07-06-build-install-split/.
- Reviewer: PASS on second review (first pass CHANGES_REQUESTED on two
  findings — tsc/vite failure not naming the artefact path, dry-run
  banner not mentioning the split — both fixed and re-tested).
- Deferred: on-Pi verification (tasks.md §8.1/§8.2) — same posture as
  C11; the human debug session that motivated this change is where
  those tasks close. bats scripts/install.test.bats (§6.3) — replayed
  manually.

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Це повноцінний greenfield-репозиторій "mytv": Fastify-backend (discovery Samsung TV через SSDP, сесії Smart View WebSocket, керування/гучність/входи/браузер), React+Vite SPA, повний OpenSpec-архів пропозицій/дизайну/задач по кожній capability, набір agentic skills/команд (OPSX, code-reviewer) та bash-скрипти для systemd-деплою.

Estimated code review effort: 5 (Critical) | ~180+ хвилин

Changes

Документація для агентних skills та workflow

Layer / File(s) Summary
Fastify best-practices skill
.agents/skills/fastify-best-practices/SKILL.md, rules/*.md, tile.json
Повний набір документаційних правил Fastify (auth, schemas, hooks, testing тощо) без коду продукту.
React+Vite best-practices skill
.agents/skills/react-vite-best-practices/**
Правила оптимізації Vite/React бандлів, код-сплітингу, dev-режиму, env та bundle-аналізу.
OPSX-команди та Claude skills
.claude/commands/opsx/*, .claude/agents/code-reviewer.md, .claude/skills/*
Команди propose/apply/archive/explore/sync для OpenSpec, незалежний code-reviewer агент, skills для samsung-ip-control-protocol та frontend-design-check.
Репозиторний agent-конфіг
AGENTS.md, CLAUDE.md, DESIGN.md, LOOP.md, .coderabbit.yaml, .gitignore, skills-lock.json, package.json
House rules безпеки/фронтенду/бекенду, процес Loop Engineering, дизайн-гайд, конфіг CodeRabbit та кореневі npm-скрипти.

Backend сервіс mytv (Fastify)

Layer / File(s) Summary
App factory, помилки, логування, mDNS
back-end/src/app.ts, errors.ts, logger.ts, mdns.ts, plugins/static-spa.ts, index.ts
Fastify-інстанс single-origin, редагування секретів у логах, JSON error envelope, mDNS-оголошення mytv.local, SPA fallback.
UPnP/SSDP discovery
back-end/src/discovery/**
SSDP-транспорт, парсинг description XML, реєстр пристроїв з дедупом по UDN/IP.
TV session lifecycle та Smart View WS
back-end/src/tv/session*.ts, manager.ts, jsonrpc.ts, errors.ts, token-store.ts, types.ts
State machine сесії, handshake/ping через WebSocket, збереження токена з правами 0600, мапінг помилок у доменний union.
Команди TV, HTTP/WS маршрути та брокер
back-end/src/tv/keys.ts, inputs.ts, volume.ts, browser.ts, routes/**, ws/broker.ts
Endpoints для клавіш/гучності/входів/браузера, WebSocket fan-out подій devices.

Frontend SPA mytv (React + Vite)

Layer / File(s) Summary
Build tooling та entrypoint
front-end/package.json, vite.config.ts, tsconfig*, App.tsx, main.tsx, *.css
Vite-проксі до backend, точка входу з ErrorBoundary/ToastProvider.
API client та data-хуки
front-end/src/api/client.ts, errors/messages.ts, data/**
apiClient/ApiError, мапінг помилок у toast-копі, хуки useDevices/useDeviceSession/useVolume/useInputs/useSendKey/useBrowserLaunch/useWsHealth.
UI-примітиви
front-end/src/ui/**
useToast/ToastHost, WsHealthChip, ErrorBoundary.
Екрани
front-end/src/screens/**
DeviceListScreen, RemoteScreen (DPad/RotaryKnob/URL-браузер), InputsModal.

OpenSpec специфікації, вимоги та архів змін

Layer / File(s) Summary
Поточні specs та документація вимог
openspec/config.yaml, openspec/specs/**, docs/*.md
Актуальні capability specs та документи requirements/capabilities/product-brief/current-state.
Архів OpenSpec change proposals
openspec/changes/archive/**
15 архівованих capability-змін (proposal/design/tasks/delta-specs) — від platform-foundation до build-install-split.

Скрипти розгортання

Layer / File(s) Summary
build/install/uninstall
scripts/build.sh, install.sh, uninstall.sh, mytv.service.tmpl, install.test.bats
Двоетапний деплой: standalone-збірка без root, ідемпотентна systemd-інсталяція з npm ci --omit=dev, bats smoke-тести.

Sequence Diagram(s)

sequenceDiagram
  participant SPA
  participant BackendAPI as Fastify API
  participant SessionManager
  participant SmartViewWS as Smart View WS
  SPA->>BackendAPI: POST /api/devices/:udn/connect
  BackendAPI->>SessionManager: ensure(udn).connect()
  SessionManager->>SmartViewWS: open + handshake
  SmartViewWS-->>SessionManager: token / Connected
  SessionManager-->>BackendAPI: state=Connected
  BackendAPI-->>SPA: 200 + WS event session
  SPA->>BackendAPI: POST /api/devices/:udn/key {key}
  BackendAPI->>SessionManager: enqueue(ms.remote.control)
  SessionManager->>SmartViewWS: send frame
  BackendAPI-->>SPA: 204
Loading

Estimated code review effort: 5 (Critical) | ~180 хвилин

Мій вердикт як ментора курсу: сам обсяг і структура роботи демонструють дисципліноване застосування spec-driven agentic workflow — кожна capability пройшла через propose → apply → archive з чіткими design/tasks/specs і навіть журналом docs/current-state.md. Це саме той процес, якого хочеться від "greenfield" курсу: не хаотичний код, а трасовані рішення. Із зауважень — PR настільки величезний (весь продукт + вся інфраструктура агентів одним пакетом), що для реального рев'ю варто було б розбивати на менші, незалежно мержабельні шматки навіть попри спільну історію change-логів. Але як демонстрація agentic-практик — дуже сильно, тримайте темп! 💪

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning Назва не описує зміст змін і не відображає головний результат PR. Замініть її на коротку змістовну назву, що прямо описує зміни, наприклад: "Add mytv Samsung TV remote app".
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed Опис заповнює всі обов’язкові розділи шаблону та містить справжнє ім’я, відео, проєкт і опис практик.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch anton-petrenko-solution

Comment @coderabbitai help to get the list of available commands.

@AntoniPetrenko

Copy link
Copy Markdown
Owner Author

@coderabbitai help

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

CodeRabbit commands

These commands are invoked using PR/Issue comments.

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai rate limit to show your current review rate limit status.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai evaluate custom pre-merge check --instructions <custom-checks-instructions> --name <custom-checks-title> [--mode <error|warning>] to validate the custom pre-merge checks instructions. Defaults to error when --mode is omitted.
  • @coderabbitai ignore pre-merge checks to override pre-merge checks and get an approval on PR.
  • @coderabbitai run pre-merge checks to run pre-merge checks on the pull request.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai generate unit tests to generate unit tests for this PR.
  • @coderabbitai emit path instructions to emit generated path-specific review instructions for this repository.
  • @coderabbitai resolve merge conflict to automatically resolve merge conflicts.
  • @coderabbitai autofix to automatically fix issues identified in unresolved review comments.
  • @coderabbitai resolve to resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai generate configuration to create a PR that adds the current resolved configuration as .coderabbit.yaml (or show it if already present).
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Status, support, documentation and community

  • Visit our status page to check the current availability of CodeRabbit.
  • Create a ticket on our support page for assistance with any issues or questions.
  • Visit our documentation site for detailed information on how to use CodeRabbit.
  • Join our Discord community to connect with other users and get help from the community.
  • Follow us on X/Twitter for updates and announcements.

@AntoniPetrenko

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@AntoniPetrenko

Copy link
Copy Markdown
Owner Author

@coderabbitai rate limit

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Your plan includes PR reviews subject to rate limits. Reviews are available now.

@AntoniPetrenko

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 68

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
scripts/install.test.bats (1)

1-245: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Дуже пристойний набір bats-тестів.

Покриття справді відповідає заявленому в описі PR підходу "специфікації-тести-evals": є негативні сценарії (відсутній node/systemctl, відсутні артефакти збірки), перевірка ідемпотентності рендеру юніта та dry-run поведінки без реальних мутацій системи. Це саме той рівень верифікації агентних змін, який хочеться бачити — респект.

Одна дрібниця на майбутнє: тест 5.2/5.3 (рядки 158-184) перевіряють verify_artifacts з install.sh, але немає симетричного тесту на створення групи mytv під час create_service_user (див. коментар у install.sh про --user-group) — варто додати, коли виправите той момент.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install.test.bats` around lines 1 - 245, The install.sh test suite
covers verify_artifacts well, but it is missing the symmetric check for
create_service_user and the --user-group behavior mentioned in the script
comment. Add a bats test in install.test.bats that exercises create_service_user
with a stubbed useradd and verifies the mytv group-related flag/behavior is
used, referencing create_service_user and the --user-group path so the test
remains findable if layout changes.
front-end/README.md (1)

1-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

README досі шаблонний, не про mytv.

Весь файл — це дефолтний Vite/React boilerplate, жодного слова про сам проєкт (запуск разом з back-end, single-origin serving, Orange Pi деплой). Непогано було б замінити на короткий опис реального фронтенду, хоча б з посиланням на кореневий docs/product-brief.md.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front-end/README.md` around lines 1 - 76, The README is still the default
Vite/React boilerplate and doesn’t describe this project; replace the template
text in the README with a short front-end overview for mytv, including how it
runs with the back-end, single-origin serving, and Orange Pi deployment, and
point readers to docs/product-brief.md for the full product description.
front-end/src/App.css (1)

1-185: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Мертвий файл: App.css більше ніде не імпортується.

App.tsx тепер імпортує лише DeviceListScreen, RemoteScreen, useDevices і Device — жодного import './App.css'. Це стандартний шаблонний CSS від Vite+React (.hero, .framework, .vite, #docs, #next-steps, .ticks), що лишився з болерплейту й ніде не застосовується у реальних DS-екранах. Заразом порушує DS-правило "не хардкодити кольори/тіні/градієнти" (border-top, box-shadow, довільні transform), хоч це вже неактуально, бо файл мертвий.

Приберіть файл разом з рештою невикористаних default-Vite артефактів (логотипи, .svg тощо, якщо є).

🧹 Пропоноване видалення
-.counter { ... }
-.hero { ... }
-#center { ... }
-#next-steps { ... }
-#docs { ... }
-#next-steps ul { ... }
-#spacer { ... }
-.ticks { ... }

(видалити файл front-end/src/App.css повністю)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front-end/src/App.css` around lines 1 - 185, `App.css` is dead code and
should be removed because `App.tsx` no longer imports it and the selectors
(`.hero`, `.framework`, `.vite`, `#docs`, `#next-steps`, `.ticks`) are only Vite
starter styles with no use in the current screens. Delete the `App.css` file and
clean up any leftover unused default Vite assets referenced by the boilerplate
if they are still present, so the frontend only keeps styles actually used by
`DeviceListScreen` and `RemoteScreen`.
back-end/README.md (1)

1-193: 📐 Maintainability & Code Quality | 🔵 Trivial

Домашка процесно виглядає повною — усі 4 критерії на місці.

Пройшовся по опису PR: справжнє імʼя автора вказано в назві, є посилання на демо-відео, а опис агентних практик — не формальний, а змістовний (контекст-файли, /next-change цикл, окремий code-reviewer суб-агент як checker, OpenSpec як специфікації/тести/evals, конкретні інструменти й MCP). Результат заявлений як робочий і доведений до кінця, а не "згенеровано і кинуто" — і цей README.md це підтверджує конкретикою (реальна модель TV, конкретні env-змінні, ручні репро-кроки). Гарна інженерна робота саме на рівні процесу, а не лише коду.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/README.md` around lines 1 - 193, No code change is needed here; the
README already covers the requested process criteria with concrete details, so
keep the current content as-is unless you want to make only minor wording tweaks
for clarity in the sections describing the PR process, OpenSpec workflow, and
reviewer/checker setup.

Source: Path instructions

♻️ Duplicate comments (1)
back-end/src/tv/token-store.test.ts (1)

19-37: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Непогане покриття happy-path, але бракує тесту на паралельні saveToken.

Варто додати тест, що викликає store.saveToken('udn-1', ...) і store.saveToken('udn-2', ...) без await між ними (Promise.all) і перевіряє, що обидва токени збереглись — це саме той сценарій, що виявляє race condition, описаний у зауваженні до token-store.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/tv/token-store.test.ts` around lines 19 - 37, Add a test for
concurrent writes in token-store.test.ts: the current saveToken/loadTokens
coverage only checks sequential saves, but the race condition happens when
createTokenStore().saveToken is called in parallel. Add a case that invokes
saveToken for 'udn-1' and 'udn-2' without awaiting between them (using
Promise.all), then verify loadTokens returns both entries. Use the existing
createTokenStore, saveToken, and loadTokens symbols so the new test targets the
same behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.agents/skills/fastify-best-practices/rules/logging.md:
- Around line 151-162: The custom `user` serializer is still emitting PII by
including `email`, so update the serializer to return only a safe identifier
such as `id` and remove any sensitive user fields from the logged object. Keep
the `request.log.info({ user: request.user }, ...)` usage, but ensure it only
receives the sanitized output from the `user` serializer.

In @.agents/skills/fastify-best-practices/rules/routes.md:
- Around line 195-206: Two fenced directory-tree blocks in the markdown rule doc
are missing a language tag and trigger markdownlint warnings. Update both code
fences to use the text language so the examples remain rendered as plain text;
check the duplicated tree example in the same document and keep the formatting
consistent across both blocks.

In @.agents/skills/fastify-best-practices/SKILL.md:
- Around line 1-6: File start violates MD041 because the markdown begins with
YAML front-matter instead of a top-level heading. Update the SKILL.md content so
the front-matter is followed by a first-line H1 like “Fastify Best Practices,”
or ensure the markdownlint configuration explicitly allows front-matter before
the heading. Reference the existing front-matter block in SKILL.md when making
the change.

In @.agents/skills/react-vite-best-practices/rules/asset-svg-components.md:
- Around line 44-156: The SVG-to-component guidance is too broad and conflicts
with Orbit DS icon rules, so narrow these examples in the asset-svg-components
rule. Update the guidance around the vite-plugin-svgr setup, the `*.svg?react`
import pattern, and the `Icon` component example to explicitly say this approach
is only allowed for non-UI assets or DS-owned icons, not general mytv UI. Add a
clear note that frontend UI should continue using DS primitives and Material
Symbols Rounded only, so agents don’t adopt arbitrary SVG components in
`front-end/src/**/*.{ts,tsx,js,jsx,css,html}`.

In @.agents/skills/react-vite-best-practices/rules/build-manual-chunks.md:
- Around line 70-86: The manual chunking logic in manualChunks currently checks
id.includes('react') too early, which can incorrectly capture react-router-dom
and other specific packages. Update the matching order so more specific package
checks (such as react-router-dom and react-dom) run before the generic react
case, and keep the existing vendor-react, vendor-react-dom, and vendor-router
chunk names aligned with the intended split behavior.

In @.agents/skills/react-vite-best-practices/rules/build-minification.md:
- Around line 162-166: The example in processData returns an undeclared value,
so it won’t compile. Update the example to return a value that is actually
defined inside processData, or declare and assign the result before returning
it, so the snippet is valid TypeScript. Keep the function name processData and
the logger.log usage unchanged while fixing the return value.

In @.agents/skills/react-vite-best-practices/rules/build-tree-shaking.md:
- Around line 111-129: Remove the optimizeDeps block from the tree-shaking
example so the snippet only demonstrates production tree-shaking via
build.rollupOptions.treeshake in the vite.config.ts example. If you want to keep
optimizeDeps.include, move that guidance to the dev-dependency-prebundling rule
instead; otherwise keep the example focused on the build configuration and its
unique symbols defineConfig, build.rollupOptions.treeshake, and optimizeDeps.

In @.agents/skills/react-vite-best-practices/rules/bundle-visualizer.md:
- Around line 106-121: Provide a language identifier for the fenced markdown
block in the bundle visualizer example: the opening fence currently has no
language, which triggers markdownlint MD040 and hurts readability. Update the
fence in the bundle-visualizer rule snippet to use text so the example remains a
valid fenced code block while preserving the existing content.

In @.agents/skills/react-vite-best-practices/rules/env-modes.md:
- Around line 40-59: The example env block in env-modes.md uses hard-coded
localhost, absolute API URLs, and telemetry settings that conflict with the repo
rules. Update the examples in the env mode section to use same-origin relative
endpoints like /api and /ws, and remove or replace Sentry/analytics-style
variables with neutral local-first placeholders. Keep the guidance aligned with
the env-mode examples and avoid any hard-coded hostnames or runtime telemetry
fields.

In @.agents/skills/react-vite-best-practices/rules/env-sensitive-data.md:
- Around line 16-60: The examples in env-sensitive-data.md still use secret-like
placeholder values and include a telemetry service in the “Correct” section.
Update the documentation examples to use only clearly synthetic placeholders
such as <redacted> and <secret>, remove the VITE_SENTRY_DSN example entirely,
and keep the guidance aligned with local-first/offline usage. Make sure the env
samples and the explanatory text reference the public-vs-private split without
showing any realistic secrets; the relevant sections are the env examples and
the “Correct” block.

In @.agents/skills/react-vite-best-practices/rules/env-vite-prefix.md:
- Around line 44-54: Update the env example in the relevant rule doc so it does
not present VITE_ENABLE_ANALYTICS as a “good” client-side pattern; replace that
analytics/telemetry flag with a local-only, offline-safe Vite example. Keep the
existing VITE_* vs server-only distinction, but make the client-side sample
clearly unrelated to analytics, and ensure the surrounding guidance in the env
rule aligns with the local-first policy.
- Around line 85-95: Update the `.env.development` example in the VITE env
best-practices rule so it uses a same-origin/relative API base instead of an
absolute localhost URL. Replace the `VITE_API_URL` example value currently shown
for `.env.development` with a relative path like `/api`, keeping the surrounding
`.env.production` and `.env.staging` examples unchanged.

In @.agents/skills/react-vite-best-practices/rules/split-dynamic-imports.md:
- Around line 134-149: Move the dynamic import out of App so AdminPanel is
created once at module scope instead of on every render. Define the lazy(...)
component with a stable symbol name outside App, then conditionally render that
already-created component inside App using the existing Suspense fallback. This
keeps the component identity stable and avoids unnecessary remounts or state
resets.

In @.agents/skills/react-vite-best-practices/rules/split-prefetch-hints.md:
- Around line 175-180: The prefetch scheduling in preloadFns currently invokes
fn() without handling its returned promise, which can surface as an unhandled
rejection. Update the callback inside preloadFns.forEach so that both
requestIdleCallback and setTimeout paths explicitly handle failures from fn()
and fail open by swallowing or safely catching the rejection. Use preloadFns and
fn as the key symbols to locate the async prefetch wrapper and ensure the
behavior remains silent on prefetch errors.

In @.agents/skills/react-vite-best-practices/rules/split-suspense-boundaries.md:
- Around line 96-115: The ErrorBoundary usage is passing an element via fallback
instead of the fallback component prop, so ErrorFallback never receives error
and resetErrorBoundary. Update the ErrorBoundary wrapper in the suspense/routes
example to use FallbackComponent with ErrorFallback, and keep ErrorFallback
defined to accept the injected props so the retry button can work correctly.

In @.claude/agents/code-reviewer.md:
- Line 20: The guidance currently points reviewers to the wrong TV protocol
skill; update the reviewer instruction in the code-reviewer agent docs to
reference Samsung Smart View WebSocket instead of `samsung-ip-control-protocol`.
Keep the check focused on the WebSocket handshake and envelope format, and
adjust the existing TV I/O guidance so it names the Smart View WebSocket
skill/location used in this repo.

In @.claude/commands/opsx/archive.md:
- Around line 34-49: The archive flow currently allows proceeding after a
confirmation even when artifacts or tasks are incomplete, which can hide
unfinished work. Update the archive command logic in the archive.md flow so that
incomplete artifacts/tasks are treated as a hard stop unless the required green
verification and PASS from code-reviewer are already present, and remove the
“prompt to continue” path for incomplete status checks. Use the artifact/task
validation steps and the archive gating logic to ensure archiving only happens
when the change is fully complete.

In @.claude/skills/samsung-ip-control-protocol/SKILL.md:
- Around line 1-19: Mark the samsung-ip-control-protocol skill as
legacy/reference-only instead of active: update the skill metadata and guidance
in SKILL.md so it no longer tells agents to load it for current TV I/O work,
since backend TV transport has moved to Smart View WebSocket. Keep the document
available for historical reference, but make the applicability section and load
instructions explicitly exclude it from the active path for mytv and point
agents away from using it for current back-end/** TV communication.
- Around line 76-93: Fix the method catalog markdown table in SKILL.md: the
`powerControl` row contains unescaped pipe characters and the `3.1.3` row has an
inconsistent column count, which breaks the table layout. Update the table entry
for `powerControl` and the mute control row so the markdown remains a valid
3-column table, or move those notes out into bullets; keep the rest of the
method list aligned under the same catalog section.

In `@back-end/.env.example`:
- Around line 3-6: Dotenv-linter is flagging cosmetic issues in the env sample:
reorder the keys so HOST and LOG_LEVEL appear before PORT, and ensure the file
ends with a trailing blank line. Update the .env.example contents accordingly
and keep SERVE_SPA in the same section.

In `@back-end/src/discovery/index.ts`:
- Around line 85-157: `handleHit` only guards `fetchDescription`, so any
exception from `registry.upsert` (or later logic) can reject the task and bubble
into `queue.add(...)` as an unhandled promise rejection. Wrap the
`registry.upsert` block inside `handleHit` in its own try/catch, log the failure
with `logger.debug` or similar using `description.udn`, `ip`, and `port`, and
return safely. Also make sure the `transport.on('hit', ...)` path does not leave
the `queue.add(() => handleHit(hit))` promise unobserved if `handleHit` can
still reject.

In `@back-end/src/discovery/registry.ts`:
- Around line 52-80: The IP deduplication check in upsert currently runs only
for new UDNs, so an existing device can be updated onto an IP already used by
another online device. Move or reuse the conflict scan so it also applies in the
existing branch before devices.set, while still allowing the same UDN to update
its own record; use upsert, devices, and the emitter added/updated paths to
locate the change.

In `@back-end/src/logger.ts`:
- Line 3: The sensitive-key redaction in logger.ts is currently case-sensitive,
so keys like Token or Authorization can bypass masking. Update the key check
used by the recursive redaction logic to normalize casing before consulting
SENSITIVE_KEYS, and make SENSITIVE_KEYS match the normalized form consistently.
Keep the existing redaction flow intact in the logger’s redact helper so
token-related fields are removed regardless of key depth or key capitalization.

In `@back-end/src/mdns.test.ts`:
- Around line 109-127: The mdns test only covers the synchronous publish() throw
path, but the real Avahi port-conflict is handled asynchronously through
rawClient.server.mdns.on('error'). Extend the startMdns test using the
MdnsClient stub to include a server.mdns.on handler and simulate an async
EADDRINUSE error so the error-state branch in mdns.ts is covered; keep the same
assertions on handle.state, handle.error, and handle.address for this
socket-error path.

In `@back-end/src/plugins/static-spa.ts`:
- Around line 18-32: The URL decoding in resolveStaticFile can throw on
malformed percent-encoding, which causes the static SPA fallback to fail with an
error instead of returning no match. Wrap the decodeURIComponent handling in a
try/catch inside resolveStaticFile, and return null when decoding fails so the
notFound/static lookup path in static-spa.ts treats the request as missing. Keep
the existing traversal checks with resolve, isInsideRoot, and existsSync
unchanged.

In `@back-end/src/routes/browser.test.ts`:
- Around line 134-210: Add a test in browser.test.ts to cover the
transport-level rejection path after a session is already connected: mock
session.enqueue() to reject with a TvError and verify the /browser POST response
is mapped by the global error handler to an allowed union code instead of
leaking wire-level details. Reuse the existing POST /browser test setup, the
connect flow, and the post helper/stub assertions to ensure no frame is sent
when enqueue fails. Locate the change around the browser route tests and the
session.enqueue() call path used by the browser route handler.

In `@back-end/src/tv/manager.ts`:
- Around line 111-113: The onRemoved handler in manager.ts only removes the
device from deviceIndex, so if registry ever starts emitting removed it will
leave stale entries in sessions. Update onRemoved to also clean up any
session/WebSocket state for the same device.udn, and add a TODO or defensive
safeguard around the removed path in TVManager so future registry behavior
cannot leak session entries.
- Around line 18-21: resolveControlPort currently turns any non-empty
MYTV_CONTROL_PORT_<udn> override into Number(override) without validating the
result, which can produce NaN and send sessions to an invalid port. Update
resolveControlPort in manager.ts to parse the env override safely and fall back
to SMART_VIEW_PORT when the override is missing, non-numeric, or otherwise
invalid. Keep the logic localized in resolveControlPort so the default port is
always used unless a valid numeric override is provided.

In `@back-end/src/tv/session.test.ts`:
- Around line 112-176: The tests in session.test.ts are flaky because they rely
on fixed setTimeout sleeps to wait for heartbeat and reconnect state changes.
Replace the hard-coded delays in the heartbeat/reconnect tests with a wait
helper that resolves from session.on('state', ...) once the expected state kind
appears, and use that helper around the existing createSession/connect flow so
the assertions wait for Reconnecting, Connected, and Disconnected
deterministically.

In `@back-end/src/tv/token-store.ts`:
- Around line 46-55: The catch block in token-store’s load path is silently
discarding malformed tokens.json by resetting cache to {} and returning {},
which makes unexpected token loss hard to diagnose. Update the error handling in
the token-store logic to keep the existing ENOENT fallback but log a
warning/error for any other parse/read failure, including the caught err
details, before clearing cache and returning an empty object. Use the existing
cache handling in token-store.ts and the surrounding load/read function to place
the log where the failure is handled.

In `@front-end/src/api/client.ts`:
- Around line 18-32: Add a timeout to the shared request flow in request() so
fetch calls cannot hang forever; currently fetch(path, init) has no abort path,
which can leave useBrowserLaunch and useInputs stuck in isPending. Update
request() to use AbortController (or accept an existing signal/timeout option)
and abort slow requests with a clear timeout error, then make sure the ApiError
handling still works for non-OK responses and callers can surface the failure.

In `@front-end/src/data/useBrowserLaunch.ts`:
- Around line 36-67: The `launch` error-handling block in `useBrowserLaunch`
duplicates the same `ApiError`/generic toast/log/rethrow pattern used in
`useInputs.setInput` and likely other data hooks, so extract that logic into a
shared helper such as `reportApiError` or `useApiAction`. Keep `launch` focused
on calling `post(udn, url)` and delegate the `ApiError` logging,
`push(messageFor(err))`, fallback toast, and rethrow behavior to the shared
helper so future toast or logging changes happen in one place.

In `@front-end/src/data/useDeviceSession.ts`:
- Around line 89-107: The WebSocket handler in useDeviceSession accepts any
string for message.state, which can let invalid session states reach setState
and downstream UI mappings. In onMessage, after parsing the WsSessionMessage
payload, narrow message.state to the allowed ClientSessionState values only
('Disconnected', 'Connecting', 'Connected', 'Offline') before calling setState.
Use the existing onMessage and setState flow in useDeviceSession to keep the
validation local and prevent unsupported states from entering
sessionToBadgeStatus and STYLE consumers.

In `@front-end/src/fonts.css`:
- Line 66: `word-wrap` is deprecated in the font stylesheet, so update the CSS
rule in the affected selector to use `overflow-wrap` instead of `word-wrap`.
Keep the same wrapping behavior while replacing the deprecated property so the
Stylelint `property-no-deprecated` warning is resolved.
- Around line 9-45: In the `@font-face` blocks in fonts.css, the font-family value
for Montserrat is using quoted names that violate the font-family-name-quotes
rule. Update all Montserrat `@font-face` declarations so the font-family is
unquoted consistently across the affected blocks, and keep the rest of the
font-face settings unchanged.

In `@front-end/src/screens/DeviceListScreen.tsx`:
- Around line 14-42: The layout in DeviceListScreen still uses hardcoded spacing
values instead of the existing design tokens. Update the top-level wrapper,
header row, and title block in DeviceListScreen to use the same spacing token
approach already used by EmptyState in this file, replacing raw pixel values
like padding, marginBottom, gap, and similar with the appropriate var(--space-*)
tokens. Keep the structure and styling intent the same, but make spacing
consistent across the screen.

In `@front-end/src/screens/RemoteScreen.tsx`:
- Around line 128-230: RemoteScreen.tsx still uses hardcoded pixel values for
layout spacing, so the screen is inconsistent with the design-system spacing
approach. Update the styles in the RemoteScreen component to replace raw
padding, gap, and similar spacing values with the existing var(--space-*)
tokens, following the same pattern used in DeviceListScreen. Keep the structure
and behavior of the layout the same, but make the spacing token-driven across
the main container and the nested button/control groups.
- Around line 156-163: On RemoteScreen, there are two accent actions competing
for attention: the primary Open button and the power button using accent
styling. Update the power button implementation to use the default/neutral tone
instead of accent so only the main action remains emphasized, and keep the
primary Button for handleLaunchBrowser as the single accent element on the
screen.

In `@front-end/src/ui/ErrorBoundary.tsx`:
- Around line 41-85: The error fallback in ErrorBoundary is using raw divs with
inline layout styles instead of DS primitives. Refactor the render path in
ErrorBoundary to build the full screen, centering, and card layout using
components from `@ds/components/`** (for example a screen/container/stack/card
primitive), and if the needed layout primitive does not exist, add it to the
design system rather than keeping inline styles in the fallback UI.
- Around line 67-77: The fallback copy in ErrorBoundary is written in third
person, but the UI guidelines require English sentence case in second person.
Update the strings in the ErrorBoundary render path so they speak directly to
the user (for example, revise the messages shown in the “Something broke.” block
and the “The remote screen ran into an unexpected error...” block), while
keeping the tone concise and consistent with the existing ErrorBoundary
component.

In `@front-end/src/ui/ToastHost.tsx`:
- Around line 14-27: The toast container in ToastHost uses a raw inline-styled
<div> for layout, but the guideline requires all UI elements to come from
`@ds/components`. Replace this stack wrapper with an appropriate design-system
layout primitive (or add one to the DS if missing) and keep the ToastHost
composition using that component instead of inline styles.

In `@front-end/src/ui/useToast.ts`:
- Around line 14-23: The local Toast type name conflicts conceptually with the
DS Toast component, so rename the interface in useToast.ts to a more specific
identifier like ToastItem or ToastState. Update all references within the toast
hook/state helpers to use the new name, especially the Toast interface and any
functions or arrays in useToast that consume it, to avoid future alias-import
collisions.
- Around line 152-167: The auto-dismiss timer in useToast is being reset to the
full remaining duration whenever any toast list change reruns the effect, so
existing toasts can live longer than intended. Update the Auto-dismiss useEffect
in useToast to compute each timeout from the actual elapsed time since the
toast’s createdAt (or equivalent persisted start timestamp) instead of blindly
using toast.remainingMs, and ensure dismiss scheduling for a given toast is
stable across unrelated state.toasts updates. Also add/adjust a
ToastHost.test.tsx case that simulates multiple concurrent toast pushes so the
original toast still dismisses after the intended 5s window.

In `@front-end/src/ui/WsHealthChip.tsx`:
- Around line 8-52: The WsHealthChip is reimplementing a status pill with ad-hoc
inline styles instead of using the design-system Badge primitive. Refactor
WsHealthChip to compose the existing Badge component (preferably the appropriate
variant/size) and move any missing visual treatment into the DS rather than
hardcoding spacing, dot sizing, and uppercase styling here. Keep the WebSocket
health mapping in STYLE and use WsHealthChip as a thin wrapper around the DS
component so DeviceListScreen and RemoteScreen get a consistent, reusable status
chip.

In `@openspec/changes/archive/2026-07-04-default-port-80/design.md`:
- Around line 44-47: The current test assertion relies on the health snapshot,
but that snapshot does not expose the mDNS SRV port, so it cannot detect a
mismatch between the listen port and the advertised port. Update the relevant
assertion in app.test.ts to verify the actual port passed to startMdns() or
another direct advertised-port source tied to getPort(), rather than checking
/api/health. Keep the test focused on the resolved SRV port so it directly
validates the new default 80 behavior.

In `@openspec/changes/archive/2026-07-04-device-list-ui/tasks.md`:
- Around line 18-19: The grep check is currently counting scaffold SVG matches
as success even though src/assets/vite.svg and src/assets/react.svg still
contain real hits, so the scenario is not actually satisfied. Update the tasks
in the device-list UI change to either remove those Vite scaffold assets or
narrow the verification to the intended source subtrees, and make sure the
acceptance text around the grep results reflects the actual cleanup in the
relevant sections.
- Around line 24-25: The task notes currently include real LAN-identifying
device details from the partial repro run, which should not be committed. In the
task entry for the device-list UI verification, replace the concrete Samsung TV
name and IP with redacted placeholders and keep only generic wording; update the
relevant checklist text so it no longer preserves local network data while still
referencing the same verification steps and the partial repro note.

In `@openspec/changes/archive/2026-07-04-remote-control-keys/design.md`:
- Around line 54-59: The fallback that uses a pointer-events/opacity wrapper for
DPad is not allowed; keep all UI behavior inside `@ds/components/`** instead.
Update the DPad primitive to support a real disabled state (matching IconButton)
and then wire RemoteScreen to use that disabled prop alongside the existing
state from useDeviceSession and sendKey handlers. Avoid any ad-hoc wrapper CSS
or token-based workaround in the command controls section.

In `@openspec/changes/archive/2026-07-04-smart-view-ws-transport/proposal.md`:
- Around line 5-16: The Smart View WS transport contract is mixing JSON-RPC
semantics with the WebSocket envelope, which will drive the wrong implementation
and tests. Update the `JsonRpcTransport`-style API in
`back-end/src/tv/jsonrpc.ts` and the related design text so `call()` is
described as a WS send/await-ack flow using the Smart View envelope with
`TypeOfRemote`, not a decoded JSON-RPC round-trip, and align all mentions of
`TypeOfCode` to the actual protocol fields used by the session actor.

In
`@openspec/changes/archive/2026-07-04-smart-view-ws-transport/specs/upnp-tv-discovery/spec.md`:
- Around line 5-17: The Samsung pre-filter in the discovery orchestrator is too
broad because it treats any `uuid:` prefix as a Samsung signature, which will
incorrectly let non-Samsung UPnP devices through. Update the SSDP header check
in the spec around the discovery flow so only truly Samsung-specific signals are
accepted (for example, the case-insensitive `samsung` substring or a
Samsung-specific UUID pattern if that is actually narrower than generic
`uuid:`), and ensure the `USN`/`SERVER`/`ST` drop behavior in the non-Samsung
scenario still rejects generic `uuid:` responses before enqueueing the
description fetch.

In `@openspec/changes/archive/2026-07-04-tv-connection-lifecycle/design.md`:
- Around line 15-20: Update the design note in the TV connection lifecycle
document to remove any mention that the pairing token appears in a back-end log
line. In the relevant Non-Goals section, rephrase the pairing flow description
so it says the token is persisted only in the token store and never logged,
keeping the rest of the MVP pairing description intact. Use the surrounding
pairing-token wording in the document to locate and revise the sentence.

In `@openspec/changes/archive/2026-07-04-tv-connection-lifecycle/proposal.md`:
- Line 33: The proposal currently states that the MVP prints the pairing token
to the back-end log, which should be removed. Update the pairing UI/logging
description in the proposal so the token stays backend-only and any log or
status output is redacted or neutral; if you need user feedback, mention the
pairing step without exposing the value. Locate the change in the pairing-token
MVP text and revise that wording to align with the backend-only token handling
requirement.

In `@openspec/changes/archive/2026-07-04-upnp-tv-discovery/tasks.md`:
- Line 19: Remove the global DISCOVERY_ENABLED env-flag from the discovery
startup change in the onReady-based discovery setup, since it can disable the
feature in production. Keep the opt-out confined to the test harness or a DI
stub around the discovery loop/transport registration in back-end/src/app.ts,
while preserving app.discovery exposure and the existing registry/broker/handle
wiring for normal startup.

In
`@openspec/changes/archive/2026-07-05-error-surfacing/specs/error-surfacing/spec.md`:
- Around line 5-21: The requirement in error-surfacing/spec.md is internally
inconsistent: it says every user-initiated command failing with ApiError must
show exactly one toast, but the broader spec already treats some cases as
dev-only or intentionally toastless. Update the spec text to either explicitly
list the exceptions (for example validation and disconnect-related flows) or
narrow the rule to the exact commands covered by FR-ERROR-01/FR-REMOTE-03, so
the acceptance criteria are unambiguous.

In `@openspec/changes/archive/2026-07-05-input-management/proposal.md`:
- Around line 7-13: This proposal is still written as an active Smart View
contract, but it should be marked as a rejected/archived draft instead. Update
the wording in the proposal to clearly state that `directSourceControl`, cached
`activeId`/refresh behavior, and the `devices` input WebSocket payload are not
the current contract, and that the real spec uses the static catalog,
`ms.remote.control`, and `{ udn, key }` events. Make this explicit in the
proposal title/summary and in the `input-management` sections so future changes
are not guided toward the wrong protocol.

In `@openspec/changes/archive/2026-07-05-volume-control/design.md`:
- Around line 28-43: The volume lifecycle for muted is inconsistent between the
API contract and the state-push contract. Update the design in the volume module
section so the behavior is described consistently for the tracker used by GET
/api/devices/:udn/volume and the Connected/Disconnected/Offline transitions:
either preserve the last-known muted value across disconnects/reconnects, or
explicitly state that the tracker resets to false on disconnect and Connected
emits that reset snapshot. Make the D1 POST /mute and D2 state-push bullets
agree on the same source of truth for muted, using the volume tracker semantics
referenced in the design text.

In `@openspec/changes/archive/2026-07-05-volume-control/proposal.md`:
- Around line 7-15: The proposal still describes the old hotel-TV volume
contract with direct level reporting and absolute volume set, so update the
archived proposal to match the Smart View-only write-only model. In the
proposal.md content around the volume API and `RemoteScreen` volume block,
either mark the old `directVolumeControl`/`POST /volume { level }`/`level:
number` behavior as discarded draft, or rewrite it to the final `level:
null`-style contract so it aligns with the current spec.

In `@openspec/changes/archive/2026-07-05-volume-control/tasks.md`:
- Around line 17-24: The volume API contract is inconsistent between the
frontend hook plan and the backend route, so the initial mount request will not
match the server. Align the route used by useVolume with the backend’s volume
endpoint, then update the corresponding router registration and any tests so
GET, delta POST, and mute behavior all use the same path consistently.

In
`@openspec/changes/archive/2026-07-06-build-install-split/specs/systemd-installer/spec.md`:
- Line 5: The systemd installer requirement in the spec is now install-only, so
the existing FR-DEPLOY-01 coverage tag is contradictory. Update the
`systemd-installer` spec entry to remove that coverage reference or move it to
the `deploy-build` spec where the build behavior lives, keeping
`scripts/install.sh` focused on verifying artefacts, installing runtime
dependencies, and setting up the systemd unit.

In `@openspec/changes/archive/2026-07-06-build-install-split/tasks.md`:
- Around line 41-53: The build/install handoff still has deferred verification
items that leave the installation flow incomplete. Either complete the remaining
checks for 6.3, 8.1, and 8.2 on the real Orange Pi, or explicitly record them as
follow-up in docs/current-state.md so the archive reflects the unresolved state.
Update the task list entries to remove ambiguity about what is done versus
deferred, and keep the verification status aligned with the actual handoff
scope.

In `@openspec/changes/archive/2026-07-06-systemd-installer/proposal.md`:
- Around line 17-18: The systemd installer proposal currently ties the dedicated
mytv user’s home to the repository checkout, which should be moved to a real
state directory instead. Update the mytv service-user setup described in
install.sh and the mytv.service.tmpl notes so token storage and any user state
live under /var/lib/mytv (or another persistent data dir), not `@REPO_ROOT`@, and
keep uninstall.sh from depending on the source tree for rollback.

In `@openspec/changes/archive/2026-07-06-tv-browser-launch/design.md`:
- Around line 107-109: The route entry log is still including the full
user-provided URL at info level, which can leak secrets from query strings.
Update the logging in the route described here to avoid emitting raw url; prefer
logging only host/origin or a scrubbed URL, and keep the existing correlationId,
udn, and ok fields in the entry/exit logs. Ensure the fix is applied where the
route logging is defined so the logger.ts redaction is no longer relied on for
this case.

In `@openspec/changes/archive/2026-07-06-tv-browser-launch/proposal.md`:
- Around line 23-26: Remove the outdated “modal” mention from the New
Capabilities description so it matches the current inline Input + Button UX used
elsewhere in the proposal/design. Update the `tv-browser-launch` capability
summary to describe only the inline SPA flow, keeping the wording consistent
across `proposal.md` so future changes do not reintroduce the modal variant.

In `@openspec/changes/archive/2026-07-06-tv-browser-launch/tasks.md`:
- Line 11: Update the launch task note to remove the requirement to log the full
launch URL at info level. In the relevant task entry for the TV browser launch
flow, replace the URL logging guidance with a safer form of observability that
only records correlationId or a normalized origin/host. Make sure the wording no
longer depends on logger.ts token-stripping, since that redaction is
insufficient for query strings and sensitive URL data.

In `@openspec/specs/systemd-installer/spec.md`:
- Around line 9-10: The contract around `scripts/install.sh` is inconsistent
because `--dry-run` currently allows build-related steps that the script is
otherwise forbidden to perform. Update `install.sh` so the dry-run path in the
main installer flow only previews actions and does not execute `npm ci`,
TypeScript, or Vite build steps; keep all build behavior out of `install.sh`
entirely and leave it to `scripts/build.sh`. Ensure the logic around the install
entrypoint and any `--dry-run` handling clearly separates preview from execution
while still validating the presence of `back-end/dist/index.js` and
`front-end/dist/index.html` before any systemd changes.

In `@scripts/build.sh`:
- Around line 14-45: The build script duplicates shared helper logic that also
exists in install and uninstall scripts, so extract the common `log`, `die`,
`need_cmd`, Node version parsing in `check_prereqs`, and repository path
resolution in `resolve_paths` into a shared `scripts/lib.sh`, then `source` it
from `build.sh` (and the matching scripts) so all three scripts use one
implementation and stay in sync.

In `@scripts/mytv.service.tmpl`:
- Around line 7-29: The systemd unit for the mytv service is missing basic
hardening for a network-facing daemon. Add sandboxing directives such as
NoNewPrivileges, ProtectSystem, ProtectHome, and PrivateTmp in the [Service]
section of mytv.service.tmpl, keeping the existing User, Group,
WorkingDirectory, and ExecStart setup intact. Ensure the new restrictions do not
rely on AmbientCapabilities, since the privileged bind is handled via setcap on
the binary.

In `@scripts/uninstall.sh`:
- Around line 30-35: The unit-presence check in the uninstall flow is wrong
because `systemctl list-unit-files` can succeed even when `mytv.service` is not
installed, so the `else` branch and related logging are misleading. Update the
logic in `scripts/uninstall.sh` around the `systemctl` handling to either check
the actual unit file path with a filesystem test or skip the pre-check entirely
and use a best-effort `systemctl disable --now mytv` with suppressed failure,
keeping the log message aligned with the real outcome.

---

Outside diff comments:
In `@back-end/README.md`:
- Around line 1-193: No code change is needed here; the README already covers
the requested process criteria with concrete details, so keep the current
content as-is unless you want to make only minor wording tweaks for clarity in
the sections describing the PR process, OpenSpec workflow, and reviewer/checker
setup.

In `@front-end/README.md`:
- Around line 1-76: The README is still the default Vite/React boilerplate and
doesn’t describe this project; replace the template text in the README with a
short front-end overview for mytv, including how it runs with the back-end,
single-origin serving, and Orange Pi deployment, and point readers to
docs/product-brief.md for the full product description.

In `@front-end/src/App.css`:
- Around line 1-185: `App.css` is dead code and should be removed because
`App.tsx` no longer imports it and the selectors (`.hero`, `.framework`,
`.vite`, `#docs`, `#next-steps`, `.ticks`) are only Vite starter styles with no
use in the current screens. Delete the `App.css` file and clean up any leftover
unused default Vite assets referenced by the boilerplate if they are still
present, so the frontend only keeps styles actually used by `DeviceListScreen`
and `RemoteScreen`.

In `@scripts/install.test.bats`:
- Around line 1-245: The install.sh test suite covers verify_artifacts well, but
it is missing the symmetric check for create_service_user and the --user-group
behavior mentioned in the script comment. Add a bats test in install.test.bats
that exercises create_service_user with a stubbed useradd and verifies the mytv
group-related flag/behavior is used, referencing create_service_user and the
--user-group path so the test remains findable if layout changes.

---

Duplicate comments:
In `@back-end/src/tv/token-store.test.ts`:
- Around line 19-37: Add a test for concurrent writes in token-store.test.ts:
the current saveToken/loadTokens coverage only checks sequential saves, but the
race condition happens when createTokenStore().saveToken is called in parallel.
Add a case that invokes saveToken for 'udn-1' and 'udn-2' without awaiting
between them (using Promise.all), then verify loadTokens returns both entries.
Use the existing createTokenStore, saveToken, and loadTokens symbols so the new
test targets the same behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment thread back-end/.env.example
Comment on lines +3 to +6
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info
SERVE_SPA=1 No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Дрібні lint-зауваження від dotenv-linter.

Порядок ключів (HOST/LOG_LEVEL перед PORT) та відсутній порожній рядок в кінці файлу — суто косметично, але лінтер про це прямо каже.

🧹 Пропозиція
-PORT=3000
-HOST=0.0.0.0
-LOG_LEVEL=info
-SERVE_SPA=1
+HOST=0.0.0.0
+LOG_LEVEL=info
+PORT=3000
+SERVE_SPA=1
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info
SERVE_SPA=1
HOST=0.0.0.0
LOG_LEVEL=info
PORT=3000
SERVE_SPA=1
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 4-4: [UnorderedKey] The HOST key should go before the PORT key

(UnorderedKey)


[warning] 5-5: [UnorderedKey] The LOG_LEVEL key should go before the PORT key

(UnorderedKey)


[warning] 6-6: [EndingBlankLine] No blank line at the end of the file

(EndingBlankLine)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/.env.example` around lines 3 - 6, Dotenv-linter is flagging cosmetic
issues in the env sample: reorder the keys so HOST and LOG_LEVEL appear before
PORT, and ensure the file ends with a trailing blank line. Update the
.env.example contents accordingly and keep SERVE_SPA in the same section.

Source: Linters/SAST tools

Comment thread back-end/src/logger.ts
@@ -0,0 +1,84 @@
import pino, { type DestinationStream, type LoggerOptions } from 'pino';

const SENSITIVE_KEYS = new Set(['AccessToken', 'authorization', 'token']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Порівняння ключів чутливе до регістру — можливий витік токена.

Молодець, що редакція рекурсивна й окремо чистить token= у рядках (regex із gi). Але звірка ключів у SENSITIVE_KEYS.has(key) регістрозалежна: ключ на кшталт Token чи Authorization (якщо десь потрапить у лог у такому вигляді) не буде вирізаний. Нормалізуй регістр, щоб гарантія «редагується незалежно від глибини» була ще й незалежною від регістру.

Як per coding guidelines: «redact the pairing token ... from every log line».

🛡️ Пропозиція
-const SENSITIVE_KEYS = new Set(['AccessToken', 'authorization', 'token']);
+const SENSITIVE_KEYS = new Set(['accesstoken', 'authorization', 'token']);
-      if (SENSITIVE_KEYS.has(key)) continue;
+      if (SENSITIVE_KEYS.has(key.toLowerCase())) continue;

Also applies to: 38-39

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/logger.ts` at line 3, The sensitive-key redaction in logger.ts
is currently case-sensitive, so keys like Token or Authorization can bypass
masking. Update the key check used by the recursive redaction logic to normalize
casing before consulting SENSITIVE_KEYS, and make SENSITIVE_KEYS match the
normalized form consistently. Keep the existing redaction flow intact in the
logger’s redact helper so token-related fields are removed regardless of key
depth or key capitalization.

Source: Coding guidelines

Comment thread back-end/src/mdns.test.ts
Comment on lines +109 to +127
test('publish failure leaves the handle in error state without throwing', async () => {
const failingClient: MdnsClient = {
publish() {
const err = new Error('bind EADDRINUSE 0.0.0.0:5353');
(err as NodeJS.ErrnoException).code = 'EADDRINUSE';
throw err;
},
destroy() {},
};
const handle = startMdns(3000, {
client: failingClient,
networkInterfaces: () => iface('10.0.0.5'),
pollIntervalMs: 0,
});
assert.equal(handle.state, 'error');
assert.match(handle.error ?? '', /EADDRINUSE/);
assert.equal(handle.address, undefined);
await handle.stop();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Бракує тесту на async socket-error шлях (реальний Avahi-конфлікт).

Тут перевіряється лише синхронний throw у publish(). А mdns.ts (рядки 61-238 контексту) ловить реальний port-conflict через rawClient.server?.mdns?.on('error', ...) — саме цей шлях і задокументований AVAHI_HINT як типова причина збою на Orange Pi. Варто додати стаб з server: { mdns: { on } }, який симулює async EADDRINUSE, щоб покрити цю гілку.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/mdns.test.ts` around lines 109 - 127, The mdns test only covers
the synchronous publish() throw path, but the real Avahi port-conflict is
handled asynchronously through rawClient.server.mdns.on('error'). Extend the
startMdns test using the MdnsClient stub to include a server.mdns.on handler and
simulate an async EADDRINUSE error so the error-state branch in mdns.ts is
covered; keep the same assertions on handle.state, handle.error, and
handle.address for this socket-error path.

Comment on lines +18 to +32
function resolveStaticFile(root: string, urlPath: string): string | null {
const requestPath = decodeURIComponent(urlPath.split('?')[0] ?? '').replace(
/^\/+/,
'',
);
if (requestPath === '') return null;

const resolvedRoot = resolve(root);
const candidate = resolve(resolvedRoot, requestPath);
const isInsideRoot =
candidate === resolvedRoot || candidate.startsWith(resolvedRoot + sep);
if (!isInsideRoot || !existsSync(candidate)) return null;

return statSync(candidate).isFile() ? candidate : null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

decodeURIComponent кидає на некоректному URL → 500 замість 404.

Захист від traversal тут акуратний (resolve + перевірка resolvedRoot + sep) — це добре. Але запит із зіпсованим percent-encoding (напр. /%E0%A4%A) змусить decodeURIComponent кинути URIError, який синхронно вилетить із notFound-хендлера й перетвориться на 500 замість очікуваного 404. Оберни декодування у try/catch і повертай null.

🛡️ Пропозиція
-  const requestPath = decodeURIComponent(urlPath.split('?')[0] ?? '').replace(
-    /^\/+/,
-    '',
-  );
+  let requestPath: string;
+  try {
+    requestPath = decodeURIComponent(urlPath.split('?')[0] ?? '').replace(
+      /^\/+/,
+      '',
+    );
+  } catch {
+    return null;
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function resolveStaticFile(root: string, urlPath: string): string | null {
const requestPath = decodeURIComponent(urlPath.split('?')[0] ?? '').replace(
/^\/+/,
'',
);
if (requestPath === '') return null;
const resolvedRoot = resolve(root);
const candidate = resolve(resolvedRoot, requestPath);
const isInsideRoot =
candidate === resolvedRoot || candidate.startsWith(resolvedRoot + sep);
if (!isInsideRoot || !existsSync(candidate)) return null;
return statSync(candidate).isFile() ? candidate : null;
}
function resolveStaticFile(root: string, urlPath: string): string | null {
let requestPath: string;
try {
requestPath = decodeURIComponent(urlPath.split('?')[0] ?? '').replace(
/^\/+/,
'',
);
} catch {
return null;
}
if (requestPath === '') return null;
const resolvedRoot = resolve(root);
const candidate = resolve(resolvedRoot, requestPath);
const isInsideRoot =
candidate === resolvedRoot || candidate.startsWith(resolvedRoot + sep);
if (!isInsideRoot || !existsSync(candidate)) return null;
return statSync(candidate).isFile() ? candidate : null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/plugins/static-spa.ts` around lines 18 - 32, The URL decoding in
resolveStaticFile can throw on malformed percent-encoding, which causes the
static SPA fallback to fail with an error instead of returning no match. Wrap
the decodeURIComponent handling in a try/catch inside resolveStaticFile, and
return null when decoding fails so the notFound/static lookup path in
static-spa.ts treats the request as missing. Keep the existing traversal checks
with resolve, isInsideRoot, and existsSync unchanged.

Comment on lines +111 to +113
const onRemoved = (device: Device): void => {
deviceIndex.delete(device.udn);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Сесія не прибирається при removed.

onRemoved чистить лише deviceIndex, а не sessions — якщо колись registry почне емітити removed (зараз, за коментарем у registry.ts, не емітить), відкрита сесія/WS-з'єднання для видаленого UDN залишиться висіти в Map назавжди. Зараз мертвий код, але вартий TODO/захисту на майбутнє, коли removed таки увімкнуть.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/tv/manager.ts` around lines 111 - 113, The onRemoved handler in
manager.ts only removes the device from deviceIndex, so if registry ever starts
emitting removed it will leave stale entries in sessions. Update onRemoved to
also clean up any session/WebSocket state for the same device.udn, and add a
TODO or defensive safeguard around the removed path in TVManager so future
registry behavior cannot leak session entries.

- [x] 2.1 Create `back-end/src/tv/browser.ts` — export `launchBrowserParams(url: string): Record<string, unknown>` returning `{ event: 'ed.apps.launch', to: 'host', data: { appId: 'org.tizen.browser', action_type: 'NATIVE_LAUNCH', metaTag: url } }` (typed via a local `LaunchBrowserParams` interface + `satisfies`, same pattern as `keyControlParams` in `back-end/src/tv/keys.ts`). Do NOT hardcode the URL; take it as a parameter.
- [x] 2.2 Create `back-end/src/routes/browser.ts` — `POST /devices/:udn/browser` with Fastify JSON schema `{ type: 'object', required: ['url'], additionalProperties: false, properties: { url: { type: 'string', minLength: 1, maxLength: 2048 } } }`. Handler: WHATWG parse (`new URL(url)`); reject non-`http:`/`https:` with `HttpError.validation(...)` → `400 validation`. Fetch session via `discovery.sessions.get(udn) ?? .ensure(udn)`; if state !== `Connected` throw `new HttpError(409, 'SessionNotConnected', ...)`. Enqueue `transport.call('ms.channel.emit', launchBrowserParams(url))`. Reply `204`.
- [x] 2.3 Wire `registerBrowserRoute(app)` in `back-end/src/app.ts` under the existing `/api` prefix block, right after `registerInputRoutes(...)`.
- [x] 2.4 Confirm the URL is logged at `info` on entry (existing route-level Fastify logging is enough; verify nothing extra needs adding) and that `logger.ts`'s existing `token=` query-fragment stripping applies to logged URLs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Не логуй повний URL на рівні info.

Line 11 пропонує логувати launch URL, а це зберігатиме browsing history й будь-які секрети в query string; redaction, яка прибирає лише token= фрагменти, тут замала. Краще лишити correlationId або нормалізований origin/host.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openspec/changes/archive/2026-07-06-tv-browser-launch/tasks.md` at line 11,
Update the launch task note to remove the requirement to log the full launch URL
at info level. In the relevant task entry for the TV browser launch flow,
replace the URL logging guidance with a safer form of observability that only
records correlationId or a normalized origin/host. Make sure the wording no
longer depends on logger.ts token-stripping, since that redaction is
insufficient for query strings and sensitive URL data.

Comment thread scripts/build.sh
Comment on lines +14 to +45
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing prerequisite: $1 not on PATH"
}

check_prereqs() {
need_cmd bash
need_cmd node
need_cmd npm
need_cmd mktemp

local node_version node_major
node_version="$(node --version 2>/dev/null || true)"
node_major="${node_version#v}"
node_major="${node_major%%.*}"
if [[ -z "$node_major" || "$node_major" -lt 20 ]]; then
die "node >= 20 required, found: ${node_version:-none}"
fi
}

resolve_paths() {
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$script_dir/.." && pwd)"

[[ -d "$REPO_ROOT/back-end" ]] || die "REPO_ROOT missing back-end/: $REPO_ROOT"
[[ -d "$REPO_ROOT/front-end" ]] || die "REPO_ROOT missing front-end/: $REPO_ROOT"

NODE_BIN="$(command -v node)"

log "REPO_ROOT=$REPO_ROOT"
log "NODE_BIN=$NODE_BIN"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Гарна структура, але є дублювання логіки з install.sh.

check_prereqs (перевірка node/npm/mktemp, парсинг мажорної версії) та resolve_paths тут майже побуквено повторюють однойменні функції в scripts/install.sh (рядки 32-77), а log/die — ще й у scripts/uninstall.sh. Три окремі копії однієї логіки — це класичне порушення DRY: зміна мінімальної версії Node чи способу резолву REPO_ROOT доведеться синхронізувати вручну в трьох файлах.

Варто винести спільні функції (log, die, need_cmd, парсинг версії node, резолв REPO_ROOT) у scripts/lib.sh і підключати через source на початку кожного скрипта — це не зашкодить bats-тестам, які й так source-ять скрипти напряму.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build.sh` around lines 14 - 45, The build script duplicates shared
helper logic that also exists in install and uninstall scripts, so extract the
common `log`, `die`, `need_cmd`, Node version parsing in `check_prereqs`, and
repository path resolution in `resolve_paths` into a shared `scripts/lib.sh`,
then `source` it from `build.sh` (and the matching scripts) so all three scripts
use one implementation and stay in sync.

Comment thread scripts/mytv.service.tmpl
Comment on lines +7 to +29
[Service]
Type=simple
User=mytv
Group=mytv
WorkingDirectory=@REPO_ROOT@/back-end
ExecStart=@NODE_BIN@ dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=80
Environment=HOST=0.0.0.0
Environment=LOG_LEVEL=info
Environment=SERVE_SPA=1
# HOME is what back-end/src/tv/token-store.ts resolves against when
# XDG_CONFIG_HOME is unset: tokens land at /var/lib/mytv/.mytv/tokens.json
# (0600, owned by the mytv user). Do NOT set XDG_CONFIG_HOME here — it
# would shift the token file to $XDG_CONFIG_HOME/mytv/tokens.json and
# desync from the path the docs / uninstall recipe reference.
Environment=HOME=/var/lib/mytv
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mytv

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Варто додати базове systemd-хардення для сервісу, що слухає в мережі.

Юніт запускається під окремим системним юзером без входу — це вже добре, — але немає жодних sandboxing-директив (NoNewPrivileges, ProtectSystem, ProtectHome, PrivateTmp). Для пристрою, що приймає підключення в локальній мережі, кілька рядків суттєво звужують поверхню атаки без ризику зламати cap_net_bind_service (яка виставляється через setcap на бінарник, а не через AmbientCapabilities).

🛡️ Пропоноване доповнення
 [Service]
 Type=simple
 User=mytv
 Group=mytv
 WorkingDirectory=`@REPO_ROOT`@/back-end
 ExecStart=`@NODE_BIN`@ dist/index.js
 Restart=on-failure
 RestartSec=5
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+ReadWritePaths=/var/lib/mytv
 Environment=NODE_ENV=production
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[Service]
Type=simple
User=mytv
Group=mytv
WorkingDirectory=@REPO_ROOT@/back-end
ExecStart=@NODE_BIN@ dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=80
Environment=HOST=0.0.0.0
Environment=LOG_LEVEL=info
Environment=SERVE_SPA=1
# HOME is what back-end/src/tv/token-store.ts resolves against when
# XDG_CONFIG_HOME is unset: tokens land at /var/lib/mytv/.mytv/tokens.json
# (0600, owned by the mytv user). Do NOT set XDG_CONFIG_HOME here — it
# would shift the token file to $XDG_CONFIG_HOME/mytv/tokens.json and
# desync from the path the docs / uninstall recipe reference.
Environment=HOME=/var/lib/mytv
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mytv
[Service]
Type=simple
User=mytv
Group=mytv
WorkingDirectory=`@REPO_ROOT`@/back-end
ExecStart=`@NODE_BIN`@ dist/index.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/mytv
Environment=NODE_ENV=production
Environment=PORT=80
Environment=HOST=0.0.0.0
Environment=LOG_LEVEL=info
Environment=SERVE_SPA=1
# HOME is what back-end/src/tv/token-store.ts resolves against when
# XDG_CONFIG_HOME is unset: tokens land at /var/lib/mytv/.mytv/tokens.json
# (0600, owned by the mytv user). Do NOT set XDG_CONFIG_HOME here — it
# would shift the token file to $XDG_CONFIG_HOME/mytv/tokens.json and
# desync from the path the docs / uninstall recipe reference.
Environment=HOME=/var/lib/mytv
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mytv
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/mytv.service.tmpl` around lines 7 - 29, The systemd unit for the mytv
service is missing basic hardening for a network-facing daemon. Add sandboxing
directives such as NoNewPrivileges, ProtectSystem, ProtectHome, and PrivateTmp
in the [Service] section of mytv.service.tmpl, keeping the existing User, Group,
WorkingDirectory, and ExecStart setup intact. Ensure the new restrictions do not
rely on AmbientCapabilities, since the privileged bind is handled via setcap on
the binary.

Comment thread scripts/uninstall.sh
Comment on lines +30 to +35
if systemctl list-unit-files mytv.service >/dev/null 2>&1; then
log "stopping and disabling mytv.service"
systemctl disable --now mytv 2>/dev/null || true
else
log "mytv.service not registered; nothing to stop"
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

systemd list-unit-files exit code when pattern matches zero units

💡 Result:

The systemctl list-unit-files command returns an exit code of 0 even when the provided pattern matches zero units [1][2]. Historically, this behavior has been noted as a point of confusion for users, as it does not signal an error or a "not found" state when no files match the input pattern [1]. While some users have requested that it return a non-zero exit code (such as 1) to indicate that no matches were found, the command continues to exit with 0 in current versions of systemd [1][2]. Consequently, if you are using this command in a script to verify the existence of a unit file, you cannot rely solely on the exit code to determine if a match occurred [1][2]. You should instead check if the output is empty or process the output directly [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files scripts/uninstall.sh
wc -l scripts/uninstall.sh
rg -n 'list-unit-files|UNIT_PATH|disable --now|mytv\.service' scripts/uninstall.sh
sed -n '1,120p' scripts/uninstall.sh

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 2128


systemctl list-unit-files тут не підходить як перевірка.
systemctl list-unit-files mytv.service повертає 0 навіть без збігів, тому else для “unit не встановлено” тут не спрацює, а лог буде оманливим. Краще перевіряти фактичний шлях юніта ([[ -f "$UNIT_PATH" ]]) або просто робити best-effort systemctl disable --now mytv із придушенням помилки.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/uninstall.sh` around lines 30 - 35, The unit-presence check in the
uninstall flow is wrong because `systemctl list-unit-files` can succeed even
when `mytv.service` is not installed, so the `else` branch and related logging
are misleading. Update the logic in `scripts/uninstall.sh` around the
`systemctl` handling to either check the actual unit file path with a filesystem
test or skip the pre-check entirely and use a best-effort `systemctl disable
--now mytv` with suppressed failure, keeping the log message aligned with the
real outcome.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

Comment on lines +151 to +162
// Custom serializer for users
user: (user) => ({
id: user.id,
email: user.email,
// Exclude sensitive fields
}),
},
},
});

// Use custom serializer
request.log.info({ user: request.user }, 'User action');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '130,190p' .agents/skills/fastify-best-practices/rules/logging.md

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 2128


Не серіалізуйте email у логах. У цьому прикладі user-serializer усе ще повертає PII, тож для логування залиште лише безпечний ідентифікатор.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/logging.md around lines 151 -
162, The custom `user` serializer is still emitting PII by including `email`, so
update the serializer to return only a safe identifier such as `id` and remove
any sensitive user fields from the logged object. Keep the `request.log.info({
user: request.user }, ...)` usage, but ensure it only receives the sanitized
output from the `user` serializer.

Comment on lines +195 to +206
```
src/
routes/
users/
index.ts # Route definitions
handlers.ts # Handler functions
schemas.ts # JSON schemas
posts/
index.ts
handlers.ts
schemas.ts
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Дайте обом fenced-блокам мову.

Ці два блоки з деревом директорій піднімають markdownlint warning. Позначте їх як text.

🧹 Пропонована правка
-```
+```text

Зробіть те саме для другого такого блоку нижче.

Also applies to: 436-448

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 195-195: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/routes.md around lines 195 -
206, Two fenced directory-tree blocks in the markdown rule doc are missing a
language tag and trigger markdownlint warnings. Update both code fences to use
the text language so the examples remain rendered as plain text; check the
duplicated tree example in the same document and keep the formatting consistent
across both blocks.

Source: Linters/SAST tools

Comment on lines +1 to +6
---
name: fastify-best-practices
description: "Guides development of Fastify Node.js backend servers and REST APIs using TypeScript or JavaScript. Use when building, configuring, or debugging a Fastify application — including defining routes, implementing plugins, setting up JSON Schema validation, handling errors, optimising performance, managing authentication, configuring CORS and security headers, integrating databases, working with WebSockets, and deploying to production. Covers the full Fastify request lifecycle (hooks, serialization, logging with Pino) and TypeScript integration via strip types. Trigger terms: Fastify, Node.js server, REST API, API routes, backend framework, fastify.config, server.ts, app.ts."
metadata:
tags: fastify, nodejs, typescript, backend, api, server, http
---

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Виправ MD041: перший рядок має бути top-level heading.

Лінтер вказує, що файл починається з YAML front-matter, а не з # Heading. Для markdownlint-стандарту варто або додати # Fastify Best Practices заголовок після front-matter, або налаштувати виняток для front-matter у конфіг лінтера.

📝 Пропоноване виправлення
 ---
 name: fastify-best-practices
 description: "..."
 metadata:
   tags: fastify, nodejs, typescript, backend, api, server, http
 ---
+
+# Fastify Best Practices
 
 ## When to use
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
name: fastify-best-practices
description: "Guides development of Fastify Node.js backend servers and REST APIs using TypeScript or JavaScript. Use when building, configuring, or debugging a Fastify application — including defining routes, implementing plugins, setting up JSON Schema validation, handling errors, optimising performance, managing authentication, configuring CORS and security headers, integrating databases, working with WebSockets, and deploying to production. Covers the full Fastify request lifecycle (hooks, serialization, logging with Pino) and TypeScript integration via strip types. Trigger terms: Fastify, Node.js server, REST API, API routes, backend framework, fastify.config, server.ts, app.ts."
metadata:
tags: fastify, nodejs, typescript, backend, api, server, http
---
---
name: fastify-best-practices
description: "Guides development of Fastify Node.js backend servers and REST APIs using TypeScript or JavaScript. Use when building, configuring, or debugging a Fastify application — including defining routes, implementing plugins, setting up JSON Schema validation, handling errors, optimising performance, managing authentication, configuring CORS and security headers, integrating databases, working with WebSockets, and deploying to production. Covers the full Fastify request lifecycle (hooks, serialization, logging with Pino) and TypeScript integration via strip types. Trigger terms: Fastify, Node.js server, REST API, API routes, backend framework, fastify.config, server.ts, app.ts."
metadata:
tags: fastify, nodejs, typescript, backend, api, server, http
---
# Fastify Best Practices
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/SKILL.md around lines 1 - 6, File
start violates MD041 because the markdown begins with YAML front-matter instead
of a top-level heading. Update the SKILL.md content so the front-matter is
followed by a first-line H1 like “Fastify Best Practices,” or ensure the
markdownlint configuration explicitly allows front-matter before the heading.
Reference the existing front-matter block in SKILL.md when making the change.

Source: Linters/SAST tools

Comment on lines +44 to +156
```typescript
// ✅ Good: vite.config.ts - Configure SVGR plugin
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import svgr from 'vite-plugin-svgr'

export default defineConfig({
plugins: [
react(),
svgr({
exportAsDefault: false,
svgrOptions: {
plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'],
svgoConfig: {
plugins: [
{
name: 'removeViewBox',
active: false, // Keep viewBox for scaling
},
],
},
},
}),
],
})
```

```typescript
// ✅ Good: Import as React component for full styling control
import Logo from './assets/logo.svg?react'
import logoUrl from './assets/logo.svg'

function Header() {
return (
<header>
{/* As component - fully styleable */}
<Logo className="w-8 h-8 text-blue-600 hover:text-blue-700" />

{/* As image when styling isn't needed */}
<img src={logoUrl} alt="Logo" className="w-8 h-8" />
</header>
)
}
```

```typescript
// ✅ Good: TypeScript support
// src/vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />

declare module '*.svg?react' {
import type { FunctionComponent, SVGProps } from 'react'
const content: FunctionComponent<SVGProps<SVGSVGElement>>
export default content
}

declare module '*.svg' {
const content: string
export default content
}
```

```typescript
// ✅ Good: Dynamic SVG colors via currentColor
import SearchIcon from './assets/search.svg?react'

function SearchButton({ active }: { active: boolean }) {
return (
<button className={active ? 'text-blue-600' : 'text-gray-400'}>
<SearchIcon className="w-5 h-5" />
Search
</button>
)
}
```

```typescript
// ✅ Good: Icon component pattern with tree shaking
import type { SVGProps, FunctionComponent } from 'react'

import HomeIcon from '@/assets/icons/home.svg?react'
import SettingsIcon from '@/assets/icons/settings.svg?react'
import UserIcon from '@/assets/icons/user.svg?react'

const icons = {
home: HomeIcon,
settings: SettingsIcon,
user: UserIcon,
} as const

type IconName = keyof typeof icons

interface IconProps extends SVGProps<SVGSVGElement> {
name: IconName
size?: number
}

export function Icon({ name, size = 24, className, ...props }: IconProps) {
const IconComponent = icons[name]
return (
<IconComponent
width={size}
height={size}
className={className}
{...props}
/>
)
}

// Usage
// <Icon name="home" size={20} className="text-gray-600" />
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Обмежте це правило рамками mytv UI.

Нинішні приклади вчать агентів тягнути svg?react + Tailwind у фронтенд. Для цього репозиторію це конфліктує з Orbit DS: іконки мають лишатися Material Symbols Rounded, а не довільні SVG-компоненти. Додайте явну ремарку, що ця техніка допустима лише для не-UI assetів або DS-owned іконок.

As per coding guidelines and path instructions, front-end/src/**/*.{ts,tsx,js,jsx,css,html}: use DS primitives and Material Symbols Rounded only.

Пропозиція
+// NOTE: In mytv UI, keep SVG-as-component guidance only for non-UI artwork.
+// UI icons must still come from Orbit DS / Material Symbols Rounded.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```typescript
// ✅ Good: vite.config.ts - Configure SVGR plugin
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import svgr from 'vite-plugin-svgr'
export default defineConfig({
plugins: [
react(),
svgr({
exportAsDefault: false,
svgrOptions: {
plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'],
svgoConfig: {
plugins: [
{
name: 'removeViewBox',
active: false, // Keep viewBox for scaling
},
],
},
},
}),
],
})
```
```typescript
// ✅ Good: Import as React component for full styling control
import Logo from './assets/logo.svg?react'
import logoUrl from './assets/logo.svg'
function Header() {
return (
<header>
{/* As component - fully styleable */}
<Logo className="w-8 h-8 text-blue-600 hover:text-blue-700" />
{/* As image when styling isn't needed */}
<img src={logoUrl} alt="Logo" className="w-8 h-8" />
</header>
)
}
```
```typescript
// ✅ Good: TypeScript support
// src/vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
declare module '*.svg?react' {
import type { FunctionComponent, SVGProps } from 'react'
const content: FunctionComponent<SVGProps<SVGSVGElement>>
export default content
}
declare module '*.svg' {
const content: string
export default content
}
```
```typescript
// ✅ Good: Dynamic SVG colors via currentColor
import SearchIcon from './assets/search.svg?react'
function SearchButton({ active }: { active: boolean }) {
return (
<button className={active ? 'text-blue-600' : 'text-gray-400'}>
<SearchIcon className="w-5 h-5" />
Search
</button>
)
}
```
```typescript
// ✅ Good: Icon component pattern with tree shaking
import type { SVGProps, FunctionComponent } from 'react'
import HomeIcon from '@/assets/icons/home.svg?react'
import SettingsIcon from '@/assets/icons/settings.svg?react'
import UserIcon from '@/assets/icons/user.svg?react'
const icons = {
home: HomeIcon,
settings: SettingsIcon,
user: UserIcon,
} as const
type IconName = keyof typeof icons
interface IconProps extends SVGProps<SVGSVGElement> {
name: IconName
size?: number
}
export function Icon({ name, size = 24, className, ...props }: IconProps) {
const IconComponent = icons[name]
return (
<IconComponent
width={size}
height={size}
className={className}
{...props}
/>
)
}
// Usage
// <Icon name="home" size={20} className="text-gray-600" />
```
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/react-vite-best-practices/rules/asset-svg-components.md
around lines 44 - 156, The SVG-to-component guidance is too broad and conflicts
with Orbit DS icon rules, so narrow these examples in the asset-svg-components
rule. Update the guidance around the vite-plugin-svgr setup, the `*.svg?react`
import pattern, and the `Icon` component example to explicitly say this approach
is only allowed for non-UI assets or DS-owned icons, not general mytv UI. Add a
clear note that frontend UI should continue using DS primitives and Material
Symbols Rounded only, so agents don’t adopt arbitrary SVG components in
`front-end/src/**/*.{ts,tsx,js,jsx,css,html}`.

Sources: Coding guidelines, Path instructions

Comment on lines +70 to +86
manualChunks(id) {
// Node modules go to vendor chunk
if (id.includes('node_modules')) {
// Split large libraries into separate chunks
if (id.includes('react-dom')) {
return 'vendor-react-dom'
}
if (id.includes('react')) {
return 'vendor-react'
}
if (id.includes('@tanstack')) {
return 'vendor-tanstack'
}
// Other node_modules
return 'vendor'
}
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Виправте динамічний поділ чанків.

id.includes('react') перехоплює react-router-dom та інші специфічні пакети, тож приклад уже не відповідає попередньому vendor-router. Спочатку перевіряйте точні пакети, потім — загальний react.

Пропонований патч
 manualChunks(id) {
   if (id.includes('node_modules')) {
+    if (id.includes('react-router-dom')) {
+      return 'vendor-router'
+    }
+    if (id.includes('`@tanstack`')) {
+      return 'vendor-tanstack'
+    }
     if (id.includes('react-dom')) {
       return 'vendor-react-dom'
     }
     if (id.includes('react')) {
       return 'vendor-react'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
manualChunks(id) {
// Node modules go to vendor chunk
if (id.includes('node_modules')) {
// Split large libraries into separate chunks
if (id.includes('react-dom')) {
return 'vendor-react-dom'
}
if (id.includes('react')) {
return 'vendor-react'
}
if (id.includes('@tanstack')) {
return 'vendor-tanstack'
}
// Other node_modules
return 'vendor'
}
},
manualChunks(id) {
// Node modules go to vendor chunk
if (id.includes('node_modules')) {
// Split large libraries into separate chunks
if (id.includes('react-router-dom')) {
return 'vendor-router'
}
if (id.includes('`@tanstack`')) {
return 'vendor-tanstack'
}
if (id.includes('react-dom')) {
return 'vendor-react-dom'
}
if (id.includes('react')) {
return 'vendor-react'
}
// Other node_modules
return 'vendor'
}
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/react-vite-best-practices/rules/build-manual-chunks.md around
lines 70 - 86, The manual chunking logic in manualChunks currently checks
id.includes('react') too early, which can incorrectly capture react-router-dom
and other specific packages. Update the matching order so more specific package
checks (such as react-router-dom and react-dom) run before the generic react
case, and keep the existing vendor-react, vendor-react-dom, and vendor-router
chunk names aligned with the intended split behavior.

Comment on lines +41 to +85
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 'var(--space-8)',
background: 'var(--base-100)',
}}
>
<Card style={{ maxWidth: 480, textAlign: 'center' }}>
<span
className="material-symbols-rounded"
aria-hidden="true"
style={{ fontSize: 48, color: 'var(--offline)' }}
>
error_outline
</span>
<div
style={{
fontSize: 'var(--text-h2)',
fontWeight: 'var(--weight-bold)',
color: 'var(--fg-1)',
marginTop: 'var(--space-4)',
}}
>
Something broke.
</div>
<div
style={{
fontSize: 'var(--text-body)',
color: 'var(--fg-2)',
marginTop: 'var(--space-2)',
}}
>
The remote screen ran into an unexpected error. Try reloading.
</div>
<div style={{ marginTop: 'var(--space-5)' }}>
<Button variant="primary" onClick={this.handleReload}>
Try again
</Button>
</div>
</Card>
</div>
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Layout зроблено на raw <div style={{...}}>, а не через DS-примітиви.

Токени використані правильно, але сама структура (flex-контейнер, картка-обгортка) не йде через @ds/components/**. Якщо в DS немає відповідного layout-примітиву (Stack/Box/Screen) — його варто додати, а не інлайнити стилі напряму в фолбек-екрані.

As per coding guidelines: "every UI element should come from @ds/components/**, and missing primitives should be added to the design system instead of using inline styles."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front-end/src/ui/ErrorBoundary.tsx` around lines 41 - 85, The error fallback
in ErrorBoundary is using raw divs with inline layout styles instead of DS
primitives. Refactor the render path in ErrorBoundary to build the full screen,
centering, and card layout using components from `@ds/components/`** (for example
a screen/container/stack/card primitive), and if the needed layout primitive
does not exist, add it to the design system rather than keeping inline styles in
the fallback UI.

Source: Coding guidelines

Comment on lines +67 to +77
Something broke.
</div>
<div
style={{
fontSize: 'var(--text-body)',
color: 'var(--fg-2)',
marginTop: 'var(--space-2)',
}}
>
The remote screen ran into an unexpected error. Try reloading.
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Копірайт fallback-екрана не у "second person".

"Something broke." / "The remote screen ran into an unexpected error." — це опис від третьої особи, а не звернення до користувача.

As per coding guidelines: "Keep UI copy in English, sentence case, and second person."

✏️ Приклад переформулювання
-            Something broke.
+            You've hit a snag.
...
-            The remote screen ran into an unexpected error. Try reloading.
+            You ran into an unexpected error on the remote screen. Try reloading.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Something broke.
</div>
<div
style={{
fontSize: 'var(--text-body)',
color: 'var(--fg-2)',
marginTop: 'var(--space-2)',
}}
>
The remote screen ran into an unexpected error. Try reloading.
</div>
You've hit a snag.
</div>
<div
style={{
fontSize: 'var(--text-body)',
color: 'var(--fg-2)',
marginTop: 'var(--space-2)',
}}
>
You ran into an unexpected error on the remote screen. Try reloading.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front-end/src/ui/ErrorBoundary.tsx` around lines 67 - 77, The fallback copy
in ErrorBoundary is written in third person, but the UI guidelines require
English sentence case in second person. Update the strings in the ErrorBoundary
render path so they speak directly to the user (for example, revise the messages
shown in the “Something broke.” block and the “The remote screen ran into an
unexpected error...” block), while keeping the tone concise and consistent with
the existing ErrorBoundary component.

Source: Coding guidelines

Comment on lines +14 to +27
return (
<div
aria-live="polite"
style={{
position: 'fixed',
right: 'var(--space-8)',
bottom: 'var(--space-8)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-3)',
zIndex: 1100,
pointerEvents: 'none',
}}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Той самий inline-style-layout патерн, що й у ErrorBoundary.tsx.

Контейнер-стек toast-ів зроблено через raw <div style={{...}}> замість DS-примітиву. Токени коректні, але це ще один кандидат на layout-компонент у дизайн-системі.

As per coding guidelines: "every UI element should come from @ds/components/**, and missing primitives should be added to the design system instead of using inline styles."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front-end/src/ui/ToastHost.tsx` around lines 14 - 27, The toast container in
ToastHost uses a raw inline-styled <div> for layout, but the guideline requires
all UI elements to come from `@ds/components`. Replace this stack wrapper with an
appropriate design-system layout primitive (or add one to the DS if missing) and
keep the ToastHost composition using that component instead of inline styles.

Source: Coding guidelines

Comment on lines +14 to +23
export interface Toast {
id: string;
tone: ToastTone;
message: string;
createdAt: number;
/** Set when the toast is hovered; auto-dismiss timer pauses until leave. */
pausedAt?: number;
/** Remaining ms until auto-dismiss when unpaused. */
remainingMs: number;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Назва типу Toast конфліктує з DS-компонентом Toast з @ds/components/feedback/Toast.jsx.

Наразі не колізує (не імпортуються в одному файлі), але варто перейменувати на ToastItem/ToastState, щоб уникнути потреби в alias-імпортах пізніше.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front-end/src/ui/useToast.ts` around lines 14 - 23, The local Toast type name
conflicts conceptually with the DS Toast component, so rename the interface in
useToast.ts to a more specific identifier like ToastItem or ToastState. Update
all references within the toast hook/state helpers to use the new name,
especially the Toast interface and any functions or arrays in useToast that
consume it, to avoid future alias-import collisions.

Comment on lines +152 to +167
// Auto-dismiss: schedule a per-toast timer for whatever remainingMs is
// left, keyed by `toast.createdAt` so a resume that resets `createdAt`
// re-runs this effect and refreshes the timer.
useEffect(() => {
const timers: Array<ReturnType<typeof setTimeout>> = [];
for (const toast of state.toasts) {
if (toast.pausedAt) continue;
const timer = setTimeout(() => {
dispatch({ type: 'dismiss', id: toast.id });
}, toast.remainingMs);
timers.push(timer);
}
return () => {
for (const t of timers) clearTimeout(t);
};
}, [state.toasts]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Auto-dismiss таймер перезапускається "з нуля" при будь-якій іншій дії з toast-ами — реальні toast-и живуть довше за задекларовані 5с.

useEffect залежить від [state.toasts], тож будь-який push/dismiss/pause/resume ІНШОГО toast чистить і перепланує таймер поточного toast-а з повним toast.remainingMs, а не з урахуванням часу, що вже минув з createdAt. При активному toast-трафіку (наприклад, кілька помилок поспіль) старіші toast-и можуть висіти довше 5с або взагалі "нескінченно" продовжувати оновлюватись.

Тест ToastHost.test.tsx цей сценарій не ловить, бо перевіряє лише один toast без паралельного push.

🐛 Пропонований фікс: рахувати delay від фактично минулого часу
   useEffect(() => {
     const timers: Array<ReturnType<typeof setTimeout>> = [];
     for (const toast of state.toasts) {
       if (toast.pausedAt) continue;
-      const timer = setTimeout(() => {
-        dispatch({ type: 'dismiss', id: toast.id });
-      }, toast.remainingMs);
+      const delay = Math.max(0, toast.remainingMs - (clock() - toast.createdAt));
+      const timer = setTimeout(() => {
+        dispatch({ type: 'dismiss', id: toast.id });
+      }, delay);
       timers.push(timer);
     }
     return () => {
       for (const t of timers) clearTimeout(t);
     };
-  }, [state.toasts]);
+  }, [state.toasts, clock]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Auto-dismiss: schedule a per-toast timer for whatever remainingMs is
// left, keyed by `toast.createdAt` so a resume that resets `createdAt`
// re-runs this effect and refreshes the timer.
useEffect(() => {
const timers: Array<ReturnType<typeof setTimeout>> = [];
for (const toast of state.toasts) {
if (toast.pausedAt) continue;
const timer = setTimeout(() => {
dispatch({ type: 'dismiss', id: toast.id });
}, toast.remainingMs);
timers.push(timer);
}
return () => {
for (const t of timers) clearTimeout(t);
};
}, [state.toasts]);
// Auto-dismiss: schedule a per-toast timer for whatever remainingMs is
// left, keyed by `toast.createdAt` so a resume that resets `createdAt`
// re-runs this effect and refreshes the timer.
useEffect(() => {
const timers: Array<ReturnType<typeof setTimeout>> = [];
for (const toast of state.toasts) {
if (toast.pausedAt) continue;
const delay = Math.max(0, toast.remainingMs - (clock() - toast.createdAt));
const timer = setTimeout(() => {
dispatch({ type: 'dismiss', id: toast.id });
}, delay);
timers.push(timer);
}
return () => {
for (const t of timers) clearTimeout(t);
};
}, [state.toasts, clock]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front-end/src/ui/useToast.ts` around lines 152 - 167, The auto-dismiss timer
in useToast is being reset to the full remaining duration whenever any toast
list change reruns the effect, so existing toasts can live longer than intended.
Update the Auto-dismiss useEffect in useToast to compute each timeout from the
actual elapsed time since the toast’s createdAt (or equivalent persisted start
timestamp) instead of blindly using toast.remainingMs, and ensure dismiss
scheduling for a given toast is stable across unrelated state.toasts updates.
Also add/adjust a ToastHost.test.tsx case that simulates multiple concurrent
toast pushes so the original toast still dismisses after the intended 5s window.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 113

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
.agents/skills/react-vite-best-practices/README.md (1)

17-49: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Приведіть README до markdownlint-сумісного вигляду.

Зараз тут одночасно MD022 і MD040: заголовки без порожніх рядків та fenced block без мови. Це дрібно, але ламатиме doc-lint у CI.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/react-vite-best-practices/README.md around lines 17 - 49, The
README markdown currently violates markdownlint rules MD022 and MD040. Add the
required blank lines around the section headings in the React Vite best
practices list, and update the fenced example block in the Usage section to
include a language identifier. Keep the structure and content the same, just
adjust the formatting so the README passes doc-lint.

Source: Linters/SAST tools

back-end/src/logger.test.ts (1)

1-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Бракує тесту на редакцію ключа authorization.

SENSITIVE_KEYS в logger.ts містить 'authorization', але жоден тест тут це не перевіряє — покриття неповне для одного з трьох захищених ключів.

✅ Пропонований тест
+test('redacts a lower-case `authorization` key', () => {
+  const { logger, output } = captureLogger();
+  logger.info({ headers: { authorization: 'Bearer S3CR3T-AUTH' } }, 'incoming request');
+  assert.ok(!output().includes('S3CR3T-AUTH'));
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/logger.test.ts` around lines 1 - 66, Add a test in
logger.test.ts to cover redaction of the lower-case authorization key, since
SENSITIVE_KEYS in logger.ts already includes it but no existing assertion
verifies it. Extend the captureLogger-based tests with a new case that logs an
object containing authorization and asserts the sensitive value does not appear
in output, following the same pattern used by the AccessToken and token
redaction tests.
front-end/README.md (1)

1-76: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

README все ще шаблонний create-vite текст — але це не блокер.

Файл не описує сам продукт mytv (screens, dev/build команди, зв'язок із back-end на одному origin). Дрібниця для onboarding, не критично.

Окремо, як ментор курсу: опис PR дає гарні докази виконання вимог (1)-(4) — вказано реальне ім'я автора, є посилання на відео-демо, є змістовний опис агентних практик (maker≠checker через окремого code-reviewer суб-агента, цикл через /next-change, OpenSpec specs+verification gates), і результат доведено до робочого end-to-end стану, а не "згенеровано і кинуто". Гарна робота з процесом! 👏

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front-end/README.md` around lines 1 - 76, The README still contains the
default create-vite template instead of project-specific onboarding content.
Update the top-level README to describe mytv: what the app does, the main
screens/features, how to run dev/build, and how it connects to the back-end on
the same origin. Keep it focused on this repo’s actual setup and remove the
generic Vite/template guidance.

Source: Path instructions

openspec/changes/archive/2026-07-06-systemd-installer/design.md (1)

1-162: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Додайте H1 на початок документа.

## Context на першому рядку знову тригерить MD041. Краще почати з верхнього заголовка, а вже потім іти в секції.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openspec/changes/archive/2026-07-06-systemd-installer/design.md` around lines
1 - 162, The document starts with a second-level heading, which triggers the
markdown lint rule for missing top-level title. Add a single H1 at the beginning
of the design doc, before the current Context section, and keep the existing
section structure under it so the file still reads correctly.

Source: Linters/SAST tools


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 98833b3b-6975-4c13-bf58-e9fbed6ec9b9

📥 Commits

Reviewing files that changed from the base of the PR and between 75fe221 and 337d605.

⛔ Files ignored due to path filters (87)
  • back-end/package-lock.json is excluded by !**/package-lock.json
  • docs/orbit-tv-remote-design-system/SKILL.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/_adherence.oxlintrc.json is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/_ds_bundle.js is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/_ds_manifest.json is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/controls/AppShortcut.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/controls/AppShortcut.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/controls/AppShortcut.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/controls/DPad.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/controls/DPad.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/controls/DPad.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/controls/RotaryKnob.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/controls/RotaryKnob.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/controls/RotaryKnob.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/controls/controls.card.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/Badge.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/Badge.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/Badge.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/Button.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/Button.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/Button.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/Card.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/Card.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/Card.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/DeviceCard.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/DeviceCard.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/DeviceCard.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/IconButton.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/IconButton.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/IconButton.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/ListRow.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/ListRow.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/core/core.card.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/feedback/Modal.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/feedback/Modal.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/feedback/Modal.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/feedback/Toast.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/feedback/Toast.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/feedback/Toast.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/feedback/feedback.card.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/forms/Input.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/forms/Input.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/forms/Input.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/forms/Slider.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/forms/Slider.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/forms/Slider.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/forms/Toggle.d.ts is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/forms/Toggle.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/forms/Toggle.prompt.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/components/forms/forms.card.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/guidelines/colors-accent.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/guidelines/colors-neutrals.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/guidelines/colors-semantic.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/guidelines/iconography.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/guidelines/radius.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/guidelines/shadows.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/guidelines/spacing.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/guidelines/theme-comparison.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/guidelines/type-scale.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/guidelines/type-weights.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/readme.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/styles.css is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/tokens/base.css is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/tokens/colors.css is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/tokens/fonts.css is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/tokens/shadows.css is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/tokens/spacing.css is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/tokens/typography.css is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/ui_kits/tv-remote/DeviceListScreen.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/ui_kits/tv-remote/README.md is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/ui_kits/tv-remote/RemoteScreen.jsx is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/orbit-tv-remote-design-system/ui_kits/tv-remote/index.html is excluded by !docs/orbit-tv-remote-design-system/**
  • docs/samsung-ip-control-protocol/HTV_IPControl_Protocol_20251120_v.2.8.pdf is excluded by !**/*.pdf, !docs/samsung-ip-control-protocol/**
  • docs/samsung-ip-control-protocol/IP CONTROL V2.postman_collection.json is excluded by !docs/samsung-ip-control-protocol/**
  • front-end/package-lock.json is excluded by !**/package-lock.json
  • front-end/public/favicon.svg is excluded by !**/*.svg
  • front-end/public/fonts/material-symbols-rounded.woff2 is excluded by !**/*.woff2
  • front-end/public/fonts/montserrat-400.woff2 is excluded by !**/*.woff2
  • front-end/public/fonts/montserrat-500.woff2 is excluded by !**/*.woff2
  • front-end/public/fonts/montserrat-600.woff2 is excluded by !**/*.woff2
  • front-end/public/fonts/montserrat-700.woff2 is excluded by !**/*.woff2
  • front-end/public/fonts/montserrat-800.woff2 is excluded by !**/*.woff2
  • front-end/public/icons.svg is excluded by !**/*.svg
  • front-end/src/assets/hero.png is excluded by !**/*.png
  • front-end/src/assets/react.svg is excluded by !**/*.svg
  • front-end/src/assets/vite.svg is excluded by !**/*.svg
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (266)
  • .agents/skills/fastify-best-practices/SKILL.md
  • .agents/skills/fastify-best-practices/rules/authentication.md
  • .agents/skills/fastify-best-practices/rules/configuration.md
  • .agents/skills/fastify-best-practices/rules/content-type.md
  • .agents/skills/fastify-best-practices/rules/cors-security.md
  • .agents/skills/fastify-best-practices/rules/database.md
  • .agents/skills/fastify-best-practices/rules/decorators.md
  • .agents/skills/fastify-best-practices/rules/deployment.md
  • .agents/skills/fastify-best-practices/rules/error-handling.md
  • .agents/skills/fastify-best-practices/rules/hooks.md
  • .agents/skills/fastify-best-practices/rules/http-proxy.md
  • .agents/skills/fastify-best-practices/rules/logging.md
  • .agents/skills/fastify-best-practices/rules/performance.md
  • .agents/skills/fastify-best-practices/rules/plugins.md
  • .agents/skills/fastify-best-practices/rules/routes.md
  • .agents/skills/fastify-best-practices/rules/schemas.md
  • .agents/skills/fastify-best-practices/rules/serialization.md
  • .agents/skills/fastify-best-practices/rules/testing.md
  • .agents/skills/fastify-best-practices/rules/typescript.md
  • .agents/skills/fastify-best-practices/rules/websockets.md
  • .agents/skills/fastify-best-practices/tile.json
  • .agents/skills/react-vite-best-practices/AGENTS.md
  • .agents/skills/react-vite-best-practices/README.md
  • .agents/skills/react-vite-best-practices/SKILL.md
  • .agents/skills/react-vite-best-practices/rules/_sections.md
  • .agents/skills/react-vite-best-practices/rules/_template.md
  • .agents/skills/react-vite-best-practices/rules/asset-fonts.md
  • .agents/skills/react-vite-best-practices/rules/asset-image-optimization.md
  • .agents/skills/react-vite-best-practices/rules/asset-public-dir.md
  • .agents/skills/react-vite-best-practices/rules/asset-svg-components.md
  • .agents/skills/react-vite-best-practices/rules/build-asset-hashing.md
  • .agents/skills/react-vite-best-practices/rules/build-compression.md
  • .agents/skills/react-vite-best-practices/rules/build-manual-chunks.md
  • .agents/skills/react-vite-best-practices/rules/build-minification.md
  • .agents/skills/react-vite-best-practices/rules/build-sourcemaps.md
  • .agents/skills/react-vite-best-practices/rules/build-target-modern.md
  • .agents/skills/react-vite-best-practices/rules/build-tree-shaking.md
  • .agents/skills/react-vite-best-practices/rules/bundle-visualizer.md
  • .agents/skills/react-vite-best-practices/rules/dev-dependency-prebundling.md
  • .agents/skills/react-vite-best-practices/rules/dev-fast-refresh.md
  • .agents/skills/react-vite-best-practices/rules/dev-hmr-config.md
  • .agents/skills/react-vite-best-practices/rules/env-modes.md
  • .agents/skills/react-vite-best-practices/rules/env-sensitive-data.md
  • .agents/skills/react-vite-best-practices/rules/env-vite-prefix.md
  • .agents/skills/react-vite-best-practices/rules/split-component-lazy.md
  • .agents/skills/react-vite-best-practices/rules/split-dynamic-imports.md
  • .agents/skills/react-vite-best-practices/rules/split-prefetch-hints.md
  • .agents/skills/react-vite-best-practices/rules/split-route-lazy.md
  • .agents/skills/react-vite-best-practices/rules/split-suspense-boundaries.md
  • .claude/agents/code-reviewer.md
  • .claude/commands/next-change.md
  • .claude/commands/opsx/apply.md
  • .claude/commands/opsx/archive.md
  • .claude/commands/opsx/explore.md
  • .claude/commands/opsx/propose.md
  • .claude/commands/opsx/sync.md
  • .claude/skills/frontend-design-check/SKILL.md
  • .claude/skills/openspec-apply-change/SKILL.md
  • .claude/skills/openspec-archive-change/SKILL.md
  • .claude/skills/openspec-explore/SKILL.md
  • .claude/skills/openspec-propose/SKILL.md
  • .claude/skills/openspec-sync-specs/SKILL.md
  • .claude/skills/orbit-tv-remote-design
  • .claude/skills/samsung-ip-control-protocol/SKILL.md
  • .coderabbit.yaml
  • .gitignore
  • AGENTS.md
  • CLAUDE.md
  • DESIGN.md
  • LOOP.md
  • back-end/.env.example
  • back-end/.gitignore
  • back-end/README.md
  • back-end/package.json
  • back-end/src/app.test.ts
  • back-end/src/app.ts
  • back-end/src/discovery/description.test.ts
  • back-end/src/discovery/description.ts
  • back-end/src/discovery/discovery.test.ts
  • back-end/src/discovery/index.test.ts
  • back-end/src/discovery/index.ts
  • back-end/src/discovery/registry.test.ts
  • back-end/src/discovery/registry.ts
  • back-end/src/discovery/ssdp.ts
  • back-end/src/errors.ts
  • back-end/src/index.ts
  • back-end/src/logger.test.ts
  • back-end/src/logger.ts
  • back-end/src/mdns.test.ts
  • back-end/src/mdns.ts
  • back-end/src/plugins/static-spa.ts
  • back-end/src/routes/browser.test.ts
  • back-end/src/routes/browser.ts
  • back-end/src/routes/devices.ts
  • back-end/src/routes/health.ts
  • back-end/src/routes/inputs.test.ts
  • back-end/src/routes/inputs.ts
  • back-end/src/routes/keys.test.ts
  • back-end/src/routes/keys.ts
  • back-end/src/routes/sessions.ts
  • back-end/src/routes/volume.test.ts
  • back-end/src/routes/volume.ts
  • back-end/src/tv/browser.test.ts
  • back-end/src/tv/browser.ts
  • back-end/src/tv/errors.test.ts
  • back-end/src/tv/errors.ts
  • back-end/src/tv/inputs.test.ts
  • back-end/src/tv/inputs.ts
  • back-end/src/tv/jsonrpc.test.ts
  • back-end/src/tv/jsonrpc.ts
  • back-end/src/tv/keys.test.ts
  • back-end/src/tv/keys.ts
  • back-end/src/tv/manager.ts
  • back-end/src/tv/session-integration.test.ts
  • back-end/src/tv/session.test.ts
  • back-end/src/tv/session.ts
  • back-end/src/tv/token-store.test.ts
  • back-end/src/tv/token-store.ts
  • back-end/src/tv/types.ts
  • back-end/src/tv/volume.test.ts
  • back-end/src/tv/volume.ts
  • back-end/src/websocket.test.ts
  • back-end/src/ws/broker.ts
  • back-end/tsconfig.json
  • docs/capabilities.md
  • docs/current-state.md
  • docs/product-brief.md
  • docs/requirements.md
  • front-end/.gitignore
  • front-end/README.md
  • front-end/eslint.config.js
  • front-end/index.html
  • front-end/package.json
  • front-end/src/App.css
  • front-end/src/App.tsx
  • front-end/src/api/client.ts
  • front-end/src/data/inputs.ts
  • front-end/src/data/keys.ts
  • front-end/src/data/types.ts
  • front-end/src/data/useBrowserLaunch.ts
  • front-end/src/data/useDeviceSession.ts
  • front-end/src/data/useDevices.test.ts
  • front-end/src/data/useDevices.ts
  • front-end/src/data/useInputs.ts
  • front-end/src/data/useSendKey.test.tsx
  • front-end/src/data/useSendKey.ts
  • front-end/src/data/useVolume.ts
  • front-end/src/data/useWsHealth.ts
  • front-end/src/ds.d.ts
  • front-end/src/errors/messages.test.ts
  • front-end/src/errors/messages.ts
  • front-end/src/fonts.css
  • front-end/src/index.css
  • front-end/src/main.tsx
  • front-end/src/screens/DeviceListScreen.test.tsx
  • front-end/src/screens/DeviceListScreen.tsx
  • front-end/src/screens/InputsModal.test.tsx
  • front-end/src/screens/InputsModal.tsx
  • front-end/src/screens/RemoteScreen.test.tsx
  • front-end/src/screens/RemoteScreen.tsx
  • front-end/src/ui/ErrorBoundary.tsx
  • front-end/src/ui/ToastHost.test.tsx
  • front-end/src/ui/ToastHost.tsx
  • front-end/src/ui/WsHealthChip.tsx
  • front-end/src/ui/useToast.ts
  • front-end/tsconfig.app.json
  • front-end/tsconfig.json
  • front-end/tsconfig.node.json
  • front-end/vite.config.ts
  • openspec/changes/archive/2026-07-04-default-port-80/.openspec.yaml
  • openspec/changes/archive/2026-07-04-default-port-80/design.md
  • openspec/changes/archive/2026-07-04-default-port-80/proposal.md
  • openspec/changes/archive/2026-07-04-default-port-80/specs/platform-foundation/spec.md
  • openspec/changes/archive/2026-07-04-default-port-80/tasks.md
  • openspec/changes/archive/2026-07-04-device-list-ui/.openspec.yaml
  • openspec/changes/archive/2026-07-04-device-list-ui/design.md
  • openspec/changes/archive/2026-07-04-device-list-ui/proposal.md
  • openspec/changes/archive/2026-07-04-device-list-ui/specs/device-list-ui/spec.md
  • openspec/changes/archive/2026-07-04-device-list-ui/tasks.md
  • openspec/changes/archive/2026-07-04-mdns-advertisement/.openspec.yaml
  • openspec/changes/archive/2026-07-04-mdns-advertisement/design.md
  • openspec/changes/archive/2026-07-04-mdns-advertisement/proposal.md
  • openspec/changes/archive/2026-07-04-mdns-advertisement/specs/mdns-advertisement/spec.md
  • openspec/changes/archive/2026-07-04-mdns-advertisement/tasks.md
  • openspec/changes/archive/2026-07-04-platform-foundation/.openspec.yaml
  • openspec/changes/archive/2026-07-04-platform-foundation/design.md
  • openspec/changes/archive/2026-07-04-platform-foundation/proposal.md
  • openspec/changes/archive/2026-07-04-platform-foundation/specs/platform-foundation/spec.md
  • openspec/changes/archive/2026-07-04-platform-foundation/tasks.md
  • openspec/changes/archive/2026-07-04-remote-control-keys/.openspec.yaml
  • openspec/changes/archive/2026-07-04-remote-control-keys/design.md
  • openspec/changes/archive/2026-07-04-remote-control-keys/proposal.md
  • openspec/changes/archive/2026-07-04-remote-control-keys/specs/remote-control-keys/spec.md
  • openspec/changes/archive/2026-07-04-remote-control-keys/tasks.md
  • openspec/changes/archive/2026-07-04-smart-view-ws-transport/.openspec.yaml
  • openspec/changes/archive/2026-07-04-smart-view-ws-transport/design.md
  • openspec/changes/archive/2026-07-04-smart-view-ws-transport/proposal.md
  • openspec/changes/archive/2026-07-04-smart-view-ws-transport/specs/tv-connection-lifecycle/spec.md
  • openspec/changes/archive/2026-07-04-smart-view-ws-transport/specs/upnp-tv-discovery/spec.md
  • openspec/changes/archive/2026-07-04-smart-view-ws-transport/tasks.md
  • openspec/changes/archive/2026-07-04-tv-connection-lifecycle/.openspec.yaml
  • openspec/changes/archive/2026-07-04-tv-connection-lifecycle/design.md
  • openspec/changes/archive/2026-07-04-tv-connection-lifecycle/proposal.md
  • openspec/changes/archive/2026-07-04-tv-connection-lifecycle/specs/tv-connection-lifecycle/spec.md
  • openspec/changes/archive/2026-07-04-tv-connection-lifecycle/tasks.md
  • openspec/changes/archive/2026-07-04-upnp-tv-discovery/.openspec.yaml
  • openspec/changes/archive/2026-07-04-upnp-tv-discovery/design.md
  • openspec/changes/archive/2026-07-04-upnp-tv-discovery/proposal.md
  • openspec/changes/archive/2026-07-04-upnp-tv-discovery/specs/upnp-tv-discovery/spec.md
  • openspec/changes/archive/2026-07-04-upnp-tv-discovery/tasks.md
  • openspec/changes/archive/2026-07-05-error-surfacing/.openspec.yaml
  • openspec/changes/archive/2026-07-05-error-surfacing/design.md
  • openspec/changes/archive/2026-07-05-error-surfacing/proposal.md
  • openspec/changes/archive/2026-07-05-error-surfacing/specs/error-surfacing/spec.md
  • openspec/changes/archive/2026-07-05-error-surfacing/tasks.md
  • openspec/changes/archive/2026-07-05-input-management/.openspec.yaml
  • openspec/changes/archive/2026-07-05-input-management/design.md
  • openspec/changes/archive/2026-07-05-input-management/proposal.md
  • openspec/changes/archive/2026-07-05-input-management/specs/input-management/spec.md
  • openspec/changes/archive/2026-07-05-input-management/tasks.md
  • openspec/changes/archive/2026-07-05-volume-control/.openspec.yaml
  • openspec/changes/archive/2026-07-05-volume-control/design.md
  • openspec/changes/archive/2026-07-05-volume-control/proposal.md
  • openspec/changes/archive/2026-07-05-volume-control/specs/volume-control/spec.md
  • openspec/changes/archive/2026-07-05-volume-control/tasks.md
  • openspec/changes/archive/2026-07-06-build-install-split/.openspec.yaml
  • openspec/changes/archive/2026-07-06-build-install-split/design.md
  • openspec/changes/archive/2026-07-06-build-install-split/proposal.md
  • openspec/changes/archive/2026-07-06-build-install-split/specs/deploy-build/spec.md
  • openspec/changes/archive/2026-07-06-build-install-split/specs/systemd-installer/spec.md
  • openspec/changes/archive/2026-07-06-build-install-split/tasks.md
  • openspec/changes/archive/2026-07-06-systemd-installer/.openspec.yaml
  • openspec/changes/archive/2026-07-06-systemd-installer/design.md
  • openspec/changes/archive/2026-07-06-systemd-installer/proposal.md
  • openspec/changes/archive/2026-07-06-systemd-installer/specs/systemd-installer/spec.md
  • openspec/changes/archive/2026-07-06-systemd-installer/tasks.md
  • openspec/changes/archive/2026-07-06-tv-browser-launch/.openspec.yaml
  • openspec/changes/archive/2026-07-06-tv-browser-launch/design.md
  • openspec/changes/archive/2026-07-06-tv-browser-launch/proposal.md
  • openspec/changes/archive/2026-07-06-tv-browser-launch/specs/tv-browser-launch/spec.md
  • openspec/changes/archive/2026-07-06-tv-browser-launch/tasks.md
  • openspec/changes/archive/2026-07-06-volume-rotary-knob/.openspec.yaml
  • openspec/changes/archive/2026-07-06-volume-rotary-knob/design.md
  • openspec/changes/archive/2026-07-06-volume-rotary-knob/proposal.md
  • openspec/changes/archive/2026-07-06-volume-rotary-knob/specs/volume-control/spec.md
  • openspec/changes/archive/2026-07-06-volume-rotary-knob/tasks.md
  • openspec/config.yaml
  • openspec/specs/deploy-build/spec.md
  • openspec/specs/device-list-ui/spec.md
  • openspec/specs/error-surfacing/spec.md
  • openspec/specs/input-management/spec.md
  • openspec/specs/mdns-advertisement/spec.md
  • openspec/specs/platform-foundation/spec.md
  • openspec/specs/remote-control-keys/spec.md
  • openspec/specs/systemd-installer/spec.md
  • openspec/specs/tv-browser-launch/spec.md
  • openspec/specs/tv-connection-lifecycle/spec.md
  • openspec/specs/upnp-tv-discovery/spec.md
  • openspec/specs/volume-control/spec.md
  • package.json
  • scripts/build.sh
  • scripts/install.sh
  • scripts/install.test.bats
  • scripts/mytv.service.tmpl
  • scripts/uninstall.sh
  • skills-lock.json

Comment on lines +110 to +124
app.post('/auth/refresh', async (request, reply) => {
const { refreshToken } = request.body;
const stored = refreshTokens.get(refreshToken);

if (!stored || stored.expires < Date.now()) {
refreshTokens.delete(refreshToken);
return reply.code(401).send({ error: 'Invalid refresh token' });
}

// Delete old token (rotation)
refreshTokens.delete(refreshToken);

const user = await db.users.findById(stored.userId);
const accessToken = app.jwt.sign({ id: user.id, role: user.role });
const newRefreshToken = randomBytes(32).toString('hex');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Перевірте, що користувач ще існує перед sign().

db.users.findById може повернути null; тоді user.id впаде, і застарілий refresh token дасть 500 замість 401.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/authentication.md around lines
110 - 124, In the refresh flow handled by app.post('/auth/refresh'),
db.users.findById can return null, so add an explicit user existence check
before calling app.jwt.sign and before reading user.id or user.role. If the user
is missing, treat the refresh token as invalid by deleting the token if needed
and returning a 401 response instead of letting the handler throw a 500.

Comment on lines +293 to +335
import fastifyOauth2 from '@fastify/oauth2';

app.register(fastifyOauth2, {
name: 'googleOAuth2',
scope: ['profile', 'email'],
credentials: {
client: {
id: process.env.GOOGLE_CLIENT_ID,
secret: process.env.GOOGLE_CLIENT_SECRET,
},
},
startRedirectPath: '/auth/google',
callbackUri: 'http://localhost:3000/auth/google/callback',
discovery: {
issuer: 'https://accounts.google.com',
},
});

app.get('/auth/google/callback', async (request, reply) => {
const { token } = await app.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request);

// Fetch user info from Google
const userInfo = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${token.access_token}` },
}).then((r) => r.json());

// Find or create user
let user = await db.users.findByEmail(userInfo.email);
if (!user) {
user = await db.users.create({
email: userInfo.email,
name: userInfo.name,
provider: 'google',
providerId: userInfo.id,
});
}

// Generate JWT
const jwt = app.jwt.sign({ id: user.id, role: user.role });

// Redirect to frontend with token
return reply.redirect(`/auth/success?token=${jwt}`);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '260,360p' .agents/skills/fastify-best-practices/rules/authentication.md

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 2693


🏁 Script executed:

rg -n "callbackUri: 'http://localhost:3000/auth/google/callback'|reply.redirect\(`/auth/success\?token=\$\{jwt\}`\)|session-based|HttpOnly cookie|callbackUri" .agents/skills/fastify-best-practices/rules/authentication.md

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 324


Не кладіть JWT у query string редіректа. ?token=... потрапляє в history, Referer і логи. Завершуйте OAuth через session або HttpOnly cookie; callbackUri винесіть у конфіг, а не фіксуйте localhost, щоб приклад працював поза dev.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/authentication.md around lines
293 - 335, The Google OAuth callback flow in app.get('/auth/google/callback') is
exposing the signed JWT via reply.redirect query string, and the callbackUri is
hardcoded to localhost. Move the auth result into a session or an HttpOnly
cookie instead of appending token=... to the redirect URL, and make callbackUri
configurable via environment/config so the fastifyOauth2 setup works outside
local development.

Comment on lines +212 to +216
app.addHook('onRequest', async (request, reply) => {
if (dynamicConfig.maintenanceMode && !request.url.startsWith('/health')) {
reply.code(503).send({ error: 'Service under maintenance' });
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Fastify async hook reply.send without return lifecycle docs onRequest

💡 Result:

In Fastify, using an async hook (such as onRequest) and calling reply.send without returning the reply instance—or awaiting the send operation—will cause the request lifecycle to continue, often leading to errors like FST_ERR_REP_ALREADY_SENT [1][2]. When you use an async function for a hook, Fastify expects the function to either return a value or resolve its promise to determine when the hook is finished [1][2]. If you call reply.send asynchronously (e.g., inside a setTimeout, setImmediate, or an asynchronous operation) and do not signal that the hook should wait, the hook function will resolve immediately, allowing the rest of the request lifecycle (including subsequent hooks and the main route handler) to execute [1][2]. To correctly respond from an async hook: 1. If calling reply.send synchronously within the async function: You can simply call it and return the result, or await it [1][3]. 2. If calling reply.send asynchronously: You must return the reply instance (e.g., return reply) to signal to Fastify that it should wait for the response to be sent before proceeding [1][2]. Example of correct pattern for asynchronous response: fastify.addHook('onRequest', async (request, reply) => { // Asynchronous operation setImmediate( => { reply.send('Early response'); }); // Mandatory: signals Fastify to wait for the response return reply; }); It is critical to avoid mixing callback-style (using the done parameter) and async/Promise-style hooks, as this can cause the hook chain to execute multiple times or lead to unexpected lifecycle behavior [1][2]. If you do not return the reply instance, Fastify will assume the hook has completed without sending a response, and will continue to process the request as normal [1][2].

Citations:


Поверніть відповідь із async onRequest

Тут треба return reply.code(503).send(...) або throw: без цього Fastify може продовжити lifecycle, і maintenance-guard не зупинить наступні хуки чи handler.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/configuration.md around lines
212 - 216, The async onRequest hook in addHook currently sends a 503 response
for maintenance mode but does not stop the request lifecycle. Update the
onRequest callback to either return reply.code(503).send(...) or throw after
sending so Fastify does not continue to later hooks or the route handler; use
the existing onRequest and dynamicConfig.maintenanceMode guard to locate the
fix.

Comment on lines +20 to +31
// Simple CORS - allow all origins
app.register(cors);

// Configured CORS
app.register(cors, {
origin: ['https://example.com', 'https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count'],
credentials: true,
maxAge: 86400, // 24 hours
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show file size and the relevant slices with line numbers.
wc -l .agents/skills/fastify-best-practices/rules/cors-security.md
sed -n '1,120p' .agents/skills/fastify-best-practices/rules/cors-security.md | cat -n

# Also inspect any mentions of CORS, dev-only, security guide, or allowlist in nearby docs.
rg -n -C 3 "CORS|cors|dev-only|allowlist|allow list|credentials: true|origin:" .agents/skills/fastify-best-practices -g '*.md'

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 12902


🌐 Web query:

@fastify/cors default options origin true credentials true reflect request origin security documentation

💡 Result:

In the @fastify/cors plugin, setting origin: true configures the server to reflect the value of the incoming Origin header in the Access-Control-Allow-Origin response header [1][2]. When combined with credentials: true, the server instructs the browser to allow cross-origin requests to include credentials (such as cookies or HTTP authentication) for the reflected origin [1][3]. This configuration is generally not recommended for production environments due to significant security risks, primarily related to reflection exploits [1][2][3]. When origin is set to true, any site can potentially make authenticated requests to your server, as the server will dynamically trust whichever origin is provided in the request [1][4][5]. An attacker could exploit this by crafting a malicious site that, when visited by a logged-in user, makes authorized requests to your API, effectively bypassing the intended security boundaries of CORS [5]. To secure your application, you should: 1. Use an explicit allowlist: Instead of setting origin to true, provide an array of trusted origin strings or regular expressions [1][2][3]. 2. Use a validation function: If you need dynamic logic, use a function for the origin option to strictly validate the incoming request origin against a known set of allowed hosts before returning it [1][2]. 3. Avoid wildcard origins with credentials: Browsers strictly forbid the use of the wildcard '*' origin when credentials: true is set; the server must respond with a specific, non-wildcard origin [3][6]. By explicitly validating origins, you ensure that only trusted clients can perform authenticated cross-origin operations against your service [3].

Citations:


🌐 Web query:

@fastify/cors default behavior app.register(cors) allow all origins default origin false docs

💡 Result:

In the @fastify/cors plugin, the default value for the origin option is '' [1][2][3]. This special '' value configures the Access-Control-Allow-Origin header to allow any origin [2][4]. Regarding your specific question about setting origin to false: 1. Setting origin: false (in the plugin configuration or via a dynamic callback) disables CORS for those requests [2][5][6]. 2. It does not mean the default behavior of the plugin is to disable CORS; rather, it is one of several specific configuration options available to control behavior [2][4]. To summarize the origin option behaviors: - '*': The default value, which allows all origins [1][3]. - true: Reflects the request origin in the Access-Control-Allow-Origin header (e.g., dynamically allows the requester) [2][5]. - false: Disables CORS entirely [2][4]. - Specific String, RegExp, Array, or Function: Used for custom origin validation [2][6]. You can override these defaults globally when calling fastify.register(cors, { origin:... }) or per-route using the config.cors option [2][4][5].

Citations:


Не показуйте permissive CORS як базовий приклад. app.register(cors) за замовчуванням дозволяє всі origins, а origin: true разом із credentials: true віддзеркалює будь-який Origin. Для security-гайду це занадто небезпечний шаблон для копіювання — залиште лише явний allowlist або позначте ці варіанти як dev-only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/cors-security.md around lines 20
- 31, The CORS examples in the Fastify best-practices guide are too permissive
for a security template: remove the default “allow all origins” usage from the
`app.register(cors)` example and avoid presenting `origin: true` with
`credentials: true` as a safe baseline. Update the `cors-security.md` guidance
to show only an explicit allowlist configuration, or clearly label any
permissive setup as development-only, so readers are steered toward secure
defaults.

Comment on lines +359 to +367
app.addHook('onRequest', async (request, reply) => {
if (request.url.startsWith('/admin')) {
const clientIp = request.ip;

if (!isIpAllowed(clientIp, allowedIps)) {
reply.code(403).send({ error: 'Forbidden' });
}
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Поверніть return із onRequest після send(). Без цього хук виглядає як блокуючий, але lifecycle може піти далі до handler’а й упертися в already sent.

🔧 Мінімальний фікс
 if (!isIpAllowed(clientIp, allowedIps)) {
-  reply.code(403).send({ error: 'Forbidden' });
+  return reply.code(403).send({ error: 'Forbidden' });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
app.addHook('onRequest', async (request, reply) => {
if (request.url.startsWith('/admin')) {
const clientIp = request.ip;
if (!isIpAllowed(clientIp, allowedIps)) {
reply.code(403).send({ error: 'Forbidden' });
}
}
});
app.addHook('onRequest', async (request, reply) => {
if (request.url.startsWith('/admin')) {
const clientIp = request.ip;
if (!isIpAllowed(clientIp, allowedIps)) {
return reply.code(403).send({ error: 'Forbidden' });
}
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/cors-security.md around lines
359 - 367, The onRequest hook in app.addHook currently sends a 403 for
disallowed admin IPs but does not stop execution afterward, so the request
lifecycle can continue into the handler and trigger an already sent error.
Update the onRequest logic to return immediately after reply.code(403).send(...)
when isIpAllowed(clientIp, allowedIps) is false, keeping the control flow in the
hook explicit and preventing downstream execution.

Comment thread package.json
Comment on lines +1 to +39
{
"name": "2026-fwdays-agentic-greenfield-task-ap",
"version": "1.0.0",
"private": true,
"description": "Курс fwdays Academy · Agentic Engineering: Greenfield.",
"homepage": "https://github.com/AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP#readme",
"bugs": {
"url": "https://github.com/AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP.git"
},
"license": "ISC",
"author": "",
"directories": {
"doc": "docs"
},
"scripts": {
"skills:sync": "metaskills",
"skills:claude": "metaskills claude",
"back:dev": "npm --prefix back-end run dev",
"back:build": "npm --prefix back-end run build",
"back:test": "npm --prefix back-end run test",
"back:install": "npm --prefix back-end install",
"back:discover": "npm --prefix back-end run discover",
"front:dev": "npm --prefix front-end run dev",
"front:build": "npm --prefix front-end run build",
"front:test": "npm --prefix front-end run test",
"front:install": "npm --prefix front-end install",
"install:all": "npm install && npm run back:install && npm run front:install",
"deploy:build": "bash scripts/build.sh",
"deploy:install": "bash scripts/install.sh",
"deploy:uninstall": "bash scripts/uninstall.sh"
},
"devDependencies": {
"metaskills": "^1.0.4"
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Процесна оцінка (Agentic Engineering rubric).

Привіт, Антоне! Гарна робота з інфраструктурою деплою — тепер по суті рубрики курсу:

  1. Реальне ім'я автора — вказано в описі PR ("Anton Petrenko"). ✅
  2. Відео-демо — посилання є в описі PR. ✅
  3. Змістовний опис Agentic Engineering практик — є: контекст-файли, /next-change цикли, maker≠checker через окремого code-reviewer суб-агента, OpenSpec специфікації, build/test verification gates. ✅
  4. Доведено до кінця — заявлено end-to-end робочий стан. ✅

Дрібне зауваження: поле "author" тут порожнє (Line 15). Було б акуратніше вказати реальне ім'я прямо в package.json, а не лише в описі PR — це дрібниця, але прямо стосується пункту (1) рубрики.

Based on path instructions: "оціни ДОКАЗИ: (1) вказане справжнє імʼя автора; (2) є посилання на відео-демо...; (3) є ЗМІСТОВНИЙ опис застосованих практик...; (4) результат доведено до кінця".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 1 - 39, The package metadata is missing the real
author name even though the PR description contains it; update the package.json
author field in the main package metadata to the actual author’s name so the
project records authorship directly in the manifest, using the existing
package.json structure as the place to fix it.

Source: Path instructions

Comment thread scripts/install.sh
Comment on lines +62 to +98
resolve_paths() {
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$script_dir/.." && pwd)"
TEMPLATE_PATH="$script_dir/mytv.service.tmpl"

[[ -d "$REPO_ROOT/back-end" ]] || die "REPO_ROOT missing back-end/: $REPO_ROOT"
[[ -d "$REPO_ROOT/front-end" ]] || die "REPO_ROOT missing front-end/: $REPO_ROOT"
[[ -f "$TEMPLATE_PATH" ]] || die "systemd unit template missing: $TEMPLATE_PATH"

NODE_BIN="$(readlink -f "$(command -v node)")"
[[ -x "$NODE_BIN" ]] || die "resolved node binary not executable: $NODE_BIN"

log "REPO_ROOT=$REPO_ROOT"
log "NODE_BIN=$NODE_BIN"
}

verify_artifacts() {
local be="$REPO_ROOT/back-end/dist/index.js"
local fe="$REPO_ROOT/front-end/dist/index.html"
local missing=()
[[ -s "$be" ]] || missing+=("$be")
[[ -s "$fe" ]] || missing+=("$fe")
if (( ${#missing[@]} > 0 )); then
for m in "${missing[@]}"; do
printf '[install] ERROR: missing artefact: %s\n' "$m" >&2
done
die "run ./scripts/build.sh from a machine with node >= 20 (locally, or rsync the dists in from a dev laptop) and try again"
fi
log "artefacts present: $be"
log "artefacts present: $fe"
}

install_prod_deps() {
log "installing production runtime deps (npm ci --omit=dev in back-end/)"
npm ci --omit=dev --prefix "$REPO_ROOT/back-end"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Перевірка: чи є явна обробка прав доступу для SERVICE_USER на REPO_ROOT
rg -n 'SERVICE_USER|chmod|setfacl|ReadWritePaths' scripts/install.sh scripts/mytv.service.tmpl

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 651


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- scripts/install.sh:1-220 ---'
sed -n '1,220p' scripts/install.sh | cat -n

echo
echo '--- scripts/mytv.service.tmpl ---'
cat -n scripts/mytv.service.tmpl

echo
echo '--- permission-related checks ---'
rg -n 'REPO_ROOT|SERVICE_HOME|SERVICE_USER|WorkingDirectory|ExecStart|setfacl|chmod|chown|install -d|ReadWritePaths' scripts/install.sh scripts/mytv.service.tmpl

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 11006


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- scripts/install.sh:220-end ---'
sed -n '220,360p' scripts/install.sh | cat -n

echo
echo '--- repo docs mentioning install path / permissions ---'
rg -n 'install.sh|/opt|/home|mytv.service|REPO_ROOT|permissions|permission' README* docs scripts/*.md scripts/*.txt

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 12280


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- docs / README install instructions ---'
rg -n 'sudo ./scripts/install.sh|./scripts/build.sh|clone|/opt/mytv|/home/pi|working directory|REPO_ROOT|permissions' README* docs back-end/README.md front-end/README.md

echo
echo '--- any explicit path assumptions in scripts ---'
rg -n 'cd "\$script_dir/.."|WorkingDirectory=`@REPO_ROOT`@|User=mytv|Group=mytv|HOME=/var/lib/mytv|install -d -o "\$SERVICE_USER"' scripts/install.sh scripts/mytv.service.tmpl

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 6233


Перевір доступність REPO_ROOT для mytv
scripts/install.sh:62-98 — WorkingDirectory=@REPO_ROOT@/back-end запускається під User=mytv, але скрипт не гарантує x на всіх батьківських директоріях до REPO_ROOT. Якщо репозиторій лежить у приватному шляху, сервіс впаде з Permission denied на старті. Або задокументуй вимогу тримати клон у traversable path (наприклад, /opt/mytv), або додай явну перевірку/ACL після create_service_user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install.sh` around lines 62 - 98, The install flow currently assumes
the mytv user can traverse the full REPO_ROOT path, but resolve_paths() only
checks for the repo subdirectories and not execute/search permission on parent
directories. Update scripts/install.sh around resolve_paths()/verify_artifacts()
to either enforce and document that the clone must live in a traversable
location like /opt/mytv, or add an explicit post-create_service_user permission
check (or ACL setup) that verifies mytv can access REPO_ROOT before generating
the systemd unit.

Comment thread scripts/install.sh
Comment on lines +100 to +114
grant_port_capability() {
local current
current="$(getcap "$NODE_BIN" 2>/dev/null || true)"
if [[ "$current" == *"cap_net_bind_service"*"ep"* ]]; then
log "cap_net_bind_service already set on $NODE_BIN"
return 0
fi
log "granting cap_net_bind_service to $NODE_BIN"
run setcap 'cap_net_bind_service=+ep' "$NODE_BIN"
if (( DRY_RUN == 0 )); then
current="$(getcap "$NODE_BIN" 2>/dev/null || true)"
[[ "$current" == *"cap_net_bind_service"*"ep"* ]] \
|| die "setcap succeeded but capability not present on $NODE_BIN (got: '$current')"
fi
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔵 Trivial | 🏗️ Heavy lift

Розглянь AmbientCapabilities замість setcap на спільному бінарнику node.

setcap cap_net_bind_service=+ep (Line 108) видає можливість слухати привілейовані порти будь-якому процесу, який виконує саме цей файл NODE_BIN — не лише сервісу mytv. Якщо цей самий node використовується іншими скриптами/сервісами на хості, вони теж неявно отримують цю можливість (ширша поверхня атаки, ніж потрібно). Крім того, ця можливість "злітає" при оновленні/заміні бінарника node (наприклад через nvm), і install.sh довелось би перезапускати для повторного setcap.

Стандартний і безпечніший підхід для systemd — AmbientCapabilities=CAP_NET_BIND_SERVICE (+ CapabilityBoundingSet=CAP_NET_BIND_SERVICE) прямо в mytv.service.tmpl: можливість буде обмежена саме цим сервісом і не залежить від конкретного шляху/версії бінарника node.

🔒 Приклад заміни в mytv.service.tmpl замість setcap в install.sh
 [Service]
 Type=simple
 User=mytv
 Group=mytv
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE
 WorkingDirectory=`@REPO_ROOT`@/back-end
 ExecStart=`@NODE_BIN`@ dist/index.js
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install.sh` around lines 100 - 114, `grant_port_capability()`
currently uses `setcap` on the shared `NODE_BIN`, which grants
CAP_NET_BIND_SERVICE to every process running that binary and can be lost on
node updates. Remove this capability setup from the installer and move the
privilege to the systemd service template (`mytv.service.tmpl`) by configuring
`AmbientCapabilities=CAP_NET_BIND_SERVICE` and
`CapabilityBoundingSet=CAP_NET_BIND_SERVICE` so only `mytv` gets the
port-binding permission.

Comment thread scripts/install.test.bats
Comment on lines +71 to +86
@test "resolve_paths resolves REPO_ROOT and NODE_BIN to absolute existing paths" {
# Skip on non-Linux because check_prereqs would otherwise refuse.
case "${OSTYPE:-}" in
darwin*) : ;; # still runnable — we skip check_prereqs manually below
esac

source_install
resolve_paths
[ -n "$REPO_ROOT" ]
[[ "$REPO_ROOT" = /* ]]
[ -d "$REPO_ROOT/back-end" ]
[ -d "$REPO_ROOT/front-end" ]
[ -n "$NODE_BIN" ]
[[ "$NODE_BIN" = /* ]]
[ -x "$NODE_BIN" ]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Коментар про "skip on non-Linux" нічого насправді не пропускає.

case "${OSTYPE:-}" in darwin*) : ;; esac (Line 73-75) — це no-op незалежно від значення OSTYPE: немає *) гілки й немає return/skip, тож заявлений у коментарі "skip" не відбувається. На практиці тест все одно проходить на macOS, бо resolve_paths не викликає check_prereqs, — але коментар вводить в оману майбутнього читача.

🧹 Пропозиція: прибрати мертвий код або зробити реальний skip
-  # Skip on non-Linux because check_prereqs would otherwise refuse.
-  case "${OSTYPE:-}" in
-    darwin*) : ;;   # still runnable — we skip check_prereqs manually below
-  esac
-
   source_install
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@test "resolve_paths resolves REPO_ROOT and NODE_BIN to absolute existing paths" {
# Skip on non-Linux because check_prereqs would otherwise refuse.
case "${OSTYPE:-}" in
darwin*) : ;; # still runnable — we skip check_prereqs manually below
esac
source_install
resolve_paths
[ -n "$REPO_ROOT" ]
[[ "$REPO_ROOT" = /* ]]
[ -d "$REPO_ROOT/back-end" ]
[ -d "$REPO_ROOT/front-end" ]
[ -n "$NODE_BIN" ]
[[ "$NODE_BIN" = /* ]]
[ -x "$NODE_BIN" ]
}
`@test` "resolve_paths resolves REPO_ROOT and NODE_BIN to absolute existing paths" {
source_install
resolve_paths
[ -n "$REPO_ROOT" ]
[[ "$REPO_ROOT" = /* ]]
[ -d "$REPO_ROOT/back-end" ]
[ -d "$REPO_ROOT/front-end" ]
[ -n "$NODE_BIN" ]
[[ "$NODE_BIN" = /* ]]
[ -x "$NODE_BIN" ]
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install.test.bats` around lines 71 - 86, The "skip on non-Linux"
block in the resolve_paths Bats test is dead code because the current case
statement in install.test.bats does nothing and does not actually skip anything.
Update the test around source_install and resolve_paths so the intent matches
behavior: either remove the misleading darwin* no-op entirely, or replace it
with a real skip/return path for the test. Keep the test expectations for
REPO_ROOT and NODE_BIN unchanged, but ensure the control flow in this test
clearly reflects whether macOS should run this case.

Comment thread scripts/mytv.service.tmpl
Comment on lines +7 to +28
[Service]
Type=simple
User=mytv
Group=mytv
WorkingDirectory=@REPO_ROOT@/back-end
ExecStart=@NODE_BIN@ dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=80
Environment=HOST=0.0.0.0
Environment=LOG_LEVEL=info
Environment=SERVE_SPA=1
# HOME is what back-end/src/tv/token-store.ts resolves against when
# XDG_CONFIG_HOME is unset: tokens land at /var/lib/mytv/.mytv/tokens.json
# (0600, owned by the mytv user). Do NOT set XDG_CONFIG_HOME here — it
# would shift the token file to $XDG_CONFIG_HOME/mytv/tokens.json and
# desync from the path the docs / uninstall recipe reference.
Environment=HOME=/var/lib/mytv
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mytv

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Додай базове systemd-хардення до [Service].

Юніт нічого не обмежує (NoNewPrivileges, ProtectSystem, PrivateTmp, ProtectHome тощо відсутні). Для сервісу, що слухає привілейований порт і керує пристроями в локальній мережі, це доступний "defense in depth", який коштує кілька рядків.

🛡️ Приклад мінімального хардення
 [Service]
 Type=simple
 User=mytv
 Group=mytv
+NoNewPrivileges=yes
+ProtectSystem=strict
+PrivateTmp=yes
+ReadWritePaths=/var/lib/mytv
 WorkingDirectory=`@REPO_ROOT`@/back-end
 ExecStart=`@NODE_BIN`@ dist/index.js
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[Service]
Type=simple
User=mytv
Group=mytv
WorkingDirectory=@REPO_ROOT@/back-end
ExecStart=@NODE_BIN@ dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=80
Environment=HOST=0.0.0.0
Environment=LOG_LEVEL=info
Environment=SERVE_SPA=1
# HOME is what back-end/src/tv/token-store.ts resolves against when
# XDG_CONFIG_HOME is unset: tokens land at /var/lib/mytv/.mytv/tokens.json
# (0600, owned by the mytv user). Do NOT set XDG_CONFIG_HOME here — it
# would shift the token file to $XDG_CONFIG_HOME/mytv/tokens.json and
# desync from the path the docs / uninstall recipe reference.
Environment=HOME=/var/lib/mytv
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mytv
[Service]
Type=simple
User=mytv
Group=mytv
NoNewPrivileges=yes
ProtectSystem=strict
PrivateTmp=yes
ReadWritePaths=/var/lib/mytv
WorkingDirectory=`@REPO_ROOT`@/back-end
ExecStart=`@NODE_BIN`@ dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=80
Environment=HOST=0.0.0.0
Environment=LOG_LEVEL=info
Environment=SERVE_SPA=1
# HOME is what back-end/src/tv/token-store.ts resolves against when
# XDG_CONFIG_HOME is unset: tokens land at /var/lib/mytv/.mytv/tokens.json
# (0600, owned by the mytv user). Do NOT set XDG_CONFIG_HOME here — it
# would shift the token file to $XDG_CONFIG_HOME/mytv/tokens.json and
# desync from the path the docs / uninstall recipe reference.
Environment=HOME=/var/lib/mytv
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mytv
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/mytv.service.tmpl` around lines 7 - 28, The mytv systemd service in
the [Service] section lacks basic hardening directives, leaving it unnecessarily
permissive. Update the service template around the existing ExecStart,
Environment, and logging settings to add sensible protections such as
NoNewPrivileges, PrivateTmp, ProtectSystem, and ProtectHome, while keeping the
service functional. Use the existing mytv service stanza as the place to apply
these restrictions and ensure any added settings do not break token storage or
runtime requirements.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 70

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
openspec/changes/archive/2026-07-04-tv-connection-lifecycle/tasks.md (1)

38-43: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Не відмічайте live-smoke як done, якщо Connected не досягнуто.

У цьому блоці ви самі пишете, що під час 6.2 Connected не було досягнуто, а 6.3 відкладено. Для архівної задачі це виглядає як завищений статус виконання; краще рознести verified, partial і deferred явно, щоб не продавати незавершений цикл як готовий. As per path instructions, тут треба показати end-to-end доказ, а не лише проміжний прогрес.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openspec/changes/archive/2026-07-04-tv-connection-lifecycle/tasks.md` around
lines 38 - 43, Adjust the 6.2/6.3 archive status in tasks.md so the live-smoke
entry is not marked fully done unless `Connected` was actually reached. In the
`6.2` and `6.3` bullets, use explicit `partial`/`deferred` wording and keep the
`Connected` outcome separate from the verified transport and pairing steps;
refer to the existing `6.2`, `6.3`, and the `Connected → Reconnecting →
Connected` session tests only as supporting evidence, not as completion proof.

Source: Path instructions

♻️ Duplicate comments (11)
scripts/uninstall.sh (1)

30-35: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

systemctl list-unit-files не підходить як перевірка існування юніта — вже піднімалось раніше.

Ця команда повертає 0 навіть коли збігів немає, тож гілка "not registered" ніколи реально не спрацює і лог буде хибним. Краще перевіряти [[ -f "$UNIT_PATH" ]] або просто робити best-effort disable --now із придушенням помилки.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/uninstall.sh` around lines 30 - 35, The unit-existence check in the
uninstall flow is wrong because `systemctl list-unit-files` can succeed even
when `mytv.service` is not present, so the “not registered” branch in the
uninstall script is misleading. Update the logic in the uninstall script around
the `mytv.service` handling to use a real unit-file check such as `[[ -f
"$UNIT_PATH" ]]`, or remove the precheck and make `systemctl disable --now mytv`
a best-effort call with errors suppressed. Keep the existing `log` messages
aligned with the actual behavior.
scripts/mytv.service.tmpl (1)

7-29: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Хардення systemd-юніта досі відсутнє — вже піднімалось раніше.

Немає NoNewPrivileges, ProtectSystem, ProtectHome, PrivateTmp для мережевого сервісу. cap_net_bind_service виставляється через setcap на бінарник (не AmbientCapabilities), тож ці директиви безпечно додати без ризику зламати біндинг порту.

🛡️ Пропоноване доповнення
 [Service]
 Type=simple
 User=mytv
 Group=mytv
 WorkingDirectory=`@REPO_ROOT`@/back-end
 ExecStart=`@NODE_BIN`@ dist/index.js
 Restart=on-failure
 RestartSec=5
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+ReadWritePaths=/var/lib/mytv
 Environment=NODE_ENV=production
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/mytv.service.tmpl` around lines 7 - 29, The systemd unit in
mytv.service.tmpl is missing basic hardening directives, so update the service
section to add NoNewPrivileges, ProtectSystem, ProtectHome, and PrivateTmp while
keeping the existing ExecStart and environment setup intact. Since the binary
already gets cap_net_bind_service via setcap rather than AmbientCapabilities,
these protections can be added safely without affecting port binding; apply the
change directly in the unit template alongside the current [Service] settings.
scripts/build.sh (1)

14-45: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Дублювання спільної логіки з install.sh (і частково uninstall.sh) — вже піднімалось раніше.

check_prereqs/resolve_paths/log/die/need_cmd тут майже ідентичні до однойменних у scripts/install.sh. Витягнути в scripts/lib.sh і підключати через source — це не зашкодить bats-тестам.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build.sh` around lines 14 - 45, The helper logic in build.sh is
duplicated with install.sh, so extract the shared functions need_cmd,
check_prereqs, resolve_paths, log, and die into a reusable scripts/lib.sh and
source it from build.sh (and the matching install/uninstall scripts if needed).
Keep the behavior identical by moving the common implementations without
changing their call sites such as check_prereqs and resolve_paths, so the shared
shell tests and bats setup continue to work.
back-end/src/mdns.test.ts (1)

109-127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Бракує тесту на async socket-error шлях (реальний Avahi-конфлікт) — досі не додано.

Як і в попередньому review: реальний port-conflict (EADDRINUSE на 5353) обробляється асинхронно через rawClient.server?.mdns?.on('error', ...), а не через синхронний throw у publish(). Саме цей шлях задокументований AVAHI_HINT як типова причина збою на Orange Pi — і саме він лишається без тесту.

Додай стаб зі структурою { server: { mdns: { on } } }, який симулює async EADDRINUSE, і перевір ті самі handle.state/handle.error/handle.address.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/mdns.test.ts` around lines 109 - 127, The current test covers
only the synchronous publish() throw path, but the review asks for the async
socket-error path handled via rawClient.server?.mdns?.on('error', ...). Update
the mdns.test.ts coverage around startMdns to use a stub client shaped like {
server: { mdns: { on } } } that emits an EADDRINUSE error asynchronously,
matching the AVAHI_HINT conflict case. Verify the same handle.state,
handle.error, and handle.address assertions for the async error path, and keep
the existing error-state expectations in the startMdns behavior.
back-end/src/routes/browser.test.ts (1)

134-210: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Бракує тесту на відхилення команди на рівні транспорту — досі не додано.

Як і раніше: немає кейсу, де session.enqueue() резолвиться відхиленням (наприклад, TvError) вже після проходження перевірки Connected — це єдиний шлях, що фактично перевіряє, чи глобальний error-handler мапить TvError в дозволений union кодів, а не «сирі» wire-рівневі деталі, як вимагає coding guideline для back-end/**/*.ts.

Мокни session.enqueue() так, щоб він відхилявся TvError вже після connect, і перевір, що POST /browser повертає код з дозволеного union, а не сирі деталі, і що фрейм не надсилається.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/routes/browser.test.ts` around lines 134 - 210, The browser
route tests are missing coverage for transport-level command rejection after the
session is already connected. Add a case in browser.test.ts around the POST
/browser flow that connects first, then mocks session.enqueue() to reject with
TvError, and assert the response maps to an allowed union error code rather than
exposing raw wire details. Also verify no frame is sent by checking stub.calls
does not increase, using the existing post() helper and the browser route
behavior as the reference point.

Source: Coding guidelines

back-end/src/logger.ts (1)

3-3: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Порівняння ключів досі регістрозалежне — токен може просочитись у лог.

Це зауваження вже піднімалось раніше і лишилось невиправленим: SENSITIVE_KEYS.has(key) не спрацює для Token, AccessToken у іншому регістрі чи Authorization (як HTTP-заголовок може прийти "як є" з undici-відповіді, минаючи Fastify-нормалізацію заголовків запиту). Guideline explicitly вимагає редагувати pairing token "from every log line including nested objects" — регістр тут не застережений.

🛡️ Пропозиція (як і в попередньому review)
-const SENSITIVE_KEYS = new Set(['AccessToken', 'authorization', 'token']);
+const SENSITIVE_KEYS = new Set(['accesstoken', 'authorization', 'token']);
-      if (SENSITIVE_KEYS.has(key)) continue;
+      if (SENSITIVE_KEYS.has(key.toLowerCase())) continue;

Also applies to: 38-39

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/logger.ts` at line 3, SENSITIVE_KEYS matching in logger.ts is
still case-sensitive, so secret fields like Token, AccessToken, or Authorization
can slip into logs. Update the redaction logic used by the logger’s object
traversal/redaction path to normalize keys before checking SENSITIVE_KEYS (for
example, compare lowercased key names) and make sure the same normalization
applies recursively to nested objects and header-like payloads. Use the existing
SENSITIVE_KEYS set and the logging/redaction helper in logger.ts so all log
lines are sanitized consistently regardless of key casing.

Source: Coding guidelines

front-end/src/screens/DeviceListScreen.tsx (1)

14-42: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Хардкод spacing замість design-токенів — досі не виправлено.

Верхній layout (padding: '40px 48px', marginBottom: 32, gap: 16, marginBottom: 6) використовує сирі пікселі, тоді як EmptyState нижче у цьому ж файлі (рядки 86-94) вже коректно бере var(--space-8) var(--space-6) і var(--space-3). Токени для spacing в DS явно є — просто застосовані не послідовно.

♻️ Пропозиція (як і в попередньому review)
-    <div style={{ minHeight: '100%', padding: '40px 48px' }}>
+    <div style={{ minHeight: '100%', padding: 'var(--space-10) var(--space-8)' }}>
       <div style={{ maxWidth: 640, margin: '0 auto' }}>
-        <div style={{ marginBottom: 32, display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 16 }}>
+        <div style={{ marginBottom: 'var(--space-6)', display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--space-3)' }}>
           <div>
             <div
               style={{
                 ...
-                marginBottom: 6,
+                marginBottom: 'var(--space-1)',
               }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front-end/src/screens/DeviceListScreen.tsx` around lines 14 - 42, The
top-level layout in DeviceListScreen still uses hardcoded pixel spacing instead
of design tokens. Update the wrapping container and header spacing in
DeviceListScreen to use the same spacing tokens already used by EmptyState (for
example the existing var(--space-*) values) instead of raw values like 40px,
48px, 32, 16, and 6. Keep the component structure the same, but make the spacing
consistent with the design system across this screen.

Source: Coding guidelines

openspec/changes/archive/2026-07-06-tv-browser-launch/design.md (1)

106-109: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Не логуйте сирий url у info-події.

У Line 108 ви все ще пишете повний user-provided URL. Це вже піднімалось раніше: query string може містити секрети, а нинішній scrubber закриває лише token= фрагменти, не весь потенційно чутливий URL. Логуйте лише host/origin або scrubbed URL тут, а не покладайтесь на глобальну redaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openspec/changes/archive/2026-07-06-tv-browser-launch/design.md` around lines
106 - 109, The D6 logging note still allows a raw user-provided url to be
emitted at info, which should be removed or sanitized in the design. Update the
logging guidance in the archive design so the entry log uses only safe fields
like correlationId, udn, and host/origin, or explicitly a scrubbed URL, and make
it clear not to rely on logger.ts redaction alone. Use the D6 Logging &
redaction section as the place to adjust this guidance.
openspec/changes/archive/2026-07-04-smart-view-ws-transport/specs/upnp-tv-discovery/spec.md (1)

5-7: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Звузьте Samsung-сигнатуру у pre-fetch фільтрі.

uuid: — загальний UPnP-маркер, тож цей критерій пропускає не-Samsung пристрої й зводить нанівець відсікання шуму до fetch. Залиште лише справді Samsung-специфічний патерн, інакше вимога не дає очікуваного ефекту.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@openspec/changes/archive/2026-07-04-smart-view-ws-transport/specs/upnp-tv-discovery/spec.md`
around lines 5 - 7, The pre-fetch Samsung filter in the SSDP discovery flow is
too broad because the `uuid:` check matches generic UPnP devices; tighten the
header inspection in the discovery orchestrator so only truly Samsung-specific
signatures are accepted before enqueueing a fetch. Update the logic that
examines `USN`, `SERVER`, and `ST` to rely on Samsung-specific markers and keep
the post-fetch `isSamsungTv` check as the fallback for ambiguous cases.
.agents/skills/react-vite-best-practices/rules/build-tree-shaking.md (1)

111-129: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Винесіть optimizeDeps із tree-shaking прикладу.

optimizeDeps — це dev pre-bundling, а не production tree shaking, тож цей блок тут змішує два різні механізми. Лишіть тут тільки build.rollupOptions.treeshake або перенесіть цей фрагмент у dev-dependency-prebundling.md.

Пропонований diff
 export default defineConfig({
   plugins: [react()],
   build: {
     rollupOptions: {
       treeshake: {
         moduleSideEffects: 'no-external',
         propertyReadSideEffects: false,
         tryCatchDeoptimization: false,
       },
     },
   },
-  optimizeDeps: {
-    include: ['lodash-es'],
-  },
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/react-vite-best-practices/rules/build-tree-shaking.md around
lines 111 - 129, The tree-shaking example in the Vite config mixes production
treeshake settings with dev-only dependency pre-bundling. Remove the
optimizeDeps block from this example and keep only the
build.rollupOptions.treeshake configuration in the snippet, or move the
optimizeDeps guidance to the dev-dependency-prebundling documentation. Use the
defineConfig example as the reference point when updating the tree-shaking rule
content.
.agents/skills/react-vite-best-practices/rules/bundle-visualizer.md (1)

106-121: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Додайте мову до fenced-блоку.

``` на лінії 106 ламає MD040; позначте блок як text, щоб приклад був коректним для markdownlint і читався однозначно. Якщо MD031 ще шумітиме, тримайте блок відокремленим порожніми рядками.

Пропонований diff
-```
+```text

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.agents/skills/fastify-best-practices/rules/authentication.md:
- Around line 91-107: The login flow in the authentication examples is exposing
access tokens to the client, which should be avoided in this repo. Update the
`app.post('/auth/login')` handler to stop returning `accessToken` in the
response and instead keep it server-side or place it in an `httpOnly` cookie,
using the same session/cookie approach in the redirect flow that currently
appends `token=`. Refer to `validateCredentials`, `app.jwt.sign`, and the
login/redirect auth handlers when rewriting the examples so they demonstrate
cookie-based or session-based auth only.

In @.agents/skills/fastify-best-practices/rules/cors-security.md:
- Around line 75-78: The CORS example using cors should not present origin: true
together with credentials: true as a general pattern. Update the example to use
an explicit allowlist in the app.register(cors, ...) configuration, or clearly
mark the current setup as a narrow exception for trusted auth-free routes. Keep
the guidance tied to the cors security rule so readers understand when to use
origin and credentials safely.

In @.agents/skills/fastify-best-practices/rules/database.md:
- Around line 234-245: The update logic in the update(id, data) method builds
the SQL SET clause directly from Object.keys(data), which allows arbitrary field
names and can break on empty updates. Replace this with an explicit allowlist of
permitted user columns before constructing the SET clause, and reject or
short-circuit when data has no updatable fields. Keep the fix localized to
update() and its SQL assembly around setClause and app.pg.query.
- Around line 48-63: The /transfer handler in app.post currently commits the
transaction without verifying that both UPDATE statements affected exactly one
row. After each client.query UPDATE inside the try block, check the returned
rowCount and throw an error if it is not 1 so the catch path can perform
ROLLBACK; keep the fix localized to the transfer flow and use the existing
client transaction logic.

In @.agents/skills/fastify-best-practices/rules/decorators.md:
- Around line 154-171: The user service methods are returning the raw database
query result instead of the actual user object, so update the `userService` in
`fastify.decorate` to unwrap `rows[0]` from both `findById()` and `create()`.
Keep the route usage in `app.get('/users/:id', ...)` aligned with that change so
`this.userService.findById(...)` returns the user record directly rather than
the driver wrapper.

In @.agents/skills/fastify-best-practices/rules/error-handling.md:
- Around line 72-83: The validation branch in app.setErrorHandler is returning
Fastify’s raw error.validation array, but the shared httpError contract expects
details to be an array of { field, message }. Update the validation handling in
this error handler to map error.validation into that shape before sending the
400 response, or adjust the httpError schema if you intend to keep the raw
payload; keep the response format consistent across all error branches.

In @.agents/skills/fastify-best-practices/rules/hooks.md:
- Around line 167-177: The preSerialization hook in app.addHook is injecting
_meta into every payload, but this will be stripped or behave inconsistently on
routes that use a response schema. Update the shared response contract used by
the hook so _meta is explicitly included in the relevant schemas, or move the
metadata into a response header instead; reference the preSerialization hook and
the payload transformation logic when making the change.
- Around line 145-159: The transaction lifecycle in the hook setup is misplaced:
committing in onResponse happens after the response is already flushed, so
transaction failures cannot be surfaced to the client. Update the pattern around
app.addHook('preHandler'), app.addHook('onResponse'), and app.addHook('onError')
to commit or rollback before sending the response, using a try/finally wrapper
around the handler or another pre-send hook, while still keeping
request.transaction cleanup tied to the request flow.

In @.agents/skills/fastify-best-practices/rules/logging.md:
- Around line 249-257: In the error handler using app.setErrorHandler and
request.log.error, stop logging raw request.body and request.query because they
may contain sensitive data. Update the logging pattern to keep only safe request
metadata (for example url and method) and, if request payload details are
needed, apply redaction or sanitize them before logging.

In @.agents/skills/fastify-best-practices/rules/performance.md:
- Around line 191-207: The streaming export handler in the app.get('/export')
example writes to reply.raw inside a for-await loop without honoring
backpressure, so replace the direct writes with a pipeline-based approach or
explicitly await the drain event after write returns false. Update the
cursor-to-response flow in this route so db.users.findCursor() is consumed in a
backpressure-aware way, keeping the JSON array framing but preventing buffer
growth on slow clients.

In @.agents/skills/fastify-best-practices/rules/routes.md:
- Around line 350-364: The 405 handling in app.setNotFoundHandler is too narrow
because app.hasRoute is only checking method 'GET', so non-GET routes can still
fall through to 404. Update the logic in this handler to inspect the request
path without the query string and check across all supported methods, using
app.hasRoute with the route’s path and method detection rather than a hardcoded
GET, so the correct 405 response is returned whenever the path exists under a
different method.

In @.agents/skills/fastify-best-practices/rules/schemas.md:
- Around line 101-114: The schema registration is putting $id into the object
shape instead of the TypeBox schema options, so references like ErrorResponse#
and PaginationQuery# will not resolve correctly. Update the schema definitions
around ErrorResponse and PaginationQuery to pass $id through the Type.Object
options, then register those schemas directly with app.addSchema using the named
schema objects rather than reconstructing them with spread syntax.

In @.agents/skills/fastify-best-practices/rules/serialization.md:
- Around line 187-195: The serializerCompiler example uses fastJson(schema)
without importing or defining it, so make the snippet self-contained by adding
the fast-json-stringify import in the example and using it within
serializerCompiler on the Fastify app setup. Keep the reference consistent with
serializerCompiler and the stringify callback so the sample can run as written.

In @.agents/skills/fastify-best-practices/rules/typescript.md:
- Around line 22-30: The TypeScript startup example is inconsistent with the
documented Node.js version. Update the package.json example in typescript.md so
the start and dev scripts match Node.js 22.6+ behavior by including the required
--experimental-strip-types flag, or revise the documented minimum Node version
to 23.6+ if you want to rely on default type stripping. Refer to the
package.json scripts shown in the example and keep the version guidance aligned
with the command syntax.

In @.agents/skills/fastify-best-practices/rules/websockets.md:
- Around line 265-280: The websocket auth example still accepts a token from
request.query.token, which exposes secrets in the URL. Update the preValidation
logic in the app.get('/ws') example to use only the Authorization header (via
request.headers.authorization) and remove all query-string token handling,
keeping the rest of the verifyToken flow unchanged.

In @.agents/skills/react-vite-best-practices/rules/asset-fonts.md:
- Around line 12-125: The font example in this rule conflicts with the repo’s UI
guideline because it teaches Inter/Google Fonts instead of the required
Montserrat stack. Update the examples in the font asset rule to use Montserrat
everywhere, including the self-hosted `@font-face` declarations, preload example,
and any font-family references, so the guidance matches the front-end standard
in the rule content. Keep the focus on the existing font-loading best practices
while replacing Inter-specific symbols with the Montserrat equivalents.

In @.agents/skills/react-vite-best-practices/rules/asset-image-optimization.md:
- Around line 107-115: Remove the contradictory WebP example setting that uses
lossless mode in the ViteImageOptimizer config, and update the example in
asset-image-optimization.md to show a lossy WebP optimization instead. Keep the
png and jpeg settings as-is, but adjust the webp option so the snippet aligns
with the rule’s intended guidance and the ViteImageOptimizer usage shown in the
diff.

In @.agents/skills/react-vite-best-practices/rules/build-asset-hashing.md:
- Around line 87-105: The express static asset example mixes ESM imports with
__dirname, which is not available in native ESM and will break when copied
as-is. Update the snippet around the app setup and express.static calls to
either use CommonJS consistently or derive the directory using
fileURLToPath(import.meta.url) before joining dist paths, so the example works
in an ESM context.

In @.agents/skills/react-vite-best-practices/rules/build-minification.md:
- Around line 107-112: Add a caution to the property-mangling example in the
build/minification guidance: the mangle.properties configuration with regex /^_/
should be presented as safe only for fully internal objects, and the note should
warn that applying it to JSON/API/localStorage-backed data can break consumer
contracts and runtime behavior. Update the surrounding explanation in the
build-minification rule to explicitly mention this limitation so readers
understand when property mangling should not be used.

In @.agents/skills/react-vite-best-practices/rules/dev-fast-refresh.md:
- Around line 17-83: The Fast Refresh rule is too broad because it treats named
component exports as inherently bad, even though they usually work; narrow the
guidance to mixed component/non-component exports and module-level side effects
only. Update the examples and “Problems” text in dev-fast-refresh to remove the
blanket warning about multiple named component exports, and keep the focus on
cases where component files also export constants/utilities or run top-level
side effects. Preserve references to the relevant examples in the rule so
readers understand when Fast Refresh actually breaks versus when named exports
are acceptable.

In @.agents/skills/react-vite-best-practices/rules/split-route-lazy.md:
- Around line 84-103: The second example in the split-route-lazy rule uses Link
inside NavLink without importing it, so the snippet will not compile. Update the
example’s imports to include Link from the appropriate source alongside the
existing lazy usage, and keep the NavLink/preloadDashboard example otherwise
unchanged.

In @.agents/skills/react-vite-best-practices/SKILL.md:
- Around line 98-101: The Vite config example currently uses __dirname in a way
that breaks in ESM contexts; update the react-vite-best-practices snippet by
removing __dirname or explicitly marking it as CommonJS, and prefer the ESM-safe
pattern using fileURLToPath(import.meta.url) with dirname, consistent with the
existing vite.config.ts example.

In @.claude/commands/opsx/apply.md:
- Around line 10-37: The apply command flow is missing the selected store flag
on subsequent OpenSpec calls, so store-scoped operations can hit the wrong root.
Update the opsx/apply.md workflow to thread `--store <id>` through every
specs/changes command after store selection, including `openspec status`,
`openspec instructions apply`, and any `openspec list`/follow-up calls, using
the same store id discovered in the store-selection step.

In @.claude/commands/opsx/archive.md:
- Around line 10-37: The `/opsx:archive` flow is missing store scoping for
repository reads, so it can inspect the wrong OpenSpec root in store mode.
Update the archive command logic to detect when a store is selected (using the
same store-selection handling used elsewhere) and pass `--store <id>` to the
`openspec list` and `openspec status` calls in this command. Keep the existing
prompt/selection behavior intact, and ensure the `archive` command path
consistently uses the selected store for all spec/change reads.

In @.claude/commands/opsx/explore.md:
- Around line 14-115: The `/opsx:explore` flow is missing store scoping for
OpenSpec reads, so it can query the wrong context when a store is selected.
Update the explore command logic to resolve the store with `openspec store list
--json` and then pass `--store <id>` to the initial `openspec list --json` and
any follow-up `openspec status --change ... --json` calls, keeping the flag on
all spec/change reads just like the other OpenSpec commands.

In @.claude/skills/openspec-apply-change/SKILL.md:
- Around line 30-32: The markdown examples in SKILL.md contain unlabeled fence
blocks, which will keep triggering markdownlint. Update the affected examples in
the skill docs by adding bash to command fences and text to output fences, and
keep the surrounding blank lines around each fenced block. Use the existing
fenced example sections in the document to locate and fix all untagged blocks
consistently.

In @.claude/skills/openspec-archive-change/SKILL.md:
- Around line 74-86: In the archive-change examples within SKILL.md, one shell
fence and the final output fence are missing language tags, which will fail
markdownlint. Update the relevant fenced code blocks in the archive instructions
so the command block uses bash and the summary/output block uses text, keeping
the surrounding archive flow and symbols like changeRoot and
planningHome.changesDir unchanged.

In @.claude/skills/openspec-explore/SKILL.md:
- Around line 56-71: Several fenced examples in SKILL.md are missing language
tags, causing markdownlint MD040/MD031 warnings; update the affected fences to
use the appropriate identifiers consistently (for example, use text for ASCII
diagrams and dialogue examples, and bash for shell commands) so all fenced
blocks in the open-spec guide have explicit languages. Use the existing fenced
sections in the documentation as the targets and keep the content unchanged
while only adding the correct fence labels.

In @.claude/skills/openspec-propose/SKILL.md:
- Around line 23-87: The command examples in the Steps section still omit the
store flag after the Store selection guidance, so update the `openspec
new/status/instructions` examples in `SKILL.md` to consistently include `--store
<id>` wherever the store-selection rule applies. Keep the existing
nearest-local-root behavior only for the no-store case, and make sure the change
is reflected in the step-by-step commands by referencing the same `openspec`
commands already listed.

In @.claude/skills/openspec-sync-specs/SKILL.md:
- Around line 16-35: The openspec sync workflow is missing --store on the
discovery and status steps, so it can target the nearest local root instead of
the selected store. Update the instructions in SKILL.md so the `openspec list
--json` and `openspec status --change "<name>" --json` steps also use the same
store id resolved earlier, and keep the flag consistent with the store-selection
guidance for commands like `list` and `status`.

In `@back-end/src/discovery/description.ts`:
- Around line 27-50: `fetchDescription` currently trusts SSDP-provided
`location` values and reads the full response body, which can enable SSRF/DoS.
Update `fetchDescription` to validate the target host before calling `request`
(for example by allowing only private/local addresses or otherwise explicitly
trusted hosts) and add a maximum response size check before/while consuming
`body.text()`. If broad LAN trust is intentional, document that assumption in
the security/threat-model notes tied to `fetchDescription`, `request`, and
`parseDescriptionXml`.

In `@back-end/src/discovery/registry.test.ts`:
- Around line 98-119: The current registry behavior leaves an old offline UDN
row behind when a new UDN takes over the same IP, so the snapshot can accumulate
duplicate physical-device entries. Update the device registry logic around
createDeviceRegistry()/upsert() and snapshot() to either merge the old offline
record into the new one or evict stale offline rows for the same IP, keeping
only one physical TV row per IP. If this history is intentionally preserved,
clarify that in the test expectations; otherwise adjust the registry to collapse
the offline “ghost” entry before snapshotting.

In `@back-end/src/discovery/ssdp.ts`:
- Around line 67-117: The SSDP transport start flow in start() can leave a
half-initialized instance if client.start() succeeds but notifySocket.bind() or
notifySocket.addMembership() fails. Wrap the startup sequence in try/catch, and
on failure stop the NodeSsdpClient via client?.stop(), clear
client/notifySocket, and reset started so retries or embedded restarts can
recover cleanly. Use the existing start(), client, notifySocket, and started
symbols to locate and update the lifecycle handling.

In `@back-end/src/routes/browser.ts`:
- Around line 53-60: The `request.log.info` call in `browser` is logging the
full user-provided `url`, which can leak unrelated secrets that
`TOKEN_QUERY_FRAGMENT` does not redact. Update the `browser` route logging to
avoid emitting the raw `url` and instead log only safe fields like `host` or
`origin` alongside `correlationId` and `udn`. Keep the
`request.id`/`parsed.host` context for debugging, and ensure no full query
string from the user input is sent to logs.

In `@back-end/src/routes/inputs.test.ts`:
- Around line 1-73: The test setup boilerplate in this spec is duplicated from
the keys route tests, so extract the shared harness into a reusable test helper.
Move `StubSsdpTransport`, `stubMdnsClient`, and the `createApp` bootstrap logic
into a common factory such as `createTestApp()` in a shared test-helpers module,
and have both `inputs.test.ts` and `keys.test.ts` use it while still allowing
each test to inject its own stub transport and receive `{ app, baseUrl, ssdp }`.

In `@back-end/src/routes/keys.ts`:
- Around line 30-40: The session lookup and connected-state guard in the keys
route duplicates the same get-or-ensure + Connected check used elsewhere.
Extract this logic into a shared helper such as ensureConnectedSession(manager,
udn) in the tv module, and have both the keys route and sendKey in tv/volume.ts
call it. Keep the helper responsible for manager.get/ensure, unknown UDN
notFound, and the SessionNotConnected 409 so the behavior stays consistent in
one place.

In `@back-end/src/routes/volume.test.ts`:
- Around line 149-160: The mute route tests are order-dependent because they
assume the session state is set by earlier tests. Update the related tests
around POST /mute and the connected-state scenario to explicitly establish their
own state using the existing connect/disconnect flow before asserting, so each
test is isolated and no longer depends on node:test execution order or file
position.

In `@back-end/src/routes/volume.ts`:
- Around line 31-32: `registerVolumeRoutes` currently uses a factory parameter
(`volume: VolumeModule`) while `keys.ts`/`sessions.ts` resolve dependencies from
`app.discovery?.sessions`; align the dependency-injection pattern across these
route modules for consistency. Update `registerVolumeRoutes` to follow the same
style used by the other routes, or refactor the other routes to match this
factory-based approach, and keep the implementation centered around the
`registerVolumeRoutes` entry point and its Fastify registration flow.

In `@back-end/src/tv/errors.ts`:
- Around line 94-118: `mapWsError` is leaking raw WebSocket close codes and
Smart View event names into `TvError.message`, which then gets exposed through
`toHttpEnvelope`. Update `mapWsError` in `errors.ts` so `TvFailed`,
`TvNotReachable`, and `TvUnknown` use sanitized, user-safe messages instead of
interpolating `signal.code` or `signal.event`. If you still need the raw values
for debugging, keep them out of `message` and log or store them separately; add
or extend tests in `errors.test.ts` to verify the HTTP envelope does not contain
raw WS codes or event names.

In `@back-end/src/tv/keys.ts`:
- Around line 1-12: No code change is needed here: the review comment is
confirming that keys.ts already documents the enum-only key contract and the
AGENTS.md/D1 references correctly, so leave this docstring as-is and keep the
related session-integration.test.ts coverage intact.

In `@back-end/src/tv/session-integration.test.ts`:
- Around line 217-227: The test in test('heartbeat failure that never recovers
ends in Disconnected (retry cap)') relies on a fixed setTimeout sleep before
asserting the session state, which can flake under CI load. Replace that single
hard-coded delay with a short polling loop that repeatedly fetches the session
endpoint until the state becomes Disconnected or a timeout is reached, then keep
the existing assertion and cleanup around fakeTv.setOffline().

In `@docs/requirements.md`:
- Around line 69-72: Update the requirements text to make Smart View WebSocket
the source of truth instead of IP Control. In docs/requirements.md, revise the
TV Connection, Remote Control, and Constraints wording so it consistently
references Smart View WS on port 8001; use the existing requirement IDs/section
titles to locate the affected entries and keep the brief aligned with the same
transport terminology.

In `@front-end/package.json`:
- Line 2: The package name is still using the old mislabeled product identifier,
so update the name field in package.json to match the actual mytv product naming
used elsewhere in the app. Use the package metadata entry as the source of truth
and replace the legacy samsung-ip-controll-ui value with the correct
mytv-aligned package name so the project identity is consistent.

In `@front-end/src/App.css`:
- Around line 1-184: Remove the dead Vite starter stylesheet by deleting the
unused App.css content and any related references, since App.tsx does not import
it and the remaining selectors like .counter, .hero, `#center`, and `#next-steps`
are only legacy scaffolding. Verify the app still uses its intended styling from
the active entry point and that no components depend on these obsolete classes
before removing the file.

In `@front-end/src/App.tsx`:
- Around line 7-13: App currently ignores the loading state from useDevices(),
so DeviceListScreen can show the empty state before the initial sync finishes.
Update App to read and pass loading (or an equivalent sync-complete flag) from
useDevices into DeviceListScreen, and make RemoteScreen/DeviceListScreen
rendering depend on that state so EmptyState is only shown after loading
completes.

In `@front-end/src/data/useVolume.ts`:
- Around line 56-119: The volume WebSocket in useEffect is one-shot and never
reconnects, so muted can freeze after disconnects. Add reconnect handling for
the socket by wiring onclose/onerror (or, better, extracting the existing
reconnect logic from useWsHealth into a shared useReconnectingSocket helper) and
use it here instead of only listening for message events. Keep the initial fetch
logic, but ensure the socket is recreated with backoff and listeners are
reattached after failures.

In `@front-end/src/ds.d.ts`:
- Around line 8-71: The `declare module` blocks in `ds.d.ts` are repeating the
same `import type ...` plus `export const ...: ComponentType<...>` pattern for
each component, so replace the boilerplate with a shared helper/type or
generator-friendly structure instead of manually duplicating 13 nearly identical
declarations. Keep the existing module names like
`@ds/components/core/Button.jsx` and `@ds/components/feedback/Toast.jsx`, but
centralize the typing pattern so new `@ds/**` components can be added with
minimal duplication.

In `@front-end/src/errors/messages.ts`:
- Around line 30-34: The TvUnknown message in messages.ts uses overly technical
wording (“check the back-end log”) that does not match the user-facing tone of
the other error messages. Update the message returned by the TvUnknown case in
the message mapping to use clearer, second-person guidance consistent with
nearby messages such as Tap Connect and Try again, while keeping the warning
tone and the same TvUnknown branch.

In `@front-end/src/screens/InputsModal.tsx`:
- Around line 1-41: The InputsModal changes need explicit visual verification
beyond tests/build output, since a successful build or npm run front:test does
not confirm the modal behaves correctly in the browser. Open InputsModal in the
front-end dev server, click through the input rows, and verify the modal closes
and the disabled/connected states render as expected; if anything is off, adjust
the component behavior in InputsModal/handleRowClick accordingly before merging.
- Around line 14-25: The row click flow in InputsModal is not guarded against
in-flight setInput calls, so a second tap can trigger another request before the
first finishes. Add a local pending state in InputsModal and set it around
handleRowClick so rows are disabled while setInput(entry.id) is pending, then
clear it in finally before calling onClose(). Use the existing InputsModal and
handleRowClick symbols to keep the fix localized.

In `@LOOP.md`:
- Around line 60-62: The fenced block in the commit template needs an explicit
language tag and cleaner surrounding markdown spacing. Update the fenced section
in LOOP.md around the Co-Authored-By example to use the same block but with a
text fence, and ensure the indentation/blank lines around the block match
markdownlint expectations so the template passes lint.

In
`@openspec/changes/archive/2026-07-04-default-port-80/specs/platform-foundation/spec.md`:
- Around line 7-10: Update the `GET /api/health` contract in the `Scenario: API
responds under /api` so it matches the actual response shape from `health.ts`;
the current spec incorrectly narrows the JSON to only `{ "status": "ok" }`.
Adjust the expected body to include the additional `mdns` field returned by the
health route, keeping the scenario aligned with the real `GET /api/health`
behavior.

In `@openspec/changes/archive/2026-07-04-default-port-80/tasks.md`:
- Line 10: The sync step references the wrong change path, so update the task
wording to use the archive location for this change instead of the non-archive
`openspec/changes/default-port-80/...` path. Adjust the instruction in the task
entry that describes syncing `platform-foundation/spec.md` so it points to the
existing archive change directory, preserving the same delta details and
scenarios.

In `@openspec/changes/archive/2026-07-04-mdns-advertisement/tasks.md`:
- Around line 23-24: The verification note mixes two different ports, so make
the mDNS health-check example consistent by using one actual endpoint throughout
or explicitly stating why `3302` differs from the advertised `3000`. Update the
task note in this archive entry so the `curl` example, hostname, and
health-check result all refer to the same port, keeping the `mdns.state`
verification and `mDNS interface change detected; re-advertising` note intact.

In `@openspec/changes/archive/2026-07-04-platform-foundation/design.md`:
- Around line 42-48: The design doc currently describes Pino/Fastify path-based
redaction, but the actual implementation uses recursive formatting to strip
nested AccessToken values. Update the D3 section to match the real behavior in
logger.ts by describing the formatter-based redaction approach rather than
pino.redact, and keep the references to req.id/correlationId and JSON-RPC id so
the logging contract remains clear.

In
`@openspec/changes/archive/2026-07-04-platform-foundation/specs/platform-foundation/spec.md`:
- Around line 45-57: The requirement is too broad because it forbids absolute
hosts everywhere in the front-end change set, including Vite dev-proxy
configuration that must point to localhost. Update the “Front-end uses relative
HTTP paths” requirement in platform-foundation/spec.md to scope the ban only to
runtime HTTP/WebSocket calls in app code, and explicitly exempt vite.config.ts
or other proxy/server configuration; keep the scenarios in terms of client calls
through relative paths while allowing the dev proxy target to remain absolute.

In `@openspec/changes/archive/2026-07-04-remote-control-keys/proposal.md`:
- Line 7: The proposal still references samsung-ip-control-protocol, but this
transport should be aligned with the Smart View D1 wording used in design.md.
Update the command endpoint description in proposal.md to describe dispatching
remoteKeyControl through the Smart View transport/session queue owned by
tv-connection-lifecycle, keeping the request/response behavior unchanged. Use
the existing remoteKeyControl and tv-connection-lifecycle symbols as anchors
when revising the text.

In `@openspec/changes/archive/2026-07-04-tv-connection-lifecycle/tasks.md`:
- Around line 13-14: Update the pairing task in the archive notes so it is not
marked as complete while the real TV flow remains speculative. In the `2.4
Implement pairing` entry, replace the completed status with a
blocked/experimental state or add explicit wording that the production
`connect()` path for Samsung TVs is not yet verified, since `getAccessToken`,
`TvNotSupported`, and the `MYTV_TOKEN_<UDN>` preload path are only
test/speculative fallbacks. Keep the wording aligned with the existing
`connect()`/`getAccessToken` flow description and make sure the task status
reflects the actual implementation maturity.

In `@openspec/changes/archive/2026-07-04-upnp-tv-discovery/design.md`:
- Around line 23-29: Clarify the D1 architecture note so it no longer implies
node-ssdp handles both discovery directions end-to-end. Update the SSDP section
in the design doc referenced by the D1 heading to state that node-ssdp is used
for M-SEARCH/client discovery, while NOTIFY handling is implemented separately
via a raw dgram socket on port 1900. Make the responsibility split explicit so
readers can locate the transport boundary without ambiguity.

In `@openspec/changes/archive/2026-07-05-error-surfacing/tasks.md`:
- Around line 34-35: Step 5.5 is still shown as pending, so the task record
should either be completed with a real manual QA result or explicitly marked as
deferred in the current state notes. Update the task entry in tasks.md and, if
TV/LAN is unavailable, add a brief note in docs/current-state.md referencing the
manual QA gap so the status is not misleading. Use the existing “5.5 Manual QA”
and “docs/current-state.md” references to locate the affected documentation.

In
`@openspec/changes/archive/2026-07-05-input-management/specs/input-management/spec.md`:
- Line 1: The spec markdown starts with a level-2 heading, which triggers the
MD041 lint rule; update the document to begin with a top-level H1 heading before
the existing “ADDED Requirements” section. Make the change in the spec content
itself so the file remains valid Markdown and navigable, using the document’s
main title as the H1.
- Around line 54-57: The acceptance criterion is too narrow because it only
checks InputsModal.tsx, so hard-coded colors in Modal, ListRow, or shared styles
can still slip through. Broaden the scenario in the inputs-management spec to
cover the full inputs flow or replace the grep-based check with an automated
lint/test that validates all relevant inputs UI components and shared styling
symbols.

In `@openspec/changes/archive/2026-07-05-input-management/tasks.md`:
- Around line 4-6: The inputs endpoint is defined under one path in the backend
plan but consumed under a different path in the frontend, which will cause the
initial mount to fail with 404 and duplicate the contract. Align the route so
the same inputs list is served from one canonical path, then update the related
frontend hook and tests to use that single endpoint. Use the existing inputs
route registration in app.ts and the broker wiring in createDevicesBroker as the
places to verify the final contract.

In `@openspec/config.yaml`:
- Around line 4-16: Update the root config in the config’s project/architecture
description to reflect Smart View WebSocket control instead of the old IP
Control JSON-RPC/HTTPS contract. Replace references in the affected config block
to JSON-RPC, Samsung IP Control, HTTPS ports, and request IDs with Smart View WS
details such as a single WS session per TV, ws:// on port 8001,
ms.channel.connect, and token redaction in logs. Use the existing config
sections in openspec/config.yaml as the anchor for the rewrite so future
seed-context points to the correct transport.

In `@openspec/specs/platform-foundation/spec.md`:
- Around line 11-15: The /api/health scenario is too narrow and omits the
existing mdns contract used by diagnostics. Update the spec in the Scenario
under the API health behavior so it references the mdns-related
response/behavior from health handling in health.ts, and make the wording
consistent with mdns-advertisement/spec.md so both specs describe the same
/api/health contract.

In `@openspec/specs/upnp-tv-discovery/spec.md`:
- Around line 92-100: The SSDP pre-filter in the discovery orchestrator is too
broad because the generic `uuid:` check will match most non-Samsung devices and
let them through to fetch. Tighten the header inspection logic around the
Samsung-specific discriminator used before enqueueing description fetches so
only `USN`, `SERVER`, or `ST` values with an actual Samsung signature are
accepted, and keep the `debug`-level drop path for everything else. Update the
behavior described in the SSDP discovery scenario so the fast-path truly blocks
non-Samsung hits before `undici.request` is reached.

In `@scripts/install.sh`:
- Around line 33-37: The `case` in `scripts/install.sh` has a dead pattern
because `linux*` already matches `linux-gnu*`, making the extra alternative
unreachable. Simplify the `OSTYPE` match in the host check by removing the
redundant `linux-gnu*` branch and keep the existing no-op handling in the
`linux*`/Linux path.
- Around line 95-98: `install_prod_deps()` is still running `npm ci --omit=dev`
directly, so `--dry-run` does not stay non-mutating and can change
`back-end/node_modules`; wrap that install call in `run()` so it is skipped in
dry-run mode, and update the help/logging around the non-mutating command list
to remove `npm ci` from what preview mode implies. Use `install_prod_deps`,
`run`, and the install/help output in `scripts/install.sh` to locate the change.

In `@scripts/install.test.bats`:
- Around line 120-135: Add a dry-run test for install_prod_deps, since the
current test only covers the run() helper and not the npm ci path. Update the
tests in scripts/install.test.bats to invoke install_prod_deps with DRY_RUN=1
and verify it goes through run() instead of executing a real npm ci. Use the
install_prod_deps function and the existing DRY-RUN behavior to assert the
command is logged, and consider stubbing npm with a shim that fails if a real
execution happens.

In `@scripts/uninstall.sh`:
- Around line 16-20: The OSTYPE check in uninstall.sh still includes the
redundant linux-gnu* pattern, which is the same host-detection issue as in
install.sh. Update the case statement in the host validation logic to use a
single Linux match that correctly covers Linux OSTYPE values, and keep the die
paths unchanged; use the existing OSTYPE-based switch in the uninstall script to
make the fix consistent with the corresponding installer check.

---

Outside diff comments:
In `@openspec/changes/archive/2026-07-04-tv-connection-lifecycle/tasks.md`:
- Around line 38-43: Adjust the 6.2/6.3 archive status in tasks.md so the
live-smoke entry is not marked fully done unless `Connected` was actually
reached. In the `6.2` and `6.3` bullets, use explicit `partial`/`deferred`
wording and keep the `Connected` outcome separate from the verified transport
and pairing steps; refer to the existing `6.2`, `6.3`, and the `Connected →
Reconnecting → Connected` session tests only as supporting evidence, not as
completion proof.

---

Duplicate comments:
In @.agents/skills/react-vite-best-practices/rules/build-tree-shaking.md:
- Around line 111-129: The tree-shaking example in the Vite config mixes
production treeshake settings with dev-only dependency pre-bundling. Remove the
optimizeDeps block from this example and keep only the
build.rollupOptions.treeshake configuration in the snippet, or move the
optimizeDeps guidance to the dev-dependency-prebundling documentation. Use the
defineConfig example as the reference point when updating the tree-shaking rule
content.

In `@back-end/src/logger.ts`:
- Line 3: SENSITIVE_KEYS matching in logger.ts is still case-sensitive, so
secret fields like Token, AccessToken, or Authorization can slip into logs.
Update the redaction logic used by the logger’s object traversal/redaction path
to normalize keys before checking SENSITIVE_KEYS (for example, compare
lowercased key names) and make sure the same normalization applies recursively
to nested objects and header-like payloads. Use the existing SENSITIVE_KEYS set
and the logging/redaction helper in logger.ts so all log lines are sanitized
consistently regardless of key casing.

In `@back-end/src/mdns.test.ts`:
- Around line 109-127: The current test covers only the synchronous publish()
throw path, but the review asks for the async socket-error path handled via
rawClient.server?.mdns?.on('error', ...). Update the mdns.test.ts coverage
around startMdns to use a stub client shaped like { server: { mdns: { on } } }
that emits an EADDRINUSE error asynchronously, matching the AVAHI_HINT conflict
case. Verify the same handle.state, handle.error, and handle.address assertions
for the async error path, and keep the existing error-state expectations in the
startMdns behavior.

In `@back-end/src/routes/browser.test.ts`:
- Around line 134-210: The browser route tests are missing coverage for
transport-level command rejection after the session is already connected. Add a
case in browser.test.ts around the POST /browser flow that connects first, then
mocks session.enqueue() to reject with TvError, and assert the response maps to
an allowed union error code rather than exposing raw wire details. Also verify
no frame is sent by checking stub.calls does not increase, using the existing
post() helper and the browser route behavior as the reference point.

In `@front-end/src/screens/DeviceListScreen.tsx`:
- Around line 14-42: The top-level layout in DeviceListScreen still uses
hardcoded pixel spacing instead of design tokens. Update the wrapping container
and header spacing in DeviceListScreen to use the same spacing tokens already
used by EmptyState (for example the existing var(--space-*) values) instead of
raw values like 40px, 48px, 32, 16, and 6. Keep the component structure the
same, but make the spacing consistent with the design system across this screen.

In
`@openspec/changes/archive/2026-07-04-smart-view-ws-transport/specs/upnp-tv-discovery/spec.md`:
- Around line 5-7: The pre-fetch Samsung filter in the SSDP discovery flow is
too broad because the `uuid:` check matches generic UPnP devices; tighten the
header inspection in the discovery orchestrator so only truly Samsung-specific
signatures are accepted before enqueueing a fetch. Update the logic that
examines `USN`, `SERVER`, and `ST` to rely on Samsung-specific markers and keep
the post-fetch `isSamsungTv` check as the fallback for ambiguous cases.

In `@openspec/changes/archive/2026-07-06-tv-browser-launch/design.md`:
- Around line 106-109: The D6 logging note still allows a raw user-provided url
to be emitted at info, which should be removed or sanitized in the design.
Update the logging guidance in the archive design so the entry log uses only
safe fields like correlationId, udn, and host/origin, or explicitly a scrubbed
URL, and make it clear not to rely on logger.ts redaction alone. Use the D6
Logging & redaction section as the place to adjust this guidance.

In `@scripts/build.sh`:
- Around line 14-45: The helper logic in build.sh is duplicated with install.sh,
so extract the shared functions need_cmd, check_prereqs, resolve_paths, log, and
die into a reusable scripts/lib.sh and source it from build.sh (and the matching
install/uninstall scripts if needed). Keep the behavior identical by moving the
common implementations without changing their call sites such as check_prereqs
and resolve_paths, so the shared shell tests and bats setup continue to work.

In `@scripts/mytv.service.tmpl`:
- Around line 7-29: The systemd unit in mytv.service.tmpl is missing basic
hardening directives, so update the service section to add NoNewPrivileges,
ProtectSystem, ProtectHome, and PrivateTmp while keeping the existing ExecStart
and environment setup intact. Since the binary already gets cap_net_bind_service
via setcap rather than AmbientCapabilities, these protections can be added
safely without affecting port binding; apply the change directly in the unit
template alongside the current [Service] settings.

In `@scripts/uninstall.sh`:
- Around line 30-35: The unit-existence check in the uninstall flow is wrong
because `systemctl list-unit-files` can succeed even when `mytv.service` is not
present, so the “not registered” branch in the uninstall script is misleading.
Update the logic in the uninstall script around the `mytv.service` handling to
use a real unit-file check such as `[[ -f "$UNIT_PATH" ]]`, or remove the
precheck and make `systemctl disable --now mytv` a best-effort call with errors
suppressed. Keep the existing `log` messages aligned with the actual behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment on lines +84 to +103
```typescript
// ✅ Good: Preload on hover for instant navigation
const Dashboard = lazy(() => import('./pages/Dashboard'))

function NavLink() {
const preloadDashboard = () => {
import('./pages/Dashboard')
}

return (
<Link
to="/dashboard"
onMouseEnter={preloadDashboard}
onFocus={preloadDashboard}
>
Dashboard
</Link>
)
}
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE=".agents/skills/react-vite-best-practices/rules/split-route-lazy.md"

echo "== file stats =="
wc -l "$FILE"

echo "== relevant excerpt =="
sed -n '1,170p' "$FILE" | cat -n

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 4459


Додайте Link до імпорту в другому прикладі. Другий snippet не збереться як є: Link використано, але не імпортовано.

🛠️ Можливий фікс
-import { BrowserRouter, Routes, Route } from 'react-router-dom'
+import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/react-vite-best-practices/rules/split-route-lazy.md around
lines 84 - 103, The second example in the split-route-lazy rule uses Link inside
NavLink without importing it, so the snippet will not compile. Update the
example’s imports to include Link from the appropriate source alongside the
existing lazy usage, and keep the NavLink/preloadDashboard example otherwise
unchanged.

Comment on lines +27 to +50
export async function fetchDescription(
location: string,
options: FetchDescriptionOptions = {},
): Promise<DeviceDescription | null> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const { statusCode, body } = await request(location, {
method: 'GET',
signal: controller.signal,
});
if (statusCode < 200 || statusCode >= 300) {
await body.dump();
return null;
}
const xml = await body.text();
return parseDescriptionXml(xml);
} catch {
return null;
} finally {
clearTimeout(timer);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Files ==\n'
git ls-files | rg '(^|/)(description\.ts|discovery|smart-view-ws-transport|upnp|design\.md|AGENTS\.md)$'

printf '\n== Outline: back-end/src/discovery/description.ts ==\n'
ast-grep outline back-end/src/discovery/description.ts --view expanded || true

printf '\n== Search fetchDescription usage ==\n'
rg -n "fetchDescription\(" back-end || true

printf '\n== Search relevant docs ==\n'
rg -n "SSDP|LOCATION|SSRF|threat model|design\.md|D1|smart-view-ws-transport|upnp-tv-discovery" . || true

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 50409


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== back-end/src/discovery/description.ts ==\n'
sed -n '1,140p' back-end/src/discovery/description.ts

printf '\n== back-end/src/discovery/index.ts (location fetch path) ==\n'
sed -n '70,130p' back-end/src/discovery/index.ts

printf '\n== openspec/specs/upnp-tv-discovery/spec.md relevant lines ==\n'
sed -n '1,140p' openspec/specs/upnp-tv-discovery/spec.md

printf '\n== archived smart-view-ws-transport spec relevant lines ==\n'
sed -n '1,80p' openspec/changes/archive/2026-07-04-smart-view-ws-transport/specs/upnp-tv-discovery/spec.md

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 16683


fetchDescription треба обмежити довірені location-адреси

request(location, ...) бере URL із SSDP без перевірки хоста й читає тіло цілком через body.text(). Будь-який пристрій у LAN може підсунути LOCATION на внутрішній сервіс або дуже велике тіло, тож тут лишається blind SSRF/DoS. Додай перевірку на приватний/локальний хост і ліміт на розмір відповіді; якщо такий рівень довіри до LAN задуманий, зафіксуй це в threat model.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/discovery/description.ts` around lines 27 - 50,
`fetchDescription` currently trusts SSDP-provided `location` values and reads
the full response body, which can enable SSRF/DoS. Update `fetchDescription` to
validate the target host before calling `request` (for example by allowing only
private/local addresses or otherwise explicitly trusted hosts) and add a maximum
response size check before/while consuming `body.text()`. If broad LAN trust is
intentional, document that assumption in the security/threat-model notes tied to
`fetchDescription`, `request`, and `parseDescriptionXml`.

Comment on lines +98 to +119
test('IP-based dedup: once the winner ages offline, a new UDN at the same IP is accepted', () => {
const registry = createDeviceRegistry();

registry.upsert(
{ udn: 'udn-a', name: 'Living Room', model: 'UN65', ip: '10.0.0.42', port: 9197 },
0,
);
registry.markOfflineOlderThan(60_000, 120_000);

const second = registry.upsert(
{ udn: 'udn-b', name: 'Living Room', model: 'UN65', ip: '10.0.0.42', port: 9119 },
120_000,
);
assert.ok(second, 'a new UDN may take over an IP once the previous holder is offline');

const snap = registry.snapshot();
assert.equal(snap.length, 2);
const a = snap.find((d) => d.udn === 'udn-a')!;
const b = snap.find((d) => d.udn === 'udn-b')!;
assert.equal(a.status, 'offline');
assert.equal(b.status, 'online');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔵 Trivial

Перевір: чи офлайн-рядки при зміні UDN на тій самій IP колись зливаються/видаляються?

Тест підтверджує, що основний "collapse" сценарій (два живі UDN на одній IP → одна запис) працює правильно й відповідає house rule "discovery should collapse multiple UPnP UDNs that share one IP into a single physical TV row". Але коли перший власник IP іде в офлайн і нова IP займається іншим UDN, snapshot зберігає ОБИДВА рядки (line 114: assert.equal(snap.length, 2)) — старий залишається офлайн назавжди. Немає доступу до registry.ts, щоб перевірити, чи є десь merge/eviction для таких історичних записів — якщо ні, UI пристроїв з часом накопичуватиме "привидів" для тієї самої фізичної IP.

Чи це свідомий продуктовий вибір (історія пристроїв), чи варто зливати/чистити такі записи?

Based on coding guidelines: "discovery should collapse multiple UPnP UDNs that share one IP into a single physical TV row."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/discovery/registry.test.ts` around lines 98 - 119, The current
registry behavior leaves an old offline UDN row behind when a new UDN takes over
the same IP, so the snapshot can accumulate duplicate physical-device entries.
Update the device registry logic around createDeviceRegistry()/upsert() and
snapshot() to either merge the old offline record into the new one or evict
stale offline rows for the same IP, keeping only one physical TV row per IP. If
this history is intentionally preserved, clarify that in the test expectations;
otherwise adjust the registry to collapse the offline “ghost” entry before
snapshotting.

Comment on lines +67 to +117
async function start(): Promise<void> {
if (started) return;
started = true;

client = new NodeSsdpClient({ reuseAddr: true });
client.on('response', (headers) => {
const location = typeof headers.LOCATION === 'string' ? headers.LOCATION : '';
const st = typeof headers.ST === 'string' ? headers.ST : '';
const usn = typeof headers.USN === 'string' ? headers.USN : '';
const server = typeof headers.SERVER === 'string' ? headers.SERVER : undefined;
handleHit({
location,
st,
usn,
...(server !== undefined && { server }),
source: 'msearch',
});
});
await client.start();
logger.info('SSDP client started');

notifySocket = createSocket({ type: 'udp4', reuseAddr: true });
notifySocket.on('error', (err) => {
logger.warn(
{ err, port: SSDP_PORT },
'SSDP NOTIFY listener socket error',
);
});
notifySocket.on('message', (msg) => {
const parsed = parseNotify(msg.toString('utf8'));
if (!parsed) return;
handleHit({ ...parsed, source: 'notify' });
});

await new Promise<void>((resolve, reject) => {
notifySocket!.bind(SSDP_PORT, () => {
try {
notifySocket!.addMembership(SSDP_MULTICAST_IP);
notifySocket!.unref();
logger.info(
{ port: SSDP_PORT, group: SSDP_MULTICAST_IP },
'SSDP NOTIFY listener joined multicast group',
);
resolve();
} catch (err) {
reject(err);
}
});
notifySocket!.once('error', reject);
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and nearby symbols first.
ast-grep outline back-end/src/discovery/ssdp.ts --view expanded

# Read the full file with line numbers.
wc -l back-end/src/discovery/ssdp.ts
cat -n back-end/src/discovery/ssdp.ts | sed -n '1,240p'

# Find stop/cleanup logic and any callers of start().
rg -n "async function start|function stop|client\.stop|notifySocket|createSocket|NodeSsdpClient|started =" back-end/src/discovery/ssdp.ts back-end/src/discovery -g '*.ts' -g '*.js'

# Check whether start() is paired with external cleanup assumptions.
rg -n "\.start\(\)|\.stop\(\)|ssdp" back-end/src -g '*.ts' -g '*.js'

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 15052


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the discovery startup/teardown flow.
ast-grep outline back-end/src/discovery/index.ts --view expanded
wc -l back-end/src/discovery/index.ts
cat -n back-end/src/discovery/index.ts | sed -n '1,260p'

# Inspect app lifecycle around discovery startup failures.
ast-grep outline back-end/src/app.ts --view expanded
wc -l back-end/src/app.ts
cat -n back-end/src/app.ts | sed -n '120,240p'

# Look for tests around SSDP transport start/stop behavior.
wc -l back-end/src/discovery/discovery.test.ts
cat -n back-end/src/discovery/discovery.test.ts | sed -n '1,260p'

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 21822


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the server entrypoint and inspect startup error handling.
git ls-files 'back-end/src/*' 'back-end/*.ts' | rg 'main|server|index|app\.ts$'
rg -n "listen\(|app\.listen|close\(\)|catch \(.*listen|onReady" back-end/src back-end -g '*.ts' -g '*.js'

# Read the likely entrypoint(s) if present.
for f in back-end/src/main.ts back-end/src/server.ts back-end/src/index.ts; do
  if [ -f "$f" ]; then
    echo "### $f"
    wc -l "$f"
    cat -n "$f" | sed -n '1,240p'
  fi
done

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 11260


🌐 Web query:

Fastify onReady failure onClose hooks listen rejects cleanup startup failure

💡 Result:

In Fastify, the startup and shutdown lifecycles are distinct processes, and failures during startup do not automatically trigger onClose hooks unless the server has reached a state where they are registered and effectively invoked [1][2]. Startup Lifecycle and Failure The onReady hooks are triggered before the server begins listening [1]. If an error occurs during the execution of onReady hooks or other startup procedures (such as plugin registration), the startup process is halted, and the error is propagated to the caller of.ready or the.listen promise [1][3][4]. Because the server has not yet successfully entered the listening state, it is generally not considered fully booted, and the standard graceful shutdown sequence—which includes the execution of onClose hooks—is not automatically initiated as a recovery mechanism for a failed startup [1][2]. onClose Lifecycle The onClose hooks are specifically designed for the graceful shutdown sequence, which is triggered when fastify.close is explicitly called [1][5][2]. At this stage: 1. The server stops accepting new requests (and may reject them with 503 if configured) [1][2]. 2. In-flight requests are allowed to complete [5][2]. 3. Connection draining occurs [2]. 4. Finally, onClose hooks execute to allow for the release of resources like database connections [1][2]. Handling Cleanup on Startup Failure If a startup failure occurs (e.g., a plugin fails to connect to a database), you must manually ensure that any partially initialized resources are cleaned up [4]. Fastify does not implicitly track and revert the state of all plugins if a startup hook fails. Developers are encouraged to use proper error handling during registration (e.g., using.after or checking the promise returned by register) to catch these failures early and perform necessary cleanup actions before the application exits or retries [4]. Regarding listen and lifecycle state, if.listen is called and fails, the promise is rejected [6]. It is important to note that if a listen attempt fails, Fastify may not implicitly clear internal state associated with that failed attempt, which can lead to complications if multiple listen attempts are made [7][6]. Always ensure that resources are managed cleanly within your plugin or application logic to avoid dangling connections or unfinished cleanup tasks [4].

Citations:


Почисти частковий старт SSDP-транспорту back-end/src/discovery/ssdp.ts:67-117
Якщо client.start() уже спрацював, а bind() або addMembership() падає, started лишається true, а клієнт — без зупинки. Додай try/catch з client?.stop() і скиданням полів, щоб не лишати напівзапущений transport у retry/embedded сценаріях.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/discovery/ssdp.ts` around lines 67 - 117, The SSDP transport
start flow in start() can leave a half-initialized instance if client.start()
succeeds but notifySocket.bind() or notifySocket.addMembership() fails. Wrap the
startup sequence in try/catch, and on failure stop the NodeSsdpClient via
client?.stop(), clear client/notifySocket, and reset started so retries or
embedded restarts can recover cleanly. Use the existing start(), client,
notifySocket, and started symbols to locate and update the lifecycle handling.

Comment on lines +53 to +60
// Log the full URL alongside host — `logger.ts`'s TOKEN_QUERY_FRAGMENT
// regex redacts any `?token=…` / `&token=…` fragment before it hits
// the sink, so a user-pasted URL that carries a session token
// won't leak.
request.log.info(
{ correlationId: request.id, udn, url, host: parsed.host },
'browser launch',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Логування повного URL — потенційний витік чужих секретів у логи.

Регулярка TOKEN_QUERY_FRAGMENT у logger.ts вирізає лише token=/&token= — вона захищає саме pairing-токен TV. Але сюди прилітає довільний URL, який вставляє користувач: якщо в ньому є query-параметри іншого сервісу (session id, auth code, api key тощо), вони підуть у лог без редагування. Варто логувати лише host/origin, а не повний url, якщо повний рядок не є критичним для діагностики.

🔒 Пропозиція
       request.log.info(
-        { correlationId: request.id, udn, url, host: parsed.host },
+        { correlationId: request.id, udn, host: parsed.host, path: parsed.pathname },
         'browser launch',
       );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Log the full URL alongside host — `logger.ts`'s TOKEN_QUERY_FRAGMENT
// regex redacts any `?token=…` / `&token=…` fragment before it hits
// the sink, so a user-pasted URL that carries a session token
// won't leak.
request.log.info(
{ correlationId: request.id, udn, url, host: parsed.host },
'browser launch',
);
// Log the full URL alongside host — `logger.ts`'s TOKEN_QUERY_FRAGMENT
// regex redacts any `?token=…` / `&token=…` fragment before it hits
// the sink, so a user-pasted URL that carries a session token
// won't leak.
request.log.info(
{ correlationId: request.id, udn, host: parsed.host, path: parsed.pathname },
'browser launch',
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@back-end/src/routes/browser.ts` around lines 53 - 60, The `request.log.info`
call in `browser` is logging the full user-provided `url`, which can leak
unrelated secrets that `TOKEN_QUERY_FRAGMENT` does not redact. Update the
`browser` route logging to avoid emitting the raw `url` and instead log only
safe fields like `host` or `origin` alongside `correlationId` and `udn`. Keep
the `request.id`/`parsed.host` context for debugging, and ensure no full query
string from the user input is sent to logs.

Source: Coding guidelines

Comment on lines +92 to +100
Before enqueueing an SSDP hit for a description-XML fetch, the discovery orchestrator SHALL inspect the SSDP headers (`USN`, `SERVER`, `ST`) and drop the hit at level `debug` if none of them contain a Samsung signature — either the case-insensitive substring `samsung` or a Samsung UUID prefix pattern (`uuid:` prefix used by Samsung Tizen TVs). Post-fetch `isSamsungTv` remains as a defensive backstop for edge-case firmware that hides the vendor in headers but exposes it in the description XML.

The intent is efficiency, not correctness: on a busy LAN with dozens of non-Samsung UPnP devices (Sonos speakers, printers, Chromecasts) each SSDP hit currently rides through an `undici` fetch + XML parse before being rejected. The pre-filter drops these before we ever touch the network.

#### Scenario: Non-Samsung SSDP hit is dropped before description fetch

- **WHEN** an SSDP `M-SEARCH` response or `NOTIFY` arrives with headers containing no Samsung signature (e.g. `USN: uuid:5f9e…::urn:schemas-upnp-org:device:MediaRenderer:1`, `SERVER: Linux/… IpBridge/1.…`)
- **THEN** the discovery orchestrator does NOT enqueue a description fetch for that hit, no `undici.request` runs for its `LOCATION`, and a `debug`-level log line records the drop reason

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Приберіть занадто широкий uuid:-критерій.

Гілка з UUID майже не відсікає шум: більшість SSDP USN починаються саме так, тож non-Samsung відповіді все одно підуть у fetch і зламають fast-path. Якщо мета — економити undici/XML, тут потрібен Samsung-специфічний discriminator.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openspec/specs/upnp-tv-discovery/spec.md` around lines 92 - 100, The SSDP
pre-filter in the discovery orchestrator is too broad because the generic
`uuid:` check will match most non-Samsung devices and let them through to fetch.
Tighten the header inspection logic around the Samsung-specific discriminator
used before enqueueing description fetches so only `USN`, `SERVER`, or `ST`
values with an actual Samsung signature are accepted, and keep the `debug`-level
drop path for everything else. Update the behavior described in the SSDP
discovery scenario so the fast-path truly blocks non-Samsung hits before
`undici.request` is reached.

Comment thread scripts/install.sh
Comment on lines +33 to +37
case "${OSTYPE:-}" in
darwin*) die "unsupported host: this script requires systemd-based Linux (detected macOS via OSTYPE=$OSTYPE)" ;;
linux*|linux-gnu*) : ;;
*) die "unsupported host: this script requires systemd-based Linux (OSTYPE=${OSTYPE:-unknown})" ;;
esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Мертвий патерн у case: linux-gnu* ніколи не спрацює.

linux* вже покриває linux-gnu*, тож друга альтернатива в тому ж пункті недосяжна (shellcheck SC2221/SC2222). Функціонально нешкідливо (обидва варіанти роблять : ), але варто прибрати зайве.

🧹 Пропоноване спрощення
-    linux*|linux-gnu*) : ;;
+    linux*) : ;;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case "${OSTYPE:-}" in
darwin*) die "unsupported host: this script requires systemd-based Linux (detected macOS via OSTYPE=$OSTYPE)" ;;
linux*|linux-gnu*) : ;;
*) die "unsupported host: this script requires systemd-based Linux (OSTYPE=${OSTYPE:-unknown})" ;;
esac
case "${OSTYPE:-}" in
darwin*) die "unsupported host: this script requires systemd-based Linux (detected macOS via OSTYPE=$OSTYPE)" ;;
linux*) : ;;
*) die "unsupported host: this script requires systemd-based Linux (OSTYPE=${OSTYPE:-unknown})" ;;
esac
🧰 Tools
🪛 Shellcheck (0.11.0)

[warning] 35-35: This pattern always overrides a later one on line 35.

(SC2221)


[warning] 35-35: This pattern never matches because of a previous pattern on line 35.

(SC2222)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install.sh` around lines 33 - 37, The `case` in `scripts/install.sh`
has a dead pattern because `linux*` already matches `linux-gnu*`, making the
extra alternative unreachable. Simplify the `OSTYPE` match in the host check by
removing the redundant `linux-gnu*` branch and keep the existing no-op handling
in the `linux*`/Linux path.

Source: Linters/SAST tools

Comment thread scripts/install.sh
Comment on lines +95 to +98
install_prod_deps() {
log "installing production runtime deps (npm ci --omit=dev in back-end/)"
npm ci --omit=dev --prefix "$REPO_ROOT/back-end"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File map ==\n'
git ls-files scripts/install.sh

printf '\n== Relevant sections of scripts/install.sh ==\n'
wc -l scripts/install.sh
sed -n '1,140p' scripts/install.sh
printf '\n--- help section ---\n'
sed -n '190,245p' scripts/install.sh
printf '\n--- search for run()/dry-run ---\n'
rg -n '^\s*(run|install_prod_deps|dry-run|mutating|non-mutating)\b|npm ci --omit=dev|verify_artifacts' scripts/install.sh

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 7273


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the file in smaller slices to avoid noise.
nl -ba scripts/install.sh | sed -n '1,130p'
printf '\n---\n'
nl -ba scripts/install.sh | sed -n '190,245p'
printf '\n--- grep ---\n'
grep -nE '^(run|install_prod_deps|dry-run|verify_artifacts)|npm ci --omit=dev|non-mutating|mutating' scripts/install.sh

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 231


scripts/install.sh:95-98, 216-224 — --dry-run має пропускати npm ci --omit=dev. Зараз install_prod_deps() виконує його напряму, тож preview все одно перевстановлює back-end/node_modules і прибирає devDependencies. Обгорни цей виклик у run() і прибери npm ci зі списку non-mutating у help/логах.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install.sh` around lines 95 - 98, `install_prod_deps()` is still
running `npm ci --omit=dev` directly, so `--dry-run` does not stay non-mutating
and can change `back-end/node_modules`; wrap that install call in `run()` so it
is skipped in dry-run mode, and update the help/logging around the non-mutating
command list to remove `npm ci` from what preview mode implies. Use
`install_prod_deps`, `run`, and the install/help output in `scripts/install.sh`
to locate the change.

Comment thread scripts/install.test.bats
Comment on lines +120 to +135
# --- 4.7: --dry-run prints mutating commands without executing them ------

@test "dry-run flag records mutating commands via run() without executing them" {
source_install
DRY_RUN=1
run run useradd --system --shell /usr/sbin/nologin mytv
[ "$status" -eq 0 ]
[[ "$output" == *"[DRY-RUN]"* ]]
[[ "$output" == *"useradd"* ]]
# And confirm the same for a systemctl invocation.
run run systemctl daemon-reload
[ "$status" -eq 0 ]
[[ "$output" == *"[DRY-RUN]"* ]]
[[ "$output" == *"systemctl daemon-reload"* ]]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Варто додати тест на install_prod_deps під --dry-run.

Тест 4.7 перевіряє лише сам хелпер run(), але не факт, що install_prod_deps ним користується. Якщо застосуєте фікс із scripts/install.sh (обгортання npm ci через run), додайте тест, що при DRY_RUN=1 виклик install_prod_deps не виконує реальний npm ci (наприклад, підмінити npm шимом, який фейлить за спробою реального виконання).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install.test.bats` around lines 120 - 135, Add a dry-run test for
install_prod_deps, since the current test only covers the run() helper and not
the npm ci path. Update the tests in scripts/install.test.bats to invoke
install_prod_deps with DRY_RUN=1 and verify it goes through run() instead of
executing a real npm ci. Use the install_prod_deps function and the existing
DRY-RUN behavior to assert the command is logged, and consider stubbing npm with
a shim that fails if a real execution happens.

Comment thread scripts/uninstall.sh
Comment on lines +16 to +20
case "${OSTYPE:-}" in
darwin*) die "unsupported host: this script requires systemd-based Linux (detected macOS via OSTYPE=$OSTYPE)" ;;
linux*|linux-gnu*) : ;;
*) die "unsupported host: this script requires systemd-based Linux (OSTYPE=${OSTYPE:-unknown})" ;;
esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Той самий мертвий linux-gnu* патерн, що і в install.sh.

🧰 Tools
🪛 Shellcheck (0.11.0)

[warning] 18-18: This pattern always overrides a later one on line 18.

(SC2221)


[warning] 18-18: This pattern never matches because of a previous pattern on line 18.

(SC2222)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/uninstall.sh` around lines 16 - 20, The OSTYPE check in uninstall.sh
still includes the redundant linux-gnu* pattern, which is the same
host-detection issue as in install.sh. Update the case statement in the host
validation logic to use a single Linux match that correctly covers Linux OSTYPE
values, and keep the die paths unchanged; use the existing OSTYPE-based switch
in the uninstall script to make the fix consistent with the corresponding
installer check.

Source: Linters/SAST tools

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

Comment on lines +91 to +107
app.post('/auth/login', async (request, reply) => {
const { email, password } = request.body;
const user = await validateCredentials(email, password);

if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' });
}

const accessToken = app.jwt.sign({ id: user.id, role: user.role });
const refreshToken = randomBytes(32).toString('hex');

refreshTokens.set(refreshToken, {
userId: user.id,
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
});

return { accessToken, refreshToken };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file structure first
ast-grep outline .agents/skills/fastify-best-practices/rules/authentication.md --view expanded || true

# Read the relevant sections with line numbers
sed -n '1,220p' .agents/skills/fastify-best-practices/rules/authentication.md | cat -n

# Find all token-related mentions in the file
rg -n -C 3 -i 'token|jwt|refresh|cookie|http-only|http only|bearer|accesstoken' .agents/skills/fastify-best-practices/rules/authentication.md

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 13182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for repo-wide policy references around AccessToken / frontend exposure
rg -n -C 3 -i 'AccessToken|access token|bearer token|frontend|httpOnly|http-only|token.*frontend|frontend.*token' .

# Inspect nearby auth guidance files if present
fd -t f -i 'authentication.md|auth*.md|security*.md|jwt*.md' .

# Read any likely policy/docs files that mention token handling (first 250 lines max each)
for f in $(fd -t f -i 'authentication.md|auth*.md|security*.md|jwt*.md' .); do
  echo "===== $f ====="
  sed -n '1,250p' "$f" | cat -n
done

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 50410


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find actual policy docs in the repository root and nearby
fd -a -t f 'AGENTS.md|agents.md|DESIGN.md|README.md|*.md' .

# Search for the exact "AccessToken never leaves the back-end" rule outside archived specs
rg -n -C 2 'AccessToken never leaves the back-end|never leaves the back-end|AccessToken' . \
  --glob '!openspec/changes/archive/**'

# Inspect the auth skill file location and any surrounding guidance files in .agents
fd -a -t f .agents

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 555


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=".agents/skills/fastify-best-practices/rules/authentication.md"

# Show the sections around the cited lines
sed -n '85,115p' "$file" | cat -n
echo
sed -n '320,340p' "$file" | cat -n
echo
sed -n '470,490p' "$file" | cat -n

# Narrow search for token-returning examples in this file
rg -n -C 2 'return \{ token \}|return \{ accessToken, refreshToken \}|redirect\(/auth/success\?token=|httpOnly: true' "$file"

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 3205


Не віддавайте AccessToken у клієнтські відповіді.
У 91-107 і 330-335 приклади повертають токен браузеру ({ accessToken, refreshToken } та token= в redirect URL). Для цього репо токен має лишатися server-side або йти в httpOnly cookie; тут краще показати session/cookie flow замість SPA-експозиції.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/authentication.md around lines
91 - 107, The login flow in the authentication examples is exposing access
tokens to the client, which should be avoided in this repo. Update the
`app.post('/auth/login')` handler to stop returning `accessToken` in the
response and instead keep it server-side or place it in an `httpOnly` cookie,
using the same session/cookie approach in the redirect flow that currently
appends `token=`. Refer to `validateCredentials`, `app.jwt.sign`, and the
login/redirect auth handlers when rewriting the examples so they demonstrate
cookie-based or session-based auth only.

Comment on lines +75 to +78
app.register(cors, {
origin: true, // Reflect request origin
credentials: true,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files '.agents/skills/fastify-best-practices/rules/cors-security.md' \
  '.agents/skills/fastify-best-practices/rules/*' \
  | sed 's#^`#FILE` #'

echo '--- cors-security.md (excerpt) ---'
wc -l .agents/skills/fastify-best-practices/rules/cors-security.md
sed -n '1,220p' .agents/skills/fastify-best-practices/rules/cors-security.md

echo '--- search for cors / credentials / origin guidance ---'
rg -n "cors|credentials|origin: true|allowlist|dev-only|credentialed" .agents/skills/fastify-best-practices/rules -S

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 9095


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('.agents/skills/fastify-best-practices/rules/cors-security.md')
text = p.read_text(encoding='utf-8')
for needle in [
    'origin: true',
    'credentials: true',
    'dev-only',
    'allowlist',
    'production',
    'local',
]:
    print(f'== {needle} ==')
    for i, line in enumerate(text.splitlines(), 1):
        if needle in line:
            start = max(1, i-4)
            end = min(len(text.splitlines()), i+4)
            for j in range(start, end+1):
                print(f'{j:4d}: {text.splitlines()[j-1]}')
            print()
PY

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 2521


Не показуйте origin: true разом із credentials: true як загальний приклад.
Це відкриває credentialed-запити з будь-якого походження; для такого гіда краще показати явний allowlist або явно позначити кейс як виняток для довірених, auth-free роутів.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/cors-security.md around lines 75
- 78, The CORS example using cors should not present origin: true together with
credentials: true as a general pattern. Update the example to use an explicit
allowlist in the app.register(cors, ...) configuration, or clearly mark the
current setup as a narrow exception for trusted auth-free routes. Keep the
guidance tied to the cors security rule so readers understand when to use origin
and credentials safely.

Comment on lines +48 to +63
app.post('/transfer', async (request) => {
const { fromId, toId, amount } = request.body;
const client = await app.pg.connect();

try {
await client.query('BEGIN');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, fromId],
);
await client.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, toId],
);
await client.query('COMMIT');
return { success: true };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=".agents/skills/fastify-best-practices/rules/database.md"

# Show the relevant ranges with line numbers
sed -n '1,120p' "$file" | nl -ba | sed -n '40,80p'
echo "----"
sed -n '220,270p' "$file" | nl -ba | sed -n '220,270p'

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 231


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=".agents/skills/fastify-best-practices/rules/database.md"

# Map structure first, then inspect surrounding examples more precisely if needed.
ast-grep outline "$file" --view expanded || true

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 261


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=".agents/skills/fastify-best-practices/rules/database.md"

# Search for transaction and dynamic-update examples to inspect precise behavior.
rg -n "transfer|UPDATE accounts|rowCount|COMMIT|ROLLBACK|identifier|dynamic" "$file"

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 573


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=".agents/skills/fastify-best-practices/rules/database.md"

echo "== lines around 48-63 =="
sed -n '48,63p' "$file" | cat -n
echo

echo "== lines around 234-245 =="
sed -n '234,245p' "$file" | cat -n
echo

echo "== quick grep for transfer / dynamic SQL markers =="
rg -n "transfer|rowCount|ROLLBACK|COMMIT|identifier|sql" "$file"

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 1866


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=".agents/skills/fastify-best-practices/rules/database.md"

sed -n '60,70p' "$file" | cat -n

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 478


Перевіряйте rowCount після кожного UPDATE перед COMMIT. Якщо будь-який запит не зачепив рівно 1 рядок, кидайте помилку, щоб catch зробив ROLLBACK; інакше переказ може зафіксуватися з порушеним інваріантом балансу.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/database.md around lines 48 -
63, The /transfer handler in app.post currently commits the transaction without
verifying that both UPDATE statements affected exactly one row. After each
client.query UPDATE inside the try block, check the returned rowCount and throw
an error if it is not 1 so the catch path can perform ROLLBACK; keep the fix
localized to the transfer flow and use the existing client transaction logic.

Comment on lines +234 to +245
async update(id: string, data: Partial<User>): Promise<User | null> {
const fields = Object.keys(data);
const values = Object.values(data);
const setClause = fields
.map((f, i) => `${f} = $${i + 2}`)
.join(', ');

const { rows } = await app.pg.query(
`UPDATE users SET ${setClause} WHERE id = $1 RETURNING *`,
[id, ...values],
);
return rows[0] || null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and inspect the relevant range.
git ls-files .agents/skills/fastify-best-practices/rules/database.md
wc -l .agents/skills/fastify-best-practices/rules/database.md
sed -n '220,260p' .agents/skills/fastify-best-practices/rules/database.md

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 1427


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect nearby context in the same file.
sed -n '200,255p' .agents/skills/fastify-best-practices/rules/database.md

# 2) Use a tiny read-only probe to show the emitted SQL for a safe, malicious, and empty payload.
python3 - <<'PY'
def build_query(data):
    fields = list(data.keys())
    values = list(data.values())
    set_clause = ', '.join(f"{f} = ${i + 2}" for i, f in enumerate(fields))
    query = f"UPDATE users SET {set_clause} WHERE id = $1 RETURNING *"
    return query, [None, *values]

cases = [
    {"name": "Alice"},
    {"name": "Alice", "email = 'x' --": "ignored"},
    {},
]
for case in cases:
    q, params = build_query(case)
    print("CASE:", case)
    print("QUERY:", q)
    print("PARAMS:", params)
    print("---")
PY

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 2049


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether there are any allowlist/validation helpers nearby in the repository.
rg -n "allowlist|whitelist|validate.*field|Object\.keys\(data\)|UPDATE users SET" .agents -S

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 418


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the exact docs section to see whether this is guidance text or production code.
ast-grep outline .agents/skills/fastify-best-practices/rules/database.md || true

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 261


Не збирай SET із довільних ключів — Object.keys(data) тут напряму стає SQL-ідентифікаторами без allowlist, тож можливі ін’єкція через назву поля й поламаний UPDATE ... SET на порожньому data. Залиш явний список дозволених колонок і відхиляй empty update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/database.md around lines 234 -
245, The update logic in the update(id, data) method builds the SQL SET clause
directly from Object.keys(data), which allows arbitrary field names and can
break on empty updates. Replace this with an explicit allowlist of permitted
user columns before constructing the SET clause, and reject or short-circuit
when data has no updatable fields. Keep the fix localized to update() and its
SQL assembly around setClause and app.pg.query.

Comment on lines +154 to +171
const userService = {
findById: (id: string) => fastify.db.query('SELECT * FROM users WHERE id = $1', [id]),
create: (data: CreateUserInput) => fastify.db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[data.name, data.email]
),
};

fastify.decorate('userService', userService);
}, {
dependencies: ['database-plugin'],
});

// Use in routes
app.get('/users/:id', async function (request) {
const user = await this.userService.findById(request.params.id);
return user;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=".agents/skills/fastify-best-practices/rules/decorators.md"

echo "== file info =="
wc -l "$file"
echo

echo "== relevant section (lines 140-185) =="
sed -n '140,185p' "$file" | cat -n
echo

echo "== search for related decorator/type guidance in repository =="
rg -n --hidden -S "FastifyInstance|decorate\('userService'|userService|QueryResult|rows\[0\]|database-plugin" .agents . 2>/dev/null || true

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 11849


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== decorators.md around FastifyInstance augmentation =="
sed -n '50,85p' .agents/skills/fastify-best-practices/rules/decorators.md | cat -n
echo

echo "== decorators.md around the userService example =="
sed -n '145,175p' .agents/skills/fastify-best-practices/rules/decorators.md | cat -n
echo

echo "== decorators.md around later FastifyInstance augmentation =="
sed -n '355,375p' .agents/skills/fastify-best-practices/rules/decorators.md | cat -n
echo

echo "== database.md around repository return values =="
sed -n '35,55p' .agents/skills/fastify-best-practices/rules/database.md | cat -n
echo

echo "== database.md around createUserRepository examples =="
sed -n '200,250p' .agents/skills/fastify-best-practices/rules/database.md | cat -n

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 5654


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== search for DatabaseClient definition and query return type =="
rg -n --hidden -S "interface DatabaseClient|type DatabaseClient|class DatabaseClient|query\\(" .agents/skills/fastify-best-practices/rules/database.md .agents/skills/fastify-best-practices/rules/decorators.md | sed -n '1,200p'
echo

echo "== surrounding context in database.md where DatabaseClient is introduced =="
sed -n '1,90p' .agents/skills/fastify-best-practices/rules/database.md | cat -n

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 5266


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== decorators.md around db decorator definition =="
sed -n '120,155p' .agents/skills/fastify-best-practices/rules/decorators.md | cat -n
echo

echo "== decorators.md around the userService example =="
sed -n '145,172p' .agents/skills/fastify-best-practices/rules/decorators.md | cat -n
echo

echo "== any db type/contract mentions in decorators.md =="
rg -n --hidden -S "db:|decorate\\('db'|interface .*db|DatabaseClient|query: async|query\\(" .agents/skills/fastify-best-practices/rules/decorators.md | sed -n '1,200p'

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 2894


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== decorators.md around the Fastify type augmentation =="
sed -n '60,75p' .agents/skills/fastify-best-practices/rules/decorators.md | cat -n
echo

echo "== all DatabaseClient mentions in the fastify guide rules =="
rg -n --hidden -S "DatabaseClient|createDatabaseConnection|interface FastifyInstance|declare module 'fastify'" .agents/skills/fastify-best-practices/rules | sed -n '1,220p'
echo

echo "== decorators.md around asyncService example near line 403 =="
sed -n '395,415p' .agents/skills/fastify-best-practices/rules/decorators.md | cat -n

Repository: AntoniPetrenko/2026-fwdays-agentic-greenfield-task-AP

Length of output: 2997


Поверніть rows[0] із сервісу. Зараз findById() і create() віддають сирий результат запиту, тож приклад маршруту повертає обгортку драйвера замість користувача.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/fastify-best-practices/rules/decorators.md around lines 154 -
171, The user service methods are returning the raw database query result
instead of the actual user object, so update the `userService` in
`fastify.decorate` to unwrap `rows[0]` from both `findById()` and `create()`.
Keep the route usage in `app.get('/users/:id', ...)` aligned with that change so
`this.userService.findById(...)` returns the user record directly rather than
the driver wrapper.

Comment on lines +74 to +86
```bash
mkdir -p "<planningHome.changesDir>/archive"
```

Generate target name using current date: `YYYY-MM-DD-<change-name>`

**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory

```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Додайте мови до shell- та output-fence-ів.

У прикладах архівації один shell-блок і підсумковий output-блок лишаються без мовного тегу, тому markdownlint не пройде. Позначте перший як bash, другий як text.

🛠️ Можлива правка
-```
+```bash
-```
+```text

Also applies to: 99-108

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 74-74: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/skills/openspec-archive-change/SKILL.md around lines 74 - 86, In the
archive-change examples within SKILL.md, one shell fence and the final output
fence are missing language tags, which will fail markdownlint. Update the
relevant fenced code blocks in the archive instructions so the command block
uses bash and the summary/output block uses text, keeping the surrounding
archive flow and symbols like changeRoot and planningHome.changesDir unchanged.

Source: Linters/SAST tools

Comment on lines +56 to +71
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Проставте мови для всіх fenced-прикладів у цій довідці.

Тут змішані ASCII-діаграми, shell-команди й діалоги, але кілька fences досі без мови. Уніфікуйте їх як text або bash, і markdownlint перестане сипати MD040/MD031.

🛠️ Можлива правка
-```
+```text
-```
+```bash

Also applies to: 87-89, 150-170, 173-201, 204-218, 221-247, 262-274

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 56-56: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 56-56: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/skills/openspec-explore/SKILL.md around lines 56 - 71, Several
fenced examples in SKILL.md are missing language tags, causing markdownlint
MD040/MD031 warnings; update the affected fences to use the appropriate
identifiers consistently (for example, use text for ASCII diagrams and dialogue
examples, and bash for shell commands) so all fenced blocks in the open-spec
guide have explicit languages. Use the existing fenced sections in the
documentation as the targets and keep the content unchanged while only adding
the correct fence labels.

Source: Linters/SAST tools

Comment on lines +23 to +87
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.

**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.

**Steps**

1. **If no clear input provided, ask what they want to build**

Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."

From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).

**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.

2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.

3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.

4. **Create artifacts in sequence until apply-ready**

Use the **TodoWrite tool** to track progress through the artifacts.

Loop through artifacts in dependency order (artifacts with no pending dependencies first):

a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"

b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done

c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation

5. **Show final status**
```bash
openspec status --change "<name>"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Додай --store до прикладів команд.

Після блоку Store selection кроки 2–5 все ще викликають openspec ... без флага, тож у multi-store середовищі вони підуть у nearest local root і можуть змінити не той репозиторій.

Пропонований фікс
- openspec new change "<name>"
+ openspec new change "<name>" --store "<id>"

- openspec status --change "<name>" --json
+ openspec status --change "<name>" --json --store "<id>"
🧰 Tools
🪛 LanguageTool

[style] ~23-~23: It’s more common nowadays to write this noun as one word.
Context: ...apply --- Store selection: If the user names a store (a store is a standalone OpenSp...

(RECOMMENDED_COMPOUNDS)


[style] ~29-~29: You have already used this phrasing in nearby sentences. Consider replacing it to add variety to your writing.
Context: ... no clear input provided, ask what they want to build** Use the **AskUserQuestion t...

(REP_WANT_TO_VB)


[style] ~32-~32: You have already used this phrasing in nearby sentences. Consider replacing it to add variety to your writing.
Context: ... you want to work on? Describe what you want to build or fix." From their descripti...

(REP_WANT_TO_VB)


[style] ~36-~36: You have already used this phrasing in nearby sentences. Consider replacing it to add variety to your writing.
Context: ...eed without understanding what the user wants to build. 2. **Create the change director...

(REP_WANT_TO_VB)

🪛 markdownlint-cli2 (0.22.1)

[warning] 39-39: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 41-41: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 45-45: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 47-47: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 61-61: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 63-63: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 86-86: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🪛 SkillSpector (2.3.7)

[error] 81: [MP3] Memory Manipulation: Skill manipulates agent memory, state, or stored context. Memory corruption can alter personality, override safety rules, or cause unpredictable behavior.

Remediation: Protect agent memory and state from modification by untrusted content. Use read-only memory for critical instructions and validate all state changes.

(Memory Poisoning (MP3))

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/skills/openspec-propose/SKILL.md around lines 23 - 87, The command
examples in the Steps section still omit the store flag after the Store
selection guidance, so update the `openspec new/status/instructions` examples in
`SKILL.md` to consistently include `--store <id>` wherever the store-selection
rule applies. Keep the existing nearest-local-root behavior only for the
no-store case, and make sure the change is reflected in the step-by-step
commands by referencing the same `openspec` commands already listed.

Comment on lines +16 to +35
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.

**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.

**Steps**

1. **If no change name provided, prompt for selection**

Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.

Show changes that have delta specs (under `specs/` directory).

**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.

2. **Resolve change context**

Run:
```bash
openspec status --change "<name>" --json
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ті самі приклади теж мають нести --store.

openspec list --json і openspec status --change ... без флага знову підуть у nearest local root, а не в обраний store. Для цього workflow це легко дає зміну не того набору spec-файлів.

Пропонований фікс
- openspec list --json
+ openspec list --json --store "<id>"

- openspec status --change "<name>" --json
+ openspec status --change "<name>" --json --store "<id>"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json --store "<id>"` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
🧰 Tools
🪛 LanguageTool

[style] ~16-~16: It’s more common nowadays to write this noun as one word.
Context: ...uirement). Store selection: If the user names a store (a store is a standalone OpenSp...

(RECOMMENDED_COMPOUNDS)

🪛 markdownlint-cli2 (0.22.1)

[warning] 33-33: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/skills/openspec-sync-specs/SKILL.md around lines 16 - 35, The
openspec sync workflow is missing --store on the discovery and status steps, so
it can target the nearest local root instead of the selected store. Update the
instructions in SKILL.md so the `openspec list --json` and `openspec status
--change "<name>" --json` steps also use the same store id resolved earlier, and
keep the flag consistent with the store-selection guidance for commands like
`list` and `status`.

Comment thread LOOP.md
Comment on lines +60 to +62
```
Co-Authored-By: Claude <noreply@anthropic.com>
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Підправте fenced-блок у шаблоні коміту.

Тут бракує мови fence, і markdownlint ще може сваритися на відступи навколо блоку. Для такого прикладу краще явно ставити text.

Пропонований diff
-     ```
+     ```text
      Co-Authored-By: Claude <noreply@anthropic.com>
-     ```
+     ```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
Co-Authored-By: Claude <noreply@anthropic.com>
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 60-60: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 60-60: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 62-62: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@LOOP.md` around lines 60 - 62, The fenced block in the commit template needs
an explicit language tag and cleaner surrounding markdown spacing. Update the
fenced section in LOOP.md around the Co-Authored-By example to use the same
block but with a text fence, and ensure the indentation/blank lines around the
block match markdownlint expectations so the template passes lint.

Source: Linters/SAST tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants