diff --git a/backlog/features.json b/backlog/features.json index 0fa4683..cfc34af 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -6692,6 +6692,23 @@ "close": true }, "completed_at": "2026-08-22T15:42:35Z" + }, + { + "id": "F-158", + "type": "fix", + "title": "POS use same-origin API proxy on LAN", + "description": "Browser POS requests must use /api proxy instead of localhost:3000; proxy forwards backend cookies and terminal headers.", + "priority": "high", + "risk": "med", + "status": "done", + "created_at": "2026-08-22", + "gates": { + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-22T15:50:54Z" } ] } diff --git a/project/apps/pos/next.config.ts b/project/apps/pos/next.config.ts index dbcda97..bcc7096 100644 --- a/project/apps/pos/next.config.ts +++ b/project/apps/pos/next.config.ts @@ -1,10 +1,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - port: 3006, - env: { - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000', - }, + allowedDevOrigins: ['192.168.18.93', 'localhost'], }; export default nextConfig; diff --git a/project/apps/pos/src/app/(terminal)/layout.tsx b/project/apps/pos/src/app/(terminal)/layout.tsx index 624b81a..c0e3191 100644 --- a/project/apps/pos/src/app/(terminal)/layout.tsx +++ b/project/apps/pos/src/app/(terminal)/layout.tsx @@ -1,7 +1,3 @@ -import type { NextLayout } from 'next'; - -const TerminalLayout: NextLayout = ({ children }: { children: React.ReactNode }) => { +export default function TerminalLayout({ children }: { children: React.ReactNode }) { return <>{children}; -}; - -export default TerminalLayout; +} diff --git a/project/apps/pos/src/app/(terminal)/page.tsx b/project/apps/pos/src/app/(terminal)/page.tsx index 7f3fa94..b1e28aa 100644 --- a/project/apps/pos/src/app/(terminal)/page.tsx +++ b/project/apps/pos/src/app/(terminal)/page.tsx @@ -30,26 +30,65 @@ interface Config { paymentMethods: { id: string; code: string; label: string; kind: string }[]; } +interface Customer { + id: string; + email: string; + firstName?: string; + lastName?: string; +} + export default function RegisterPage() { const [config, setConfig] = useState(null); + const [configError, setConfigError] = useState(''); + const [needsBinding, setNeedsBinding] = useState(false); + const [bindingCode, setBindingCode] = useState(''); + const [binding, setBinding] = useState(false); const [cart, setCart] = useState([]); const [search, setSearch] = useState(''); const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const [selectedItem, setSelectedItem] = useState(null); const [showDiscountPanel, setShowDiscountPanel] = useState(false); - const [customer, setCustomer] = useState<{ id: string; email: string; firstName?: string; lastName?: string } | null>(null); + const [customer, setCustomer] = useState(null); const [showCustomerSearch, setShowCustomerSearch] = useState(false); const [customerQuery, setCustomerQuery] = useState(''); - const [customerResults, setCustomerResults] = useState([]); + const [customerResults, setCustomerResults] = useState([]); const [processing, setProcessing] = useState(false); const [lastSale, setLastSale] = useState<{ orderId: string; totalCents: number } | null>(null); const [error, setError] = useState(''); - useEffect(() => { - posApi.config().then(setConfig).catch(() => setConfig(null)); + const loadConfig = useCallback(async () => { + setConfigError(''); + try { + setConfig(await posApi.config()); + setNeedsBinding(false); + } catch (err: unknown) { + const apiError = err as { code?: string; message?: string }; + setConfig(null); + setNeedsBinding(apiError.code === 'MISSING_TERMINAL_ID'); + setConfigError(apiError.message ?? 'No se pudo cargar la configuración del TPV'); + } }, []); + useEffect(() => { + void loadConfig(); + }, [loadConfig]); + + const bindTerminal = async (event: React.FormEvent) => { + event.preventDefault(); + setBinding(true); + setConfigError(''); + try { + await posApi.bind(bindingCode.trim().toUpperCase()); + setBindingCode(''); + await loadConfig(); + } catch (err: unknown) { + setConfigError(err instanceof Error ? err.message : 'No se pudo vincular el terminal'); + } finally { + setBinding(false); + } + }; + const doSearch = useCallback(async (q: string) => { if (q.trim().length < 2) { setSearchResults([]); return; } setSearching(true); @@ -104,7 +143,7 @@ export default function RegisterPage() { try { const res = await fetch(`/api/pos/customers/search?q=${encodeURIComponent(q)}`, { credentials: 'include' }); if (res.ok) { - const data = await res.json() as { items: typeof customer[] }; + const data = await res.json() as { items: Customer[] }; setCustomerResults(data.items ?? []); } } catch { setCustomerResults([]); } @@ -138,15 +177,56 @@ export default function RegisterPage() { setCart([]); setCustomer(null); setTimeout(() => setLastSale(null), 5000); - } catch (err: { message?: string }) { - setError((err as { message?: string }).message ?? 'Error'); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Error'); } finally { setProcessing(false); } }; + if (!config && needsBinding) { + return ( +
+
+
+

Vincular terminal

+

Introduce el código de 8 caracteres generado en administración.

+
+ setBindingCode(event.target.value.toUpperCase())} + minLength={8} + maxLength={8} + autoComplete="off" + className="w-full rounded-xl border border-gray-300 px-4 py-3 text-center font-mono text-xl tracking-widest outline-none focus:ring-2 focus:ring-[#2D6A4F]" + placeholder="AB12CD34" + required + autoFocus + /> + {configError &&

{configError}

} + +
+
+ ); + } + if (!config) { - return
Cargando TPV…
; + return ( +
+

{configError || 'Cargando TPV…'}

+ {configError && ( + + )} +
+ ); } if (config.session?.status !== 'OPEN') { diff --git a/project/apps/pos/src/app/api/[...path]/route.ts b/project/apps/pos/src/app/api/[...path]/route.ts index 24c0332..2c71dc0 100644 --- a/project/apps/pos/src/app/api/[...path]/route.ts +++ b/project/apps/pos/src/app/api/[...path]/route.ts @@ -1,32 +1,79 @@ import { NextRequest, NextResponse } from 'next/server'; -const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; +const BACKEND_URL = process.env.POS_BACKEND_URL ?? 'http://127.0.0.1:3000'; + +function apiPath(request: NextRequest): string { + return request.nextUrl.pathname.replace(/^\/api\//, ''); +} + +function backendUrl(request: NextRequest): string { + return `${BACKEND_URL}/${apiPath(request)}${request.nextUrl.search}`; +} + +function requestHeaders(request: NextRequest, hasBody = false): Headers { + const headers = new Headers(); + const cookie = request.headers.get('cookie'); + const terminalId = request.headers.get('x-terminal-id') ?? request.cookies.get('pos_terminal_id')?.value; + + if (cookie) headers.set('cookie', cookie); + if (terminalId) headers.set('x-terminal-id', terminalId); + if (hasBody) headers.set('content-type', request.headers.get('content-type') ?? 'application/json'); + + return headers; +} + +function proxyResponse(backendResponse: Response): NextResponse { + const response = new NextResponse(backendResponse.body, { + status: backendResponse.status, + statusText: backendResponse.statusText, + }); + + const contentType = backendResponse.headers.get('content-type'); + const setCookie = backendResponse.headers.get('set-cookie'); + if (contentType) response.headers.set('content-type', contentType); + if (setCookie) response.headers.set('set-cookie', setCookie); + + return response; +} export async function GET(request: NextRequest) { - const path = request.nextUrl.pathname.replace('/api/', ''); - const search = request.nextUrl.search; - const cookie = request.headers.get('cookie') ?? ''; - const terminalId = request.headers.get('x-terminal-id'); - - const headers: Record = { Cookie: cookie }; - if (terminalId) headers['x-terminal-id'] = terminalId; - - const res = await fetch(`${API}/${path}${search}`, { headers, credentials: 'include' }); - const body = await res.text(); - return new NextResponse(body, { status: res.status, headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' } }); + try { + const response = await fetch(backendUrl(request), { + headers: requestHeaders(request), + cache: 'no-store', + }); + return proxyResponse(response); + } catch { + return NextResponse.json({ message: 'Backend no disponible' }, { status: 502 }); + } } export async function POST(request: NextRequest) { - const path = request.nextUrl.pathname.replace('/api/', ''); - const search = request.nextUrl.search; - const cookie = request.headers.get('cookie') ?? ''; - const terminalId = request.headers.get('x-terminal-id'); - const body = await request.text(); + try { + const body = await request.text(); + const response = await fetch(backendUrl(request), { + method: 'POST', + headers: requestHeaders(request, true), + body, + cache: 'no-store', + }); + const bindingPayload = response.ok && apiPath(request) === 'pos/terminals/bind' + ? await response.clone().json() as { terminalId?: string } + : null; + const proxied = proxyResponse(response); - const headers: Record = { 'Content-Type': 'application/json', Cookie: cookie }; - if (terminalId) headers['x-terminal-id'] = terminalId; + if (bindingPayload?.terminalId) { + proxied.cookies.set('pos_terminal_id', bindingPayload.terminalId, { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 365 * 24 * 60 * 60, + path: '/', + }); + } - const res = await fetch(`${API}/${path}${search}`, { method: 'POST', headers, body, credentials: 'include' }); - const resBody = await res.text(); - return new NextResponse(resBody, { status: res.status, headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' } }); + return proxied; + } catch { + return NextResponse.json({ message: 'Backend no disponible' }, { status: 502 }); + } } diff --git a/project/apps/pos/src/lib/api-client.ts b/project/apps/pos/src/lib/api-client.ts index 8d46f29..faaa3e0 100644 --- a/project/apps/pos/src/lib/api-client.ts +++ b/project/apps/pos/src/lib/api-client.ts @@ -1,4 +1,6 @@ -const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; +// Browser requests stay on the POS origin. The Next.js route handler proxies +// `/api/*` to the backend, so LAN clients never resolve their own localhost. +const API = '/api'; async function apiFetch(path: string, init?: RequestInit): Promise { const res = await fetch(`${API}${path}`, { @@ -10,8 +12,14 @@ async function apiFetch(path: string, init?: RequestInit): Promise { }, }); if (!res.ok) { - const err = await res.json().catch(() => ({ message: res.statusText })); - throw Object.assign(new Error((err as { message?: string }).message ?? 'Request failed'), { status: res.status }); + const err = await res.json().catch(() => ({ message: res.statusText })) as { + message?: string; + error?: { message?: string; code?: string }; + }; + throw Object.assign( + new Error(err.error?.message ?? err.message ?? 'Request failed'), + { status: res.status, code: err.error?.code }, + ); } return res.json() as Promise; } @@ -21,9 +29,12 @@ export const posApi = { me: () => apiFetch('/pos/terminals/me'), /** Bind terminal with code. */ bind: (bindingCode: string) => - apiFetch('/pos/terminals/bind', { method: 'POST', body: JSON.stringify({ bindingCode }) }), + apiFetch<{ terminalId: string; storeId: string }>('/pos/terminals/bind', { + method: 'POST', + body: JSON.stringify({ bindingCode }), + }), /** Get POS config (store + terminal + payment methods + session status). */ - config: () => apiFetch('/pos/config'), + config: () => apiFetch('/pos/config'), /** List products by query. */ searchProducts: (q: string, storeId?: string, limit = 20) => apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`), diff --git a/project/apps/pos/src/middleware.ts b/project/apps/pos/src/middleware.ts index 4cf8dfe..f076a16 100644 --- a/project/apps/pos/src/middleware.ts +++ b/project/apps/pos/src/middleware.ts @@ -11,9 +11,13 @@ export function middleware(request: NextRequest) { return NextResponse.next(); } - // Check for backoffice session cookie + // The identity backend issues the mdv_session cookie. Keep legacy names + // accepted during migration, but do not redirect valid current sessions. const cookie = request.headers.get('cookie') ?? ''; - const hasSession = cookie.includes('backoffice_session') || cookie.includes('session_token'); + const hasSession = + cookie.includes('mdv_session=') || + cookie.includes('backoffice_session=') || + cookie.includes('session_token='); if (!hasSession) { return NextResponse.redirect(new URL('/login', request.url)); diff --git a/work/artifacts/F-158/architect.md b/work/artifacts/F-158/architect.md new file mode 100644 index 0000000..255ecdf --- /dev/null +++ b/work/artifacts/F-158/architect.md @@ -0,0 +1,20 @@ +# F-158 — Diseño + +## Decisión +Separar explícitamente URL pública y URL privada: + +- Navegador POS: siempre usa `/api` del mismo origen (`:3006`). +- Route handler Next.js: usa `POS_BACKEND_URL`, server-only, con fallback `http://127.0.0.1:3000`. +- No exponer ni incrustar `NEXT_PUBLIC_API_URL` en el bundle. + +## Proxy +- Construye destino desde el catch-all path y query string. +- Reenvía Cookie, Content-Type y `x-terminal-id`. +- Propaga status, Content-Type y todos los `Set-Cookie` del backend. +- GET y POST cubren las operaciones actuales del cliente POS. + +## Desarrollo LAN +`allowedDevOrigins` incluye `192.168.18.93` y `localhost` para recursos Next/HMR. + +## Seguridad +La URL backend queda server-only. Los paths proceden del segmento catch-all y se concatenan contra una base fija, sin aceptar host suministrado por el cliente. diff --git a/work/artifacts/F-158/documenter.md b/work/artifacts/F-158/documenter.md new file mode 100644 index 0000000..9e747fd --- /dev/null +++ b/work/artifacts/F-158/documenter.md @@ -0,0 +1,14 @@ +# F-158 — Documentación + +## Acceso LAN +El TPV se abre en `http://192.168.18.93:3006`. Todas las llamadas del navegador permanecen en ese origen mediante `/api`; solo el servidor Next accede al backend en `127.0.0.1:3000`. + +## Primera vinculación +Después del login, un dispositivo sin terminal muestra la pantalla **Vincular terminal**. Debe introducir el código de 8 caracteres. La vinculación se guarda en una cookie HttpOnly durante un año. + +## Entorno local actual +- Terminal: `TPV Principal` +- Código de vinculación: `B794401C` +- Backend: puerto 3000 +- Admin: puerto 3004 +- POS: puerto 3006 diff --git a/work/artifacts/F-158/implementer.md b/work/artifacts/F-158/implementer.md new file mode 100644 index 0000000..290e9f2 --- /dev/null +++ b/work/artifacts/F-158/implementer.md @@ -0,0 +1,24 @@ +# F-158 — Implementer + +## Implementación +- El cliente POS usa exclusivamente `/api`, nunca `localhost:3000` desde el navegador. +- El proxy Next usa `POS_BACKEND_URL` server-only con fallback `127.0.0.1:3000`. +- El proxy reenvía sesión, terminal y Content-Type; propaga `Set-Cookie` del login. +- La vinculación guarda `pos_terminal_id` como cookie HttpOnly durante un año y el proxy la traduce a `x-terminal-id`. +- Middleware reconoce la cookie real `mdv_session` además de nombres legacy. +- Añadida pantalla de vinculación inicial de 8 caracteres y estados de error/reintento. +- Corregidos errores TypeScript previos del esqueleto POS (`NextLayout`, tipos de config/customer/catch). +- `allowedDevOrigins` permite `192.168.18.93` y `localhost`. + +## Evidencia +- `cd project/apps/pos && npm run build`: PASS, 4 rutas + middleware. +- `cd project/apps/pos && npx tsc --noEmit`: PASS. +- `cd project && node_modules/.bin/tsc --noEmit`: PASS. +- Login proxy: HTTP 200 + cookie `mdv_session`. +- Bind proxy: HTTP 200 + cookie `pos_terminal_id`. +- Config proxy autenticado/vinculado: HTTP 200 con store, terminal y métodos de pago. +- Servicios persistentes: 3000, 3004 y 3006 escuchando. +- `./scripts/verify.sh`: PASS. + +## Configuración local +Se creó `TPV Principal` para la tienda por defecto. Código de vinculación: `B794401C`. diff --git a/work/artifacts/F-158/leader-close.json b/work/artifacts/F-158/leader-close.json new file mode 100644 index 0000000..f6a3e3c --- /dev/null +++ b/work/artifacts/F-158/leader-close.json @@ -0,0 +1,16 @@ +{ + "feature_id": "F-158", + "agent": "leader", + "stage": "close", + "verdict": "APPROVED", + "summary": "POS LAN usa proxy same-origin y completa login, vinculación y config sin localhost del navegador.", + "checks": [ + { "item": "Reviewer approved", "ok": true }, + { "item": "Security approved", "ok": true }, + { "item": "QA approved", "ok": true }, + { "item": "POS build passed", "ok": true }, + { "item": "Runtime acceptance passed", "ok": true }, + { "item": "verify.sh passed", "ok": true } + ], + "issues": [] +} diff --git a/work/artifacts/F-158/qa.json b/work/artifacts/F-158/qa.json new file mode 100644 index 0000000..25db81a --- /dev/null +++ b/work/artifacts/F-158/qa.json @@ -0,0 +1,16 @@ +{ + "feature_id": "F-158", + "agent": "qa", + "stage": "qa_gate", + "verdict": "APPROVED", + "summary": "Flujo LAN completo validado contra 192.168.18.93:3006.", + "checks": [ + { "item": "Login through POS proxy", "ok": true, "evidence": "HTTP 200 and mdv_session cookie" }, + { "item": "Terminal bind through POS proxy", "ok": true, "evidence": "HTTP 200 and pos_terminal_id cookie" }, + { "item": "POS config through proxy", "ok": true, "evidence": "HTTP 200 with store, terminal and payment methods" }, + { "item": "No direct browser backend request", "ok": true, "evidence": "api-client uses /api" }, + { "item": "Build and types", "ok": true, "evidence": "POS build and both TypeScript checks passed" }, + { "item": "Runtime services", "ok": true, "evidence": "Ports 3000, 3004 and 3006 listening" } + ], + "issues": [] +} diff --git a/work/artifacts/F-158/reviewer.json b/work/artifacts/F-158/reviewer.json new file mode 100644 index 0000000..644c475 --- /dev/null +++ b/work/artifacts/F-158/reviewer.json @@ -0,0 +1,15 @@ +{ + "feature_id": "F-158", + "agent": "reviewer", + "stage": "review_gate", + "verdict": "APPROVED", + "summary": "El POS elimina la dependencia de localhost en navegador y completa login, vinculación y config por proxy same-origin.", + "checks": [ + { "item": "No browser localhost URL", "ok": true, "evidence": "api-client base is /api; source search has no NEXT_PUBLIC_API_URL" }, + { "item": "Proxy contract", "ok": true, "evidence": "Cookies, terminal header, status and content type forwarded" }, + { "item": "Terminal persistence", "ok": true, "evidence": "HttpOnly pos_terminal_id cookie translated to x-terminal-id" }, + { "item": "POS production build", "ok": true, "evidence": "Next.js 15 build passed" }, + { "item": "Runtime flow", "ok": true, "evidence": "login 200, bind 200, config 200 through :3006" } + ], + "issues": [] +} diff --git a/work/artifacts/F-158/security.json b/work/artifacts/F-158/security.json new file mode 100644 index 0000000..ca86091 --- /dev/null +++ b/work/artifacts/F-158/security.json @@ -0,0 +1,14 @@ +{ + "feature_id": "F-158", + "agent": "security", + "stage": "security_gate", + "verdict": "APPROVED", + "summary": "La URL backend queda server-only y las cookies de sesión/terminal se mantienen HttpOnly y same-origin.", + "checks": [ + { "item": "Backend URL not public", "ok": true, "evidence": "POS_BACKEND_URL is only read in server route handler" }, + { "item": "Terminal cookie protection", "ok": true, "evidence": "HttpOnly, SameSite=Lax, Secure in production" }, + { "item": "Fixed upstream host", "ok": true, "evidence": "Client controls path only; upstream base comes from trusted env" }, + { "item": "No broad header forwarding", "ok": true, "evidence": "Only cookie, content-type and x-terminal-id forwarded" } + ], + "issues": [] +} diff --git a/work/current.md b/work/current.md index ae1e3c0..fdfe754 100644 --- a/work/current.md +++ b/work/current.md @@ -1,17 +1,17 @@ -# Feature activa: F-157 — Reporting sections live on Reporting page +# Feature activa: F-158 — POS use same-origin API proxy on LAN ## Problema -El menú principal muestra Dashboard, Ventas y Productos de Reporting como entradas indentadas. El operador requiere una única entrada principal **Reporting**. Al abrirla, sus apartados deben aparecer dentro del área de Reporting, igual que las pestañas internas de Ajustes. +El TPV abierto en `http://192.168.18.93:3006` ejecuta peticiones del navegador contra `http://localhost:3000`. En un cliente LAN, `localhost` apunta al propio cliente y produce `ERR_CONNECTION_REFUSED`. ## Alcance -- El sidebar principal muestra solo `Reporting`. -- `/reporting` abre por defecto el apartado Dashboard. -- Dashboard, Ventas y Productos se muestran como navegación local dentro de Reporting. -- La navegación local permanece visible en las páginas de los tres apartados. -- No se cambian APIs ni permisos de Reporting. +- El cliente POS usa rutas same-origin `/api/...`. +- El route handler de Next.js reenvía las peticiones al backend privado (`127.0.0.1:3000`). +- El proxy conserva cookies de sesión y `x-terminal-id`. +- El login propaga `Set-Cookie` al navegador. +- Next dev permite el origen LAN `192.168.18.93`. ## Aceptación -1. No aparecen `/reporting/dashboard`, `/reporting/sales` ni `/reporting/products` en el menú principal. -2. Al pulsar Reporting aparece una lista local con Dashboard, Ventas y Productos. -3. El apartado activo queda visualmente marcado. -4. El build del admin y `verify.sh` pasan. +1. El navegador no solicita directamente `localhost:3000`. +2. `GET /api/pos/config` llega al backend mediante el proxy. +3. Login conserva la cookie de sesión. +4. POS build, TypeScript y `verify.sh` pasan. diff --git a/work/runtime-status.json b/work/runtime-status.json index 424d810..74058d0 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -1,68 +1,68 @@ { - "feature_id": "F-157", + "feature_id": "F-158", "stage": "close", "agent": "leader", - "action": "Close F-157 after approved gates", + "action": "Close F-158 after LAN acceptance", "state": "running", "next_agent": "leader", "waiting_for": "Seleccionar una feature pending y actualizar este estado", - "updated_at": "2026-08-22T15:42:24Z", + "updated_at": "2026-08-22T15:50:42Z", "timeline": [ { - "ts": "2026-08-22T15:38:27Z", + "ts": "2026-08-22T15:42:54Z", "agent": "leader", "stage": "intake", "state": "running", - "message": "Define Reporting navigation as page-local sections" + "message": "Define LAN-safe POS API flow" }, { - "ts": "2026-08-22T15:39:01Z", + "ts": "2026-08-22T15:43:10Z", "agent": "architect", "stage": "design", "state": "running", - "message": "Design page-local Reporting navigation" + "message": "Design same-origin POS proxy and cookie forwarding" }, { - "ts": "2026-08-22T15:39:21Z", + "ts": "2026-08-22T15:43:38Z", "agent": "implementer", "stage": "build", "state": "running", - "message": "Move Reporting sections from sidebar into Reporting layout" + "message": "Implement LAN-safe POS API proxy" }, { - "ts": "2026-08-22T15:41:38Z", + "ts": "2026-08-22T15:49:47Z", "agent": "reviewer", "stage": "review_gate", "state": "running", - "message": "Review Reporting navigation and build evidence" + "message": "Review POS same-origin proxy and binding flow" }, { - "ts": "2026-08-22T15:41:52Z", + "ts": "2026-08-22T15:49:59Z", "agent": "security", "stage": "security_gate", "state": "running", - "message": "Check navigation RBAC and client URL safety" + "message": "Audit POS proxy cookies, headers and backend URL" }, { - "ts": "2026-08-22T15:42:06Z", + "ts": "2026-08-22T15:50:19Z", "agent": "qa", "stage": "qa_gate", "state": "running", - "message": "Validate Reporting sidebar and local section acceptance" + "message": "Run POS LAN login-bind-config acceptance" }, { - "ts": "2026-08-22T15:42:15Z", + "ts": "2026-08-22T15:50:31Z", "agent": "documenter", "stage": "document", "state": "running", - "message": "Document Reporting navigation behavior" + "message": "Document POS LAN and terminal binding behavior" }, { - "ts": "2026-08-22T15:42:24Z", + "ts": "2026-08-22T15:50:42Z", "agent": "leader", "stage": "close", "state": "running", - "message": "Close F-157 after approved gates" + "message": "Close F-158 after LAN acceptance" } ] }