# SiteCraft — Architecture

SiteCraft is a multi-tenant SaaS website builder and e-commerce platform: merchants register, subscribe to a plan, build sites with a GrapesJS editor, sell products, take Razorpay payments and publish on a platform subdomain or their own domain.

"SiteCraft" is a working name. It is set in `.env` (`platform.name`) and nowhere is it hard-coded in business logic.

---

## 1. System overview

```
                        ┌──────────────────────────────────────────────┐
  merchant / admin ───► │  app.<baseDomain>  (admin + merchant UI)     │
                        │   /admin/*       Super Admin  (auth:platform)│
                        │   /dashboard/*   Merchant     (auth:tenant)  │
                        │   /builder/*     GrapesJS editor   (Phase 3) │
                        │   /api/*         JSON for editor/AJAX        │
                        └───────────────┬──────────────────────────────┘
                                        │  one CodeIgniter 4 app,
  shoppers ──► store.<baseDomain> ──┐   │  one MySQL database
  shoppers ──► www.customer.com ────┴──►│  Storefront (Phase 4): Host header
                                        │  → website_domains → website
  Razorpay ──► /webhooks/razorpay/platform   (subscription events)
  Razorpay ──► /webhooks/razorpay/store/{website}  (shop payment events)
```

One codebase serves everything. The **Host header** decides which area answers: hosts listed in `platform.appHosts` (plus the base domain itself, `www.<base>` and bare IPs) get the admin/merchant app; any other host is resolved to a published website. For store hosts `Config/Routes.php` registers **only** the storefront routes and returns, so admin, dashboard, builder and auth URLs do not exist there (they 404), and a published site cannot expose them.

### Request lifecycle (dashboard)

1. Global filters: `csrf` (all POSTs except `webhooks/*`), `invalidchars`, `secureheaders`.
2. Route filters: `auth[:platform|:tenant]` → `can:<permission>` → optional `throttle`.
3. `AuthFilter` re-loads the user from the database on every request (one indexed join), so suspending a user or tenant, changing a role or changing a password takes effect on the very next click.
4. For merchant users, `AuthFilter` binds `TenantContext` to `users.tenant_id`. **The tenant is never taken from the URL, form or headers.**
5. Controller → service → models. Every tenant-owned model extends `TenantScopedModel`.

---

## 2. Multi-tenancy

**Model chosen:** shared database, shared schema, `tenant_id` on every tenant-owned row.

| Option | Why not (for this product) |
|---|---|
| Database per tenant | Thousands of schemas to migrate; cross-tenant admin reporting becomes N queries; hard on shared hosting. |
| Schema per tenant | Same migration fan-out; not portable to XAMPP. |
| **Shared schema + tenant_id** | One migration path, cheap per-tenant cost, admin reporting is a plain `GROUP BY`. Risk is a forgotten `WHERE` — mitigated below. |

### How isolation is enforced

