Files
keysat-root/PRODUCT_BRANDING_DESIGN.md
Keysat 843ff0e5d7 Initial backup of root workspace files
Glue files not covered by subproject repos: top-level docs, logo,
keysat-design-system, and crosscheck tests. Subproject folders are
gitignored (each has its own Gitea remote).
2026-06-12 17:51:40 -05:00

10 KiB

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:

-- 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):

{
  "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>:
    :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.