# SPQ-v2 Production Hardening Roadmap (v5.2) — Remaining Work **Generated:** 2026-07-05 (cleaned 2026-07-07 — removed completed implementation steps) **Source audit:** Production-readiness audit of SPQ-v2 (React 19 / Vite 8 / Tailwind v4 / Zustand / PocketBase 0.39) **Scope:** Remaining items only — completed items (1.1, 1.5, 1.6, 1.7, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.8, 2.9, 2.10) are DONE and their implementation steps removed. See Execution Memos below for what was completed. --- ## Table of Contents - [Conventions & Pre-flight](#conventions--pre-flight) - [Phase 1 — Critical Fixes — Remaining Work](#phase-1--critical-fixes--remaining-work) - [1.2 DeepSeek AI auth-injecting reverse proxy (server-side)](#12-deepseek-ai-auth-injecting-reverse-proxy-server-side) - [1.3 Block self-signup role escalation (PB hook)](#13-block-self-signup-role-escalation-pb-hook) - [1.4 Security headers + CSP (nginx snippet)](#14-security-headers--csp-nginx-snippet) - [Phase 2 — Optimization & UX — Remaining Work](#phase-2--optimization--ux--remaining-work) - [2.7 Logo uploads to PocketBase file records](#27-logo-uploads-to-pocketbase-file-records) - [Phase 3 — Deployment & Monitoring](#phase-3--deployment--monitoring) - [3.1 GlitchTip self-hosted (Docker)](#31-glitchtip-self-hosted-docker) - [3.2 PocketBase production hardening](#32-pocketbase-production-hardening) - [3.3 Frontend hosting (nginx static)](#33-frontend-hosting-nginx-static) - [3.4 Reverse proxy for /pb and /api/ai/chat](#34-reverse-proxy-for-pb-and-apiaichat) - [3.5 Structured logging](#35-structured-logging) - [3.6 Offline banner](#36-offline-banner) - [3.7 Dependency audit](#37-dependency-audit) - [Phase 4 — Recommended Features](#phase-4--recommended-features) - [4.1 Quote approval notifications](#41-quote-approval-notifications) - [4.2 Quote expiry enforcement](#42-quote-expiry-enforcement) - [4.3 Financial audit log (append-only, hash-chained)](#43-financial-audit-log-append-only-hash-chained) - [4.4 Multi-vehicle customer history](#44-multi-vehicle-customer-history) - [4.5 Technician time-card CSV export](#45-technician-time-card-csv-export) - [4.6 Recurring maintenance reminders](#46-recurring-maintenance-reminders) - [4.7 Offline-first for technicians (service worker)](#47-offline-first-for-technicians-service-worker) - [4.8 Two-factor auth for advisors (TOTP)](#48-two-factor-auth-for-advisors-totp) - [4.9 Data export / GDPR](#49-data-export--gdpr) - [4.10 Multi-shop readiness (decision)](#410-multi-shop-readiness-decision) - [Appendix A — Verification command cheat sheet](#appendix-a--verification-command-cheat-sheet) - [Appendix B — PocketBase backup & migration procedure](#appendix-b--pocketbase-backup--migration-procedure) - [Appendix C — Environment variable reference](#appendix-c--environment-variable-reference) - [Execution Memos (Historical Record)](#execution-memos-historical-record) --- ## Conventions & Pre-flight ### Scope decisions (locked) - AI proxy: self-hosted auth-injecting reverse proxy (nginx + tiny validator). - PB migrations: included. - No git / no CI — local home-server workflow. - Hosting: nginx on the same box as PocketBase. - Error tracking: self-hosted GlitchTip (Sentry-protocol compatible). - Tests: zod schemas + vitest alongside each fix. ### Standing rules (from AGENTS.md) - Before any PB migration, back up the DB (Appendix B). - **Do not open `/tmp/opencode/pb-admin.txt` without permission.** Migration authoring does not require it. - **Modal changes** must follow the background-location overlay pattern documented in AGENTS.md. - **Verify after every step** with the commands in the step's `Verify:` block, not just at the end. ### Pre-flight: confirm the PocketBase container Before any PB step, confirm the container ID (it may have changed since 2026-07-05): ```bash docker ps --format '{{.ID}} {{.Image}} {{.Names}} {{.Ports}}' | grep pocketbase ``` Export it for the session: ```bash export PB_CID= ``` All `docker exec` commands in this roadmap use `$PB_CID`. --- ## Phase 1 — Critical Fixes — Remaining Work ### 1.2 DeepSeek AI auth-injecting reverse proxy (server-side) **Status:** Frontend done (ai.ts sends PB token, handles 401/429, VITE_AI_ENABLED gate). Server-side pending. **What remains — server-side installation:** **Step 1 — Write the token validator (Node, no deps):** ```js // /opt/spq/ai-proxy/validate.js // Tiny HTTP service: returns 200 if the incoming Authorization Bearer token // is a valid PocketBase user token, 401 otherwise. Called by nginx auth_request. const http = require('http'); const PB_URL = process.env.PB_URL || 'http://127.0.0.1:8091'; const PORT = parseInt(process.env.PORT || '8092', 10); const VALIDATE_PATH = '/api/collections/users/refresh'; const server = http.createServer(async (req, res) => { const auth = req.headers.authorization; if (!auth || !auth.startsWith('Bearer ')) { res.writeHead(401); return res.end('no token'); } try { const r = await fetch(`${PB_URL}${VALIDATE_PATH}`, { method: 'POST', headers: { Authorization: auth } }); res.writeHead(r.status === 200 ? 200 : 401); res.end(r.status === 200 ? 'ok' : 'invalid'); } catch { res.writeHead(502); res.end('pb unreachable'); } }); server.listen(PORT, '127.0.0.1', () => { console.log(`[spq-ai-validator] listening on 127.0.0.1:${PORT}`); }); ``` **Step 2 — systemd unit:** ```ini # /opt/spq/ai-proxy/spq-ai-validator.service # Copy to /etc/systemd/system/ and: sudo systemctl enable --now spq-ai-validator [Unit] Description=SPQ DeepSeek token validator After=network.target docker.service [Service] Type=simple WorkingDirectory=/opt/spq/ai-proxy ExecStart=/usr/bin/node validate.js Environment=PB_URL=http://127.0.0.1:8091 Environment=PORT=8092 Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target ``` **Step 3 — nginx location (include in the server block from 3.3):** ```nginx # /etc/nginx/snippets/spq-deepseek.conf limit_req_zone $binary_remote_addr zone=spq_ai:10m rate=20r/m; location /deepseek/ { limit_req zone=spq_ai burst=5 nodelay; auth_request /_spq_validate; auth_request_set $spq_auth_status $upstream_status; proxy_set_header Authorization ""; proxy_set_header Authorization "Bearer $http_deepseek_api_key"; rewrite ^/deepseek/(.*)$ /$1 break; proxy_pass https://api.deepseek.com; proxy_ssl_server_name on; proxy_set_header Host api.deepseek.com; proxy_set_header X-Real-IP $remote_addr; proxy_buffering off; } location = /_spq_validate { internal; proxy_pass http://127.0.0.1:8092/; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header Authorization $http_authorization; } ``` Write the API key to `/etc/nginx/snippets/deepseek-key.conf` (mode 0600, owner root) containing `set $deepseek_key "sk-...";` and `include` it inside the location. **Step 4 — Fix the stale dev proxy in `vite.config.ts`:** The `/deepseek` proxy currently targets `https://127.0.0.1:3448` with `secure: false` — that gateway no longer exists. Point at local nginx: ```ts '/deepseek': { target: 'http://127.0.0.1:80', changeOrigin: true, rewrite: (path) => path, }, ``` Or use a `.env.local` value: `target: process.env.VITE_DEEPSEEK_DEV_PROXY || 'http://127.0.0.1:80'`. **Verify:** ```bash npm run build npm run lint # Manual: with nginx + validator running, log in, open Quote Generator, # trigger AI Suggest — should succeed. Without login — should 401. ``` --- ### 1.3 Block self-signup role escalation (PB hook) **Status:** NOT DONE — needs PB migration. **Problem:** `src/pages/Login.tsx:96-104` sends `role: 'advisor'` from the client. PocketBase auth collections allow client-set fields on self-create, so a malicious signup can send `role: 'technician'` and `shopUserId: ''` to masquerade as a technician under any shop. **Fix:** PB `onRecordBeforeCreate` hook on `users` that forces `role = 'advisor'` and clears `shopUserId` — **unless** there is a matching pending, unexpired `technicianInvites` record for the email being registered. **File:** `pb_migrations/1740000000020_users_create_role_guard.js` ```js /// migrate( (app) => { app.onRecordBeforeCreateRequest((e) => { if (e.collection.name !== 'users') return; const record = e.record; const email = (record.get('email') || '').toString().trim().toLowerCase(); if (!email) return; let invite = null; try { invite = e.dao.findFirstRecordByFilter( 'technicianInvites', `email = "${email.replace(/"/g, '')}" && status = "pending" && expiresAt >= @now` ); } catch { invite = null; } if (invite) { record.set('role', 'technician'); record.set('shopUserId', invite.get('shopUserId') || ''); } else { record.set('role', 'advisor'); record.set('shopUserId', ''); } }); }, (app) => { console.log('[M20] rollback requested — remove this migration file and restart PocketBase'); } ); ``` **Also update Login.tsx** to stop sending `role`/`shopUserId` from the client (they're now server-set by the hook): ```ts await pb.collection('users').create({ email: email.trim(), password, passwordConfirm: confirmPassword, name: name.trim() || '', displayName: name.trim() || '', emailVisibility: true, // role and shopUserId are now server-set by the M20 hook. }); ``` **Verify:** ```bash npm run build npm run lint # Manual: sign up new account → check users.role == 'advisor', shopUserId == '' # Try curl POST with role:'technician' → confirm role still 'advisor' # Create a technician invite, accept via link → confirm role == 'technician' ``` --- ### 1.4 Security headers + CSP (nginx snippet) **Status:** Frontend cleanup done (Toast `