HOCO B2B
SRS

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

TestCovers
apps/server/src/testing/permission-sweep.test.tsEvery 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.tsReseller 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.ts28 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.tsAPI 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.tsThe 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

#SeverityFindingFixEvidence
F-01Medium105 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-02MediumThe "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-03HighInvitation 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-04MediumThe 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-05MediumThe 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-06MediumDocumented 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-07LowNo 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-08Highadmin.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-09Highadmin.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-10Lowadmin.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-11LowThe 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

#SeverityFindingRecommendationEvidence
L-01Highcatalog.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-02MediumThe 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-03MediumOther 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-04MediumStatic 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-05MediumThe 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-06Lowadmin.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-07LowThe 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-08LowImage 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-09LowThe 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-10Infotrust 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-11Lowpnpm 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-12InfoSecret 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-13Info32 $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-14Infopg_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-15InfoCookie 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).

Operationp50p95
catalog.products.list (reseller; sorts, brand, category, q, availability)410–550 ms510–860 ms
catalog.facets (cached / cold)20 ms / 450 ms500 ms
catalog.products.suggest230 ms256 ms
catalog.home247 ms344 ms
orders.list (reseller)47 ms83 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.toConfirm18 717 ms → 838 ms23 514 ms → 972 ms
admin.products.list21 253 ms → 1 226 ms39 142 ms → 1 366 ms
admin.stock.levels420 ms469 ms
delegate.resellers.list40 ms69 ms
delegate.orders.list448 ms958 ms
sync.delta220 ms491 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.

Operationp50 before → afterp95 before → after
catalog.products.list popular (reseller)424 → 66 ms493 → 76 ms
catalog.products.list guest363 → 51 ms404 → 56 ms
catalog.products.list newest / brand / category / in stock397–418 → 50–58 ms456–487 → 54–62 ms
catalog.products.list next page390 → 59 ms429 → 68 ms
catalog.products.list q=cable394 → 137 ms455 → 147 ms
catalog.products.list price sorts, price range, promotion filter395–422 → 147–155 ms451–609 → 162–180 ms
catalog.products.suggest (not reworked here, see the follow-up)204 → 200 ms227 → 227 ms
delegate.orders.list (first and next page)283–290 → 53–54 ms315–319 → 57–67 ms
admin.stock.levels (default, by product name)345–351 → 95–110 ms397–406 → 111–123 ms
admin.stock.levels with q33 → 34 ms41 → 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 most limit) 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 Prisma include. 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 one GROUP 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, the inScope rule), 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.

Operationp50 before → afterp95 before → after
catalog.products.suggest q=cable / q=usb (reseller)205–207 → 124–138 ms236–251 → 145–163 ms
catalog.products.suggest guest q=chargeur141 → 98 ms157 → 109 ms
catalog.facets cold, no filter433 → 169 ms490 → 208 ms
catalog.facets cold, category (attribute facets)421 → 213 ms475 → 245 ms
catalog.facets cold, q=cable413 → 102 ms477 → 116 ms
catalog.facets cold, brand + in stock413 → 154 ms470 → 177 ms
catalog.home reseller215 → 76 ms247 → 86 ms
catalog.home guest147 → 60 ms178 → 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 SQL GROUP BY over the visible products with an active variant (visibleSql); a category counts its sub-categories' products through a LATERAL (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, as home.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 a LIMITed 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 of promotions.list and the product counts are productCountsOf (now with an optional brand condition); promotions.get ranks the eligible products in SQL — one row per product, its first in-scope variant in catalog order (default first), scopeSql for inScope, then in stock, popularity, id like popularOrder — pages with LIMIT/OFFSET on the same cursor, counts with productCountsOf, 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.

Operationp50 before → afterp95 before → after
catalog.brands reseller / guest206 / 140 → 43 / 27 ms248 / 164 → 51 / 33 ms
catalog.categories reseller / guest196 / 134 → 47 / 32 ms239 / 162 → 54 / 43 ms
catalog.products.similar reseller / guest (limit 10)193 / 144 → 80 / 57 ms219 / 172 → 92 / 69 ms
promotions.list reseller188 → 44 ms219 → 53 ms
promotions.list with a brand filter190 → 57 ms220 → 73 ms
promotions.get brand promotion / whole-catalog promotion (20 products)196 / 200 → 70 / 72 ms236 / 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 is updatedAt, publishedAt or priceDetail (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 (variantMatches on the filtered keys' values) and completeness (one query for the active variants' prices and attribute keys, required keys by definitionsFor, the description trimmed with the characters String.prototype.trim removes) filter those rows, and the former comparison (toLowerCase, id localeCompare) 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 former take: 2000 is 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.

Operationp50 before → afterp95 before → after
admin.products.list default (updated, page 1 / page 3)1 037 / 1 033 → 62 / 64 ms1 295 / 1 237 → 81 / 82 ms
admin.products.list q=usb (1 701 products)363 → 62 ms425 → 75 ms
admin.products.list by name1 049 → 82 ms1 205 → 91 ms
admin.products.list in stock + low alert1 018 → 78 ms1 187 → 87 ms
admin.products.list with a promotion977 → 128 ms1 154 → 140 ms
admin.products.list incomplete957 → 207 ms1 110 → 230 ms
admin.orders.toConfirm default (page 1 / page 3)761 / 873 → 138 / 135 ms902 / 1 059 → 158 / 148 ms
admin.orders.toConfirm on hold817 → 195 ms1 023 → 231 ms
admin.orders.toConfirm by total, descending761 → 197 ms829 → 238 ms
admin.orders.toConfirm overdue767 → 140 ms971 → 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

WhatWhere
Run the three k6 scripts on a production-sized staging instance; record p95, errors, oversell check, API memory with 500 streamsThis 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 RTORestore drill
Headers and TLS check on the public hosts (HSTS, TLS 1.2+, redirects), STORAGE_PUBLIC_URL build argument setOPS-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 criticalCI
Monitoring and alerting review: Sentry alerts, queue depth and failed jobs, disk and database size, uptimeOPS-02
Infrastructure least privilege (SEC-0.10): API port behind the proxy only (L-10), separate database roles, SSH by keyOPS-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.

On this page