843ff0e5d7
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).
157 lines
10 KiB
Markdown
157 lines
10 KiB
Markdown
# 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.
|