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).
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
- Each product's
/buy/<slug>page renders with that product's branding (logo, colors, support email, footer copy, links to terms/privacy). - 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. - 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.comthemselves (Vercel, Cloudflare Pages, whatever). Keysat only owns the/buy/...path that StartTunnel forwards. - No bare-domain rendering.
product1.comis not Keysat's. Onlyproduct1.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:
- They run their marketing site at
product1.com(NOT on Keysat). Keysat never serves the operator's apex. - They create a
licensing.product1.comsubdomain (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 theHostheader. - 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.comfully under their own control (marketing CMS, A/B tests, blog, etc.). Onlylicensing.product1.comis 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 thebrandingobject 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; addbrandingto the accepted body. Validation: hex colors regex, URL parsing on*_urlfields, length caps on copy strings, theme enum check.- Optional convenience:
PUT /v1/admin/products/<id>/brandingtaking 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=1in 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:
- Load product → load
brandingJSON. - Pass branding tokens into the template context with Keysat fallbacks for any missing field.
- 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>; } - Existing buy.html CSS uses these variables (one-time refactor of hardcoded colors → vars).
- Logo/favicon: render
<img src="{{logo_url or '/static/keysat-logo.svg'}}">and<link rel="icon" href="{{favicon_url or '/static/favicon.ico'}}">. - Theme = "dark" toggles a
data-theme="dark"attribute on<html>; CSS uses[data-theme="dark"]selectors to swap palettes. "auto" usesprefers-color-scheme. - 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.brandingcolumn (nullable JSON blob). - Server-side branding validator + Pro-tier gate.
- API: extend
GET /v1/public/products/<slug>andPATCH /v1/admin/products/<id>to accept/returnbranding. - 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_nameandbranding.support_emailin 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.