`App\Models\TenantScopedModel` (the base for every tenant table's model):

- adds `WHERE <table>.tenant_id = :current` to every find / first / findAll / count / paginate / update / delete;
- **overwrites** `tenant_id` on insert from `TenantContext` — a `tenant_id` posted by the client is ignored;
- strips `tenant_id` from update payloads (and it is never in `$allowedFields`);
- refuses `updateBatch()` and `replace()`, which cannot be scoped safely;
- **fails closed**: if no tenant is bound, it throws `TenantContextException` rather than returning everyone's data;
- `findOrFail()` returns 404 for another tenant's id — identical to a missing row, so ids can't be probed.

Every tenant table also carries an index starting with `tenant_id`, and an `ON DELETE CASCADE` FK to `tenants`, so deleting a tenant removes all its data.

Platform tables (`users`, `tenants`, `roles`, `subscription_plans`, …) use plain models. `UserModel::forCurrentTenant()` exists for merchant-side team screens.

`php spark sitecraft:selftest` proves these properties against the real database (see Testing).

### Where the tenant comes from

| Context | Source of tenant id |
|---|---|
| Merchant dashboard / builder | `users.tenant_id` of the signed-in user |
| Storefront | `Host` → `website_domains.domain` (verified only) or `websites.subdomain`, resolved by `SiteResolver` before any tenant query |
| Webhooks | Looked up from our own records (gateway order id → `payments` row), never from the payload's notes |
| CLI / cron | Explicit `service('tenant')->runAs($id, fn)` |
| Super Admin | No tenant bound; admin screens query platform tables or use `runAs()` per tenant |

---

## 3. Identity, authentication, authorisation

- **Two kinds of people, two tables.** `users` = merchants and platform staff (sign in at the app host). `customers` = shoppers of one store (sign in on that storefront, Phase 6). A shopper account can never reach the dashboard.
- **Roles** (`roles.scope` = `platform` | `tenant`): `super_admin`, `platform_staff`, `store_owner`, `store_staff`. Super Admin holds every permission implicitly and cannot be edited (prevents lock-out).
- **Permissions** are slugs (`products.manage`, `admin.plans.manage`, …) checked by the `can:` route filter and `service('auth')->can()` in code. The full matrix for all eight phases is seeded now so it never shifts under existing role customisations. Grants are cached 5 min and the cache is cleared on edit.
- **Passwords**: Argon2id (bcrypt fallback), rehash-on-login, min 10 chars with a letter and a number.
- **Sessions**: regenerated on login and password change; a fingerprint of the password hash is stored in the session, so changing/resetting a password signs out every other session.
- **Brute force**: per-account lock (5 failures → 15 min), per-IP ceiling (20 failures / 15 min), and a token-bucket `throttle` filter on every auth POST. Unknown-email and wrong-password responses are identical and take the same time (a dummy Argon2 verify runs when the email is unknown).
- **Tokens** (email verify, password reset): split selector/validator; only SHA-256 of the validator is stored; single-use with a conditional update; issuing a new one revokes older ones; reset tokens live 60 min.
- **Audit**: `activity_logs` records sign-ins, lockouts, permission denials, password events and admin changes, with IP and user agent.

---

## 4. Billing vs store payments — kept apart

| | Platform billing | Store payments |
|---|---|---|
| Who pays whom | Merchant → platform owner | Shopper → merchant |
| Razorpay account | Platform's keys in `.env` | Each website's own keys in `website_settings`, encrypted with the app key |
| Tables | `subscriptions`, `subscription_transactions`, `platform_coupons` | `orders`, `payments`, `refunds`, `coupons` |
| Webhook URL | `/webhooks/razorpay/platform` | `/webhooks/razorpay/store/{websiteId}` |

| Browser return | `POST /billing/razorpay/callback` (app host) | `POST /checkout/pay/{number}/callback?k=…` (store host) |
| Code | `Services/Billing/PlatformBillingService` | `Services/Commerce/StorePaymentService` |

Shared rules (Phase 6, implemented):

1. **Amounts come from our records.** The Razorpay order is created server-side for `subscription_transactions.amount` / `orders.grand_total` (in paise); nothing the browser posts is used for an amount.
2. **Never trust the browser's "success".** The callback's `razorpay_signature` is checked (HMAC-SHA256 of `order_id|payment_id` with the *right* account's secret), then the payment is **fetched from Razorpay** and its order id, amount and currency must match our row. Authorised-only payments are captured by us. Only then is anything marked paid.
3. **Webhooks are a second, independent path.** `X-Razorpay-Signature` over the raw body is verified with that account's webhook secret; each event is stored in `webhook_events` (unique `source + event_id`) so retries are answered `duplicate`; the event is then processed exactly like a callback (fetch + match). A payment completes even if the shopper closes the browser.
4. **Idempotent completion.** Callback, webhook and replays may all arrive; completion locks the row (`FOR UPDATE`) and does nothing when it is already captured — no double activation, no double stock movement.
5. **Separation.** Platform callbacks/webhooks only look in `subscription_transactions`; store ones only in `payments` of *that* website. The platform webhook secret is not accepted on a store URL and vice versa. Store keys may not be reused by another store.
6. **Development simulator.** With `razorpay.simulator = true` (never in production) the gateway is `RazorpaySimulator`: a local stand-in with real HMAC signatures, a fake checkout page (`/__dev/razorpay/checkout`) and the REST API under `/__dev/razorpay/v1`. Without it, `RazorpayHttp` talks to `api.razorpay.com`.

### Plan payments
Checkout (`/dashboard/billing/checkout?plan=…&cycle=…`) shows a server-side quote: plan price − platform coupon − credit for the unused part of the current paid period (upgrades); price includes 18 % GST (configurable). Buying a cheaper plan with more credit than its price is refused — use *Switch* (takes effect at period end). Paying creates a `subscription_transactions` row, a Razorpay order (`receipt st_{id}`) and opens Razorpay Checkout. Completion: invoice number `SC-YYYY-NNNNN`, coupon redemption counted, `SubscriptionService::activatePaidPeriod` (renewal extends the current period; a different plan/cycle starts a new one), receipt email. `subscriptions:process` sends renewal reminders 3 days before the end of a paid period. Super admins see transactions, revenue and webhook deliveries at *Admin → Payments* and manage platform coupons at *Admin → Coupons*.

### Store payments
The merchant connects their own Razorpay keys (*Sales → Payments → Razorpay settings*); saving verifies them against Razorpay and switches on "Pay online" at checkout. See §7 for the order flow.

---

## 5. Website builder & publishing (Phase 3)

```
 Template (platform)        Website draft (tenant)                 Published (tenant)
 website_templates          website_builder_projects               website_pages.published_html/css
 project_data (JSON)  ──►   project_data (JSON, versioned)   ──►  sanitised render per page
                            + website_settings.theme/custom_css    + website_builder_revisions (snapshot)
       ▲ apply (copy)              ▲ editor autosave (POST JSON,             ▲ publish = snapshot + server render
       │                           │   optimistic lock on draft_version)     │   (never HTML from the browser)
```

**Source of truth.** The GrapesJS project JSON (pages, components, CSS rules, symbols) is stored as-is. The editor never sends HTML to save; preview and publish re-render the stored JSON on the server (`ProjectRenderer`) and pass the result through an allowlist sanitiser (`HtmlSanitizer`, `CssSanitizer`).

**Renderer parity.** `ProjectRenderer` turns component JSON into HTML and CSS rule JSON into CSS. A browser test compares, element by element, the server's output with GrapesJS's own `getHtml()/getCss()` for every page: all 13 templates (50 pages) and edited sites are identical.

**Sanitiser (published pages).** Allowlisted elements and attributes per element; all `on*` handlers removed; URLs limited to http(s)/mailto/tel/relative and inline raster images; iframes only for YouTube, Vimeo and Google Maps, always sandboxed; `data-gjs-*` stripped; form `action` removed (the storefront attaches its handler); CSS stripped of `</style>`, `expression()`, `javascript:`, `@import` and non-https `url()`s. The editor itself also parses with GrapesJS's script/unsafe-attribute guards on.

**Shared header & footer.** Template pages carry `data-sc-global="header|footer"` markup. On first open the editor turns them into GrapesJS **symbols** (one main + an instance per page), so editing the header on any page updates every page. Instances store their full component tree, so the server renders them without special handling.

**Pages.** Page identity, name, address (`slug`) and type (`home | page | product`) live in the project JSON; `website_pages` rows are synced on every save (used for routing, visibility and SEO). Rules enforced server-side: exactly one homepage, unique lowercase addresses, reserved store paths (`/cart`, `/products`, …) refused, plan `max_pages` limit. The `product` page is a layout for every `/products/{slug}` URL (Phase 5).

**Widgets.** Catalog/cart/blog blocks are `data-sc-widget` elements whose inner HTML is a labelled sample in the editor; the storefront (Phases 4–5) replaces it with live data, so prices never need republishing.

**Theme.** Colours, two fonts and corner radius are validated (`Theme::normalise`) and emitted as CSS variables used by `storefront.css`. Only fonts on the allowlist (`Config\Builder::$fonts`, Google Fonts) are ever linked.

**Publishing & versions.** Publish = snapshot the draft into `website_builder_revisions`, render every page, store HTML/CSS, set `websites.published_version`. Applying a template, starting blank or restoring a version first snapshots the current draft. The newest N revisions are kept (`max_revisions_kept`).

**Images.** Uploads are type-sniffed, size- and quota-checked, re-encoded with GD (strips metadata and any non-image payload), resized to ≤2560px, saved under `public/media/<tenant-uuid>/` with random names, plus a WebP copy. SVG is not accepted. `public/media/.htaccess` disables script execution.

**Starter content.** `SectionLibrary` generates every section's HTML and is the single source for both editor blocks (`BlockCatalog`) and the 13 templates (`TemplateLibrary`). Template artwork is generated SVG (`tools/generate_template_art.py`) — no hotlinked photos.

## 6. Storefront & custom domains (Phase 4)

```
 request ─► Host ─► app host? ── yes ─► admin / dashboard / builder routes (session, CSRF)
                      │ no
                      ▼
            storefront routes only:  /  /{slug}  /robots.txt  /sitemap.xml  /favicon.ico  POST /__sc/forms/{contact|newsletter}
                      │
            SiteResolver: <sub>.<base> → websites.subdomain
                          other host   → website_domains (verified) ─► www/apex alias ─► plan allows custom domains?
                      │                 (unknown → 404 "Website not found")
            redirects: plan lost custom domains → 302 to subdomain · live primary domain → 301 there · prod + https → 301
            status:    tenant not active / site suspended or locked → 503 · draft / unpublished → 404 (no reason shown)
            bind TenantContext from the resolved site
            page:      redirects table → website_pages (published, visible) → cache → render
```

**What is served.** Only what was rendered at publish time (`website_pages.published_html/css`) plus the theme and custom CSS frozen at publish (`website_settings.published_theme/published_custom_css`) — editing the draft, the theme or custom CSS changes nothing live until **Publish**. At request time `StorefrontRenderer` (DOM, allowlisted output, everything escaped) adds what must stay live without republishing:

- **Menus** — a header menu replaces the links of `data-sc-menu="header"` (desktop nav and the mobile `<details>` menu); footer menus fill the `data-sc-menu="footer"` lists in order and rename their heading. No menu → the design's own links. One level of drop-downs (CSS only).
- **Widgets** — catalog/blog widgets query live, website-scoped data (`StoreData`, always `tenant_id` + `website_id` of the resolved site; active products only). Empty → an honest empty state ("New products are on their way") or the section is removed (categories, blog). The editor's sample items and notes never reach a store. Product-page blocks outside the product layout are removed; cart/account/search icons are hidden until those routes exist (`StorefrontRenderer::UNAVAILABLE_PATHS`).
- **Forms** — `data-sc-form` forms get `action`, a signed token, a honeypot and a result message (`?sc_form=sent|invalid|expired|error`).
- **Branding** — "Powered by …" (platform setting) when the plan lacks `allow_remove_branding`.

**SEO head.** Title/description/robots/canonical/OG from the page's `seo_settings`, falling back to the website's, then the platform default robots. Canonical and sitemap URLs always use the *canonical host* (live primary domain, else the host being served). JSON-LD: `WebSite` + `Organization` on the homepage, `WebPage` + `BreadcrumbList` elsewhere. `robots.txt` is the merchant's custom text or a default that keeps cart/checkout/account out, always followed by the sitemap URL; a site set to `noindex` gets `Disallow: /` and an empty sitemap. Pages that are hidden, `noindex` or canonicalised elsewhere are left out of `sitemap.xml`. App hosts answer `Disallow: /`.

**Addresses.** One address per page: trailing slashes and upper case 301 to the canonical path; `redirects` rows (managed in Phase 7) are honoured before the 404. The 404 page reuses the site's published header and footer and is `noindex`.

**No sessions, no scripts.** Storefront hosts drop the global CSRF filter (CodeIgniter's session-mode CSRF would start a session per shopper) and the debug toolbar, never touch `service('session')`, and set no cookies — the only storefront cookie is the cart token (`sc_cart`, HttpOnly, SameSite=Lax, Secure in production), set when a shopper first adds to cart. Responses carry `Content-Security-Policy: script-src 'none'; form-action 'self'; frame-ancestors 'none'` and only allowlisted embed frames. CodeIgniter's own HTTPS redirect is disabled for store hosts (it would redirect to `baseURL`); the storefront upgrades to HTTPS itself after resolving the host.

**Forms without sessions.** Token = `base36(issued_at).HMAC-SHA256("sc-form|website|issued_at", encryption.key)`, valid 24 h, bound to the website. Plus: honeypot field (bots get a fake "sent"), Origin must match the host when sent, per-IP throttle (10/min), field validation and control-character stripping server-side. Contact messages → `form_submissions` (merchant inbox + in-app notification + email to the website's contact email or the owner, capped at 50 emails/day/site); newsletter → `newsletter_subscribers` (unique per site, case-insensitive, CSV export neutralises formula injection). Post/redirect/get with 303.

**Caching.** Rendered pages are cached (`platform.pageCacheTtl`, default 600 s) under a key made of website id, a per-website *generation*, published version, `websites.updated_at`, branding flag, host and path. Publishing, unpublishing, page settings, page structure, menus, domains and website settings bump the generation (`StorefrontCache::flush`). Site and tenant status are checked live **before** the cache, so suspensions are immediate. Requests with a form result message bypass the cache. Responses: `Cache-Control: public, max-age=60`, `X-Cache: HIT|MISS`.

**Custom domains.** Two independent DNS checks (`DomainService`):

| Check | Record | Effect |
|---|---|---|
| Ownership | TXT `_sitecraft-verify.<domain>` = `sitecraft-verify=<32-hex token>` | Only this sets `verified`. Only verified domains are served or get a TLS certificate. |
| Routing | CNAME → `platform.cnameTarget`, or A → `platform.serverIp` | `routing_status = ok`. Required before a domain can become the **main address** (otherwise redirects would lead nowhere). |

`php spark domains:verify` (cron, every 10 min) checks pending domains with back-off (5 min × attempts, max 6 h; gives up after 7 days → `failed`) and re-checks verified domains daily. If the TXT record is missing on 3 consecutive checks, the domain becomes `failed`, stops serving and loses main-address status (domains change hands; DNS proof must stay current). The merchant is notified when a domain connects or disconnects. Domains are globally unique; a fresh pending claim blocks others, but an **unverified claim older than 72 h is released** to the next claimant (so squatting is impossible); verified domains are never taken over. Platform admins can re-check or remove any domain with a reason. Downgrading to a plan without custom domains keeps the rows but 302s those hosts to the free subdomain. `www.` ↔ apex: if only one is connected, the other 301s to it.

**TLS.** Subdomains use a wildcard certificate for `*.<baseDomain>`; custom domains use on-demand TLS (Caddy `on_demand_tls { ask http://127.0.0.1/__sc/tls-check }`), which answers 200 only for hosts the storefront would serve. Local development: `writable/dns-overrides.json` replaces DNS answers when `CI_ENVIRONMENT` is not `production`.

---

## 7. Catalog, cart, checkout & orders (Phase 5)

```
 dashboard ─► ProductService / CategoryService / InventoryService ─► products, variants, images, inventory (+ movements)
 shopper   ─► /products /collections/{slug} /search /products/{slug}      (cached, live data, no JS)
           ─► POST /cart/add, /cart/update   cookie sc_cart = random token; DB keeps SHA-256 only
           ─► GET/POST /checkout ─► OrderService::placeFromCart ─► orders, order_items, stock movements
           ─► /order/{number}?k=…  buyer's private page (HMAC-derived secret, stored as a hash)
 merchant  ─► orders list/detail ─► transition(pending → processing → shipped → delivered | cancelled) ─► emails, invoice
```

**Catalog.** Products belong to one website. Prices are `DECIMAL(12,2)`; a sale price counts only when lower than the price. Options (up to 3, e.g. Size × Colour) generate variants (≤100) — each has its own price (copied from the product when created), sale price, SKU and stock; changing options adds or removes combinations and keeps existing ones by option key. SKUs are unique per website across products and variants. Descriptions are edited in a small rich-text box and sanitised to text formatting on save and again on output. Deleting a product is a soft delete that frees its address and SKU; order lines keep their snapshots. Plan `max_products` is enforced on create and duplicate.

**Stock.** One `inventory` row per product (no options) or per variant. Every change writes `inventory_movements` (restock, correction, damaged, return, order, cancel) with who and why. "Keep selling when out of stock" (backorder) and "track stock" are per product.

**Product pages.** `/products/{slug}` renders the website's own *product layout* page (designed in the editor, `page_type = product`) with the `product-details`, `product-gallery`, `add-to-cart` and related-products widgets filled from the catalog; without one, a standard layout is used. The gallery switches images with CSS `:target`, options are a `<select>` (sold-out options disabled) — no JavaScript. Product pages carry `Product` + `Offer`/`AggregateOffer` JSON-LD and breadcrumbs; listings (`/products`, `/collections/{slug}` incl. sub-categories) paginate 24 per page with sort links (non-default sorts and search are `noindex`). Products and collections are in `sitemap.xml`.

**Cart.** Server-side cart (`carts`, `cart_items`). Every add/update checks the product is active on *this* website, the option belongs to it and the quantity is available (or backorder allowed); max 99 per line, 50 lines. Lines re-price from the database on every view, and lines whose product disappeared or sold out block checkout until fixed. Forms carry the signed site token plus an Origin check (and the cookie is SameSite=Lax). The header cart count is inserted per request into otherwise cached pages; pages for shoppers with a cart are `Cache-Control: private`.

**Money.** `Pricing` computes per line: GST inside the price (`line × r / (100 + r)`) when the store's prices include tax, otherwise added on top; product rate overrides the store rate. Shipping (Phase 5): flat fee per order, free above a threshold. Everything is rounded to paise per line. Totals are never read from the browser.

**Placing an order** (one transaction): lock the cart row (a double-submit waits, then finds no cart) → re-price lines → lock inventory rows in id order (no deadlocks) and re-check stock → choose an allowed payment method (COD in Phase 5, with an optional maximum) → next order number from a per-website counter (`UPDATE … LAST_INSERT_ID(order_seq + 1)`) → upsert the guest customer (by website + email) → insert order + item snapshots → commit stock (movement "order") → history row → delete the cart. After commit: confirmation email to the buyer, email + in-app notification to the merchant.

**Buyer's order page.** `/order/{number}?k={secret}`; the secret is `HMAC(order id | number | website, app key)`, stored only as SHA-256, compared in constant time; `noindex`, `no-store`, `Referrer-Policy: no-referrer`.

**Workflow.** Allowed moves: pending → processing/shipped/cancelled, processing → shipped/cancelled, shipped → delivered. Updates are compare-and-set on the current status (two staff can't both act). Shipping records courier/tracking (https links only) and assigns a per-website invoice number; delivering a COD order marks it paid; cancelling returns tracked stock and marks unpaid orders failed. Each move can email the buyer. The invoice is a printable GST tax invoice (CGST + SGST when the delivery state equals the store's state, otherwise IGST). Orders and products export to CSV with spreadsheet-formula neutralisation.

## 7a. Payments, discounts, shipping & customer accounts (Phase 6)

```
 shopper ─► POST /cart/coupon            code re-validated on every view; dropped with a reason if it stops applying
         ─► POST /checkout (no option)   → delivery options for the address (zones) → choose → POST /checkout
         ─► online: order created with stock RESERVED, Razorpay order created → /checkout/pay/{n}?k=…
                    (the only storefront page that loads a script: Razorpay Checkout, via its own CSP)
         ─► Razorpay → POST /checkout/pay/{n}/callback?k=… ─► StorePaymentService::complete ─► paid, stock COMMITTED, emails
         ─► webhook /webhooks/razorpay/store/{site}        ─► same completion (browser closed, retries)
 cron    ─► orders:expire-unpaid  (every 5–10 min) unpaid after 30 min → cancelled, reservation + coupon use released
```

**Stock reservation.** `inventory.reserved` holds units of unpaid online orders; what shoppers can buy is `quantity − reserved`. Placement locks inventory rows exactly as for COD but adds to `reserved` (`orders.stock_state = reserved`). Payment turns the reservation into a sale (movement "order"); expiry releases it (`released`). A payment that arrives *after* expiry is still captured: the order is reinstated, stock taken (it can go below zero — the timeline tells the merchant to check stock) and the discount use counted again. Unpaid online orders cannot be moved to processing/shipped.

**Refunds** (permission `orders.refund`). Partial or full, up to what is left; online orders are refunded through the merchant's Razorpay account (`rfnd_…` id recorded), COD refunds are recorded as paid back outside the store. Full refund of an order that hasn't shipped cancels it; "restock" returns tracked items. `payments.amount_refunded`, `refunds` rows and a timeline entry are written.

**Discount codes** (`coupons`, per website). Percent (optional cap), fixed amount, or free shipping; minimum order, total uses, uses per customer (by email), start/end dates, on/off. The code is stored on the cart, re-validated on each view and again *under a row lock with the buyer's email* when the order is placed. The discount is spread over lines in proportion to value, so GST is charged on the discounted price. `coupon_usage` + `used_count` are updated in the order transaction and released on cancellation/expiry. A used code can't be deleted (it is switched off instead).

**Shipping zones.** Zones match by countries, Indian states and PIN rules (`110001`, prefix `11*`, range `400001-400099`); the first active zone by priority wins. Rates: flat, free, by weight (base + per kg, rounded up) or by order value, each optionally limited to an order-value or weight band, with a delivery-time label. Without any zone, the Phase 5 flat fee + free threshold applies; with zones, addresses outside every zone can't check out. The cart shows "Calculated at checkout"; checkout asks for the address first, then shows the options. `shipping_rates.provider` is reserved for courier APIs (Shiprocket) so carrier quotes can be added as another rate source.

**Customer accounts** (`/account`). Separate from platform users: table `customers`, tokens in `customer_tokens`, cookie `sc_acct` (HttpOnly, SameSite=Lax, Secure in production), scoped to one website. Tokens are `selector.validator`; only SHA-256 of the validator is stored and compared in constant time. Sign-up (registering an email that already has guest orders keeps that customer record), sign-in (same message for unknown email and wrong password, dummy hash to equalise timing), sign-out, forgot/reset (1 hour, single use, signs out all other sessions, also lets a guest set a first password), email confirmation (7 days). **Order history appears only after the email is confirmed**, because guest orders are linked by email and an unconfirmed sign-up must not reveal someone else's orders. Signed-in checkout pre-fills details and links the order to the account. Merchants can list, search, export (CSV) and block customers; blocking ends their sessions and refuses sign-in and checkout with that email. The header account icon only appears on stores whose shop is open. Throttles: sign-in 10/min, sign-up 5/min, reset 5/min per IP.

**Merchant screens.** *Sales → Customers, Coupons, Payments (+ Razorpay settings), Shipping*; refunds and payment details on the order page.

## 7b. Content, SEO & analytics (Phase 7)

**Media library** (`/dashboard/media`, permission `media.manage`). Every upload — editor, products, categories, blog — goes through `MediaService::uploadImage` (MIME sniffing, GD re-encode that strips metadata and payloads, size limits, WebP copy, plan storage quota) and lands in `media_files` under `public/media/<tenant-uuid>/YYYY/MM/<random>`. The library adds folders, search, “missing alt text” filter, rename, alt text, bulk move/delete and a storage meter. Before deleting, `whereUsed()` searches published pages, editor drafts, product images, categories, blog posts, logos and sharing images; images in use are only removed with an explicit “Delete anyway”, and deletion is confined to `public/media`. Dashboard forms pick images through a small modal (`data-media-pick`) fed by `/dashboard/media/picker` (JSON, tenant-scoped).

**Blog** (`/dashboard/websites/{id}/blog`, `blog.manage`). Posts have title, address, rich text (sanitised by `HtmlSanitizer`, then reduced to text/media tags; only allow-listed video/map iframes survive), excerpt, featured image, category, tags, author, status and publish time, plus SEO fields. A post is live when `status = published` and `published_at <= now` — scheduling needs no job (cached pages expire within 10 minutes). Storefront: `/blog` (paginated), `/blog/category/{slug}`, `/blog/tag/{slug}` (noindex), `/blog/{slug}` with BlogPosting + breadcrumb JSON-LD, article Open Graph tags, reading time, tags and related posts, and `/blog/feed.xml` (RSS 2.0). Posts and used categories are in the sitemap. The builder’s “Blog section” widget shows the latest live posts.

**SEO** (`/dashboard/websites/{id}/seo`, `seo.manage`).
- *Search settings*: homepage title/description (these win over the home page’s own fields), default sharing image, whole-site visibility (hiding the site forces `noindex,nofollow` on every page, `X-Robots-Tag`, `Disallow: /` and an empty sitemap), verification codes for Google, Bing, Pinterest and Meta (a pasted `<meta>` tag is reduced to its code), optional GA4 measurement ID.
- *robots.txt*: built-in rules by default (keeps cart, checkout, account and search out); advanced plans can write their own — only User-agent/Allow/Disallow/Crawl-delay lines are accepted and the correct `Sitemap:` line is always appended.
- *Redirects* (`RedirectService`): manual (with bulk import of “old new” lines) and automatic whenever a live page, product, collection, blog post or blog category changes address. Sources are normalised to paths; store paths (`/cart`, `/checkout`, `/account…`, `/order…`, `/__sc…`) can’t be redirected; targets must be a local path or http(s) URL; loops and duplicates are refused; chains are collapsed. Redirects are checked before any 404 on the storefront, with hit counters. Basic plans: 50 manual redirects; advanced: unlimited.
- *SEO audit* (advanced plans, `SeoAuditService`): checks the site, pages (title length, missing description, H1 count, images without alt), products (description, photo, text), collections, blog posts (length, image) and the media library, finds duplicate titles, scores 0–100 relative to site size, and links each issue to its fix.
- Per-entity metadata lives in `seo_settings` (`SeoService` validates sharing images: library path or https; canonical: https only).

**Google Analytics (optional).** When a valid `G-…` ID is set, public cacheable pages load `gtag.js` from Google plus a same-origin config file (`/__sc/gtag.js`), and only those responses get a CSP that allows `www.googletagmanager.com`; checkout, payment, account and order pages never load it.

**Built-in analytics** (`AnalyticsService`, `analytics.view`). Counted on the server inside `StorefrontController::html()` — so cached pages count too and no JavaScript or cookies are needed. Skipped: bots/tools (user-agent rules), prefetches, non-200 responses, and private paths (`/order/…`, `/checkout/pay/…`, `/account…`). A visitor is recognised for one day by `HMAC(ip | user agent | website, daily key)`; only that 32-character hash is stored (`analytics_visitors`) and it is pruned after two days by `orders:expire-unpaid`. Browsers sending DNT or GPC are counted as a page view only. Aggregates: `analytics_daily` (views and unique visitors per path per day; the `*` row is the site total) and `analytics_sources` (referrer, device and browser per new visitor; search engines and social networks grouped by name). The merchant report adds orders, sales, average order, conversion and best sellers; basic plans see 7/30 days and top pages; advanced plans add 90/365 days, sources, devices, browsers and CSV export.

**Platform reports** (`/admin/reports`, `admin.reports.view`): MRR (yearly ÷ 12), plan revenue, new accounts, store sales (GMV) and visitors per day, accounts by plan, published websites, top stores, CSV export.

## 7c. Security hardening & operations (Phase 8)

**Two header policies.** Storefronts send their own CSP (`script-src 'none'`, set in `StorefrontController::html`). Every other response gets `AppSecurityHeaders`: scripts from the platform and Razorpay Checkout only, inline styles allowed (Bootstrap/editor), `frame-ancestors 'self'`, `object-src 'none'`, Permissions-Policy, COOP, HSTS on HTTPS. The dashboard therefore contains no inline JavaScript; behaviour is declared with data attributes (`data-confirm`, `data-confirm-if`, `data-nav-select`, `data-check-all`, `data-print`, modal `data-action-template` + `data-id-pattern`) and implemented once in `app.js`. The CodeIgniter debug toolbar needs inline scripts, so it is off unless `app.devToolbar = true` (development only), which also relaxes the CSP.

**Operations.** Scheduled commands write a heartbeat file (`writable/heartbeats/<job>`; files survive `cache:clear`). `sitecraft:doctor` turns configuration and heartbeats into pass/warn/fail; `/health` (platform host only) returns 200/503 from the database and storage checks and lists job freshness without failing on it. `backup:run` uses `mysqldump --single-transaction` piped to gzip with the password in `MYSQL_PWD` (not visible in `ps`), tars `public/media`, writes `.part` files then renames, and prunes to the newest N.

**Release layout.** `deploy/scripts/deploy.sh` unpacks each release into `/var/www/releases/<stamp>`, symlinks shared `.env`, `writable/` and `public/media`, backs up the database, migrates, atomically switches `/var/www/sitecraft`, reloads PHP-FPM and runs the doctor and health check. `php spark optimize` is deliberately **not** used: it caches Config objects, and `Config\App`/`Config\Filters` depend on the request host.

**TLS.** Caddy issues certificates on demand for any host `/__sc/tls-check` approves (store subdomains and DNS-verified custom domains), so merchants' domains get HTTPS without manual steps; a wildcard certificate is the documented option at higher sign-up rates.

## 7d. Public marketing site (post-1.0)

`MarketingController` serves `/`, `/features`, `/templates`, `/templates/{slug}` (+ `/preview`, the same server renderer and script-free CSP as the editor preview) and `/pricing` on the platform host, plus `/sitemap.xml`. Views live in `app/Views/marketing/` with their own layout and `marketing.css` (no Bootstrap, no JavaScript: the mobile menu is `<details>`, the monthly/yearly switch is radio buttons + CSS). `Libraries/Marketing/Plans` derives every price, bullet and comparison row from the plan columns the backend enforces. CSRF is skipped for these GET-only paths, so visitors get no session cookie. `SignupIntent` keeps a validated plan/template slug in the session from `/register?plan=…&template=…`; the template wizard and Billing offer it, and it's cleared once used.

## 8. Database

64 tables in five migration modules, plus small additive migrations per phase (Phase 2: `tenants.trial_used_at`, `websites.status_reason`; Phase 3: `website_settings.custom_css`, `website_templates.custom_css/pages_summary`; Phase 4: `form_submissions`, `newsletter_subscribers`, `website_domains.routing_status/routing_checked_at/check_attempts`, `website_settings.published_theme/published_custom_css`, permission `forms.view`; Phase 5: store settings on `website_settings` (legal name, business state, flat shipping, free-shipping threshold, COD switch/limit, order prefix and counters, checkout note), `orders.access_hash/cod_fee/merchant_note`, unique cart lines) (`app/Database/Migrations`), mirrored in `database/database.sql`:

| Module | Tables |
|---|---|
| Core & auth | tenants, users, roles, permissions, role_permissions, user_tokens, login_attempts, activity_logs, notifications, platform_settings, email_templates |
| Platform billing | subscription_plans, subscriptions, platform_coupons, subscription_transactions |
| Websites | website_template_categories, website_templates, websites, website_domains, website_settings, website_builder_projects, website_builder_revisions, website_saved_sections, website_pages, website_menus, website_menu_items, media_folders, media_files, seo_settings, redirects |
| Commerce | product_categories, products, product_category_map, product_tags, product_tag_map, product_images, product_attributes, product_variants, inventory, inventory_movements, customer_groups, customers, customer_addresses, coupons, shipping_zones, shipping_rates, carts, cart_items, orders, order_items, order_status_history, coupon_usage, payments, refunds, webhook_events |
| Content & support | blog_categories, blogs, blog_tags, blog_tag_map, support_tickets, support_ticket_replies, analytics_daily, form_submissions, newsletter_subscribers |

Conventions: `BIGINT UNSIGNED` ids; `DECIMAL(12,2)` money (never float); `utf8mb4_unicode_ci`; statuses as short `VARCHAR` documented in column comments (easier to extend than `ENUM`); `created_at`/`updated_at` everywhere and `deleted_at` where merchants expect undo; per-website uniqueness (`UNIQUE(website_id, slug)`) rather than global; order lines snapshot name/SKU/price so history survives product edits; `inventory.stock_key` is a generated column that enforces one stock row per product/variant even when `variant_id` is NULL.

---

## 9. Code layout

```
app/
  Commands/          spark commands (sitecraft:selftest, subscriptions:process, domains:verify)
  Config/            Platform.php (product settings), Navigation.php (menus), Routes, Filters, Services
  Controllers/
    Auth/            login, register, verification, password reset, forced change
    Admin/           Super Admin panel
    Dashboard/       merchant dashboard
    Builder/         editor JSON API, preview, publish
    Storefront/      public sites (StorefrontController) + TLS ask endpoint
    (Api/, Webhooks/ added in later phases)
  Database/
    Migrations/      SQL-DDL migrations, one per module
    Seeds/           roles, permissions, plans, settings, super admin, demo data
  Exceptions/
  Filters/           AuthFilter, GuestFilter, PermissionFilter, RateLimitFilter
  Helpers/           app_helper (money, badges, form errors)
  Models/            TenantScopedModel + models
  Libraries/         Builder/ (renderer, sanitisers, sections, templates), Storefront/ (Host, StorefrontRenderer, FormToken)
  Services/          AuthService, TenantContext, RegistrationService, TokenService, Mailer, ActivityLogger, Subscription/Website/Project/Publish/Media/Menu/DomainService
    Storefront/      SiteResolver, StoreData, StorefrontCache, FormService, DnsLookup
  Views/
    layouts/         admin, dashboard, auth  (+ builder, storefront later)
    partials/ auth/ admin/ dashboard/ account/ emails/ errors/
public/assets/       app.css, app.js, vendor/ (Bootstrap, icons, Inter — self-hosted)
database/            database.sql
docs/                this file, CHECKLIST.md
tests/smoke/         HTTP smoke tests
```

---

## 10. Decisions log

| # | Decision | Reason |
|---|---|---|
| D1 | Shared-schema multi-tenancy enforced in a base model | See §2. |
| D2 | Catalog/orders scoped to **website** as well as tenant | Business/Premium plans allow several stores per merchant; each needs its own products and order numbers. |
| D3 | Shoppers in `customers`, not `users` | Different security boundary; a shopper must never reach the dashboard. |
| D4 | Platform coupons separate from store coupons | Required separation of the two payment systems. |
| D5 | Migrations written as SQL DDL | Composite keys, generated column and FK rules are clearer as DDL; `database.sql` is dumped from them so the two cannot drift. |
| D6 | Free plan is the default on sign-up | Lets merchants start immediately; paid plans with `trial_days` start as `trialing`. |
| D7 | Email verification required before dashboard access | Protects sender reputation and gives a trusted contact for billing. Toggle: `platform.requireEmailVerification`. |
| D8 | Mail without SMTP is written to `writable/mail/` | Verification and reset flows work on XAMPP with no mail server. |
| D9 | Front-end libraries self-hosted | Works offline, no CDN dependency, no third-party script on admin pages. |
| D10 | Unshipped menu items shown greyed with a phase tag | Shows the roadmap without any link that leads nowhere. |
| D11 | Super Admin role is not editable | Avoids an administrator removing their own access. |
| D12 | Seeded admin must change password at first sign-in | Default credentials are documented, so they must not stay usable. |
| D13 | One row per subscription period; a plan change closes the live row and opens a new one | The table doubles as billing history and there is never more than one live row (tested). |
| D14 | Trial is once per tenant (`tenants.trial_used_at`) | Stops repeated trials by switching plans. |
| D15 | Downgrade from a paid period is scheduled for period end; from Free/trial/grace it is immediate | Merchants keep what they paid for; nothing to keep otherwise. |
| D16 | Over-limit websites are **locked**, never deleted; newest are locked first | A downgrade or lapse must not destroy work. Upgrading or deleting a site unlocks automatically. |
| D17 | Expiry and grace are applied by a cron command, not on page load | Predictable, idempotent, testable with an injected clock; admins can also run it on demand. |
| D18 | An email belongs to one account platform-wide | Keeps sign-in unambiguous; a person cannot be staff in two stores with one email. |
| D19 | Email templates are editable but have built-in fallbacks | A broken or disabled template never stops verification/reset mail. Variables are always HTML-escaped; scripts/handlers are stripped on save. |
| D21 | Store project JSON; render HTML on the server | The browser can't be trusted to supply publishable HTML. Parity with GrapesJS is verified by test. |
| D22 | Header/footer as GrapesJS symbols | One edit updates all pages, using a native editor feature rather than custom sync code. |
| D23 | Generated SVG artwork instead of stock photos | No broken hotlinks, no licensing questions; merchants replace art with their uploads. |
| D24 | No SVG uploads; all raster uploads re-encoded | SVG can carry script; re-encoding removes polyglot/metadata payloads. |
| D25 | Custom HTML allowed but sanitised (no scripts) | Merchants can embed markup; storefront visitors stay safe. Third-party scripts (analytics etc.) get a dedicated, vetted setting later. |
| D26 | Plan limits are a kind of domain rule (`PlanLimitException extends DomainRuleException`) | Every caller that reports business-rule errors also reports plan limits correctly (found by tests: the editor would otherwise have returned 500). |
| D27 | Per-session CSRF token (`security.regenerate = false`) | Rotating on every request broke the editor and forms in a second tab; per-session tokens are the norm (Laravel, Rails). |
| D28 | Application timezone `Asia/Kolkata` | The platform targets Indian merchants; configurable in `.env`. |
| D29 | GrapesJS 0.22.16 pinned and self-hosted, telemetry off | Stable API; merchants' editors don't call third parties. |
| D30 | Storefront routes registered only for store hosts (route-file split on Host) | Admin/dashboard URLs don't exist on a store domain at all — stronger than filtering them per request. |
| D31 | Theme and custom CSS frozen at publish (`published_theme`, `published_custom_css`) | Phase 3 saved them live with the draft, so editing colours changed the live site before publishing. |
| D32 | Domain ownership by TXT record, routing checked separately | A CNAME alone proves only that DNS points here, not who controls it; separating the two lets a domain be verified before DNS is switched (no downtime when moving from another host). |
| D33 | Verified domains must keep the TXT record (3 missed daily checks → disconnected) | Expired and re-registered domains would otherwise keep serving the previous owner's store. |
| D34 | Unverified domain claims expire after 72 h for other claimants | Prevents squatting on a competitor's domain with a never-verified claim. |
| D35 | Main address only when DNS routing is OK | Redirecting the working subdomain to a domain that doesn't reach us would take the store offline. |
| D36 | Storefront forms use a signed token + honeypot + Origin check instead of session CSRF | Keeps stores cookie-less and cacheable; CSRF on a contact form protects nothing user-specific. |
| D37 | Live data in widgets, honest empty states, unfinished shop links hidden | No sample products, no links to pages that don't exist yet (cart/account/search arrive in Phase 5). |
| D38 | Plan downgrade keeps custom domains but 302s them to the subdomain | Nothing is deleted on downgrade; upgrading again restores the domain instantly. |
| D39 | Pages cached with a generation key; status checked before the cache | Fast pages without stale suspensions; no cache-tag store needed (works with the file cache). |
| D40 | Storefront shop works without JavaScript (forms, `<select>`, CSS gallery) | Keeps the strict `script-src 'none'` CSP, works on any phone/browser, nothing to break. Richer interactions can be added later as progressive enhancement. |
| D41 | Server-side cart, cookie holds only a random token (stored hashed) | Prices and stock are always re-checked on the server; a database leak exposes no usable cart cookies. |
| D42 | Stock committed at order placement under row locks | Overselling is impossible even with simultaneous checkouts. For Razorpay orders the same step reserves stock until payment is confirmed (D50). |
| D43 | Variants always carry their own price (copied from the product at creation) | Matches the schema (`regular_price NOT NULL`) and makes per-option pricing explicit; listings show "From" the lowest option price. |
| D44 | Order page link secret derived by HMAC, stored as a hash | Every email links to the same page without storing a reusable secret. |
| D45 | Invoice number assigned at shipment, per website | A GST invoice is issued when goods are supplied; cancelled-before-shipping orders leave no gaps in the invoice series. |
| D46 | Shipping in Phase 5 = flat fee + free threshold | Enough for COD launch; zones, weight and courier rates (Shiprocket) replace it in Phase 6 without touching checkout. |
| D47 | Guest checkout only; customers upserted by email per website | Customer accounts arrive in Phase 6; orders already link to `customers` so accounts can claim their history. |
| D48 | Payment success = signature **and** a server-side fetch from Razorpay matching order/amount/currency | A valid signature proves who sent it, not that the amount is ours; the fetch closes that gap and also handles authorised-only payments (we capture). |
| D49 | Razorpay Checkout in *redirect* mode posting to a server callback | Works with the storefront's no-JS design and no sessions: the callback URL carries the order's private key; the page that loads checkout.js has its own relaxed CSP, every other store page keeps `script-src 'none'`. |
| D50 | Online orders reserve stock for 30 minutes, then release | Shoppers can't lose an item they're paying for, and abandoned payments don't lock stock forever. Late payments are still honoured (reinstated) rather than auto-refunded — merchant decides. |
| D51 | Built-in Razorpay simulator for development | The whole money flow (signatures, webhooks, capture, refunds) is testable on XAMPP without internet or Razorpay keys; it is unreachable when `CI_ENVIRONMENT = production`. |
| D52 | Merchant keys encrypted with the app key; secrets never re-displayed | A database dump alone exposes no usable payment credentials. |
| D53 | Shopper order history only after email confirmation | Guest orders link by email; without proof of ownership an account could read a stranger's orders. |
| D54 | Discount spread across lines before tax | Matches GST rules (tax on the transaction value after discount) and keeps line totals exact for invoices and refunds. |
| D55 | Shipping zones replace the flat fee only when the merchant adds one | Existing stores keep working unchanged; courier (Shiprocket) quotes slot in later as a rate provider. |
| D56 | Server-side, cookie-free analytics with daily-rotating visitor hashes | Works with cached, script-free storefronts; no consent banner needed for the built-in stats; no IPs stored. Accuracy is “unique per device per day”, which is what merchants need. |
| D57 | Blog scheduling = future `published_at`, no queue | Nothing to run or fail; visibility is a query condition. |
| D58 | Automatic redirects on every address change, with chain collapsing | Old links and search results never break; no redirect chains that cost crawl budget. |
| D59 | Image deletion checks real usage first | Prevents silent broken images on live sites; “Delete anyway” keeps the merchant in control. |
| D60 | GA4 only via an external file and only on cacheable public pages | Keeps the strict CSP everywhere else and keeps third-party scripts away from checkout and account pages. |
| D61 | Site-wide “hide from search” overrides every page setting | One switch that can’t be undone by a forgotten page-level setting while a site is under construction. |
| D62 | No inline JavaScript anywhere in the platform UI; strict script CSP | Turns any future XSS in the dashboard into a non-executable string; verified in a real browser on 54 pages. |
| D63 | Isolation proven by a route sweep, not only by unit tests | New routes are covered automatically: the sweep reads `php spark routes`. |
| D64 | Caddy with on-demand TLS as the recommended server | The only setup where merchant domains get certificates with zero operator work; the ask endpoint prevents abuse. |
| D65 | Heartbeat files + doctor + /health instead of an external scheduler | Silent cron failures (the most common self-hosting problem) become visible in three places. |
| D66 | Release folders + shared state + pre-migration backup | Fast rollback for code; data safety for migrations; uploads and settings never inside a release. |
| D67 | Marketing pricing generated from plan rows, not hand-written | Admins change prices/limits in one place; the public site can't drift from what's enforced. |
| D68 | Sign-up intent is a suggestion, never an automatic action | Picking a paid plan or premium template before an account exists must not charge or lock anything; the merchant confirms in the dashboard. |
| D20 | No admin impersonation in Phase 2 | It is the highest-risk admin feature; the detail page + reset link cover support needs. Can be added later with a banner, time limit and audit trail. |

## 11. Subscription states (Phase 2)

```
          start trial (once)            period end, grace_days>0          grace end
 Free ───────────────────────► trialing ───────────────────────► grace ─────────────► expired ─► Free
  ▲                                │ period end, grace_days=0                                     │
  │                                └──────────────────────────────────────────► expired ─► Free  │
  │  cancel_at_period_end + period end ─────────────────────────────────────────────────────────┘
  │  scheduled downgrade + period end ──► target plan (Free: active; paid: grace until paid)
  └── admin: assign plan / extend / cancel now   ·   Phase 6: payment → active (renewal, upgrade)
```

Run every 10 minutes: `php spark subscriptions:process` (idempotent, file-locked).
