Hardening report
SRV-28 findings — permission, scope and exposure sweeps, security headers, rate limits, dependency and secret scan, upload checks, slow-query pass, restore drill and load-test kit — each with severity, status and evidence.
Hardening pass of SRV-28 against the non-functional and security requirements (cross-cutting requirements). Every finding is either fixed in SRV-28 or logged for QA-01 with its severity. What can only be measured on the VPS (load tests, the production restore drill, TLS, monitoring) is listed at the end.
Severity: High — exploitable or breaks a stated objective; Medium — weakens a control or a target at realistic volumes; Low — defence in depth, gap with the documentation; Info — reviewed, nothing to change now.
What runs in CI
| Test | Covers |
|---|---|
apps/server/src/testing/permission-sweep.test.ts | Every admin.* operation of the contract (204) × the five seeded staff roles, a temporary super administrator, a reseller and a delegate: a caller without the route's declared permission gets 403 PERMISSION_DENIED whatever the body; a holder is never refused for that key. Every route declares a rule (ACL-5.1). The seeded role grants equal the matrix. The role × route table is printed. |
apps/server/src/testing/scope-sweep.test.ts | Reseller A on reseller B's order, documents, returns, invoice, ticket and addresses; delegate A on delegate B's tour, visit, reseller and order: reads 404, writes 404 or 403 OUT_OF_SCOPE, and the lists never carry the other's ids. Write bodies carry a canary (stale version, out-of-range time, unknown reason) so a broken check cannot change seeded rows. |
apps/server/src/testing/exposure-sweep.test.ts | 28 reseller, 5 guest and 16 delegate responses walked recursively: no stock quantity, purchase price, cost, margin, stock threshold or internal note key (line, cart and return quantities and ordering limits allowed). |
apps/server/src/testing/hardening.test.ts | API headers, invitation landing CSP, session cookie flags, the rate-limit rule table, invitations / WhatsApp / suggestions / offline replay / one-time codes (cooldown, hourly per phone, hourly per IP) / running exports limits, and three disguised uploads (PDF as JPEG, HTML as PNG, executable as PDF) rejected and deleted. |
packages/env/src/security-headers.test.ts, server.test.ts | The Next apps' headers; the production secret guard. |
Result at hand-off: the three sweeps pass with zero unexpected allow and zero exposure.
Findings fixed in SRV-28
| # | Severity | Finding | Fix | Evidence |
|---|---|---|---|---|
| F-01 | Medium | 105 back-office routes validated the body before checking the permission: 615 role × route probes answered 400 VALIDATION_FAILED instead of 403 (a user without the permission could learn the input schema; no request was ever allowed). | The permission a route declares in the contract (Permission: …) is enforced for every admin.* operation by an oRPC client interceptor before input validation (http/route-permissions.ts, requireRouteRule); handlers keep their finer checks. Unimplemented routes are covered too. | Permission sweep: 615 findings before, 0 after. |
| F-02 | Medium | The "credit only" rule of the matrix was not implemented: a finance manager got 403 on admin.orders.toConfirm and could not confirm an order pending only for CREDIT_OVER_LIMIT. | toConfirm shows a finance manager (without orders.validate) the orders pending only for credit; validate accepts them for that order only (no stock override). | Sweep: holder refused before, allowed after. |
| F-03 | High | Invitation tokens and offline-sync cursors fell back to a hard-coded HMAC secret when neither BETTER_AUTH_SECRET nor the specific secret is set — forgeable in a misconfigured production. | assertProductionSecrets: in production the server and the worker refuse to start without a random secret (the .env.example placeholder is refused too). | packages/env/src/server.test.ts. |
| F-04 | Medium | The API sent no Content-Security-Policy and X-Frame-Options: SAMEORIGIN. | default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none', X-Frame-Options: DENY, HSTS one year with sub-domains, nosniff, Referrer-Policy: no-referrer; the invitation landing and the (non-production) reference page set their own narrow policy. | hardening.test.ts. |
| F-05 | Medium | The dashboard and the reseller web sent no security headers. | headers() in both next.config.ts from @hoco/env/security-headers: CSP (since L-04, sent by the proxy with a per-request nonce; self, the API origin, the storage origin; frame-ancestors 'none', object-src 'none'), nosniff, X-Frame-Options: DENY, strict-origin-when-cross-origin, Permissions-Policy (no camera or microphone; geolocation for the web app's own origin only — WhatsApp button and address form — none on the back-office), HSTS in production, no X-Powered-By. The storage origin is a build argument (STORAGE_PUBLIC_URL, image recipe) because Next fixes headers at build time; without it uploads fall back to any https origin. | security-headers.test.ts; next typegen loads the configs (CommonJS and native TypeScript resolvers). |
| F-06 | Medium | Documented limits missing: support.whatsapp 30 / min per IP for guests, catalog.products.suggest 120 / min per session or IP. Two rules with the same limit shared one counter (invitations and WhatsApp would have). | Rules added; each rule has its own counter. | hardening.test.ts (31st and 121st calls answer 429 with Retry-After). |
| F-07 | Low | No test covered the one-time-code limits, invitations per IP, offline replay per user or running exports. | Tests added (all limits behaved as documented). | hardening.test.ts. |
| F-08 | High | admin.orders.toConfirm ran the four controls (5 to 7 queries) for every pending order (up to 2 000) on every call: 18.7 s p50 with 2 500 pending orders. | Kind, SLA and overdue need no query: the controls run for the requested page only, or for all candidates when the caller filters on them. | 18.7 s → 0.84 s p50 (perf copy); the L-03 follow-up loads only the page's orders: 0.76 s → 0.14 s (below). |
| F-09 | High | admin.products.list loaded the attribute definitions of the category once per product (N+1): 21 s p50 with 4 860 products. | One query per category within a list. | 21 s → 1.2 s p50; the L-03 follow-up ranks slim rows and loads only the page: 1.04 s → 62 ms (below). |
| F-10 | Low | admin.orders.list's default order (submittedAt desc nulls last, id) scanned and sorted every order. | Migration 20261021090000_srv28_indexes: orders (submitted_at DESC NULLS LAST, id DESC). | EXPLAIN ANALYZE on 10 000 orders: seq scan + top-N sort 28 ms → index scan 0.7 ms. |
| F-11 | Low | The integration suites read localhost:5442 unless the shell exported DATABASE_URL: on a machine whose .env points elsewhere they skipped silently. | vitest.config.ts loads the root .env first. | Suites run against the .env database. |
Findings logged for QA-01
| # | Severity | Finding | Recommendation | Evidence |
|---|---|---|---|---|
| L-01 | High | catalog.products.list misses NFR-01 at catalog scale: p50 550 ms, p95 860 ms with 4 860 products / 7 371 variants (target p95 < 300 ms). The whole visible catalog is loaded and priced per request, then filtered, sorted and paged in memory (SQL 40 ms, the rest is JavaScript). catalog.facets is cached (p50 20 ms) but cold calls take 450–600 ms; suggest 230–290 ms. | Fixed after the sweep: the filters that do not depend on the caller's price (visibility, category, brands, new, availability, attributes, text) are SQL conditions; relevance, popular and newest sort and page in SQL (DISTINCT ON keeps a product's first matching variant in catalog order) and only the page is loaded and priced (catalog/list.ts). Price sorts, price ranges and the promotion filter depend on the promotion each variant gets from the engine (audience, scope, stacking, caps): they rank slim rows (ids, price columns, VAT, stock flag, sort keys) priced with the engine itself, then load the page — kept in memory on purpose, exact by construction. The engine now runs only for variants an open promotion covers (same result). catalog.facets, suggest and home followed (follow-up), then brands, categories, similar products and the promotion pages (browse follow-up, p95 164–248 → 33–92 ms): suggest p95 236–251 → 145–163 ms, cold facets p95 470–490 → 116–245 ms, home p95 247 → 86 ms. | Same perf copy with two running promotions, 50 calls each (below): popular p50 424 → 66 ms, p95 493 → 76 ms; price sorts, price range, promotion filter p95 451–609 → 162–180 ms; q p95 455 → 147 ms. Equivalence: catalog/list.test.ts (former algorithm vs new over 5 callers × 21 filter sets × 5 sorts, with promotions and visibility rules; page continuity; pricer vs engine on generated promotions in both stacking modes), and 273 identical cases on the perf copy. |
| L-02 | Medium | The text search (q, suggestions) ORs conditions over joined columns, so the trigram indexes are not used: a sequential scan, ~100–200 ms at 4 860 products. | Fixed after the sweep: the candidates are a UNION of branches each served by an index — name and reference (products_*_trgm, <% through its %> commutator), SKU (product_variants_sku_trgm), barcode (unique) — then the brand and category name matches; the score and the visibility are unchanged. Same matches, same scores, same accent and case handling (f_unaccent, ILIKE); no new index. | EXPLAIN ANALYZE on the perf copy: an SKU-like query 121 → 0.9 ms, cable (1 053 hits) 156 → 56 ms (the rest is scoring the hits). catalog/list.test.ts: matches and scores equal to the former query for 18 queries × 5 callers; the plan uses the three trigram indexes. |
| L-03 | Medium | Other list endpoints page in memory: delegate.orders.list loads up to 1 000 orders with their lines for a page of 50 (p50 450 ms, p95 960 ms), admin.products.list 1.2 s, admin.stock.levels 420 ms, toConfirm 0.84 s at 10 000 orders. Acceptable at V1 volumes, linear growth. | Fixed after the sweep. delegate.orders.list and admin.stock.levels first: the delegate list reads its page and one more row (reseller and line count only), and the former cap of the delegate's 1 000 most recent orders is gone (neither the API page nor DA-18 asks for one; the cursor bounds each page — follow-up, orders/delegate-list.test.ts walks a scope of more than 1 000 orders); the stock list reads slim rows with one SQL query (same filters, sums, thresholds and last movements), keeps the summary and the sort (localeCompare, unchanged) on them, and loads descriptions and receipts for the page only. Then admin.products.list and toConfirm (follow-up): the products list pages in SQL when the filters and the sort allow it, otherwise ranks slim rows with the former comparison, and loads only the page; the queue ranks slim order rows and loads the page's orders and controls only — its former cap of the 2 000 oldest pending orders is gone (the API page asks for every SUBMITTED and PENDING order). | Perf copy, 50 calls: delegate.orders.list p50 290 → 53 ms, p95 319 → 57 ms; admin.stock.levels p50 345 → 95 ms, p95 397–406 → 111–123 ms; admin.products.list p50 957–1 049 → 62–207 ms, p95 1 110–1 295 → 70–230 ms; toConfirm p50 761–873 → 135–197 ms, p95 829–1 059 → 148–238 ms. inventory/levels.test.ts (the former implementation as reference over both views, every sort and order, filters and pages), orders/delegate-list.test.ts (pages walked with their cursors equal the single page, order, scope, line counts), catalog/admin-list.test.ts and orders/to-confirm.test.ts (the former implementations as reference over filter sets, every sort and order, walked pages, the export's ids, two callers for the queue). |
| L-04 | Medium | Static CSP keeps script-src 'unsafe-inline' (Next's "without nonces" setup); SEC-0.4 asks for nonces. | Fixed after the sweep (Next's nonce setup): each app's proxy draws a 128-bit nonce per request and sends script-src 'self' 'nonce-…' 'strict-dynamic' on the response and on the request, where Next reads it and stamps its own scripts; no 'unsafe-inline' for scripts, 'unsafe-eval' in development only (React's dev tooling). The other directives are unchanged (style-src keeps 'unsafe-inline'); the static headers stay in next.config.ts, and the storage origin is still fixed at build time (next.config.ts inlines it into the proxy). Neither app has an inline or third-party script of its own. Cost: both [locale] layouts call connection(), so every page renders per request instead of being prerendered — the pages are client components that fetch through the API, so this is a server render of the shell per navigation and no HTML caching. Responses the proxy does not match (static files, _next) carry no CSP. | security-headers.ts and its test, both proxy.ts, both [locale]/layout.tsx. |
| L-05 | Medium | The session cookie name is the same in every environment (hoco.session_token); with COOKIE_DOMAIN=.hoco.dz a production cookie is also sent to staging sub-domains (SEC-2.4 asks for environment-specific names). | Fixed after the sweep: SESSION_COOKIE_NAME (hoco-stg., hoco-pp., hoco. prefixes) in the server and both proxies (runbook). A production cookie still reaches the staging hosts, so production and test environments go in separate browser profiles. | identity/sessions.ts, proxy.ts. |
| L-06 | Low | admin.returns.decide for IN_TRANSIT with delivery.update (matrix conditional rule) is still refused by the handler (the route guard allows it). | Fixed after the sweep, as the matrix and the return lifecycle say: without orders.validate, a delivery.update holder may only record the collection of an APPROVED return (decision: "APPROVE" with a transport, no quantities) — APPROVED → IN_TRANSIT; approving or rejecting a REQUESTED return, changing quantities or deciding without a transport stay 403 PERMISSION_DENIED (missing orders.validate); a return already in transit answers 409 RETURN_TRANSITION_NOT_ALLOWED as for any caller. The decision (author, date) is kept. The back-office return page (BO-69) has no collection action yet: the screen spec does not describe one. | orders/deliveries.ts, orders/returns.ts; orders/orders.test.ts (delivery manager: refused on a requested return, on a decision without transport and with quantities; a stock manager refused; collection recorded; second call 409). |
| L-07 | Low | The back-office reseller form's "Locate" button calls the geolocation API, now blocked by geolocation=() (SEC-0.4: none on the back-office). | Fixed after the sweep, SEC-0.4 kept: the button and its message (Crm.form.locate) are removed; the latitude and longitude fields are still entered by hand. | dashboard/src/features/crm/reseller-form.tsx, dashboard/messages/*/crm.json; pnpm check:messages. |
| L-08 | Low | Image sanitising (SEC-4.3: re-encoding, EXIF / GPS stripping, thumbnails) is not implemented: originals are kept as uploaded. Magic bytes and sizes are checked (tested). | SRV-08 worker. Still open after the SRV-08 follow-up: full decoding and re-encoding need an image library (sharp, named by SRV-07), a new dependency waiting for approval. | files/procedures.ts TODO. |
| L-09 | Low | The guest catalog limit (120 / min per IP, proposed in SEC-0.5) is not implemented: mobile carriers' shared addresses (CGNAT) could throttle many guests at once. | Decide with the traffic of the pilot. | SEC-0.5. |
| L-10 | Info | trust proxy 1: IP limits trust the last X-Forwarded-For hop. Reaching the API without the reverse proxy would let a client choose its address and bypass the per-IP limits. | Firewall: the API port reachable from the proxy only (SEC-0.10). | app.ts. |
| L-11 | Low | pnpm audit --prod: no high or critical; moderate uuid < 11.1.1 through exceljs (buffer bounds in v3/v5/v6 with a caller buffer — not used); low katex through mermaid (docs site only). | Dependabot; no override needed. | pnpm audit --prod, 19 Oct 2026. |
| L-12 | Info | Secret scan: nothing committed (keys, private keys, tokens, URLs with passwords; .env untracked, .env.example placeholders only). gitleaks is not installed on the development machine; CI runs it. | — | git grep patterns. |
| L-13 | Info | 32 $queryRawUnsafe calls reviewed (12 at the sweep, 4 added by the L-01 to L-03 fixes, 7 by the follow-up, 4 by the browse follow-up, 5 by the admin lists follow-up): user input is always a positional parameter (Params, compile). The SEC-0.3 CI grep rule would flag them. | Allow-list these files in the rule. | Code review. |
| L-14 | Info | pg_stat_statements is available but not preloaded on the local container (not changed); the slow-query pass used EXPLAIN (ANALYZE, BUFFERS) and log_min_duration_statement on a disposable copy. | Preload it on the VPS for NFR-8.4. | — |
| L-15 | Info | Cookie flags reviewed: HttpOnly, SameSite=Lax, Secure in production, Domain from COOKIE_DOMAIN; cookie mutations from a foreign Origin refused. Sign-in limits (5 / min per identifier and IP, 15-minute lockout) already tested by the identity suite. | — | hardening.test.ts, identity.test.ts. |
Slow-query pass
A disposable copy of the development database (hoco_perf, dropped afterwards) was multiplied by apps/server/load/perf-seed.sql: 4 860 products, 7 371 variants and stock levels, 3 040 resellers, 10 020 orders, 33 567 order lines. Each list endpoint was called 10 times in-process (server-side time, development machine, no other load).
| Operation | p50 | p95 |
|---|---|---|
catalog.products.list (reseller; sorts, brand, category, q, availability) | 410–550 ms | 510–860 ms |
catalog.facets (cached / cold) | 20 ms / 450 ms | 500 ms |
catalog.products.suggest | 230 ms | 256 ms |
catalog.home | 247 ms | 344 ms |
orders.list (reseller) | 47 ms | 83 ms |
admin.resellers.list (with q) | 39 ms (37 ms) | 78 ms (54 ms) |
admin.orders.list (with q) | 178 ms (148 ms) | 228 ms (170 ms) |
admin.orders.toConfirm | 18 717 ms → 838 ms | 23 514 ms → 972 ms |
admin.products.list | 21 253 ms → 1 226 ms | 39 142 ms → 1 366 ms |
admin.stock.levels | 420 ms | 469 ms |
delegate.resellers.list | 40 ms | 69 ms |
delegate.orders.list | 448 ms | 958 ms |
sync.delta | 220 ms | 491 ms |
The schema was already well indexed (resellers by delegate and update time, tours and visits by delegate, orders by reseller, status and queue, trigram indexes): one index was added (F-10). The remaining time was spent in application code (L-01 to L-03).
After the L-01 to L-03 fixes
Measured on a fresh copy built the same way — hoco_perf on the shared PostgreSQL, prisma migrate deploy, reference and demo seeds, perf-seed.sql (same counts), plus two running promotions (−15 % on one brand, −50 DA for super-gros resellers on the whole catalog) so that the promotion paths run — then dropped. Each operation was called 50 times in-process over HTTP after 3 warm-up calls (development machine, no other load); "before" is the code of the sweep on the same copy and harness, which explains the gap with the first table.
| Operation | p50 before → after | p95 before → after |
|---|---|---|
catalog.products.list popular (reseller) | 424 → 66 ms | 493 → 76 ms |
catalog.products.list guest | 363 → 51 ms | 404 → 56 ms |
catalog.products.list newest / brand / category / in stock | 397–418 → 50–58 ms | 456–487 → 54–62 ms |
catalog.products.list next page | 390 → 59 ms | 429 → 68 ms |
catalog.products.list q=cable | 394 → 137 ms | 455 → 147 ms |
catalog.products.list price sorts, price range, promotion filter | 395–422 → 147–155 ms | 451–609 → 162–180 ms |
catalog.products.suggest (not reworked here, see the follow-up) | 204 → 200 ms | 227 → 227 ms |
delegate.orders.list (first and next page) | 283–290 → 53–54 ms | 315–319 → 57–67 ms |
admin.stock.levels (default, by product name) | 345–351 → 95–110 ms | 397–406 → 111–123 ms |
admin.stock.levels with q | 33 → 34 ms | 41 → 36 ms |
An index on orders (updated_at DESC, id DESC) was tried for the delegate list (11 → 3 ms of SQL) and not kept: no measurable gain end to end.
Follow-up: suggestions, facets and home
catalog.products.suggest, catalog.facets and catalog.home no longer load and price the whole visible catalog (the rest of L-01):
- Suggestions (
catalog/suggest.ts): the trigram search (L-02) gives the visible matches and their scores; one query keeps those with an active variant, and only the products returned (at mostlimit) are loaded. Brands and categories are checked against the visible catalog in SQL, and the category counts are computed for the matching categories only. - Facets (
catalog/facets.ts): the disjunctive counts (computeFacets) are unchanged, but they run on slim variant rows (ids, brand, category, price columns, VAT, stock flag, publication date) restricted in SQL to the text query; the attribute filters are evaluated in SQL (one flag per filter and variant, and a row condition when no category is selected); attribute values are read only for the selected category's variants, with one SQL query instead of a Prismainclude. The price range and the promotion count need the caller's price of every matching variant, so the slim rows are priced with the engine (only where an open promotion covers them). The 5-minute cache is unchanged; this is the cold cost. - Home (
catalog/home.ts): popular-category counts are oneGROUP BY; the recommended products come from three SQL reads of the in-stock display variants (first in-stock variant in catalog order) — popular (10 rows), new arrivals (17 rows), and the promotion candidates: only the rows an open promotion's scope covers (scopeSql, theinScoperule), priced by popularity in batches of 100 until three show a promotion — then only the picked products (≤ 10) are loaded for their cards. The promotion product counts are one SQL query (productCountsOf).
Same perf copy method (hoco_perf2: migrations, reference and demo seeds, perf-seed.sql — 4 860 products, 7 371 variants — the same two running promotions, then dropped); 50 calls each after 3 warm-up calls, in-process over HTTP; facets with the cache emptied before each call.
| Operation | p50 before → after | p95 before → after |
|---|---|---|
catalog.products.suggest q=cable / q=usb (reseller) | 205–207 → 124–138 ms | 236–251 → 145–163 ms |
catalog.products.suggest guest q=chargeur | 141 → 98 ms | 157 → 109 ms |
catalog.facets cold, no filter | 433 → 169 ms | 490 → 208 ms |
catalog.facets cold, category (attribute facets) | 421 → 213 ms | 475 → 245 ms |
catalog.facets cold, q=cable | 413 → 102 ms | 477 → 116 ms |
catalog.facets cold, brand + in stock | 413 → 154 ms | 470 → 177 ms |
catalog.home reseller | 215 → 76 ms | 247 → 86 ms |
catalog.home guest | 147 → 60 ms | 178 → 66 ms |
What remains: the suggestion time is the trigram scoring of the matches (~70 ms for cable, 1 053 hits); a cold facet call reads and prices every matching variant (SQL ~35 ms, the rest transfer and JavaScript). catalog.brands, catalog.categories, catalog.products.similar and the promotion pages followed (below).
Equivalence: catalog/reads.test.ts compares each with the former whole-catalog algorithm, with promotions of every scope kind (product, variant, category, brand, whole catalog; a capped one; audiences) and visibility rules: facets over 5 callers × 21 filter sets, suggestions over 5 callers × 14 queries × 2 limits and locales, the home's recommended products and their cards, top-category counts and promotion product counts for 5 callers, and the SQL attribute reader against the former Prisma query. The same file passes on the perf copy.
Follow-up: brands, categories, similar products and promotion pages
The last catalogue reads that loaded and priced the whole visible catalog per request (SRV-08 follow-up):
- Brands and categories (
catalog/browse.ts): the product counts are one SQLGROUP BYover the visible products with an active variant (visibleSql); a category counts its sub-categories' products through aLATERAL (category, parent)pair. Nothing is priced. - Similar products (
catalog/browse.ts): the product itself is loaded alone; the candidates are slim in-stock display rows (the first in-stock variant in catalog order, ashome.ts) of its category tree, ranked by popularity in SQL and priced with the engine only where an open promotion covers them, for the ±40 % band; the complementary fallback is aLIMITed SQL read of the linked categories (no price needed), the configured pairs (BO-28) are loaded by id. Only the returned products are loaded for their cards. - Promotion pages (
promotions/pages.ts): the brand filter ofpromotions.listand the product counts areproductCountsOf(now with an optional brand condition);promotions.getranks the eligible products in SQL — one row per product, its first in-scope variant in catalog order (default first),scopeSqlforinScope, then in stock, popularity, id likepopularOrder— pages withLIMIT/OFFSETon the same cursor, counts withproductCountsOf, and loads only the page.
Same perf method (hoco_perf3: migrations, reference and demo seeds, perf-seed.sql — 4 860 products, 7 371 variants — and two running promotions: −15 % on one brand, −50 DA for super-gros resellers on the whole catalog; then dropped); 50 calls each after 3 warm-up calls, in-process over HTTP; "before" is the code of dev on the same copy and harness.
| Operation | p50 before → after | p95 before → after |
|---|---|---|
catalog.brands reseller / guest | 206 / 140 → 43 / 27 ms | 248 / 164 → 51 / 33 ms |
catalog.categories reseller / guest | 196 / 134 → 47 / 32 ms | 239 / 162 → 54 / 43 ms |
catalog.products.similar reseller / guest (limit 10) | 193 / 144 → 80 / 57 ms | 219 / 172 → 92 / 69 ms |
promotions.list reseller | 188 → 44 ms | 219 → 53 ms |
promotions.list with a brand filter | 190 → 57 ms | 220 → 73 ms |
promotions.get brand promotion / whole-catalog promotion (20 products) | 196 / 200 → 70 / 72 ms | 236 / 232 → 83 / 81 ms |
No catalogue read loads the whole visible catalog any more; the remaining whole-catalog pricing is the cold facet call (price range and promotion count of every matching variant). Equivalence: catalog/reads.test.ts compares the five reads with the former algorithms (kept in the test) for the same 5 callers and promotions: brands and the category tree in three locales; similar and complementary products for one product per category, a configured complement pair and an unknown product (404), limits 4 and 10, with the same pricing instant; promotions.list over 10 filter sets (daily, tabs, types, brands, limits 1 to 20) walking every cursor; promotions.get for every visible promotion, limits 5 and 50, up to 4 pages, and an unknown id. The same tests pass on the perf copy.
Follow-up: back-office products and orders to confirm
The last two list endpoints of L-03 that loaded every matching row with its relations and paged in memory:
admin.products.list(catalog/admin-products.ts): when no filter needs the stock, the completeness, the promotions or the attributes and the sort isupdatedAt,publishedAtorpriceDetail(the default listing), the column conditions (status, brand, category with its descendants, text), the visibility mode, the novelty and the default variant's price are SQL conditions, SQL orders and pages (ties by id, as the former comparison) and counts, and only the page's ids are read. Otherwise one SQL query returns slim rows — default variant price, stock sums of the active variants (all when none is active), availability and worst alert level over the sellable warehouses, visibility mode, publication and update times — already filtered on stock, visibility, novelty and price, with only the columns the call reads; promotions (activePromotionsOf, unchanged), attribute filters (variantMatcheson the filtered keys' values) and completeness (one query for the active variants' prices and attribute keys, required keys bydefinitionsFor, the description trimmed with the charactersString.prototype.trimremoves) filter those rows, and the former comparison (toLowerCase, idlocaleCompare) sorts them. Either way only the page is loaded and shaped as before. The text match stays the former Prisma condition (its ids feed the query). The product export (analytics/sources.ts) reads the ranked ids (rankProducts) instead of shaping every product.admin.orders.toConfirm(orders/admin-orders.ts): the queue's conditions are unchanged; the rows are slim (status, assisted confirmation and expiry, submission and creation times, total), enough for the kind, the SLA, overdue, the counts and the sort (the same stable sort on the same base order); the orders with their lines and controls are loaded for the page only, or for every candidate when the caller filters on the controls (as before). The formertake: 2000is gone: with 2 505 pending orders the queue now shows and counts all of them.
Same perf method (hoco_perf4: migrations, reference and demo seeds, perf-seed.sql — 4 860 products, 7 371 variants, 10 020 orders of which 2 505 pending — and two running promotions: −15 % on one brand, −50 DA for super-gros resellers on the whole catalog; then dropped); 50 calls each after 3 warm-up calls, in-process over HTTP, page size 25; "before" is the code of dev on the same copy and harness.
| Operation | p50 before → after | p95 before → after |
|---|---|---|
admin.products.list default (updated, page 1 / page 3) | 1 037 / 1 033 → 62 / 64 ms | 1 295 / 1 237 → 81 / 82 ms |
admin.products.list q=usb (1 701 products) | 363 → 62 ms | 425 → 75 ms |
admin.products.list by name | 1 049 → 82 ms | 1 205 → 91 ms |
admin.products.list in stock + low alert | 1 018 → 78 ms | 1 187 → 87 ms |
admin.products.list with a promotion | 977 → 128 ms | 1 154 → 140 ms |
admin.products.list incomplete | 957 → 207 ms | 1 110 → 230 ms |
admin.orders.toConfirm default (page 1 / page 3) | 761 / 873 → 138 / 135 ms | 902 / 1 059 → 158 / 148 ms |
admin.orders.toConfirm on hold | 817 → 195 ms | 1 023 → 231 ms |
admin.orders.toConfirm by total, descending | 761 → 197 ms | 829 → 238 ms |
admin.orders.toConfirm overdue | 767 → 140 ms | 971 → 156 ms |
"Before" counted at most 2 000 pending orders (2 000 and 1 599 overdue), "after" all 2 505 (1 998 overdue). What remains: the queue's time is the four controls of the page's 25 orders (~110 ms; the slim rows take ~30 ms); the controls filter still runs them for every candidate. The products list's slim query takes ~40 ms for the whole catalogue; the completeness filter adds the active variants' attribute keys, the promotion filter their ids.
Equivalence: catalog/admin-list.test.ts compares admin.products.list with the former implementation (kept in the test) on the demo catalogue plus temporary products — inactive and archived warehouses, variants without levels or all inactive, a product without variants, visibility rules of the three modes, images, null and blank descriptions (non-breaking and ideographic spaces, BOM), missing required attributes and prices, promotions by product, variant and category, an ended and a draft one, equal update times, names equal but for case — over 38 filter sets (status, brand, category, text, availability, alert levels, visibility, completeness, promotion, novelty, price range, attr.* values and ranges, combinations) with every sort and order, the SQL-paged sorts on a middle page for every set, pages of 7 walked for every sort, the export's ids, and one page over HTTP. orders/to-confirm.test.ts compares the queue with the former implementation for an administrator and a "credit only" finance manager on clones of the demo's pending orders (ties on total and submission time, no submission time, assisted orders with and without expiry, credit-only and mixed reasons, overdue or not): 16 filter sets (kinds, reasons, channel, overdue, controls, wilaya, delegate, text) × 3 sorts × 2 orders, pages of 5 walked with their counts, and one page over HTTP.
Backup restore drill
Local drill on 19 Oct 2026: pg_dump -Fc 0.8 s (31 MB database, 1.3 MB dump), restore into a fresh hoco_restore 1.7 s, smoke check (docker/restore-smoke.sql) identical on both databases, the API on the restored database answered /ready, a sign-in, the catalog and admin.orders.list; database dropped. Procedure and results: restore drill.
Load tests
apps/server/load/ holds the k6 scripts and their README: catalog.js (list, facets, search; thresholds p95 < 300 ms), checkout.js (50 resellers on one scarce variant; no oversell, every refusal STOCK_INSUFFICIENT) and sse.js (500 streams held 5 minutes). They were not run on the development machine: they run against staging.
Remaining on the VPS
| What | Where |
|---|---|
| Run the three k6 scripts on a production-sized staging instance; record p95, errors, oversell check, API memory with 500 streams | This page and deployment and operations |
| Restore drill from last night's encrypted dump and a point-in-time recovery; time against the 4-hour objective; RPO and RTO | Restore drill |
Headers and TLS check on the public hosts (HSTS, TLS 1.2+, redirects), STORAGE_PUBLIC_URL build argument set | OPS-02 |
pg_stat_statements preloaded; slow-query dashboard (NFR-8.4) | OPS-02 |
gitleaks and OSV / pnpm audit --prod jobs in CI failing on high or critical | CI |
| Monitoring and alerting review: Sentry alerts, queue depth and failed jobs, disk and database size, uptime | OPS-02 |
| Infrastructure least privilege (SEC-0.10): API port behind the proxy only (L-10), separate database roles, SSH by key | OPS-02 |
| Malware scanning decision (SEC-4.5) | D-15 |
Development machines with shared services
On a machine that already runs shared PostgreSQL, Redis and MinIO containers, docker-compose.yml is not started: .env points at them, with HOCO's own database (hoco), its own Redis database number (never FLUSHALL) and its own buckets. Disposable copies for drills use createdb -T hoco <name> and are dropped afterwards; the other projects' databases are never touched. The test suites read the same .env.
Deployment and operations
Environments, the delivery pipeline, infrastructure sizing, domains, secrets, backups and restore, monitoring and alerts, the initial data import, training, the go-live checklist, the pilot, the maintenance and support model, and the runbook index.
Releases and team
The five delivery phases inside and after the three-month V1 frame, live plan figures, milestones and Gantt, who is responsible for what (RACI), how effort is estimated, and a capacity analysis computed from the plan with the real levers.