# Product Branding & Custom Domains — Design (v0.3.0) **Status:** approved 2026-05-08 by Grant. Slotted as **v0.3.0:0** (branding tokens) and **v0.3.0:1** (custom-domain routing). Lands after recurring-subs admin UI ships in v0.2.x. ## Problem Operators may sell multiple software products through one Keysat instance. Each product likely has its own marketing site and brand identity. Today, every product's `/buy/` page renders with Keysat branding (logo, colors, copy). Buyers landing on a Keysat purchase page from a product's marketing site experience a context break — the brand they came from disappears. ## Goals 1. Each product's `/buy/` page renders with that product's branding (logo, colors, support email, footer copy, links to terms/privacy). 2. Operators can route a custom-domain path (e.g. `product1.com/product1/buy`) to their Keysat instance via StartTunnel and have the page render branded for the right product. 3. The feature is **Pro-tier gated** — it joins recurring subs, multi-currency, and Lightning tipping as Pro features. Free tier sees Keysat default branding only. ## Non-goals (v1) - **No custom CSS or HTML.** Theme tokens (colors, logo URL, copy strings) only. Custom layouts are a "wait until someone asks" problem. - **Keysat does not host the marketing landing.** Operators host `product1.com` themselves (Vercel, Cloudflare Pages, whatever). Keysat only owns the `/buy/...` path that StartTunnel forwards. - **No bare-domain rendering.** `product1.com` is not Keysat's. Only `product1.com/product1/buy` (or whatever path the operator chooses) maps to Keysat. - **No per-product asset upload in v1.** Logos and favicons are URL references — operators host the assets themselves. Asset upload + CDN-style serving is a v0.3.x follow-up if asked for. ## Routing model (revised 2026-05-08) Operator's setup, per product: 1. They run their marketing site at `product1.com` (NOT on Keysat). Keysat never serves the operator's apex. 2. They create a `licensing.product1.com` **subdomain** (or any subdomain pattern they prefer) and point a StartTunnel forward at their Keysat instance. One forward per product. All forwards land on the same Keysat instance — they're distinguished by the `Host` header. 3. The buy link on the marketing site reads `https://licensing.product1.com/product1/buy`. Clean, brand-consistent, and the URL itself signals "billing/checkout" to the buyer. ### Why subdomains instead of path prefixes on the apex - Operator keeps `product1.com` fully under their own control (marketing CMS, A/B tests, blog, etc.). Only `licensing.product1.com` is delegated to the Keysat tunnel — much lower coordination cost, lower blast radius if the tunnel goes down. - The URL is self-documenting. A buyer hovering over the link sees `licensing.product1.com`, which is recognisable as a billing/checkout subdomain — higher trust signal than a path on the apex. - DNS / TLS lifecycle is independent of the marketing site. The operator can rotate, retire, or swap-Keysat-instance for the subdomain without touching their apex. ### URL shape inside Keysat We keep the existing path: **`/buy/`**. The buy link from the operator's marketing site reads `https://licensing.product1.com/buy/product1` — same path Keysat already serves, just on a branded subdomain. No route alias, no reserved-slug churn, full back-compat with any marketing site that's already linking to `/buy/` on the bare Keysat onion. The branding tokens get applied at render time based on the product the path resolved to. Host header is not consulted for the v1 routing — the slug in the path carries the product identity. ### Bare-host root: out of scope We do NOT render the product buy page at `https://licensing.product1.com/` (bare subdomain root). Confirmed 2026-05-08: operators always link to the explicit `/buy/` URL from their marketing site. Skipping bare-host means no Host-header middleware, no `licensing_subdomain` JSON field, no per-host product lookup table — meaningful complexity savings. A buyer who manually types just `licensing.product1.com` lands on the standard Keysat root response (operator-name banner, public-key endpoint pointer). Acceptable: this is a vanishingly rare case, and the operator's marketing site is the canonical entry point. ## Data model Add one column to `products`: ```sql -- migration 0014_product_branding.sql (v0.3.0) ALTER TABLE products ADD COLUMN branding TEXT; -- nullable JSON blob ``` Stored shape (validated server-side on write, sane defaults applied at render): ```json { "display_name": "ProductOne", // overrides products.name on the buy page only "tagline": "...", // short subtitle "logo_url": "https://.../logo.png", "favicon_url": "https://.../favicon.ico", "primary_color": "#1a73e8", // hex; used for buttons, accents "accent_color": "#ff6b00", // hex; secondary highlights "background_color": "#ffffff", // hex; page bg "text_color": "#111827", // hex; main copy "theme": "light", // "light" | "dark" | "auto" "support_email": "support@product1.com", "footer_text": "© 2026 ProductOne, LLC.", "terms_url": "https://product1.com/terms", "privacy_url": "https://product1.com/privacy", "canonical_buy_url": "https://product1.com/product1/buy" // used in receipts/emails } ``` All fields optional. Missing fields fall back to Keysat defaults. Unknown fields are silently dropped on write (forward-compat). ## API surface **Public** (no auth — buyers, SDKs): - `GET /v1/public/products/` already returns product metadata. **Extend** the response to include the `branding` object so SDKs can render branded purchase prompts (e.g. an in-app "Buy Pro" button styled to match the product). **Admin** (Bearer + admin key): - `PATCH /v1/admin/products/` already exists for product mutation; add `branding` to the accepted body. Validation: hex colors regex, URL parsing on `*_url` fields, length caps on copy strings, theme enum check. - Optional convenience: `PUT /v1/admin/products//branding` taking just the branding JSON. Adds nothing the PATCH doesn't, but cleaner separation in the admin UI. ## Admin UI New "Branding" tab on the product editor (`admin/index.html`). Inputs: - Display name override - Tagline - Logo URL + small preview - Favicon URL - Color pickers (primary, accent, background, text) with live preview swatches - Theme dropdown (light / dark / auto) - Support email - Footer text (textarea, length-capped) - Terms URL, Privacy URL, Canonical Buy URL - "Preview" button — opens `/buy/?preview=1` in a new tab with the unsaved tokens applied via query params (so admins can preview before saving) Pro-tier gate: tab visible to all, but `Save` is disabled with a "Pro tier required" tooltip on Free. The check uses the same `self_tier` plumbing as recurring/multi-currency/tipping. ## Render path `/buy/` template currently reads from a fixed `templates/buy.html`. v0.3.0 changes: 1. Load product → load `branding` JSON. 2. Pass branding tokens into the template context with Keysat fallbacks for any missing field. 3. Inject CSS variables in a `