# TitanCart Lite — Notes for the WordPress.org Plugin Review Team

This document explains the architectural decisions behind the Plugin Check (PCP) findings that remain in TitanCart Lite by design. It is referenced from `phpcs:ignore` annotations throughout the codebase. Written S446, counts updated S463 (Aug 2026): the submitted build (0.2.14 → 0.2.16) scans at **0 errors**, with the remaining warnings all within the families below.

> **Scope note (0.2.16):** these notes document the TitanCart codebase as a whole. A few referenced surfaces exist only in the separately-distributed Pro edition and are **not present in this package** — notably the Site Backup restore path (§5d), licensing/entitlement seams, and the maintenance gate. Where a section mentions one of them, it is provided as context for the shared architecture, not as a claim about a file in this ZIP.

## 1. The core decision: a self-contained eCommerce engine with its own data layer

TitanCart is architected as a portable eCommerce engine that runs inside WordPress through a deliberately thin integration layer. Concretely:

- **All commerce data lives in the plugin's own custom tables**, prefixed `{$wpdb->prefix}tc_` (products, orders, carts, customers, promotions, etc. — ~70 tables defined in `src/DB/Schema.php`). We never store commerce entities as post types, taxonomies, or meta.
- **All queries flow through one ORM seam**: `src/DB/Database.php`. Application code passes SQL with `?`-style placeholders plus values; `Database::prepare()` binds and escapes every value before the single `$wpdb` execution point per method.
- WordPress APIs are used where they are the right tool: `dbDelta()` for table creation, `current_user_can()` for capability checks, REST API `permission_callback` for endpoint auth, `wp_mail()` as the mail transport, nonces for REST.

This design is why several PCP families fire at volume. Each family below is annotated in code and summarized here.

## 2. `WordPress.DB.DirectDatabaseQuery.*` (~1,368 findings: DirectQuery / NoCaching / SchemaChange)

Expected and by design. The plugin's own tables cannot be reached through `WP_Query`/meta APIs — direct queries via the ORM are the only correct access path. On caching: hot paths use request-level memoization inside repositories/services; WordPress object caching is not layered over commerce reads because cart/stock/order state must be transactionally current (stale-cache overselling is a real-money bug). `SchemaChange` findings are the versioned activation-time migrations in `Installer.php` / `Plugin.php` (guarded, run-once).

## 3. `WordPress.NamingConventions.PrefixAllGlobals.*` (~1,226 findings)

