# 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/<slug>` 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/<slug>` 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/<slug>`**. 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/<slug>` 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/<slug>` 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/<slug>` 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/<id>` 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/<id>/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/<slug>?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/<slug>` 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 `<style>` block at the top of `<head>`:
   ```css
   :root {
     --keysat-primary: <primary_color or #f7931a>;
     --keysat-accent: <accent_color or #1a73e8>;
     --keysat-bg: <background_color or #ffffff>;
     --keysat-text: <text_color or #111827>;
   }
   ```
4. Existing buy.html CSS uses these variables (one-time refactor of hardcoded colors → vars).
5. Logo/favicon: render `<img src="{{logo_url or '/static/keysat-logo.svg'}}">` and `<link rel="icon" href="{{favicon_url or '/static/favicon.ico'}}">`.
6. Theme = "dark" toggles a `data-theme="dark"` attribute on `<html>`; CSS uses `[data-theme="dark"]` selectors to swap palettes. "auto" uses `prefers-color-scheme`.
7. Support email + footer text + terms/privacy links render in the page footer, hiding the row entirely if all are unset.

The recovery page (`/recover`) is product-scoped on submit — once a product is selected, the same branding pass applies to the success/error rendering. Phase 1 may keep `/recover` Keysat-branded if simpler.

## Phasing

**Phase 1 (v0.3.0:0) — the whole feature, one PR:**
- Migration 0014 (bumped from 0013 — tier-upgrades schema took 0013 first; see TIER_UPGRADES_DESIGN.md) — `products.branding` column (nullable JSON blob).
- Server-side branding validator + Pro-tier gate.
- API: extend `GET /v1/public/products/<slug>` and `PATCH /v1/admin/products/<id>` to accept/return `branding`.
- Admin UI: branding tab on the product editor + live-preview button.
- Buy page: refactor hardcoded colors to CSS vars, inject branded tokens at render. The existing `/buy/<slug>` route is unchanged — only the styling/copy varies per product.
- Tests: validator unit tests, integration test for branded buy-page render, integration test for the Pro-tier gate.

No Phase 2 in scope right now. If operators eventually ask for fancier surfaces (bare-host rendering, custom CSS, hosted assets, canonical-URL rendering in receipts), each of those is a discrete v0.3.x or v0.4.x follow-up.

**Possible follow-up (v0.3.x) — asset upload, only if operators ask:**
- Upload endpoint `POST /v1/admin/products/<id>/assets/{logo,favicon}`.
- Local storage under `${data_dir}/product-assets/<product_id>/...`.
- Public route `GET /assets/products/<id>/<file>` serves them with cache headers.
- Branding fields automatically populated with the served URL.

Skip until requested — most operators have a CDN or static-hosting setup already; URL references cover them.

## Open questions (defer until Phase 1 implementation)

- **Per-policy branding overrides?** Different tiers might want different colors (Pro = gold accent, Free = blue). Probably no — keep it product-level. Revisit if requested.
- **Email branding.** Receipts and recovery emails currently use Keysat copy. Phase 1 should at minimum use `branding.display_name` and `branding.support_email` in the rendered email so receipts say "ProductOne" not "Keysat". Logo embedding in HTML email is a follow-up.
- **OG / social-share metadata.** `<meta property="og:image">`, `<meta property="og:title">` should pull from branding so links posted to Twitter/Discord render with the right preview. Trivial add — include in Phase 1.

## Out of scope

- Custom CSS, custom JavaScript, custom HTML blocks.
- Per-product session/cookie domains (sessions are admin-only, scoped to admin UI).
- White-labeling the admin UI itself (operator-facing, no buyer ever sees it).
- A/B testing or experiment framework on the buy page.
