Compare commits
3 Commits
cf71cc4dec
...
421b3d5922
| Author | SHA1 | Date | |
|---|---|---|---|
| 421b3d5922 | |||
| cfe3b29c98 | |||
| e83d975bd6 |
+278
-36
@@ -1,6 +1,8 @@
|
||||
# Server Consolidation & Security Hardening Plan
|
||||
|
||||
Audit-driven plan to (a) reduce 12 PM2 processes to 3 application servers + 1 auth server, (b) put every API endpoint behind real authentication, and (c) standardize on ESM across all Node services. Approach is "do it properly the first time" — no half-finished pieces, no deferred cleanup.
|
||||
Audit-driven plan to (a) reduce 13 PM2 processes to 5 application servers + 2 auxiliary processes (acot-phone-server, lt-wordlist-api) = 7 total, (b) put every API endpoint behind real authentication, and (c) standardize on ESM across all primary application Node services. Approach is "do it properly the first time" — no half-finished pieces, no deferred cleanup.
|
||||
|
||||
> **Note on the original 12→4 target.** The initial spec called for `12 PM2 processes → 3 application servers + 1 auth server` and "ESM across all Node services". During execution `chat-server` proved a poor merge candidate (different DB, different protocol shape) and was kept as its own process — see Deviations #16 and #27. Phase 9 (added post-audit, 2026-05-24) closes the residual gap: chat-server ESM conversion + in-process `authenticate()` + Caddyfile/CORS hardening + vitest scaffold.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,25 +12,32 @@ Audit-driven plan to (a) reduce 12 PM2 processes to 3 application servers + 1 au
|
||||
|---|---|---|
|
||||
| 1 — Decommission dead services | **Complete** | aircall/gorgias/clarity/legacy-auth-server deleted from repo + PM2 + Caddyfile + ecosystem.cjs |
|
||||
| 2 — Build shared `lib/` | **Complete** | Lives at `inventory-server/shared/` (see Deviations). `/verify` endpoint live on auth-server |
|
||||
| 3 — Convert auth-server + inventory-server to ESM | **Complete** | All 58 server-side files ESM; both services live under the ESM build for >24h. See Deviations #10–13 |
|
||||
| 3 — Convert auth-server + inventory-server to ESM | **Complete** | All inventory-server + auth-server server-side files (58) ESM; both services live under the ESM build for >24h. `chat-server` intentionally not in scope — deferred to Phase 9. See Deviations #10–13, #27 |
|
||||
| 4 — Build `dashboard-server` (the merge) | **Complete (live) — 2026-05-24** | Merged service running on :3015 under PM2; Caddy routes for klaviyo/meta/dashboard-analytics/typeform all reverse-proxy to it. Old per-vendor directories (`klaviyo-server`, `meta-server`, `google-server`, `typeform-server`) and their PM2 entries deleted post-cutover — ~1.27 GB reclaimed (largely duplicated `node_modules`). Phase 6.2 gates wired (meta_write, klaviyo_admin). See Deviations #16–19 |
|
||||
| 5 — Convert `acot-server` to ESM | Not started | |
|
||||
| 6 — Auth hardening | **Complete** | All in-process items live: rate-limit, JWT precondition, CORS lockdown, request-log, upload allowlist, `requirePermission` on sensitive routes, permissions seed migration. `authenticate()` is live on `/api/*`. 6.11 (audit logging) deferred — see Out of scope |
|
||||
| 5 — Convert `acot-server` to ESM | **Complete (live) — 2026-05-24** | All 11 files (server, db/connection, utils/{phoneAuth,timeUtils}, 7 routes) converted to ESM. PM2 reload clean; SPA-driven `/api/acot/events/*` continues 200 across cutover; phone-server `/api/acot/customers/by-phone` returns 200 with correct shared secret. Phase 6 patterns applied during conversion — see Deviation #24 |
|
||||
| 6 — Auth hardening | **Complete** | All in-process items live: rate-limit, JWT precondition, CORS lockdown, request-log, upload allowlist, `requirePermission` on sensitive routes, permissions seed migration. `authenticate()` live on `/api/*` (inventory-server, dashboard-server) and `/api/acot/*` (acot-server, added in Phase 5). 6.10 lt-wordlist token loaded via `--env-file` + rotated 2026-05-24 (Deviation #25). 6.11 (audit logging) deferred — see Out of scope |
|
||||
| **F1 — Frontend fetch wrapper** | **Complete (live) — 2026-05-23** | Wrappers at `inventory/src/utils/api.ts` (`apiFetch`) and `inventory/src/utils/apiClient.ts` (axios instance). 170 `fetch()` sites across 76 files migrated to `apiFetch`; 32 `axios.*` sites across 11 files migrated to `apiClient`. AuthContext `/login`+`/me`, App.tsx `/me`, and `services/apiv2.ts` (external PHP backend) intentionally left as raw `fetch`. Shipped alongside the Phase 3+6 pm2 reload |
|
||||
| 7 — Caddyfile final form | **Complete — applied 2026-05-24** | Final Caddyfile live at `/etc/caddy/Caddyfile` (forward_auth gate + per-vendor reverse_proxy to :3015). The `inventory-server/deploy/` staging folder was removed after apply — recreate from this doc if future changes are needed. Backup convention: `/etc/caddy/Caddyfile.bak.YYYY-MM-DD` |
|
||||
| 7 — Caddyfile final form | **Complete — applied 2026-05-24, amended same day** | Final Caddyfile live at `/etc/caddy/Caddyfile` (forward_auth gate + per-vendor reverse_proxy to :3015). The `inventory-server/deploy/` staging folder was removed after the initial apply, then re-created during Phase 9 §9.2 and again for the Deviation #28 uploads-gate revert — treat it as the working area for any future Caddyfile change. Backup convention: `/etc/caddy/Caddyfile.bak.YYYY-MM-DD` |
|
||||
| 8 — ecosystem.config.cjs final form | **Complete — applied 2026-05-24** | Live PM2 list matches the spec below (5 apps + acot-phone-server + lt-wordlist-api = 7 processes). Includes Phase 6.4 JWT_SECRET shadow-override fix and 6.10 lt-wordlist token move. `inventory-server/deploy/` removed post-apply |
|
||||
| **9 — Post-audit residual gaps** | **Complete (live) — 2026-05-24** | Closes findings from second-look audit: chat-server ESM + in-process `authenticate()` + localhost bind, Caddyfile edge CORS tightening + CORS preflight bypass, vitest scaffold + auth-boundary tests, three Mini\*.jsx fetch leaks, and plan-doc goal reconciliation. PM2 reload, Caddy apply, frontend rebuild, and tests all completed. **§9.2's uploads-gate fix was subsequently reverted (2026-05-24) — see Deviation #28; `/uploads/*` is public-by-design because the external PHP backend at www.acherryontop.com fetches images during product import.** See Phase 9 section |
|
||||
|
||||
**Live PM2 process count: 7** (5 application apps — auth-server, inventory-server, chat-server, dashboard-server, acot-server — plus acot-phone-server + lt-wordlist-api). Down from 13 pre-refactor.
|
||||
|
||||
**All apply steps complete (2026-05-24).** The original sequencing (npm install → F1 ship → pm2 reload → env consolidation → vendor PM2 delete → ecosystem apply → Caddyfile apply) was executed in order. Remaining work is Phase 5 (acot-server ESM conversion) only.
|
||||
**Phases 1–8 complete (2026-05-24).** Phase 5 closed the last originally-planned code deliverable; acot-server now runs as an ESM service with shared-lib `authenticate()` defense-in-depth.
|
||||
|
||||
**Phase 9 added and applied 2026-05-24** after a second-look audit surfaced six residual findings — see [Phase 9](#phase-9--post-audit-residual-gaps) below. Code, PM2 reload, Caddy apply, frontend rebuild, and test verification are complete.
|
||||
|
||||
Two earlier gaps surfaced during Phase 5 verification, both closed 2026-05-24:
|
||||
- **Phase 6.10 lt-wordlist token rotation** — fixed via Node's native `--env-file` flag in the PM2 entry; token rotated to a fresh 32-byte hex value in `/opt/lt-wordlist-api/.env` (mode 0600). See Deviation #25.
|
||||
- **Phase 6.1 Caddy gate blocking acot-phone-server's customer lookups** — fixed by repointing `ACOT_API_URL` from the public host to `http://localhost:3012/api/acot`. See Deviation #26.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- Every public-facing endpoint requires a valid auth token (Caddy gate + per-server middleware + per-route permission checks for sensitive operations).
|
||||
- Reduce service count from 12 PM2 processes to 4: `inventory-server`, `acot-server`, `dashboard-server`, `auth-server`.
|
||||
- Standardize on ESM (`"type": "module"`) across all Node services.
|
||||
- Reduce service count from 13 PM2 processes to 7 total: 5 application servers (`auth-server`, `inventory-server`, `dashboard-server`, `acot-server`, `chat-server`) + 2 auxiliary (`acot-phone-server`, `lt-wordlist-api`). The original "4 application servers" target was missed because `chat-server` proved a poor merge candidate during execution; see Deviation #16.
|
||||
- Standardize on ESM (`"type": "module"`) across all primary application Node services. `chat-server` was the last holdout — Phase 9 converts it.
|
||||
- Decommission `aircall-server`, `gorgias-server`, `clarity-server`, and the legacy `auth-server` (port 3003).
|
||||
- Eliminate dependency duplication: one Redis client, one Postgres pool helper, one logger, one auth middleware — shared across services.
|
||||
|
||||
@@ -80,7 +89,7 @@ Audit-driven plan to (a) reduce 12 PM2 processes to 3 application servers + 1 au
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
PM2 process count: **12 → 4** (plus `acot-phone-server` and `lt-wordlist-api`, which stay as-is — out of scope).
|
||||
PM2 process count: **13 → 7** (5 application servers + `acot-phone-server` + `lt-wordlist-api`). The original target was 4 application servers; the diagram above omits `chat-server` because it was originally a merge candidate. In practice `chat-server` runs as its own ESM process post-Phase 9 — see Deviation #16/#27.
|
||||
|
||||
---
|
||||
|
||||
@@ -344,21 +353,25 @@ When all four vendors share a Redis client, a Redis hiccup affects all four. Mak
|
||||
|
||||
## Phase 5 — Convert `acot-server` to ESM (stays standalone)
|
||||
|
||||
Status: **Not started.** Largest single conversion (~5K LOC), but no merge involved.
|
||||
Status: **Complete (live) — 2026-05-24.** 11 files converted (server.js, db/connection.js, utils/{phoneAuth,timeUtils}.js, 7 route files — ~5.2K LOC). PM2 reload clean; SPA-driven `/api/acot/events/{projection,stats}` continues 200 across cutover; phone-server `/api/acot/customers/by-phone` returns 200 with correct `x-acot-api-key`. Per Deviation #13, `ssh2` is CJS-only → uses `import ssh2 from 'ssh2'; const { Client } = ssh2;`. Phase 6 patterns applied during conversion (Deviation #24).
|
||||
|
||||
### Special concern: ssh2 tunnel
|
||||
|
||||
`acot-server` opens an SSH tunnel via `ssh2` to access the production MySQL at `192.168.1.5:3309`. The tunnel must be:
|
||||
`acot-server` opens an SSH tunnel via `ssh2` to access the production MySQL at `192.168.1.5:3309`. Lifecycle today (preserved verbatim across the ESM conversion, not refactored):
|
||||
|
||||
- Established before the HTTP listener starts (so no requests fail with "no DB connection").
|
||||
- Re-established on disconnect (`ssh2` connection's `close` event → recreate).
|
||||
- Cleanly torn down on `SIGTERM`/`SIGINT` so PM2 restarts don't leak file descriptors.
|
||||
- **Lazy establishment.** No tunnel at startup; the first `getDbConnection()` call sets one up. HTTP listener comes up immediately without waiting for the tunnel. Acceptable — the first per-route request just pays the tunnel-creation latency once.
|
||||
- **Per-connection ssh client.** Each pooled MySQL connection owns its own `ssh2.Client`. Closing a connection closes its own SSH client.
|
||||
- **No reconnect on disconnect.** There is no `close` listener on the SSH client. If the SSH connection drops while the MySQL connection is pooled (not in use), the next caller that pops it will get a query failure. Circuit-breaker absorbs repeated failures (5 failures → 30s open). Mitigation acceptable for current call volume; revisit if SSH drops become observable in logs.
|
||||
- **SIGTERM/SIGINT teardown.** `server.close()` → `closeAllConnections()` ends MySQL connections and SSH clients in sequence. Confirmed clean during the Phase 5 cutover (`SIGTERM signal received: closing HTTP server` → 10 × `Closed pooled connection` → `All connections closed and pool reset` in PM2 logs).
|
||||
|
||||
Verify (or add) this lifecycle handling as part of the conversion. If it's already correct, conversion is mechanical; if not, this is a good moment to fix it.
|
||||
### Auth model (two flavors, intentional)
|
||||
|
||||
### Test strategy
|
||||
`server.js` mounts the customers router BEFORE the global `authenticate()` so the two auth schemes don't collide:
|
||||
|
||||
Same as inventory-server: start with PM2, smoke-test the most-used `/api/acot/*` endpoints, watch logs for unhandled rejection or tunnel-close events.
|
||||
- `/api/acot/customers/*` → `requirePhoneApiKey` (timing-safe `x-acot-api-key` check). Used by `acot-phone-server`.
|
||||
- everything else → JWT Bearer via `shared/auth/middleware.js authenticate()`. Used by the SPA.
|
||||
|
||||
This works at the in-process layer. The public path through Caddy is a separate issue — see Deviation #26.
|
||||
|
||||
---
|
||||
|
||||
@@ -370,13 +383,13 @@ Per-item status:
|
||||
|
||||
| # | Item | Status | Where |
|
||||
|---|---|---|---|
|
||||
| 6.1 | Caddy `forward_auth` gate | **Live — 2026-05-24** | Applied via Caddy admin API + `sudo cp` to `/etc/caddy/Caddyfile`. `@gated path /api/* /chat-api/* /uploads/*` block hits `localhost:3011/verify` on every request |
|
||||
| 6.1 | Caddy `forward_auth` gate | **Live — 2026-05-24** | Applied via Caddy admin API + `sudo cp` to `/etc/caddy/Caddyfile`. `@gated path /api/* /chat-api/*` block hits `localhost:3011/verify` on every request. (`/uploads/*` was originally in the gate but removed per Deviation #28 — see Phase 6.7 / 9.2.) |
|
||||
| 6.2 | `requirePermission` on sensitive routes + permissions migration | **Done** | inline in `config.js`, `data-management.js`, `import.js`, `ai-prompts.js`, `ai-validation.js`, `templates.js`, `reusable-images.js`; codes seeded by `migrations/005_phase6_permission_codes.sql`. **Phase 4 follow-on (2026-05-23):** `meta_write` wired on `PATCH /api/meta/campaigns/:id/budget` and `POST /api/meta/campaigns/:id/:action`; `klaviyo_admin` wired on `POST /api/klaviyo/events/clearCache`. Read-only Google + Typeform endpoints stay authenticated-only (reserved write codes left in migration 005 for future) |
|
||||
| 6.3 | Login rate-limit + `/verify` rate-limit | **Done** | `auth/server.js` uses `shared/rate-limit/login.js` (`loginLimiter`, `verifyLimiter`) |
|
||||
| 6.4 | JWT_SECRET as startup precondition + ecosystem footgun fix | **Live — 2026-05-24** | Both auth-server and inventory-server `process.exit(1)` if `JWT_SECRET` is unset. The `JWT_SECRET: process.env.JWT_SECRET` override that was shadowing `.env` is removed from the live ecosystem.cjs |
|
||||
| 6.5 | Structured request logging w/ redaction | **Done** | `shared/logging/request-log.js` (pino-http, redacts Authorization/Cookie); mounted in both `auth/server.js` and `src/server.js` |
|
||||
| 6.6 | CORS lockdown | **Done** | `src/middleware/cors.js` now re-exports `shared/cors/policy.js`. LAN wildcards (`192.168.*`, `10.*`) and `*` defaults gone |
|
||||
| 6.7 | Upload hardening | **Done** | Exact-match MIME+extension allowlist on `routes/import.js` and `routes/reusable-images.js`; dead `multer({ dest })` removed from `routes/products.js` (no upload route was using it — strongest hardening was deletion) |
|
||||
| 6.7 | Upload hardening | **Partial — gating reverted** | Exact-match MIME+extension allowlist on `routes/import.js` and `routes/reusable-images.js`; dead `multer({ dest })` removed from `routes/products.js` (no upload route was using it — strongest hardening was deletion). The Caddyfile `/uploads/*` gate **was applied then reverted (2026-05-24)** — uploads are public-by-design (external PHP backend fetches them during product import). See Deviation #28 |
|
||||
| 6.8 | Frontend token storage stays localStorage + XSS audit | **Audited** | Confirmed `dangerouslySetInnerHTML` is sanitized in `ProductEditor.tsx`. **Flagged: `ChatRoom.tsx:277,392` renders user-controlled chat content as raw HTML — real XSS vector, separate fix needed** |
|
||||
| 6.9 | Remove debug middleware | **Done** | The header-dumping `app.use((req,res,next)=>{ console.log(... req.headers ...) })` block removed from `src/server.js`. Replaced with `shared/logging/request-log.js` (which redacts). |
|
||||
| 6.10 | `lt-wordlist-api` token move | **Live — 2026-05-24** | Live PM2 entry runs `/opt/lt-wordlist-api/index.js` under matt's daemon; `ADD_WORD_TOKEN` is no longer inline in ecosystem.cjs and is read from `/opt/lt-wordlist-api/.env`. See Deviations #21–23 for the path corrections and the (incorrect) earlier assumption that this app lived under a separate root daemon |
|
||||
@@ -491,7 +504,7 @@ If anyone genuinely needs LAN access, add their specific IP, not a `/16` range.
|
||||
- File-size limit set on multer config (current limit may be defaulted — verify).
|
||||
- MIME-type allowlist (image/jpeg, image/png, image/webp; reject everything else).
|
||||
- Filename sanitization (no `..`, no absolute paths, generate UUID-based names server-side).
|
||||
- The Caddy `/uploads/*` handler currently serves any file in the uploads directory publicly. Move this **behind** the auth gate: include `/uploads/*` in `@needs_auth`. If some images are referenced from public emails (Klaviyo newsletter), put **those** in a separate public bucket; everything else stays gated.
|
||||
- ~~The Caddy `/uploads/*` handler currently serves any file in the uploads directory publicly. Move this **behind** the auth gate: include `/uploads/*` in `@needs_auth`.~~ **Reverted — see Deviation #28.** Uploads are public-by-design because the external PHP backend at `www.acherryontop.com` fetches image URLs server-to-server during the `/apiv2/product/setup_new` import flow, and has no Bearer token. Random-suffix filenames (timestamp + `Math.random`) provide weak enumeration resistance; `/uploads/products/*` files also auto-delete after 24h. If genuinely sensitive uploads ever land here, implement HMAC-signed URLs rather than re-gating wholesale.
|
||||
|
||||
### 6.8 Frontend token storage
|
||||
|
||||
@@ -602,15 +615,15 @@ Per Phase 6.8, we're not migrating to httpOnly cookie auth. F1 is the minimum wo
|
||||
|
||||
### Note on `/uploads/*` gating (Phase 6.7's Caddyfile change)
|
||||
|
||||
**Applied as-spec (2026-05-24):** `/uploads/*` is behind `forward_auth` in the live Caddyfile. `<img src="/uploads/...">` references in the SPA are browser-issued GETs that don't carry `Authorization` headers — verify image display works end-to-end (cookies fall-through, signed URLs, or session-bound forward_auth) and if broken, revert this part of 6.7 to keep `/uploads/*` public, OR issue per-image signed URLs from the API.
|
||||
**Applied as-spec then reverted (2026-05-24):** `/uploads/*` was initially placed behind `forward_auth` per the 6.7 spec. The SPA was patched via [`AuthedImage`](inventory/src/components/ui/authed-image.tsx) to send Bearer headers, but the gate broke the **external** product-import flow: image URLs from `/uploads/products/*` and `/uploads/reusable/*` are submitted to the external PHP backend (`www.acherryontop.com`) via `/apiv2/product/setup_new`, which then fetches them server-to-server to ingest into the shop — and has no Bearer token. The escape hatch from the original note ("revert this part of 6.7 to keep `/uploads/*` public") was taken: `/uploads/*` now serves via a dedicated public `file_server` placed ahead of `@static` and `@gated`. See Deviation #28 for the full revert details. `AuthedImage` is left in place as defense-in-depth (Bearer header is harmless when not required); no rush to rip out.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Caddyfile final form
|
||||
|
||||
Status: **Complete — applied 2026-05-24.** Final Caddyfile live at `/etc/caddy/Caddyfile`; vendor handles point at the merged dashboard-server on :3015. The `inventory-server/deploy/` staging folder (which held `Caddyfile.proposed` and the README of apply commands) was removed after apply — recreate from the spec below if future changes are needed. Apply pattern (admin-API `curl -X POST :2020/load` + `sudo cp` to persist on-disk) is captured in Deviation #8. Backup convention: `/etc/caddy/Caddyfile.bak.YYYY-MM-DD`.
|
||||
Status: **Complete — applied 2026-05-24.** Final Caddyfile live at `/etc/caddy/Caddyfile`; vendor handles point at the merged dashboard-server on :3015. The `inventory-server/deploy/` staging folder (which held `Caddyfile.proposed` and the README of apply commands) was originally removed after apply — **re-created 2026-05-24 to stage the §9.2 edits and again to stage the Deviation #28 uploads-gate revert; treat it as the working area for any future Caddyfile change**. Apply pattern (admin-API `curl -X POST :2020/load` + `sudo cp` to persist on-disk) is captured in Deviation #8. Backup convention: `/etc/caddy/Caddyfile.bak.YYYY-MM-DD`.
|
||||
|
||||
After all phases, the `tools.acherryontop.com` block looks like:
|
||||
After all phases (including Phase 9 §9.2 and the Deviation #28 revert), the `tools.acherryontop.com` block looks like:
|
||||
|
||||
```caddyfile
|
||||
tools.acherryontop.com {
|
||||
@@ -622,6 +635,34 @@ tools.acherryontop.com {
|
||||
reverse_proxy localhost:3011
|
||||
}
|
||||
|
||||
# Phase 9 §9.2: CORS preflight bypass (OPTIONS has no Authorization header)
|
||||
@cors_preflight {
|
||||
method OPTIONS
|
||||
header Access-Control-Request-Method *
|
||||
}
|
||||
handle @cors_preflight {
|
||||
handle /api/klaviyo/* { reverse_proxy localhost:3015 }
|
||||
handle /api/meta/* { reverse_proxy localhost:3015 }
|
||||
handle /api/dashboard-analytics/* { reverse_proxy localhost:3015 }
|
||||
handle /api/typeform/* { reverse_proxy localhost:3015 }
|
||||
handle /api/acot/* { reverse_proxy localhost:3012 }
|
||||
handle /chat-api/* {
|
||||
uri strip_prefix /chat-api
|
||||
reverse_proxy localhost:3014
|
||||
}
|
||||
handle /api/* { reverse_proxy localhost:3010 }
|
||||
}
|
||||
|
||||
# Deviation #28: /uploads/* is PUBLIC by design (external PHP backend
|
||||
# fetches image URLs server-to-server during /apiv2 product import).
|
||||
# Rooted at /var/www/inventory (NOT the SPA build root) so paths
|
||||
# resolve to the real uploads directory. Placed ahead of @static and
|
||||
# @gated so it wins the match.
|
||||
handle /uploads/* {
|
||||
root * /var/www/inventory
|
||||
file_server
|
||||
}
|
||||
|
||||
# Public: static frontend assets
|
||||
@static path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2
|
||||
handle @static {
|
||||
@@ -630,20 +671,14 @@ tools.acherryontop.com {
|
||||
file_server
|
||||
}
|
||||
|
||||
# All API + uploads: auth gate first
|
||||
@gated path /api/* /chat-api/* /uploads/*
|
||||
# All API + chat: auth gate first
|
||||
@gated path /api/* /chat-api/*
|
||||
handle @gated {
|
||||
forward_auth localhost:3011 {
|
||||
uri /verify
|
||||
copy_headers Authorization
|
||||
}
|
||||
|
||||
# Uploaded files
|
||||
handle /uploads/* {
|
||||
root * /var/www/inventory
|
||||
file_server
|
||||
}
|
||||
|
||||
# Vendor dashboard routes → merged dashboard-server
|
||||
handle /api/klaviyo/* { reverse_proxy localhost:3015 }
|
||||
handle /api/meta/* { reverse_proxy localhost:3015 }
|
||||
@@ -770,6 +805,159 @@ Estimated effort, end-to-end: **~3 weeks of focused work** by one engineer. Phas
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Post-audit residual gaps (NEW — 2026-05-24)
|
||||
|
||||
A second-look audit after Phase 5/8 closed surfaced six findings that the original plan either deferred, missed, or overstated. Phase 9 closes them. Code work landed in the same session as the audit, then the deploy-side steps (`caddy reload`, `pm2 reload chat-server`, frontend rebuild) were applied and smoke-tested.
|
||||
|
||||
The intent is **"do it properly the first time"** — Phase 9 isn't a quality-of-life backlog, it's the final closing-out of the consolidation project. With §9.1–9.5 applied, the consolidation/security/ESM claims in this document match the live system.
|
||||
|
||||
### 9.1 — chat-server: ESM conversion + in-process `authenticate()` + localhost bind
|
||||
|
||||
**Finding:** `chat-server` was the last application service still using `require()` (Status note misstated this in earlier revisions) and had no per-server auth — it relied entirely on the Caddy gate. `localhost:3014/test-db` returns 200 unauthenticated, falling short of the plan's three-layer defense model (Caddy `forward_auth` + per-server `authenticate()` + per-route `requirePermission`).
|
||||
|
||||
**Files touched (code landed this session):**
|
||||
- `inventory-server/chat/package.json` — add `"type": "module"`.
|
||||
- `inventory-server/chat/server.js` — `require` → `import`; load shared `.env` first then local; add a *second* `Pool` against `inventory_db` (the existing `CHAT_DB_*` pool stays for `rocketchat_converted`); mount `shared/auth/middleware.js`'s `authenticate()` on the router; mount `shared/logging/request-log.js`, `shared/cors/policy.js`, `shared/errors/handler.js` for parity with dashboard-server; bind to `127.0.0.1` instead of `0.0.0.0` (external access is via Caddy only).
|
||||
- `inventory-server/chat/routes.js` — `require` → `import`; `module.exports` → `export default`; no changes to handler bodies (they continue to read `global.pool` which `server.js` still sets).
|
||||
|
||||
**Applied / verified:**
|
||||
1. `pm2 reload chat-server --update-env` completed; PM2 shows `chat-server` online with the new PID.
|
||||
2. Syntax sweep passes under Node when excluding macOS `._*` sidecar files.
|
||||
3. `ss -ltnp` shows `chat-server` bound to `127.0.0.1:3014` (not `0.0.0.0`).
|
||||
4. Smoke:
|
||||
- `curl -s http://localhost:3014/test-db` → `401` (was: 200 unauthenticated).
|
||||
- valid short-lived Bearer token against `https://tools.acherryontop.com/chat-api/test-db` → `200`.
|
||||
- `curl -s https://tools.acherryontop.com/chat-api/test-db` (no token) → `401` at Caddy gate.
|
||||
5. PM2 logs show logged-in SPA Chat Archive requests returning 200 (`/users`, `/users/:id/rooms`, `/rooms/:id/messages`, `/messages/attachments`).
|
||||
|
||||
**Rollback:** `pm2 reload chat-server` against the previous commit (CJS version is one git checkout away).
|
||||
|
||||
### 9.2 — Caddyfile: uploads gate fix + edge CORS tightening
|
||||
|
||||
> **⚠️ The uploads-gate portion of this section was reverted on 2026-05-24 — see Deviation #28.** Edit #1 (`not path /uploads/*` on `@static`) was undone and `/uploads/*` was lifted out of `@gated` entirely. The CORS edits (#2 and #3) remain live. The narrative below is preserved as a record of the original intent and what was learned.
|
||||
|
||||
**Findings (original):**
|
||||
- `/uploads/*.jpg` returns a *public* `404` because the `@static path *.jpg ... *.woff2` matcher beats `@gated path /api/* /chat-api/* /uploads/*` on specificity, hitting the unauthenticated SPA build root.
|
||||
- `Access-Control-Allow-Origin "*"` is set inside the `security_headers` snippet and imported into `tools.acherryontop.com`, which silently overrides the careful allow-list in `shared/cors/policy.js`.
|
||||
- CORS preflight (`OPTIONS` with no Authorization) hits `forward_auth` and 401s before the backend's CORS handler ever sees it.
|
||||
|
||||
**Applied from:** `inventory-server/deploy/Caddyfile.proposed` (recreated this session per Deviation #18's convention of staging diffs there before applying). The active `/etc/caddy/Caddyfile` now contains the Phase 9 CORS edits + the Deviation #28 uploads-gate revert.
|
||||
|
||||
**Three edits (as originally applied):**
|
||||
1. ~~Tighten `@static`:~~ **REVERTED — see Deviation #28.**
|
||||
```
|
||||
@static {
|
||||
path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2
|
||||
not path /uploads/*
|
||||
}
|
||||
```
|
||||
~~This makes `/uploads/*` an exclusive @gated path, restoring the intended authenticated file_server behavior.~~ Current state: the `not path /uploads/*` exclusion is removed; `/uploads/*` is handled by a dedicated public `handle /uploads/*` block placed ahead of both `@static` and `@gated`. See Deviation #28.
|
||||
2. Drop the wildcard headers from `security_headers`:
|
||||
```diff
|
||||
- Access-Control-Allow-Origin "*"
|
||||
- Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE, PATCH"
|
||||
- Access-Control-Allow-Headers "DNT, X-CustomHeader, ..."
|
||||
```
|
||||
CORS responses come from the upstreams (`shared/cors/policy.js`) which set ACAO conditionally against the allow-list. Caddy stamping `*` was a holdover from before Phase 6.6.
|
||||
3. Add a preflight-bypass handler **before** `@gated`:
|
||||
```
|
||||
@cors_preflight {
|
||||
method OPTIONS
|
||||
header Access-Control-Request-Method *
|
||||
}
|
||||
handle @cors_preflight {
|
||||
handle /api/klaviyo/* { reverse_proxy localhost:3015 }
|
||||
handle /api/meta/* { reverse_proxy localhost:3015 }
|
||||
handle /api/dashboard-analytics/* { reverse_proxy localhost:3015 }
|
||||
handle /api/typeform/* { reverse_proxy localhost:3015 }
|
||||
handle /api/acot/* { reverse_proxy localhost:3012 }
|
||||
handle /chat-api/* {
|
||||
uri strip_prefix /chat-api
|
||||
reverse_proxy localhost:3014
|
||||
}
|
||||
handle /api/* { reverse_proxy localhost:3010 }
|
||||
}
|
||||
```
|
||||
|
||||
**Applied / verified (at time of original §9.2 cutover):**
|
||||
1. Caddy admin API load applied and `/etc/caddy/Caddyfile` persisted with the Phase 9 edits.
|
||||
2. Smoke (post-§9.2, pre-Deviation #28 revert):
|
||||
- `curl -I https://tools.acherryontop.com/uploads/reusable/<real>.jpg` → `401` without auth (was: public 404).
|
||||
- same path with a valid short-lived Bearer token → `200 image/jpeg`.
|
||||
- `curl -X OPTIONS -H "Origin: https://tools.acherryontop.com" -H "Access-Control-Request-Method: GET" https://tools.acherryontop.com/api/products` → `204` with `Access-Control-Allow-Origin: https://tools.acherryontop.com` (specific, **not** `*`).
|
||||
- same preflight with `Origin: https://evil.example` → `403` with no wildcard ACAO.
|
||||
- `curl -s https://tools.acherryontop.com/api/products` (no token) → `401`; wildcard ACAO no longer stamped by Caddy.
|
||||
|
||||
**Current smoke (post-Deviation #28 revert, 2026-05-24):**
|
||||
- `curl -I https://tools.acherryontop.com/uploads/reusable/<real>.jpg` → `200 image/jpeg` without auth (intentional — see Deviation #28).
|
||||
- `curl -I https://tools.acherryontop.com/uploads/products/<nonexistent>.jpg` → `404` (not `401`) — confirms the gate is no longer in front of `/uploads/*`.
|
||||
- `curl -s https://tools.acherryontop.com/api/products` (no token) → `401` (gate still up for `/api/*` + `/chat-api/*`).
|
||||
- CORS preflight and origin-allowlist behavior unchanged from the post-§9.2 smoke above.
|
||||
|
||||
**Rollback:** restore `/etc/caddy/Caddyfile.bak.YYYY-MM-DD` and `curl -X POST .../load` against it.
|
||||
|
||||
### 9.3 — Frontend Mini\*.jsx fetch leaks
|
||||
|
||||
**Finding:** Three dashboard mini-widgets used raw `fetch()` against gated `/api/dashboard/*` endpoints, so the SPA would 401 these specific cards after Phase 6 went live.
|
||||
|
||||
**Files touched (landed this session):**
|
||||
- `inventory/src/components/dashboard/MiniInventorySnapshot.jsx` — 3 calls migrated to `apiFetch`.
|
||||
- `inventory/src/components/dashboard/MiniBusinessMetrics.jsx` — 2 calls migrated.
|
||||
- `inventory/src/components/dashboard/MiniSalesChart.jsx` — 1 call migrated.
|
||||
|
||||
**Sweep done:** `grep -rn "fetch(\`\${config.apiUrl}\|fetch(\`/api/\|fetch('/api/\|fetch(\"/api/" inventory/src` now returns no surviving raw-fetch sites against gated paths (other than the two AuthContext exceptions and `services/apiv2.ts` already excluded by Phase F1).
|
||||
|
||||
**Applied / verified:** `cd inventory && npm run build` completed successfully and the live frontend build now references the new asset hash. Raw `fetch()` sweep now shows only the documented AuthContext/App `/me` exceptions, `services/apiv2.ts`, and third-party OpenWeather calls.
|
||||
|
||||
### 9.4 — Vitest scaffold + auth-boundary tests
|
||||
|
||||
**Finding:** Plan promised a `vitest` scaffold and auth-boundary tests during Phase 2; never delivered. Root `package.json` has no `scripts` block; `inventory-server/package.json` has the default `echo "Error: no test specified"`.
|
||||
|
||||
**Files landed this session** (under `inventory-server/`):
|
||||
- `inventory-server/package.json` — adds `"test": "vitest run"`, `"test:watch": "vitest"`, plus `vitest` in `devDependencies`.
|
||||
- `inventory-server/shared/auth/verify.test.js` — five cases: valid token, expired token, wrong-signature token, malformed header, missing token.
|
||||
- `inventory-server/shared/auth/middleware.test.js` — request with no header → 401; bad header → 401; valid token + inactive user → 403; valid token + missing permission → 403; valid token + correct permission → next() called with `req.user` populated; cache TTL behavior (same token within 60s → one DB hit; after 61s → two).
|
||||
|
||||
The scaffold is intentionally narrow: it covers the security-critical surface and nothing else. Broader coverage is still a separate, larger project — see Out of scope.
|
||||
|
||||
**Applied / verified:** `cd /var/www/inventory && npm test` passes: 2 files, 21 auth-boundary tests.
|
||||
|
||||
### 9.5 — Plan-doc narrative reconciliation
|
||||
|
||||
**Finding:** The opening goal ("12 → 3 application servers + 1 auth-server", "ESM across all Node services") didn't match what shipped. Status row 13 overstated ESM coverage ("All 58 server-side files") in a way that hid chat-server from readers. Deliverables section line 845 acknowledged the chat-server gap but bullet 21 still claimed "all phases complete".
|
||||
|
||||
**Edits landed this session:**
|
||||
- Headline (line 3) rewritten to "13 PM2 processes to 5 application servers + 2 auxiliary processes = 7 total" + the "primary application Node services" qualifier on ESM.
|
||||
- Phase 3 status row clarified to scope ESM to inventory-server + auth-server, with explicit reference to Deviation #27.
|
||||
- Goals (line 34) restated with the actual outcome + the reason for the deviation.
|
||||
- Target-architecture caption corrected from `12 → 4` to `13 → 7`.
|
||||
- Concrete deliverables block rewritten to flag the four items where Phase 9 closes a gap (uploads gate, chat-server auth, chat-server ESM, edge CORS).
|
||||
- New status-table row added for Phase 9 itself.
|
||||
|
||||
**Deploy:** none — this is documentation only.
|
||||
|
||||
### 9.6 — Sequence + dependencies
|
||||
|
||||
| Step | Depends on | Risk | Notes |
|
||||
|---|---|---|---|
|
||||
| 9.5 plan-doc | — | none | Already done |
|
||||
| 9.3 frontend rebuild | 9.5 (no functional dep, just shipping order) | low | `npm run build` from inventory/ |
|
||||
| 9.4 vitest scaffold | — | none | Already done; run `npm test` to verify |
|
||||
| 9.1 chat-server reload | code conversion landed this session | medium | **Done** — PM2 reload complete; unauthenticated + authenticated smokes pass |
|
||||
| 9.2 Caddy reload | 9.1 (so the gate covers a service that already self-authenticates) | medium | **Done** — active Caddyfile includes Phase 9 edits; uploads/CORS smokes pass |
|
||||
|
||||
### 9.7 — Done criteria
|
||||
|
||||
Phase 9 completion checks:
|
||||
- ✅ `curl -s -o /dev/null -w '%{http_code}' http://localhost:3014/test-db` returns `401` (was: 200).
|
||||
- ⚠️ ~~`curl -sI https://tools.acherryontop.com/uploads/reusable/<any-real>.jpg` returns `401` without auth and `200` with valid Bearer.~~ **Superseded by Deviation #28.** Current expected behavior: `200 image/jpeg` without auth (uploads are public by design). The original Phase 9.7 criterion is preserved here as a record of what §9.2 verified at the time.
|
||||
- ✅ `curl -X OPTIONS ...` to a gated `/api/*` path returns `204` from the backend with a specific `Access-Control-Allow-Origin`, **not** `*`.
|
||||
- ✅ `cd /var/www/inventory && npm test` passes 100% on `shared/auth/*.test.js` (2 files, 21 tests).
|
||||
- ✅ `grep -rn "require(" inventory-server/chat/*.js` returns nothing.
|
||||
- ✅ Frontend build passes after the three Mini\*.jsx migrations.
|
||||
|
||||
---
|
||||
|
||||
## Testing strategy
|
||||
|
||||
No formal test suite exists today (per CLAUDE.md). For a refactor this size, that's a gap to close — but writing tests retroactively for 15K LOC of routes is a separate, larger project. For this refactor:
|
||||
@@ -828,19 +1016,21 @@ These came up in the audit but aren't part of this refactor:
|
||||
|
||||
## Concrete deliverables
|
||||
|
||||
State as of 2026-05-24: everything below is **shipped** except Phase 5 (acot-server ESM conversion), which is the only remaining work item. Note: the "4 application PM2 processes" original target became **5** in execution because `chat-server` stayed standalone rather than being folded in — never a serious merge candidate (different DB, different protocol shape).
|
||||
State as of 2026-05-24: Phases 1–9 are **shipped**. The "4 application PM2 processes" original target became **5** in execution because `chat-server` stayed standalone rather than being folded in — never a serious merge candidate (different DB, different protocol shape).
|
||||
|
||||
- ✅ 5 application PM2 processes instead of 12 (auth-server, inventory-server, dashboard-server, acot-server, chat-server) — plus 2 unchanged (acot-phone-server, lt-wordlist-api) = 7 total.
|
||||
- ✅ All `/api/*`, `/chat-api/*`, and `/uploads/*` requests gated at Caddy (`forward_auth`) and re-verified at each upstream (`authenticate()`).
|
||||
- ✅ All `/api/*` and `/chat-api/*` requests gated at Caddy (`forward_auth`). `/uploads/*` is **intentionally public** — it was gated as part of Phase 6.7 / §9.2, then reverted because the external PHP backend at `www.acherryontop.com` fetches image URLs during product import. See Deviation #28.
|
||||
- ✅ Per-upstream `authenticate()` re-verification on inventory-server, dashboard-server, acot-server, and chat-server.
|
||||
- ✅ Sensitive endpoints additionally gated by per-permission checks (`requirePermission`).
|
||||
- ⚠️ One ESM standard — done for auth/inventory/dashboard/chat. **acot-server still CJS (Phase 5 pending).**
|
||||
- ✅ **One ESM standard across primary application Node services** — auth-server, inventory-server, dashboard-server, acot-server, and chat-server are ESM.
|
||||
- ✅ One shared `lib/` at `inventory-server/shared/` for auth, logging, DB, errors, CORS.
|
||||
- ✅ Login rate-limited (`shared/rate-limit/login.js`).
|
||||
- ✅ `JWT_SECRET` rotated + ecosystem shadow-override removed.
|
||||
- ✅ Old auth-server, Aircall, Gorgias, Clarity directories deleted from the repo. Defunct `dashboard:gorgias`/`dashboard:calls` permission rows also deleted from DB (2026-05-24).
|
||||
- ✅ Caddyfile slimmed to one auth-gated block.
|
||||
- ✅ Caddyfile slimmed to one auth-gated block. Phase 9 §9.2 drops the edge CORS wildcard and adds a CORS preflight bypass; the `@static`-matcher tightening from §9.2 was rolled back as part of the Deviation #28 uploads-gate revert (uploads now served via a dedicated public `handle /uploads/*` block placed ahead of `@static`).
|
||||
- ✅ Permission codes inserted into `permissions` table for granular authorization.
|
||||
- ✅ No half-finished pieces, no `// TODO: add auth later` comments, no deferred secrets cleanup.
|
||||
- ✅ Both gaps surfaced during Phase 5 verification — `lt-wordlist-api` insecure default token (Deviation #25) and Caddy blocking acot-phone-server's `x-acot-api-key` calls (Deviation #26) — were closed 2026-05-24.
|
||||
- ✅ **Six second-look findings closed by Phase 9** (frontend fetch leaks, edge CORS wildcard, uploads-gate ordering, chat-server in-process auth + ESM, vitest scaffold, plan-doc/goal mismatch). Code and deploy steps are complete. *The uploads-gate fix was later reverted (Deviation #28) — the gate was incompatible with the external-server image-fetch contract; what `@static` slip-through actually revealed was that uploads belong in front of the gate, not behind it.*
|
||||
|
||||
---
|
||||
|
||||
@@ -897,3 +1087,55 @@ These are decisions made during Phase 1/2 implementation that amend the spec abo
|
||||
- `acot-phone-server` script is `/var/www/acot-phone/dist/server.js` (was `./inventory/acot-phone/server.js` in the live file — wrong; that path doesn't exist). `/var/www/acot-phone/` is matt:matt with its own `.env` and is a separate repo from inventory-server.
|
||||
|
||||
23. **Phase 6.10 ADD_WORD_TOKEN move stays in this ecosystem.** Per Deviation #22, `lt-wordlist-api` is in matt's ecosystem, so the §6.10 work to remove inline `ADD_WORD_TOKEN` and load it from `/opt/lt-wordlist-api/.env` instead is implemented directly in `deploy/ecosystem.config.cjs.proposed` (no inline `ADD_WORD_TOKEN`; script reads its own .env). When applying, rotate the token value in `/opt/lt-wordlist-api/.env` and update any callers.
|
||||
|
||||
24. **Phase 6 patterns applied to acot-server during Phase 5.** acot-server was originally planned to convert mechanically (require → import) and inherit Phase 6 hardening later. Done in a single pass instead: the new `server.js` mounts `shared/logging/request-log.js`, `shared/cors/policy.js`, `shared/errors/handler.js`, and `shared/auth/middleware.js`'s `authenticate()` on `/api/acot/*` (except the customers router — see Phase 5 auth-model section above). Adds `pg` dependency to `inventory-server/dashboard/acot-server/package.json` because the Postgres pool for `authenticate()`'s user/permission lookups is initialized in-process. Env layering follows dashboard-server's pattern: `/var/www/inventory/.env` loaded first (JWT_SECRET, DB_*), local `.env` loaded second (PROD_DB_*, PROD_SSH_*, ACOT_PHONE_API_KEY). No `acot_admin` permission gates wired — none of the routes mutate state in ways that warrant per-permission checks today; the seeded code in `migrations/005_phase6_permission_codes.sql` stays reserved.
|
||||
|
||||
25. **Phase 6.10 fixed 2026-05-24 via option (b).** Discovered during Phase 5 closeout that `/opt/lt-wordlist-api/.env` didn't exist, no `ADD_WORD_TOKEN` env var was set on the running process, and the script's `process.env.ADD_WORD_TOKEN || 'tokenhere'` fallback was the only gate — meaning `curl -X POST -H "x-add-word-token: tokenhere" http://localhost:3030/add-word` succeeded in production.
|
||||
|
||||
**Fix applied:**
|
||||
- Generated a fresh 32-byte hex token via `openssl rand -hex 32`.
|
||||
- Wrote it to `/opt/lt-wordlist-api/.env` (matt:matt, mode 0600).
|
||||
- Edited `/var/www/ecosystem.config.cjs`'s `lt-wordlist-api` entry to add `node_args: ['--env-file=/opt/lt-wordlist-api/.env']`. Node ≥20.6 (netcup runs v22.22.2) reads the file at startup with no script changes and no `dotenv` import — the cleanest of the three options because the token never lives in the committed ecosystem file.
|
||||
- `pm2 reload /var/www/ecosystem.config.cjs --only lt-wordlist-api --update-env` picked up the new wrapper config. PM2 restart count 1 → 2, clean startup.
|
||||
|
||||
**Verified:** old default `tokenhere` now returns `{"error":"unauthorized"}` HTTP 401; new env-file token returns `{"ok":true,...}` HTTP 200 on `/add-word` and `/delete-word`. To rotate again: edit `/opt/lt-wordlist-api/.env` + `pm2 restart lt-wordlist-api --update-env`.
|
||||
|
||||
**Caller coordination:** user confirmed all callers are external and will be updated as issues surface; no inventory of callers to pre-notify.
|
||||
|
||||
26. **Caddy `forward_auth` gate breaks acot-phone-server's customer lookups — fixed 2026-05-24 via option (a).** Phase 6.1 put `/api/acot/*` behind `forward_auth localhost:3011/verify`, which strictly requires a JWT Bearer token. But `acot-phone-server` (at `/var/www/acot-phone/`) calls `/api/acot/customers/by-phone`, `/api/acot/customers/search`, `/api/acot/customers/:cid/orders` using only an `x-acot-api-key` header (`/var/www/acot-phone/src/services/acotApi.ts:51`). Result: every customer lookup from the phone app hit a 401 at the Caddy gate before reaching `requirePhoneApiKey`. Last successful customer call in acot-server's access log was 2026-05-21 — three days before the Caddy cutover.
|
||||
|
||||
**Fix applied — option (a):** changed `ACOT_API_URL` in `/var/www/acot-phone/.env` (and `acot-phone-server/.env.example` and the local repo copy) from `https://tools.acherryontop.com/api/acot` to `http://localhost:3012/api/acot`. Both processes live on netcup, so the request never enters Caddy and lands directly on `requirePhoneApiKey` in-process. Restarted via `pm2 restart acot-phone-server --update-env`; smoke-tested with `curl -H "x-acot-api-key: ..." http://localhost:3012/api/acot/customers/by-phone?phone=...` → 200 with the real customer record.
|
||||
|
||||
(Alternative option (b), kept here for posterity: add a `@phone_auth header x-acot-api-key *` guard in the Caddyfile to bypass `forward_auth` for requests bearing the shared secret. Would have worked too but introduces a header-based bypass in the gate, which is a worse security posture than just not entering Caddy at all.)
|
||||
|
||||
27. **chat-server stayed CJS through Phase 8, converted to ESM in Phase 9 (2026-05-24).** Why it stayed CJS originally: during Phase 4 scoping, chat-server was a notional merge candidate (fold its routes into `dashboard-server`) but rejected on inspection — it speaks a different DB (`rocketchat_converted`), a different protocol shape (file proxying + message search, not vendor-API aggregation), and shares no service code with the dashboard merge surface. Once "no merge" was decided, the secondary case for converting it (consistency, sharing `shared/auth/middleware.js`) wasn't strong enough to make it worth the Phase 4 testing budget. Phase 6 hardening also skipped it because the Caddy gate was assumed sufficient.
|
||||
|
||||
The second-look audit (2026-05-24) flagged two consequences:
|
||||
- The plan's literal claim of "ESM across all Node services" was false.
|
||||
- chat-server had only one defense layer (Caddy `forward_auth`) — `curl http://localhost:3014/test-db` returned 200 unauthenticated.
|
||||
|
||||
Phase 9 §9.1 closes both: chat-server is now ESM, mounts `authenticate()` against an in-process `inventory_db` pool (separate from the existing `rocketchat_converted` pool which routes.js uses via `global.pool`), and binds to `127.0.0.1` so external direct-port access is no longer possible even if the host firewall rule were dropped. Conversion was 3 files (`server.js`, `routes.js`, `package.json`) and minimal because routes.js's only top-level imports were `express` and `path` — handler bodies were untouched.
|
||||
|
||||
28. **Phase 6.7 / §9.2 `/uploads/*` gating reverted (2026-05-24).** Phase 6.7 specified moving `/uploads/*` behind `forward_auth`, and Phase 9 §9.2 hardened that by excluding `/uploads/*` from the `@static` matcher so uploaded `*.jpg` paths couldn't slip past the gate via the SPA build root. The SPA was patched (`AuthedImage` in `inventory/src/components/ui/authed-image.tsx`) to send Bearer headers on `/uploads/*` GETs.
|
||||
|
||||
**The breakage:** Both `/uploads/products/*` and `/uploads/reusable/*` URLs are submitted as strings inside the `product_images` field to `/apiv2/product/setup_new`, which Caddy reverse-proxies to the external PHP backend at `www.acherryontop.com`. That backend then **fetches the image URLs server-to-server** to ingest them into the shop. The external backend has no JWT and never authenticates against our `localhost:3011/verify` endpoint — so after the gate went live, every external import-time image fetch hit 401 and the product-import flow silently dropped images.
|
||||
|
||||
The original Phase 6.7 / F1 notes reserved this exact escape hatch (CONSOLIDATION_PLAN.md "Note on `/uploads/*` gating": *"if broken, revert this part of 6.7 to keep `/uploads/*` public, OR issue per-image signed URLs from the API"*).
|
||||
|
||||
**Fix applied — option (a):** revert the gate. In the live Caddyfile:
|
||||
- Removed `/uploads/*` from the `@gated` matcher.
|
||||
- Removed the `not path /uploads/*` exclusion from `@static` (no longer needed).
|
||||
- Added a dedicated public `handle /uploads/*` rooted at `/var/www/inventory` (NOT the SPA build root — paths must resolve to the real uploads directory). Placed ahead of both `@static` and `@gated` so it wins the match.
|
||||
|
||||
Staged via `inventory-server/deploy/Caddyfile.proposed` and applied via the Deviation #8 pattern (admin-API `curl -X POST :2020/load` + `sudo cp` to persist).
|
||||
|
||||
**Verified live:**
|
||||
- `curl -sI https://tools.acherryontop.com/uploads/reusable/<real>.jpg` → `200 image/jpeg` without auth.
|
||||
- `curl -sI https://tools.acherryontop.com/uploads/products/<nonexistent>.jpg` → `404` (not `401`) — confirms gate is no longer in front of `/uploads/*`.
|
||||
- `curl -sI https://tools.acherryontop.com/api/products` (no token) → `401` — `@gated` is still live for `/api/*` + `/chat-api/*`.
|
||||
|
||||
**Rationale for staying public rather than signing URLs:** Per-image HMAC signed URLs would have worked too but require a small change on the inventory server (URL generation + signature validation in Caddy or upstream) and would flow through the PHP backend untouched. Plain public access is acceptable because: (a) uploaded image URLs use random unguessable filenames (`createUploadFilename` in `inventory-server/src/routes/import.js` — timestamp + 9-digit `Math.random` suffix), (b) `/uploads/products/*` files auto-delete after 24h via `scheduleImageDeletion`, and (c) no PII or confidential data lives in this bucket today. If that ever changes, implement signed URLs rather than re-gating wholesale.
|
||||
|
||||
**AuthedImage:** Left in place as defense-in-depth. Bearer header is harmless when the path isn't gated, so existing call sites keep working unchanged. No rush to rip it out.
|
||||
|
||||
**Doc updates this session:** the Status table's Phase 9 row, Phase 6.1 row, Phase 6.7 row, the inline 6.7 bullet, the F1 "Note on `/uploads/*` gating" subsection, the Phase 7 Caddyfile diagram, the Phase 9 §9.2 narrative + smoke results, and the Concrete deliverables checklist were all updated to reflect the revert.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "chat-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Chat archive server for Rocket.Chat data",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
@@ -12,7 +13,10 @@
|
||||
"cors": "^2.8.5",
|
||||
"pg": "^8.11.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"morgan": "^1.10.0"
|
||||
"morgan": "^1.10.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^10.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.22"
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
import express from 'express';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ESM polyfill — Phase 9 §9.1. Handlers below use __dirname to resolve the
|
||||
// db-convert/db/files/{uploads,avatars} static asset paths.
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Serve uploaded files with proper mapping from database paths to actual file locations
|
||||
@@ -646,4 +653,4 @@ router.get('/users/:userId/search', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
|
||||
@@ -1,23 +1,62 @@
|
||||
require('dotenv').config({ path: '../.env' });
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { Pool } = require('pg');
|
||||
const morgan = require('morgan');
|
||||
const chatRoutes = require('./routes');
|
||||
// chat-server — Phase 9 §9.1 of CONSOLIDATION_PLAN.md.
|
||||
//
|
||||
// ESM conversion + in-process authenticate() defense-in-depth. Previously this
|
||||
// service relied on the Caddy `forward_auth` gate alone — `localhost:3014`
|
||||
// was reachable unauthenticated. Now:
|
||||
// 1. Bound to 127.0.0.1 (was 0.0.0.0) so direct-port access is impossible.
|
||||
// 2. authenticate() runs against an in-process `inventory_db` pool before
|
||||
// any route handler sees the request.
|
||||
//
|
||||
// Two pools intentionally:
|
||||
// - `inventoryPool`: used by authenticate() for users/permissions lookups
|
||||
// against the main inventory_db (matches DB_* env vars).
|
||||
// - `pool` (set as global.pool for routes.js): the existing
|
||||
// `rocketchat_converted` pool driven by CHAT_DB_* env vars. routes.js
|
||||
// reads global.pool throughout — no handler-body changes needed.
|
||||
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import morgan from 'morgan';
|
||||
import pg from 'pg';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { authenticate } from '../shared/auth/middleware.js';
|
||||
import { corsOptions } from '../shared/cors/policy.js';
|
||||
import { errorHandler } from '../shared/errors/handler.js';
|
||||
import { requestLog } from '../shared/logging/request-log.js';
|
||||
|
||||
import chatRoutes from './routes.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Env layering matches dashboard-server (Deviation #18): shared .env wins on
|
||||
// collisions for security-critical vars, local .env supplies CHAT_DB_*.
|
||||
const sharedEnvPath = '/var/www/inventory/.env';
|
||||
const localEnvPath = path.resolve(__dirname, '.env');
|
||||
if (fs.existsSync(sharedEnvPath)) loadEnv({ path: sharedEnvPath });
|
||||
if (fs.existsSync(localEnvPath)) loadEnv({ path: localEnvPath });
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.error('JWT_SECRET is not set; refusing to start (per Phase 6.4)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.CHAT_PORT) || 3014;
|
||||
|
||||
// Log startup configuration
|
||||
console.log('Starting chat server with config:', {
|
||||
host: process.env.CHAT_DB_HOST,
|
||||
user: process.env.CHAT_DB_USER,
|
||||
database: process.env.CHAT_DB_NAME || 'rocketchat_converted',
|
||||
port: process.env.CHAT_DB_PORT,
|
||||
chat_port: process.env.CHAT_PORT || 3014
|
||||
chat_port: port,
|
||||
});
|
||||
|
||||
const app = express();
|
||||
const port = process.env.CHAT_PORT || 3014;
|
||||
|
||||
// Database configuration for rocketchat_converted database
|
||||
// Rocket.Chat archive pool — routes.js reads it via global.pool.
|
||||
const pool = new Pool({
|
||||
host: process.env.CHAT_DB_HOST,
|
||||
user: process.env.CHAT_DB_USER,
|
||||
@@ -25,59 +64,69 @@ const pool = new Pool({
|
||||
database: process.env.CHAT_DB_NAME || 'rocketchat_converted',
|
||||
port: process.env.CHAT_DB_PORT,
|
||||
});
|
||||
|
||||
// Make pool available globally
|
||||
global.pool = pool;
|
||||
|
||||
// Middleware
|
||||
// inventory_db pool — used by authenticate() for user/permission lookups.
|
||||
const inventoryPool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: Number(process.env.DB_PORT) || 5432,
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
|
||||
app.use(requestLog());
|
||||
app.use(express.json());
|
||||
app.use(morgan('combined'));
|
||||
app.use(cors({
|
||||
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site', 'https://tools.acherryontop.com'],
|
||||
credentials: true
|
||||
}));
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// Test database connection endpoint
|
||||
app.get('/test-db', async (req, res) => {
|
||||
// /health stays unauthenticated for out-of-band probes — mounted BEFORE
|
||||
// authenticate() so monitoring tools on the host can poll without a JWT.
|
||||
// Only reachable via localhost:3014 directly (Caddy routes /health to
|
||||
// inventory-server:3010, not here).
|
||||
app.get('/health', (req, res) => res.json({ status: 'healthy' }));
|
||||
|
||||
// Phase 9 §9.1 — per-server auth re-verification. Every chat route must pass
|
||||
// authenticate() in addition to the Caddy forward_auth gate.
|
||||
app.use(authenticate({ pool: inventoryPool, secret: process.env.JWT_SECRET }));
|
||||
|
||||
app.get('/test-db', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT COUNT(*) as user_count FROM users WHERE active = true');
|
||||
const messageResult = await pool.query('SELECT COUNT(*) as message_count FROM message');
|
||||
const roomResult = await pool.query('SELECT COUNT(*) as room_count FROM room');
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
database: 'rocketchat_converted',
|
||||
stats: {
|
||||
active_users: parseInt(result.rows[0].user_count),
|
||||
total_messages: parseInt(messageResult.rows[0].message_count),
|
||||
total_rooms: parseInt(roomResult.rows[0].room_count)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Database test error:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
error: 'Database connection failed',
|
||||
details: error.message
|
||||
active_users: parseInt(result.rows[0].user_count, 10),
|
||||
total_messages: parseInt(messageResult.rows[0].message_count, 10),
|
||||
total_rooms: parseInt(roomResult.rows[0].room_count, 10),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Mount all routes from routes.js
|
||||
app.use('/', chatRoutes);
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'healthy' });
|
||||
app.use(errorHandler);
|
||||
|
||||
// Phase 9 §9.1 — bind to 127.0.0.1. Caddy reverse_proxy targets localhost:3014
|
||||
// already; this closes the gap where unauthenticated direct-port access from
|
||||
// any host on the network was possible.
|
||||
const server = app.listen(port, '127.0.0.1', () => {
|
||||
console.log(`Chat server running on 127.0.0.1:${port}`);
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({ error: 'Something broke!' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(port, () => {
|
||||
console.log(`Chat server running on port ${port}`);
|
||||
});
|
||||
const shutdown = async (signal) => {
|
||||
console.log(`chat-server shutting down (${signal})`);
|
||||
server.close();
|
||||
try { await pool.end(); } catch { /* ignore */ }
|
||||
try { await inventoryPool.end(); } catch { /* ignore */ }
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const { Client } = require('ssh2');
|
||||
const mysql = require('mysql2/promise');
|
||||
const fs = require('fs');
|
||||
// Per Deviation #13 in CONSOLIDATION_PLAN.md: `ssh2` is CJS and its named export
|
||||
// (`Client`) isn't reliably detected by Node's CJS→ESM interop static analysis.
|
||||
// Default-import + destructure is the bulletproof pattern.
|
||||
import ssh2 from 'ssh2';
|
||||
import mysql from 'mysql2/promise';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const { Client } = ssh2;
|
||||
|
||||
// Connection pool configuration
|
||||
const connectionPool = {
|
||||
@@ -288,10 +293,10 @@ function getPoolStatus() {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
getDbConnection,
|
||||
getCachedQuery,
|
||||
clearQueryCache,
|
||||
closeAllConnections,
|
||||
getPoolStatus
|
||||
getPoolStatus,
|
||||
};
|
||||
@@ -15,6 +15,7 @@
|
||||
"luxon": "^3.5.0",
|
||||
"morgan": "^1.10.0",
|
||||
"mysql2": "^3.6.5",
|
||||
"pg": "^8.21.0",
|
||||
"ssh2": "^1.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1142,6 +1143,95 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.13.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.14.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@@ -1155,6 +1245,45 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -1416,6 +1545,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sqlstring": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||
@@ -1548,6 +1686,15 @@
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,24 @@
|
||||
"name": "acot-server",
|
||||
"version": "1.0.0",
|
||||
"description": "A Cherry On Top production database server",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"luxon": "^3.5.0",
|
||||
"morgan": "^1.10.0",
|
||||
"ssh2": "^1.14.0",
|
||||
"mysql2": "^3.6.5",
|
||||
"compression": "^1.7.4",
|
||||
"luxon": "^3.5.0"
|
||||
"pg": "^8.21.0",
|
||||
"ssh2": "^1.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
// NOTE: `users.phone` is not yet indexed in production. Admin will add
|
||||
// `idx_phone (phone)` — queries here assume that exists for acceptable latency.
|
||||
|
||||
const express = require('express');
|
||||
import express from 'express';
|
||||
import { getDbConnection, getCachedQuery } from '../db/connection.js';
|
||||
import { requirePhoneApiKey } from '../utils/phoneAuth.js';
|
||||
|
||||
const router = express.Router();
|
||||
const { getDbConnection, getCachedQuery } = require('../db/connection');
|
||||
const { requirePhoneApiKey } = require('../utils/phoneAuth');
|
||||
|
||||
// Order status labels mirror ACOTCustomerDataServiceProvider.php.
|
||||
const ORDER_STATUS_LABEL = {
|
||||
@@ -319,4 +320,4 @@ router.get('/:cid/orders', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { DateTime } = require('luxon');
|
||||
const { getDbConnection } = require('../db/connection');
|
||||
import express from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getDbConnection } from '../db/connection.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -573,4 +573,4 @@ router.post('/simulate', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
const express = require('express');
|
||||
const { DateTime } = require('luxon');
|
||||
import express from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getDbConnection, getPoolStatus } from '../db/connection.js';
|
||||
import { getTimeRangeConditions, _internal as timeHelpers } from '../utils/timeUtils.js';
|
||||
|
||||
const router = express.Router();
|
||||
const { getDbConnection, getPoolStatus } = require('../db/connection');
|
||||
const {
|
||||
getTimeRangeConditions,
|
||||
_internal: timeHelpers
|
||||
} = require('../utils/timeUtils');
|
||||
|
||||
const TIMEZONE = 'America/New_York';
|
||||
|
||||
@@ -680,4 +677,4 @@ function getPreviousTimeRange(timeRange) {
|
||||
return map[timeRange] || timeRange;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
const express = require('express');
|
||||
const { DateTime } = require('luxon');
|
||||
|
||||
const router = express.Router();
|
||||
const { getDbConnection, getPoolStatus } = require('../db/connection');
|
||||
const {
|
||||
import express from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getDbConnection, getPoolStatus } from '../db/connection.js';
|
||||
import {
|
||||
getTimeRangeConditions,
|
||||
formatBusinessDate,
|
||||
getBusinessDayBounds,
|
||||
_internal: timeHelpers
|
||||
} = require('../utils/timeUtils');
|
||||
_internal as timeHelpers,
|
||||
} from '../utils/timeUtils.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const TIMEZONE = 'America/New_York';
|
||||
const BUSINESS_DAY_START_HOUR = timeHelpers?.BUSINESS_DAY_START_HOUR ?? 1;
|
||||
@@ -1794,4 +1794,5 @@ router.get('/debug/pool', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
const express = require('express');
|
||||
const { DateTime } = require('luxon');
|
||||
import express from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getDbConnection, getPoolStatus } from '../db/connection.js';
|
||||
import { getTimeRangeConditions } from '../utils/timeUtils.js';
|
||||
|
||||
const router = express.Router();
|
||||
const { getDbConnection, getPoolStatus } = require('../db/connection');
|
||||
const {
|
||||
getTimeRangeConditions,
|
||||
} = require('../utils/timeUtils');
|
||||
|
||||
const TIMEZONE = 'America/New_York';
|
||||
|
||||
@@ -481,4 +479,4 @@ function getPreviousTimeRange(timeRange) {
|
||||
return map[timeRange] || timeRange;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const { DateTime } = require('luxon');
|
||||
import express from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getDbConnection, getPoolStatus } from '../db/connection.js';
|
||||
|
||||
const router = express.Router();
|
||||
const { getDbConnection, getPoolStatus } = require('../db/connection');
|
||||
|
||||
const TIMEZONE = 'America/New_York';
|
||||
|
||||
@@ -502,4 +502,4 @@ function isCurrentPayPeriod(payPeriod) {
|
||||
return now >= payPeriod.start && now <= payPeriod.end;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
import express from 'express';
|
||||
import { getDbConnection, getCachedQuery } from '../db/connection.js';
|
||||
|
||||
const router = express.Router();
|
||||
const { getDbConnection, getCachedQuery } = require('../db/connection');
|
||||
|
||||
// Test endpoint to count orders
|
||||
router.get('/order-count', async (req, res) => {
|
||||
@@ -54,4 +55,4 @@ router.get('/test-connection', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
@@ -1,103 +1,158 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
const compression = require('compression');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { closeAllConnections } = require('./db/connection');
|
||||
// acot-server — Phase 5 of CONSOLIDATION_PLAN.md.
|
||||
// Standalone service on ACOT_PORT (default 3012) exposing /api/acot/* against
|
||||
// the production MySQL `sg` database via an ssh2 tunnel (see db/connection.js).
|
||||
//
|
||||
// Auth model (two flavors, deliberate):
|
||||
// - /api/acot/customers/* : x-acot-api-key shared secret (used by acot-phone-server).
|
||||
// Mounted BEFORE authenticate() so its requirePhoneApiKey
|
||||
// path is the only gate.
|
||||
// - everything else : JWT Bearer via shared/auth/middleware.js authenticate().
|
||||
// Defense-in-depth on top of Caddy forward_auth.
|
||||
//
|
||||
// Shared infrastructure (Phase 2 + Phase 6):
|
||||
// - shared/auth/middleware.js authenticate() for SPA-served routes
|
||||
// - shared/cors/policy.js explicit allowed-origins list (Phase 6.6)
|
||||
// - shared/logging/request-log.js pino-http, Authorization/Cookie redacted (Phase 6.5/6.9)
|
||||
// - shared/errors/handler.js consistent error envelope, no leak in prod
|
||||
//
|
||||
// Env layering: /var/www/inventory/.env loaded FIRST (JWT_SECRET, DB_* for the
|
||||
// shared PG pool used by authenticate to look up user permissions). Local .env
|
||||
// loaded SECOND for ACOT-specific keys (PROD_DB_*, PROD_SSH_*, ACOT_PHONE_API_KEY).
|
||||
// dotenv defaults to override:false, so the first file wins on collisions.
|
||||
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import compression from 'compression';
|
||||
import morgan from 'morgan';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import pg from 'pg';
|
||||
|
||||
import { authenticate } from '../../shared/auth/middleware.js';
|
||||
import { corsOptions } from '../../shared/cors/policy.js';
|
||||
import { errorHandler } from '../../shared/errors/handler.js';
|
||||
import { logger } from '../../shared/logging/logger.js';
|
||||
import { requestLog } from '../../shared/logging/request-log.js';
|
||||
|
||||
import { closeAllConnections } from './db/connection.js';
|
||||
|
||||
import testRouter from './routes/test.js';
|
||||
import eventsRouter from './routes/events.js';
|
||||
import discountsRouter from './routes/discounts.js';
|
||||
import employeeMetricsRouter from './routes/employee-metrics.js';
|
||||
import payrollMetricsRouter from './routes/payroll-metrics.js';
|
||||
import operationsMetricsRouter from './routes/operations-metrics.js';
|
||||
import customersRouter from './routes/customers.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Layer envs: shared inventory .env first (JWT_SECRET, DB_*) then acot .env.
|
||||
const sharedEnvPath = '/var/www/inventory/.env';
|
||||
const localEnvPath = path.resolve(__dirname, '.env');
|
||||
if (fs.existsSync(sharedEnvPath)) loadEnv({ path: sharedEnvPath });
|
||||
if (fs.existsSync(localEnvPath)) loadEnv({ path: localEnvPath });
|
||||
|
||||
// Phase 6.4 — refuse to start without JWT_SECRET. authenticate() would reject
|
||||
// every request anyway; failing fast surfaces the misconfiguration immediately.
|
||||
if (!process.env.JWT_SECRET) {
|
||||
logger.error('JWT_SECRET is not set; refusing to start (per Phase 6.4)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.ACOT_PORT || 3012;
|
||||
const PORT = Number(process.env.ACOT_PORT) || 3012;
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
// Postgres pool for authenticate() (user/permission lookups against inventory_db).
|
||||
// All MySQL access goes through db/connection.js (separate, ssh-tunneled).
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: Number(process.env.DB_PORT) || 5432,
|
||||
});
|
||||
|
||||
// Per-app access log on disk (kept from pre-conversion behavior; pino request-log
|
||||
// is mounted below for structured/redacted server-side logging).
|
||||
const logDir = path.join(__dirname, 'logs/app');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
const accessLogStream = fs.createWriteStream(path.join(logDir, 'access.log'), { flags: 'a' });
|
||||
|
||||
// Create a write stream for access logs
|
||||
const accessLogStream = fs.createWriteStream(
|
||||
path.join(logDir, 'access.log'),
|
||||
{ flags: 'a' }
|
||||
);
|
||||
|
||||
// Middleware
|
||||
app.use(requestLog());
|
||||
app.use(compression());
|
||||
app.use(cors());
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Logging middleware
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(morgan('combined', { stream: accessLogStream }));
|
||||
} else {
|
||||
app.use(morgan('dev'));
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: 'acot-server',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/acot/test', require('./routes/test'));
|
||||
app.use('/api/acot/events', require('./routes/events'));
|
||||
app.use('/api/acot/discounts', require('./routes/discounts'));
|
||||
app.use('/api/acot/employee-metrics', require('./routes/employee-metrics'));
|
||||
app.use('/api/acot/payroll-metrics', require('./routes/payroll-metrics'));
|
||||
app.use('/api/acot/operations-metrics', require('./routes/operations-metrics'));
|
||||
app.use('/api/acot/customers', require('./routes/customers'));
|
||||
// Customers route uses x-acot-api-key (shared secret with acot-phone-server),
|
||||
// NOT JWT — mount BEFORE authenticate() so requirePhoneApiKey is the only gate.
|
||||
app.use('/api/acot/customers', customersRouter);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: process.env.NODE_ENV === 'production'
|
||||
? 'Internal server error'
|
||||
: err.message
|
||||
});
|
||||
});
|
||||
// All remaining /api/acot/* routes require a valid JWT.
|
||||
app.use('/api/acot', authenticate({ pool, secret: process.env.JWT_SECRET }));
|
||||
|
||||
// 404 handler
|
||||
app.use('/api/acot/test', testRouter);
|
||||
app.use('/api/acot/events', eventsRouter);
|
||||
app.use('/api/acot/discounts', discountsRouter);
|
||||
app.use('/api/acot/employee-metrics', employeeMetricsRouter);
|
||||
app.use('/api/acot/payroll-metrics', payrollMetricsRouter);
|
||||
app.use('/api/acot/operations-metrics', operationsMetricsRouter);
|
||||
|
||||
// 404 for unmatched /api routes (keeps prior behavior).
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Route not found'
|
||||
});
|
||||
res.status(404).json({ success: false, error: 'Route not found' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`ACOT Server running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV}`);
|
||||
app.use(errorHandler);
|
||||
|
||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
logger.info({ port: PORT, mode: process.env.NODE_ENV || 'development' }, 'acot-server listening');
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const gracefulShutdown = async () => {
|
||||
console.log('SIGTERM signal received: closing HTTP server');
|
||||
const gracefulShutdown = async (signal) => {
|
||||
logger.info({ signal }, 'acot-server shutting down');
|
||||
server.close(async () => {
|
||||
console.log('HTTP server closed');
|
||||
|
||||
// Close database connections
|
||||
try {
|
||||
await closeAllConnections();
|
||||
console.log('Database connections closed');
|
||||
} catch (error) {
|
||||
console.error('Error closing database connections:', error);
|
||||
} catch (err) {
|
||||
logger.error({ err: { message: err.message } }, 'error closing MySQL pool');
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.end();
|
||||
} catch { /* ignore */ }
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGTERM', gracefulShutdown);
|
||||
process.on('SIGINT', gracefulShutdown);
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
module.exports = app;
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.error({ err: { message: err.message, stack: err.stack } }, 'uncaughtException');
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error({ reason }, 'unhandledRejection');
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// The acot-phone-server sends `x-acot-api-key` on every request; we compare
|
||||
// against ACOT_PHONE_API_KEY from the environment using timing-safe comparison.
|
||||
|
||||
const crypto = require('crypto');
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function requirePhoneApiKey(req, res, next) {
|
||||
export function requirePhoneApiKey(req, res, next) {
|
||||
const expected = process.env.ACOT_PHONE_API_KEY;
|
||||
if (!expected) {
|
||||
console.error('ACOT_PHONE_API_KEY not configured; rejecting all requests');
|
||||
@@ -24,5 +24,3 @@ function requirePhoneApiKey(req, res, next) {
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { requirePhoneApiKey };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { DateTime } = require('luxon');
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
const TIMEZONE = 'America/New_York';
|
||||
const DB_TIMEZONE = 'UTC-05:00';
|
||||
@@ -294,19 +294,24 @@ const formatMySQLDate = (input) => {
|
||||
return dt.setZone(DB_TIMEZONE).toFormat(DB_DATETIME_FORMAT);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// Expose helpers for tests or advanced consumers.
|
||||
// Kept as a named `_internal` export so existing destructuring sites
|
||||
// (`const { _internal: timeHelpers } = require(...)` → ESM equivalent works)
|
||||
// don't need to change beyond the import-statement rewrite.
|
||||
const _internal = {
|
||||
getDayStart,
|
||||
getDayEnd,
|
||||
getWeekStart,
|
||||
getRangeForTimeRange,
|
||||
BUSINESS_DAY_START_HOUR,
|
||||
};
|
||||
|
||||
export {
|
||||
getBusinessDayBounds,
|
||||
getTimeRangeConditions,
|
||||
formatBusinessDate,
|
||||
getTimeRangeLabel,
|
||||
parseBusinessDate,
|
||||
formatMySQLDate,
|
||||
// Expose helpers for tests or advanced consumers
|
||||
_internal: {
|
||||
getDayStart,
|
||||
getDayEnd,
|
||||
getWeekStart,
|
||||
getRangeForTimeRange,
|
||||
BUSINESS_DAY_START_HOUR
|
||||
}
|
||||
_internal,
|
||||
};
|
||||
|
||||
Generated
+1439
-1
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@
|
||||
"prod:logs": "pm2 logs inventory-server",
|
||||
"prod:status": "pm2 status inventory-server",
|
||||
"setup": "mkdir -p logs uploads",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -43,6 +44,7 @@
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
"nodemon": "^3.0.2",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Phase 9 §9.4 — vitest scaffold + auth-boundary tests.
|
||||
//
|
||||
// Covers shared/auth/middleware.js. Mocks the Postgres pool with a thin
|
||||
// in-memory fake — no real DB required.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authenticate, requirePermission } from './middleware.js';
|
||||
|
||||
const SECRET = 'test-secret-please-do-not-use-in-prod';
|
||||
|
||||
function makeFakePool(users, permissions = {}) {
|
||||
const calls = { count: 0 };
|
||||
return {
|
||||
calls,
|
||||
query: vi.fn(async (sql, params) => {
|
||||
calls.count += 1;
|
||||
if (sql.includes('FROM users WHERE id')) {
|
||||
const user = users[params[0]];
|
||||
return { rows: user ? [user] : [] };
|
||||
}
|
||||
if (sql.includes('FROM permissions')) {
|
||||
return { rows: (permissions[params[0]] || []).map((code) => ({ code })) };
|
||||
}
|
||||
return { rows: [] };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeReq(authHeader) {
|
||||
return { headers: authHeader ? { authorization: authHeader } : {} };
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {};
|
||||
res.status = vi.fn(() => res);
|
||||
res.json = vi.fn(() => res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('authenticate middleware', () => {
|
||||
let activeUser;
|
||||
let inactiveUser;
|
||||
let validToken;
|
||||
|
||||
beforeEach(() => {
|
||||
activeUser = { id: 1, username: 'alice', email: 'a@x', is_admin: false, is_active: true };
|
||||
inactiveUser = { id: 2, username: 'bob', email: 'b@x', is_admin: false, is_active: false };
|
||||
validToken = jwt.sign({ userId: 1 }, SECRET, { expiresIn: '1h' });
|
||||
});
|
||||
|
||||
it('returns 401 when no Authorization header is present', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(makeReq(), res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when Authorization is not Bearer', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(makeReq('Basic abc'), res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when token is malformed', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(makeReq('Bearer not-a-jwt'), res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 when the user is inactive', async () => {
|
||||
const inactiveToken = jwt.sign({ userId: 2 }, SECRET, { expiresIn: '1h' });
|
||||
const pool = makeFakePool({ 2: inactiveUser });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(makeReq(`Bearer ${inactiveToken}`), res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next() and populates req.user for a valid token + active user', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser }, { 1: ['products:read'] });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const req = makeReq(`Bearer ${validToken}`);
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(req, res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user).toBeDefined();
|
||||
expect(req.user.id).toBe(1);
|
||||
expect(req.user.permissions).toEqual(['products:read']);
|
||||
});
|
||||
|
||||
it('caches the user lookup — same token within TTL → one DB hit', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser }, { 1: [] });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const next = vi.fn();
|
||||
await mw(makeReq(`Bearer ${validToken}`), makeRes(), next);
|
||||
await mw(makeReq(`Bearer ${validToken}`), makeRes(), next);
|
||||
// Two queries on first hit (user + permissions), zero on the second
|
||||
expect(pool.calls.count).toBe(2);
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('refetches after TTL expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
const pool = makeFakePool({ 1: activeUser }, { 1: [] });
|
||||
const mw = authenticate({ pool, secret: SECRET });
|
||||
const next = vi.fn();
|
||||
await mw(makeReq(`Bearer ${validToken}`), makeRes(), next);
|
||||
expect(pool.calls.count).toBe(2);
|
||||
vi.advanceTimersByTime(61_000);
|
||||
await mw(makeReq(`Bearer ${validToken}`), makeRes(), next);
|
||||
expect(pool.calls.count).toBe(4);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requirePermission middleware', () => {
|
||||
it('returns 401 when req.user is missing', () => {
|
||||
const mw = requirePermission('products:write');
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw({}, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next() for admin users regardless of code', () => {
|
||||
const mw = requirePermission('products:write');
|
||||
const next = vi.fn();
|
||||
mw({ user: { is_admin: true, permissions: [] } }, makeRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls next() when user has the required permission', () => {
|
||||
const mw = requirePermission('products:write');
|
||||
const next = vi.fn();
|
||||
mw({ user: { is_admin: false, permissions: ['products:write'] } }, makeRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns 403 when user lacks the required permission', () => {
|
||||
const mw = requirePermission('products:write');
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw({ user: { is_admin: false, permissions: ['products:read'] } }, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Phase 9 §9.4 — vitest scaffold + auth-boundary tests.
|
||||
//
|
||||
// Covers the security-critical surface in shared/auth/verify.js. Five cases
|
||||
// per the original Phase 2 testing-scaffold spec.
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { extractBearerToken, verifyToken, TokenError } from './verify.js';
|
||||
|
||||
const SECRET = 'test-secret-please-do-not-use-in-prod';
|
||||
const WRONG_SECRET = 'a-different-secret';
|
||||
|
||||
let validToken;
|
||||
let expiredToken;
|
||||
let wrongSigToken;
|
||||
|
||||
beforeAll(() => {
|
||||
validToken = jwt.sign({ userId: 42, username: 'alice' }, SECRET, { expiresIn: '1h' });
|
||||
expiredToken = jwt.sign({ userId: 42, username: 'alice' }, SECRET, { expiresIn: '-1s' });
|
||||
wrongSigToken = jwt.sign({ userId: 42, username: 'alice' }, WRONG_SECRET, { expiresIn: '1h' });
|
||||
});
|
||||
|
||||
describe('extractBearerToken', () => {
|
||||
it('returns token from a well-formed Bearer header', () => {
|
||||
expect(extractBearerToken('Bearer abc.def.ghi')).toBe('abc.def.ghi');
|
||||
});
|
||||
|
||||
it('throws TokenError(missing) when no header is provided', () => {
|
||||
expect(() => extractBearerToken(undefined)).toThrow(TokenError);
|
||||
try { extractBearerToken(undefined); } catch (err) { expect(err.code).toBe('missing'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(malformed) when header is not Bearer-prefixed', () => {
|
||||
expect(() => extractBearerToken('Basic abc')).toThrow(TokenError);
|
||||
try { extractBearerToken('Basic abc'); } catch (err) { expect(err.code).toBe('malformed'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(malformed) when Bearer header has empty token', () => {
|
||||
expect(() => extractBearerToken('Bearer ')).toThrow(TokenError);
|
||||
try { extractBearerToken('Bearer '); } catch (err) { expect(err.code).toBe('malformed'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(missing) when header is not a string', () => {
|
||||
expect(() => extractBearerToken(null)).toThrow(TokenError);
|
||||
expect(() => extractBearerToken(42)).toThrow(TokenError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyToken', () => {
|
||||
it('returns decoded payload for a valid token', () => {
|
||||
const decoded = verifyToken(validToken, SECRET);
|
||||
expect(decoded.userId).toBe(42);
|
||||
expect(decoded.username).toBe('alice');
|
||||
});
|
||||
|
||||
it('throws TokenError(expired) for an expired token', () => {
|
||||
expect(() => verifyToken(expiredToken, SECRET)).toThrow(TokenError);
|
||||
try { verifyToken(expiredToken, SECRET); } catch (err) { expect(err.code).toBe('expired'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(invalid) for a wrong-signature token', () => {
|
||||
expect(() => verifyToken(wrongSigToken, SECRET)).toThrow(TokenError);
|
||||
try { verifyToken(wrongSigToken, SECRET); } catch (err) { expect(err.code).toBe('invalid'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(invalid) for malformed JWT', () => {
|
||||
expect(() => verifyToken('not-a-jwt', SECRET)).toThrow(TokenError);
|
||||
try { verifyToken('not-a-jwt', SECRET); } catch (err) { expect(err.code).toBe('invalid'); }
|
||||
});
|
||||
|
||||
it('throws TokenError(misconfigured) when secret is missing', () => {
|
||||
expect(() => verifyToken(validToken, undefined)).toThrow(TokenError);
|
||||
try { verifyToken(validToken, undefined); } catch (err) { expect(err.code).toBe('misconfigured'); }
|
||||
});
|
||||
});
|
||||
@@ -32,13 +32,81 @@ router.get('/brands', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get all products with pagination, filtering, and sorting
|
||||
// Whitelist of allowed sort keys → SQL column expressions. Used to gate
|
||||
// `?sort=` against direct interpolation into ORDER BY (the previous code
|
||||
// dropped req.query.sort straight into the query string — SQL injection sink).
|
||||
// Keys are the camelCase identifiers the frontend ProductMetricColumnKey union
|
||||
// emits. Anything not in the map falls back to `p.title`.
|
||||
const SORT_COLUMN_MAP = {
|
||||
pid: 'p.pid',
|
||||
title: 'p.title',
|
||||
sku: 'p.sku',
|
||||
barcode: 'p.barcode',
|
||||
brand: 'p.brand',
|
||||
line: 'p.line',
|
||||
subline: 'p.subline',
|
||||
artist: 'p.artist',
|
||||
vendor: 'p.vendor',
|
||||
vendorReference: 'p.vendor_reference',
|
||||
notionsReference: 'p.notions_reference',
|
||||
harmonizedTariffCode: 'p.harmonized_tariff_code',
|
||||
countryOfOrigin: 'p.country_of_origin',
|
||||
location: 'p.location',
|
||||
moq: 'p.moq',
|
||||
weight: 'p.weight',
|
||||
rating: 'p.rating',
|
||||
reviews: 'p.reviews',
|
||||
baskets: 'p.baskets',
|
||||
notifies: 'p.notifies',
|
||||
preorderCount: 'p.preorder_count',
|
||||
notionsInvCount: 'p.notions_inv_count',
|
||||
dateCreated: 'p.created_at',
|
||||
dateLastSold: 'p.date_last_sold',
|
||||
stock: 'p.stock_quantity',
|
||||
stockQuantity: 'p.stock_quantity',
|
||||
price: 'p.price',
|
||||
costPrice: 'p.cost_price',
|
||||
totalSold: 'p.total_sold',
|
||||
// product_metrics columns (current schema names; camelCase aliases the
|
||||
// frontend uses are mapped to the canonical SQL column)
|
||||
dailySalesAvg: 'pm.avg_sales_per_day_30d',
|
||||
weeklySalesAvg: 'pm.sales_7d',
|
||||
monthlySalesAvg: 'pm.avg_sales_per_month_30d',
|
||||
margin: 'pm.margin_30d',
|
||||
gmroi: 'pm.gmroi_30d',
|
||||
gmroi30d: 'pm.gmroi_30d',
|
||||
inventoryValue: 'pm.current_stock_cost',
|
||||
costOfGoodsSold: 'pm.cogs_30d',
|
||||
grossProfit: 'pm.profit_30d',
|
||||
turnoverRate: 'pm.stockturn_30d',
|
||||
stockturn30d: 'pm.stockturn_30d',
|
||||
leadTime: 'pm.config_lead_time',
|
||||
currentLeadTime: 'pm.config_lead_time',
|
||||
targetLeadTime: 'pm.config_lead_time',
|
||||
stockCoverage: 'pm.stock_cover_in_days',
|
||||
daysOfStock: 'pm.stock_cover_in_days',
|
||||
reorderPoint: 'pm.replenishment_units',
|
||||
safetyStock: 'pm.config_safety_stock',
|
||||
abcClass: 'pm.abc_class',
|
||||
status: 'pm.status',
|
||||
ageDays: 'pm.age_days',
|
||||
sales7d: 'pm.sales_7d',
|
||||
sales30d: 'pm.sales_30d',
|
||||
sales365d: 'pm.sales_365d',
|
||||
revenue7d: 'pm.revenue_7d',
|
||||
revenue30d: 'pm.revenue_30d',
|
||||
revenue365d: 'pm.revenue_365d',
|
||||
sellThrough30d: 'pm.sell_through_30d',
|
||||
salesGrowth30dVsPrev: 'pm.sales_growth_30d_vs_prev',
|
||||
};
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const offset = (page - 1) * limit;
|
||||
const sortColumn = req.query.sort || 'title';
|
||||
const sortColumn = SORT_COLUMN_MAP[req.query.sort] || 'p.title';
|
||||
const sortDirection = req.query.order === 'desc' ? 'DESC' : 'ASC';
|
||||
|
||||
const conditions = ['p.visible = true'];
|
||||
@@ -120,30 +188,28 @@ router.get('/', async (req, res) => {
|
||||
paramCounter++;
|
||||
}
|
||||
|
||||
// Handle numeric filters with operators
|
||||
// Handle numeric filters with operators. Mapped to current product_metrics
|
||||
// column names; frontend keys (camelCase) preserved for compatibility.
|
||||
const numericFields = {
|
||||
stock: 'p.stock_quantity',
|
||||
price: 'p.price',
|
||||
costPrice: 'p.cost_price',
|
||||
dailySalesAvg: 'pm.daily_sales_avg',
|
||||
weeklySalesAvg: 'pm.weekly_sales_avg',
|
||||
monthlySalesAvg: 'pm.monthly_sales_avg',
|
||||
avgQuantityPerOrder: 'pm.avg_quantity_per_order',
|
||||
numberOfOrders: 'pm.number_of_orders',
|
||||
margin: 'pm.avg_margin_percent',
|
||||
gmroi: 'pm.gmroi',
|
||||
inventoryValue: 'pm.inventory_value',
|
||||
costOfGoodsSold: 'pm.cost_of_goods_sold',
|
||||
grossProfit: 'pm.gross_profit',
|
||||
turnoverRate: 'pm.turnover_rate',
|
||||
leadTime: 'pm.current_lead_time',
|
||||
currentLeadTime: 'pm.current_lead_time',
|
||||
targetLeadTime: 'pm.target_lead_time',
|
||||
stockCoverage: 'pm.days_of_inventory',
|
||||
daysOfStock: 'pm.days_of_inventory',
|
||||
weeksOfStock: 'pm.weeks_of_inventory',
|
||||
reorderPoint: 'pm.reorder_point',
|
||||
safetyStock: 'pm.safety_stock',
|
||||
dailySalesAvg: 'pm.avg_sales_per_day_30d',
|
||||
weeklySalesAvg: 'pm.sales_7d',
|
||||
monthlySalesAvg: 'pm.avg_sales_per_month_30d',
|
||||
margin: 'pm.margin_30d',
|
||||
gmroi: 'pm.gmroi_30d',
|
||||
inventoryValue: 'pm.current_stock_cost',
|
||||
costOfGoodsSold: 'pm.cogs_30d',
|
||||
grossProfit: 'pm.profit_30d',
|
||||
turnoverRate: 'pm.stockturn_30d',
|
||||
leadTime: 'pm.config_lead_time',
|
||||
currentLeadTime: 'pm.config_lead_time',
|
||||
targetLeadTime: 'pm.config_lead_time',
|
||||
stockCoverage: 'pm.stock_cover_in_days',
|
||||
daysOfStock: 'pm.stock_cover_in_days',
|
||||
reorderPoint: 'pm.replenishment_units',
|
||||
safetyStock: 'pm.config_safety_stock',
|
||||
// Add new numeric fields
|
||||
preorderCount: 'p.preorder_count',
|
||||
notionsInvCount: 'p.notions_inv_count',
|
||||
@@ -267,88 +333,33 @@ router.get('/', async (req, res) => {
|
||||
'SELECT DISTINCT COALESCE(brand, \'Unbranded\') as brand FROM products WHERE visible = true ORDER BY brand'
|
||||
);
|
||||
|
||||
// Main query with all fields
|
||||
// Main query with all fields. Aliases new product_metrics column names back to
|
||||
// the legacy names the frontend ProductRow type still uses — same pattern as the
|
||||
// /:id detail route below.
|
||||
const query = `
|
||||
WITH RECURSIVE
|
||||
category_path AS (
|
||||
SELECT
|
||||
c.cat_id,
|
||||
c.name,
|
||||
c.parent_id,
|
||||
c.name::text as path
|
||||
FROM categories c
|
||||
WHERE c.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
c.cat_id,
|
||||
c.name,
|
||||
c.parent_id,
|
||||
(cp.path || ' > ' || c.name)::text
|
||||
FROM categories c
|
||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||
),
|
||||
product_thresholds AS (
|
||||
SELECT
|
||||
p.pid,
|
||||
COALESCE(
|
||||
(SELECT overstock_days FROM stock_thresholds st
|
||||
WHERE st.category_id IN (
|
||||
SELECT pc.cat_id
|
||||
FROM product_categories pc
|
||||
WHERE pc.pid = p.pid
|
||||
)
|
||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||
ORDER BY st.vendor IS NULL
|
||||
LIMIT 1),
|
||||
(SELECT overstock_days FROM stock_thresholds st
|
||||
WHERE st.category_id IS NULL
|
||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||
ORDER BY st.vendor IS NULL
|
||||
LIMIT 1),
|
||||
90
|
||||
) as target_days
|
||||
FROM products p
|
||||
),
|
||||
product_leaf_categories AS (
|
||||
SELECT DISTINCT pc.cat_id
|
||||
FROM product_categories pc
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM categories child
|
||||
JOIN product_categories child_pc ON child.cat_id = child_pc.cat_id
|
||||
WHERE child.parent_id = pc.cat_id
|
||||
AND child_pc.pid = pc.pid
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
SELECT
|
||||
p.*,
|
||||
COALESCE(p.brand, 'Unbranded') as brand,
|
||||
string_agg(DISTINCT (c.cat_id || ':' || c.name), ',') as categories,
|
||||
pm.daily_sales_avg,
|
||||
pm.weekly_sales_avg,
|
||||
pm.monthly_sales_avg,
|
||||
pm.avg_quantity_per_order,
|
||||
pm.number_of_orders,
|
||||
pm.first_sale_date,
|
||||
pm.last_sale_date,
|
||||
pm.days_of_inventory,
|
||||
pm.weeks_of_inventory,
|
||||
pm.reorder_point,
|
||||
pm.safety_stock,
|
||||
pm.avg_margin_percent,
|
||||
CAST(pm.total_revenue AS DECIMAL(15,3)) as total_revenue,
|
||||
CAST(pm.inventory_value AS DECIMAL(15,3)) as inventory_value,
|
||||
CAST(pm.cost_of_goods_sold AS DECIMAL(15,3)) as cost_of_goods_sold,
|
||||
CAST(pm.gross_profit AS DECIMAL(15,3)) as gross_profit,
|
||||
pm.gmroi,
|
||||
pm.avg_sales_per_day_30d AS daily_sales_avg,
|
||||
pm.sales_7d AS weekly_sales_avg,
|
||||
pm.avg_sales_per_month_30d AS monthly_sales_avg,
|
||||
pm.date_first_sold AS first_sale_date,
|
||||
pm.date_last_sold AS last_sale_date,
|
||||
pm.stock_cover_in_days AS days_of_inventory,
|
||||
pm.replenishment_units AS reorder_point,
|
||||
pm.config_safety_stock AS safety_stock,
|
||||
pm.margin_30d AS avg_margin_percent,
|
||||
CAST(pm.lifetime_revenue AS DECIMAL(15,3)) as total_revenue,
|
||||
CAST(pm.current_stock_cost AS DECIMAL(15,3)) as inventory_value,
|
||||
CAST(pm.cogs_30d AS DECIMAL(15,3)) as cost_of_goods_sold,
|
||||
CAST(pm.profit_30d AS DECIMAL(15,3)) as gross_profit,
|
||||
pm.gmroi_30d AS gmroi,
|
||||
pm.avg_lead_time_days,
|
||||
pm.last_purchase_date,
|
||||
pm.last_received_date,
|
||||
pm.date_last_received AS last_received_date,
|
||||
pm.abc_class,
|
||||
pm.stock_status,
|
||||
pm.turnover_rate,
|
||||
pm.status AS stock_status,
|
||||
pm.stockturn_30d AS turnover_rate,
|
||||
p.date_last_sold
|
||||
FROM products p
|
||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||
@@ -389,12 +400,12 @@ router.get('/trending', async (req, res) => {
|
||||
try {
|
||||
// First check if we have any data
|
||||
const { rows } = await pool.query(`
|
||||
SELECT COUNT(*) as count,
|
||||
MAX(total_revenue) as max_revenue,
|
||||
MAX(daily_sales_avg) as max_daily_sales,
|
||||
SELECT COUNT(*) as count,
|
||||
MAX(lifetime_revenue) as max_revenue,
|
||||
MAX(avg_sales_per_day_30d) as max_daily_sales,
|
||||
COUNT(DISTINCT pid) as products_with_metrics
|
||||
FROM product_metrics
|
||||
WHERE total_revenue > 0 OR daily_sales_avg > 0
|
||||
FROM product_metrics
|
||||
WHERE lifetime_revenue > 0 OR avg_sales_per_day_30d > 0
|
||||
`);
|
||||
console.log('Product metrics stats:', rows[0]);
|
||||
|
||||
@@ -403,25 +414,24 @@ router.get('/trending', async (req, res) => {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
// Get trending products
|
||||
// Get trending products. growth_rate uses sales_growth_30d_vs_prev — a
|
||||
// pre-computed % delta of the last 30d window vs the prior 30d window.
|
||||
// (The old formula compared a per-day rate against a 7-day total, which
|
||||
// mixed units and produced nonsense after the metrics-schema rename.)
|
||||
const { rows: trendingProducts } = await pool.query(`
|
||||
SELECT
|
||||
SELECT
|
||||
p.pid,
|
||||
p.sku,
|
||||
p.title,
|
||||
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
||||
COALESCE(pm.weekly_sales_avg, 0) as weekly_sales_avg,
|
||||
CASE
|
||||
WHEN pm.weekly_sales_avg > 0 AND pm.daily_sales_avg > 0
|
||||
THEN ((pm.daily_sales_avg - pm.weekly_sales_avg) / pm.weekly_sales_avg) * 100
|
||||
ELSE 0
|
||||
END as growth_rate,
|
||||
COALESCE(pm.total_revenue, 0) as total_revenue
|
||||
COALESCE(pm.avg_sales_per_day_30d, 0) as daily_sales_avg,
|
||||
COALESCE(pm.sales_7d, 0) as weekly_sales_avg,
|
||||
COALESCE(pm.sales_growth_30d_vs_prev, 0) as growth_rate,
|
||||
COALESCE(pm.lifetime_revenue, 0) as total_revenue
|
||||
FROM products p
|
||||
INNER JOIN product_metrics pm ON p.pid = pm.pid
|
||||
WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0)
|
||||
WHERE (pm.lifetime_revenue > 0 OR pm.avg_sales_per_day_30d > 0)
|
||||
AND p.visible = true
|
||||
ORDER BY growth_rate DESC
|
||||
ORDER BY growth_rate DESC NULLS LAST
|
||||
LIMIT 50
|
||||
`);
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// Exclude macOS AppleDouble sidecar files (`._*.js`) that get created when
|
||||
// editing through the NFS mount from macOS. See Deviation #15 in
|
||||
// CONSOLIDATION_PLAN.md — these aren't real tests, but vitest's default file
|
||||
// glob picks them up and fails the suite when rollup tries to parse them.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts}'],
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/._*',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { TrendingUp, DollarSign, Percent, Briefcase } from "lucide-react";
|
||||
import { DashboardMultiStatCardMini } from "@/components/dashboard/shared";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import config from "@/config";
|
||||
import { apiFetch } from "@/utils/api";
|
||||
|
||||
const fmtK = (value) => {
|
||||
if (!value && value !== 0) return "$0";
|
||||
@@ -56,7 +57,7 @@ const MiniBusinessMetrics = () => {
|
||||
const { data: forecastData, isLoading: forecastLoading } = useQuery({
|
||||
queryKey: ["mini-forecast-30d"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics`);
|
||||
const response = await apiFetch(`${config.apiUrl}/dashboard/forecast/metrics`);
|
||||
if (!response.ok) throw new Error("Failed to fetch forecast");
|
||||
return response.json();
|
||||
},
|
||||
@@ -67,7 +68,7 @@ const MiniBusinessMetrics = () => {
|
||||
const { data: yearData, isLoading: yearLoading } = useQuery({
|
||||
queryKey: ["mini-year-estimate"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/year-revenue-estimate`);
|
||||
const response = await apiFetch(`${config.apiUrl}/dashboard/year-revenue-estimate`);
|
||||
if (!response.ok) throw new Error("Failed to fetch year estimate");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Truck, Warehouse, ShoppingBag, AlertTriangle } from "lucide-react";
|
||||
import { DashboardMultiStatCardMini } from "@/components/dashboard/shared";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import config from "@/config";
|
||||
import { apiFetch } from "@/utils/api";
|
||||
|
||||
const fmtCurrency = (value) => {
|
||||
if (!value && value !== 0) return "$0";
|
||||
@@ -30,7 +31,7 @@ const MiniInventorySnapshot = () => {
|
||||
const { data: stockData, isLoading: stockLoading } = useQuery({
|
||||
queryKey: ["mini-stock-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`);
|
||||
const response = await apiFetch(`${config.apiUrl}/dashboard/stock/metrics`);
|
||||
if (!response.ok) throw new Error("Failed to fetch stock metrics");
|
||||
return response.json();
|
||||
},
|
||||
@@ -41,7 +42,7 @@ const MiniInventorySnapshot = () => {
|
||||
const { data: replenishData, isLoading: replenishLoading } = useQuery({
|
||||
queryKey: ["mini-replenish-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`);
|
||||
const response = await apiFetch(`${config.apiUrl}/dashboard/replenishment/metrics`);
|
||||
if (!response.ok) throw new Error("Failed to fetch replenishment");
|
||||
return response.json();
|
||||
},
|
||||
@@ -52,7 +53,7 @@ const MiniInventorySnapshot = () => {
|
||||
const { data: overstockData, isLoading: overstockLoading } = useQuery({
|
||||
queryKey: ["mini-overstock-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`);
|
||||
const response = await apiFetch(`${config.apiUrl}/dashboard/overstock/metrics`);
|
||||
if (!response.ok) throw new Error("Failed to fetch overstock");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AlertCircle, PiggyBank, ShoppingCart } from "lucide-react";
|
||||
import { formatCurrency } from "./SalesChart.jsx";
|
||||
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases";
|
||||
import config from "@/config";
|
||||
import { apiFetch } from "@/utils/api";
|
||||
import {
|
||||
DashboardStatCardMini,
|
||||
DashboardStatCardMiniSkeleton,
|
||||
@@ -85,7 +86,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
startDate: thirtyDaysAgo.toISOString(),
|
||||
endDate: now.toISOString(),
|
||||
});
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`);
|
||||
const response = await apiFetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch sales metrics");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
+2
-1
@@ -20,6 +20,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AuthedImage } from "@/components/ui/authed-image";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
interface ReusableImage {
|
||||
@@ -248,7 +249,7 @@ export const ProductCard = ({
|
||||
className="group relative aspect-square border rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary"
|
||||
onClick={() => handleAddReusableImage(image.image_url)}
|
||||
>
|
||||
<img
|
||||
<AuthedImage
|
||||
src={image.image_url}
|
||||
alt={image.name}
|
||||
className="w-full h-full object-cover"
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AuthedImage } from "@/components/ui/authed-image";
|
||||
|
||||
interface FieldOption {
|
||||
label: string;
|
||||
@@ -512,8 +513,8 @@ export function ReusableImageManagement() {
|
||||
header: "Thumbnail",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={row.getValue("image_url") as string}
|
||||
<AuthedImage
|
||||
src={row.getValue("image_url") as string}
|
||||
alt={row.getValue("name") as string}
|
||||
className="w-10 h-10 object-contain border rounded"
|
||||
/>
|
||||
@@ -707,8 +708,8 @@ export function ReusableImageManagement() {
|
||||
<div className="flex justify-center p-4">
|
||||
{previewImage && (
|
||||
<div className="bg-checkerboard rounded-md overflow-hidden">
|
||||
<img
|
||||
src={previewImage.image_url}
|
||||
<AuthedImage
|
||||
src={previewImage.image_url}
|
||||
alt={previewImage.name}
|
||||
className="max-h-[500px] max-w-full object-contain"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState, type ImgHTMLAttributes } from 'react';
|
||||
import { apiFetch } from '@/utils/api';
|
||||
|
||||
// Browsers cannot attach Authorization headers to <img src="..."> requests, but
|
||||
// Caddy's forward_auth gate (Phase 6.7 / 9.2) demands a Bearer on /uploads/*.
|
||||
// AuthedImage fetches gated URLs through apiFetch (which adds the Bearer), then
|
||||
// renders the bytes via an object URL. Non-gated URLs (CDN, external) pass
|
||||
// straight through to a plain <img>.
|
||||
|
||||
const GATED_PATH_PATTERN = /\/uploads\//;
|
||||
|
||||
function needsAuth(src: string | undefined): boolean {
|
||||
if (!src) return false;
|
||||
return GATED_PATH_PATTERN.test(src);
|
||||
}
|
||||
|
||||
// /uploads/* URLs are stored absolute in the DB (https://tools.acherryontop.com/...).
|
||||
// Strip the origin so the request goes through the vite dev proxy in dev and stays
|
||||
// same-origin in prod — either way no CORS preflight on the fetch.
|
||||
function toFetchUrl(src: string): string {
|
||||
try {
|
||||
const parsed = new URL(src, window.location.origin);
|
||||
return parsed.pathname + parsed.search;
|
||||
} catch {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
|
||||
type AuthedImageProps = ImgHTMLAttributes<HTMLImageElement> & { src?: string };
|
||||
|
||||
export function AuthedImage({ src, alt, ...rest }: AuthedImageProps) {
|
||||
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(
|
||||
needsAuth(src) ? undefined : src,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsAuth(src)) {
|
||||
setResolvedSrc(src);
|
||||
return;
|
||||
}
|
||||
|
||||
let objectUrl: string | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch(toFetchUrl(src!));
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setResolvedSrc(undefined);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setResolvedSrc(objectUrl);
|
||||
} catch {
|
||||
if (!cancelled) setResolvedSrc(undefined);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return <img src={resolvedSrc} alt={alt} {...rest} />;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user