The plugin is fully namespaced (`TitanCart\`, PSR-4). The findings are overwhelmingly *variables* inside namespaced class methods and template files — local scope, no global pollution. Genuine globals (helper functions in `titancart.php`, hooks, options, tables) use the `titancart_` prefix throughout (tables: `{$wpdb->prefix}tc_`, see §10a).

## 4. `WordPress.DB.PreparedSQL.InterpolatedNotPrepared` (~513) and `PluginCheck.Security.DirectDB.UnescapedDBParameter` (~300 warnings)

The interpolated variables in query strings are, in every audited case, one of:

- **Table names** built from constants: `$wpdb->prefix . TITANCART_TABLE_PREFIX . 'products'`, `$this->t('orders')`, `$db->table('categories')` — never user input (PHP cannot bind identifiers via placeholders, so interpolation is the only mechanism).
- **Placeholder lists** built from `count()`: `implode(',', array_fill(0, count($ids), '%d'))` for `IN (...)` clauses, with values passed to `prepare()`.
- **Whitelisted fragments**: ORDER BY columns from hardcoded allow-lists, integer lists passed through `array_map('intval', ...)`, or internally generated arg-free SQL.

All *values* are bound via `$wpdb->prepare()` placeholders or the ORM's `Database::prepare()`. Error-level `UnescapedDBParameter` sites carry per-site `phpcs:ignore` annotations stating the specific proof; two files use a documented file-scoped disable because every hit is the identical constant-prefix pattern (`WooImportService.php` — one-time WooCommerce migration reading Woo's own tables; `ReportService.php` — reporting reads where the only interpolations are the constant prefix and an `intval()`-built status list).

## 5. `WordPress.DB.PreparedSQL.NotPrepared` (annotated per site)

These fire where a *variable* holding SQL is passed to a query function. Every annotated site is one of: (a) the output of `$wpdb->prepare()` held in a variable (`$prepared = $wpdb->prepare(...); $wpdb->get_results($prepared);`); (b) the ORM execution seam in `Database.php`, where `$sql` is the output of the ORM's own binder; (c) literal migration DDL from in-file statement arrays; (d) the admin-only Backup **restore** path, which necessarily executes statements from the store's own dump file — gated by `manage_options` (REST `permission_callback`), a statement-type whitelist, and a required `tc_` table-name match.

## 6. `WordPress.Security.NonceVerification.*` (~60 warnings)

Two categories:

- **REST controllers**: all state-changing endpoints are registered with `permission_callback` capability checks; the standard `X-WP-Nonce` cookie-auth flow applies to the admin SPA and storefront fetches. Per WordPress core guidance, nonce checks inside the callback duplicate what the REST layer already enforces.
- **Storefront template/module reads** (`StorefrontModule.php` carries most hits): these read `$_GET` for *view state* — page number, sort order, filter selections, search terms — idempotent rendering inputs, not actions. Values are sanitized at read and never trigger writes. CSRF protection is not applicable to idempotent GET rendering.

Form POSTs that mutate state (checkout, reviews, account) go through REST endpoints with the above protections, or verify nonces where they use admin-post/admin-ajax paths.

## 7. `ValidatedSanitizedInput` (~186 warnings)

Same surface as §6: predominantly storefront view-state reads and OAuth/webhook callback parameters. Inputs are cast (`(int)`, `absint`), whitelisted, `sanitize_*`'d, or `esc_like`'d at the point of use; several sniff hits fire on reads that are immediately passed through `prepare()` placeholders or strict comparisons. **R1 remediation (S456):** the flagged classes were fixed tree-wide — every `REMOTE_ADDR` capture now flows through one validated helper (`titancart_client_ip()`: unslash → trim → `FILTER_VALIDATE_IP`, else `''`); every `HTTP_USER_AGENT` read sanitizes before its substr cap; the `dashboard.php` / `login-register.php` echo and handler sites unslash+sanitize (passwords are `wp_unslash`-only by necessity, annotated); all four `wp_verify_nonce` inputs are wrapped; the reCAPTCHA token reader sanitizes all four return paths; and the custom-fields extraction seam (`CustomerFieldsManager::extract_submitted`) sanitizes for every caller.

## 8. REST `permission_callback` audit (R1 🅱4, S456)

Every route registered with `__return_true` was audited: it is either **public by design with the rationale documented at the registration site**, or it was **hardened**. The two hardened routes:

- `GET /account/downloads/order/{id}` — the login gate moved from the handler body into `permission_callback` (`logged_in_only`), and the vestigial `email` query arg (an enumerable never-shipped guest path) was **removed**. Order *ownership* is verified in the handler and returns 404 on mismatch so foreign order ids are not disclosed. Guests use the signed `/downloads/{token}` URLs from their order email.
- `POST /payments/paypal/capture` — now requires the order's secret `order_key` (`hash_equals` against the row). Unknown order and key mismatch return the identical 404, so order ids cannot be enumerated. The route itself stays public because PayPal return flows carry no WP cookies.

The public-by-design set, in brief (full rationale at each registration): **anonymous storefront reads** (`/store/products`, `/store/products/{slug}`, search-suggest, bestsellers/on-sale, reviews list/stats, product agreement) return only what the public shop pages already render; **guest cart/checkout** (`/cart/*`, `/checkout*`, `/checkout/tax`, `/checkout/shipping-rates`, `/checkout/payment-methods` [credential-masked roster]) act on the caller's own cart-session cookie only — no route can address another visitor's cart, and all price/stock/coupon validation is server-side; **capability-by-token routes** (`/downloads/{token}`, wishlist `/shared/{key}`, gift-card apply-by-code, newsletter `/subscribe/activate` + `/unsubscribe`) treat the unguessable token/key/code as the credential; **guest write-ins** (waitlist subscribe, newsletter subscribe, review create, account register) are insert-only, schema/sanitizer-validated, moderated or double-opt-in where applicable, and carry reCAPTCHA surfaces when configured; **webhooks** (`/webhooks/stripe`) authenticate via provider signature verification inside the handler, per gateway requirements.

**R2 update (S461):** `/account/downloads/order/{order_id}` gained a dedicated `customer_owns_order` permission_callback that performs the full login + order-ownership check (404 on foreign order ids) at the registration boundary.

**R3 update (S467):** every public route registers the **literal `'__return_true'`** — the exact form this directory's guidance prescribes for intentionally-public endpoints — with a one-line justification comment on the registration line itself (family + real auth mechanism). R2's interim named callback (`PublicEndpoint::allow`) was retired: a named callable is opaque to automated tooling, while the literal plus an inline rationale is self-evidencing at every site. The complete public surface:

| Route (titancart/v1) | Method(s) | Family | Real auth mechanism |
|---|---|---|---|
| `/store/products`, `/store/products/by-ids`, `/store/products/{slug}`, `/store/categories`, `/store/filters`, `/store/search-suggest` | GET | Storefront read | None needed — public catalog data only |
| `/products/bestsellers`, `/products/on-sale` (Bestsellers ext.) | GET | Storefront read | None needed — public catalog data only |
| `/products/{id}/reviews`, `/products/{id}/reviews/stats` | GET | Storefront read | Approved reviews only |
| `/products/{id}/agreement` | GET | Storefront read | Public agreement text for the PDP gate |
| `/cart`, `/cart/count`, `/cart/items*`, `/cart/coupon*`, `/cart/notes`, `/cart/gift-cards*`, `/cart/bundles/{id}` | GET/POST/PUT/DELETE | Guest cart | Cart-session cookie binds every call to the caller's own cart; server-side price/stock validation |
| `/checkout`, `/checkout/validate-address`, `/checkout/place-order`, `/checkout/finalize`, `/checkout/shipping-methods`, `/checkout/tax`, `/checkout/shipping-rates`, `/checkout/payment-methods` | GET/POST | Guest checkout | Same cart-session binding; payment roster is credential-masked |
| `/coupon/{code}/check` | GET | Guest checkout | Validates against the caller's own cart session |
| `/account/register` | POST | Guest write-in | Insert-only; sanitizer/schema-validated; reCAPTCHA when configured |
| `/products/{id}/waitlist`, `/newsletter/subscribe` | POST | Guest write-in | Insert-only; double-opt-in (newsletter); reCAPTCHA when configured |
| `/reviews` (POST), `/products/{id}/reviews` (POST), `/reviews/{id}/vote` | POST | Guest write-in | Module checks login/verified-purchase per settings; moderation queue |
| `/newsletter/subscribe/activate`, `/newsletter/unsubscribe` | GET | Token-as-credential | Unguessable per-subscriber token, verified in handler |
| `/downloads/{token}` | GET | Token-as-credential | Signed download token; revocation/expiry/quota checks in service layer |
| `/wishlist/shared/{key}` | GET | Token-as-credential | Unguessable share key |
| `/gift-cards/apply`, `/gift-cards/remove` | POST | Token-as-credential | Gift-card code is the credential; applies to caller's own cart session |
| `/social-auth/callback` | GET/POST | OAuth return | State-token verification inside handler (user is not logged in yet by definition) |
| `/webhooks/stripe` | POST | Webhook | Stripe signature verification inside handler |
| `/payments/paypal/capture` | POST | Gateway return | Order's secret `order_key` via `hash_equals`; unknown order and key mismatch return identical 404 |

## 9. Escape-at-echo exceptions (R2 item 6, S461)

The R2 escape sweep converted every `EscapeOutput` ignore in the Lite tree to a real escaping call at the echo site (`wp_kses_post`, scoped `wp_kses` allowlists for nav menus / form fields / star SVGs / gateway instruction surfaces, `esc_html`/`esc_attr` restructures, and JSON-LD emission moved to `wp_print_inline_script_tag()` so no template echoes a variable at all). Exactly **two** annotated sites remain, because escaping is not merely unnecessary there but *impossible*:

- **`src/Storefront/template-wrapper.php`** — the theme-pipeline output boundary. The echoed buffer is a complete, already-rendered HTML document: a TitanCart template that escaped every dynamic value at its own producing echo sites, with the plugin's site header spliced in after `<body>`. This plays the same role `wp-includes/template-loader.php` plays for themes; no core API outputs an arbitrary composed document, and any escaping function applied to the whole document would strip the forms and enqueued-script tags the templates legitimately emit, breaking every storefront page identically.
- **`src/Modules/Downloadables/DownloadableUploadService.php`** (protected-file streamer) — the echoed value is a raw **binary** chunk of a purchased file (Content-Type from the stored file, `Content-Disposition: attachment`, access-gated upstream by the download-token/ownership checks in §8). Escaping functions are HTML-context transforms; there is no HTML context, and applying one would corrupt the delivered bytes.

## 10. Round 2 structural changes (S462–S463, 0.2.14)

### 10a. Global prefix rename `tc_*` / `TC_*` → `titancart_*` / `TITANCART_*`

R2 flagged 43 short-prefixed global elements. Rather than renaming the 43 in isolation, 0.2.14 renames **every** short-prefixed symbol in the WordPress-collision surface, via an explicit, audited map (not a blind substitution): 16 constants (`TITANCART_VERSION`, `TITANCART_PATH`, `TITANCART_EDITION`, `TITANCART_NO_PHONEHOME`, `TITANCART_LOG`, `TITANCART_TRUSTED_PROXIES`, …), ~70 identifiers (every global helper function — `titancart_db()`, `titancart_client_ip()`, `titancart_login_url()`, `titancart_is_vendor_site()`, …; every action/filter name; AJAX actions; cron hooks and schedules; nonce actions), 11 option/transient prefix families (`titancart_license_*`, `titancart_payment_result_*`, `titancart_*_seeded`, …), 6 localized JS objects (`titancartConfig`, `TitancartCheckout`, `TitancartAccount`, `titancartWishlist`, `titancartReviews`, `titancartSubscribePopup`) and 3 script handles. 2,202 replacements across 306 files; the post-rename audit greps zero short-prefixed symbols under the plugin tree. The build's Lite tripwires (the old constant names, the short-prefix global-call shape, short-prefixed hook/option/transient/ajax string literals, the old JS object names) fail the build on any regression.

**What deliberately keeps `tc_`, and why it is not a collision surface:**

- **Database table names** (`{$wpdb->prefix}tc_products` etc.). These are namespaced by `$wpdb->prefix` and by the constant `TITANCART_TABLE_PREFIX = 'tc_'`; renaming ~70 tables is a data migration with real-money risk for existing stores and buys nothing — WordPress's prefix guideline concerns PHP/option/hook namespaces, not `wp_`-prefixed table names (WooCommerce's `wp_wc_*` is the same shape).
- **Query vars** (`tc_product`, `tc_checkout`, …) and **nav-menu locations** (`tc_topbar`): rewrite-rule and theme-registration contracts that third-party themes built against; renaming them breaks every existing site's permalinks and menu assignments.
- **User-meta keys**, the **`tc_cookie_consent`** cookie, **WP_Error codes** (the admin SPA switches on them), and all **CSS classes / DOM ids / HTML `name` attributes** (`tc-*`): client-side or per-row data with no PHP-global collision surface.

**Back-compat bridge (Pro only, absent from this package):** existing Pro extension builds and merchant customisations reference the old names, so the Pro edition ships `src/Core/LegacyHooksBridge.php`, which defines the old constants, aliases the old functions, re-fires the old hook names after the new ones, forwards queued legacy cron events, and performs a one-shot option copy (`tc_license_*` → `titancart_license_*`). That file is **physically excluded** from the Lite build (`build/manifest.php`) and its single `require` in `titancart.php` is inside a strip region, so no short-prefixed symbol — not even a compatibility alias — exists in the WordPress.org package. A build tripwire asserts `LegacyHooksBridge` does not appear anywhere in the Lite tree.

### 10b. Site Backup removed from Lite entirely

R2's 32 unsafe-SQL findings were concentrated in the Site Backup REST controller (DB dump + restore, under `src/REST/Settings/`). Rather than ship a half-feature, the whole module — export **and** restore, the REST controller, the admin page, the settings card and the route — is stripped from Lite at build time (file exclusion + region strips in the SPA). The restore-path `NotPrepared` item described in the old §5(d) therefore no longer applies to this package; that sentence is retained above only as history.

### 10c. Remaining interpolated-SQL sites in this package

After the R2 sweep, every `$wpdb->query/get_*` call in the Lite tree is one of: (a) `$wpdb->prepare()` with placeholders; (b) migration DDL interpolating only `$wpdb->prefix . TITANCART_TABLE_PREFIX . literal` (identifiers cannot be bound; where a column name/definition comes from an in-file map — Migrations 8 and 32 — it is preg-pinned to `^[a-z0-9_]+$` / a DDL character class before interpolation, and the `SHOW COLUMNS LIKE` probe is prepared); (c) a generated `%d`/`%s` placeholder list from `count()` passed with its values to `prepare()`. One documented residue: **`src/Modules/Reports/ReportService.php:39`** builds `o.status_id NOT IN ({$ids})` once at construction from DB-sourced status rows run through `intval()`, and reuses the fragment across every dashboard query. It is integer-only by construction and not user-reachable; restructuring it into per-call `prepare()` in every report query is a behaviour-risk change we preferred not to make inside a review round.

### 10d. External-surface audit (readme "External services")

The readme section was re-audited against every `wp_enqueue_script` remote URL, every remote-fetch host (including the media sideloader) and every `https://` literal in the Lite tree. Two owner-configured surfaces that were undocumented are now disclosed (Google Maps Places address-autocomplete, keyed and off by default; YouTube/Vimeo product-video embeds, only when the owner pastes a link). A dormant `@import` of Google Fonts inside the admin bundle (reachable only from a Pro-extension render path) was removed at source so no font CDN reference remains in the package. The `ImportService.php` field-map strings flagged in R2/R3 are CSV column help text shown in the import UI — nothing is fetched from them, and as of 0.2.16 the example text contains no URL literals at all.

### 10e. Inline JS shape and escaping-function recognition (Plugin Check on 0.2.14)

Plugin Check's `Heredoc.NotAllowed` sniff flags every interpolating heredoc. All inline JS is therefore emitted as a **nowdoc** (`<<<'JS'`, no interpolation) with `%%SLOT%%` tokens filled by a single `strtr()` from `wp_json_encode()`'d or integer-cast values, then attached with `wp_add_inline_script()` / `wp_print_inline_script_tag()`. The JS bodies are inert strings; every dynamic value enters as a JSON literal. A build tripwire fails the Lite package on any unquoted `<<<` opener.

The R2 item-6 scoped-allowlist wrappers (`kses_nav`, `kses_form`, a star-SVG allowlist) were restructured so that `wp_kses( $html, <allowlist> )` appears **at the echo site** — the escaping sniff only recognises core function names, and a wrapper named `kses_*` reads as unescaped output to it even though it is `wp_kses()` internally. Behaviour is unchanged; the allowlists are now exposed as arrays/static methods and passed directly.

Remaining `NonceVerification.Recommended` rows (28) are idempotent `$_GET` view-state reads — pagination, sort, filter selections — covered by §6; each is annotated at the site.

## 11. Practical notes for testing

- The plugin creates its `tc_` tables on activation and removes them cleanly on uninstall.
- **Bundled assets are local** (icon fonts in `assets/vendor/bootstrap-icons/`). The only remote scripts are the payment/anti-abuse SDKs that their providers require to be loaded from provider origins (Stripe.js, reCAPTCHA, and the owner-keyed Google Maps Places SDK; the Pro edition adds Accept.js, Square SDK and Google Identity Services); each is a conditional `wp_enqueue_script` that fires only when the merchant has configured that provider, and each is disclosed in the readme's External Services section.
- No phone-home: the Lite build pins `TITANCART_NO_PHONEHOME`; update serving is WordPress.org-native.
- Logging is silent in production: the single logging seam (`titancart_log()`) is gated behind `WP_DEBUG || TITANCART_LOG`.

Questions welcome — we are happy to walk through any specific query path or provide the schema map.
