Build Mac AppDocsMenu

Ship

Growing past the first release

The template ships a complete loop: a page that argues, a pricing ladder, checkout, licence keys, downloads, an update feed, the policies and the machine-readable surfaces — all of it working with nothing configured and switching on as each variable is set. This document is what a product grows into afterwards, each with the contract that must not be broken on the way.

Read each section's contract before its code. In every case the code is the easy part and the contract is what took a production incident to learn. Opt-in modules with their own setup — a menubar shell, a database, a content engine — are in docs/modules/.


1. Licence keys and their lifecycle

Where you already are. Both halves exist. On the site, site/app/api/license/{activate,validate,deactivate} proxy the provider, site/lib/payments/creem/licence.ts reads a key back off a completed checkout and finds one again later by email, and site/app/api/license/recover/route.ts is a self-serve resend. In the app, mac/App/Core/License*.swift hold the key in the Keychain, activate once per Mac, revalidate weekly and keep working for two weeks offline. Enable licence keys on each product in the payment dashboard (docs/go-live.md) and the flow works end to end.

What you might add. A "manage my Macs" list, periodic validation for an app that stays open for weeks (docs/modules/menubar.md), a trial.

The contract

A provider 401 or 403 is a 502, never valid: false. This is the rule the whole family is arranged around and the easiest one to lose in a refactor. A rotated or missing API key is our misconfiguration. Reported as a licence failure it tells every paying customer their software is unlicensed — support mail, refund requests, and a very bad afternoon — while the actual fault is one environment variable. Reported as a server error, their app falls back to its offline grace window and nobody notices while you fix it. Each adapter reports a rejected credential as misconfigured (never refused), and site/lib/payments/licence-routes.ts turns that into the 502 from misconfiguredResponse() in site/lib/payments/http.ts — which makes this the path of least resistance for every provider. site/lib/payments/conformance/ holds every adapter to it.

Never lock a customer out mid-session. The client half has to be built around this or the server half does not matter. Validation runs on launch and on a slow schedule — daily, weekly — never in front of an action the user is trying to take. A failed validation starts a grace window measured in days, not a modal. Network failures, provider outages, aeroplanes and corporate proxies are all far more common than licence fraud, and every one of them is indistinguishable from it at the moment of the request. The one thing worse than an unpaid copy running is a paid copy that stopped working on a deadline.

Answers about a licence are never cached. Each adapter's client (site/lib/payments/creem/client.ts, site/lib/payments/polar/client.ts) refuses a revalidateSeconds on any /licenses path and says so in the log — a cached activation would hand a stale "active" to a key that was just deactivated, or a stale "invalid" to one that was just bought. This is enforced rather than written down because the caller who gets it wrong will be a future one shaving a round trip off a hot path.

Recovery answers identically whether or not the address bought anything. Same status, same body, same timing budget. A different answer for "no such customer" turns the endpoint into a tool for discovering who bought your product. The single exception is a send failure — we found the licence and could not deliver it — which leaks nothing an attacker can act on and saves a paying customer from waiting for an email that is never coming.

The key only ever goes to the address that owns it. It is never in the HTTP response of the recovery route, not even on success. Otherwise the endpoint hands licence keys to anyone who can guess a customer's email.

The route paths freeze the day a binary ships. A shipped build cannot be edited, so every version ever released will ask /api/license/activate for the rest of time. The response shape freezes with it: an older build has to keep understanding a newer server's answer. Add fields; never rename or remove one.


2. Downloads, the appcast, and latest-version

Where you already are. /api/download resolves the newest release through three paths and answers 503 when it cannot; /api/latest-version answers the same question as JSON for a "Check for updates…" menu item; site/public/appcast.xml is a Sparkle feed with an empty channel.

What you add. A release pipeline that publishes an asset and appends an <item> to the appcast, and the two environment variables in .env.example.

The contract

Every link points at /api/download, never at a versioned asset URL. Two reasons, both learned expensively. An asset URL carries a version number, so every place that hard-codes one goes stale at the next release. And the one route is on your own domain, so it survives the binary moving hosts entirely.

Never NEXT_PUBLIC_DOWNLOAD_URL. NEXT_PUBLIC_ values are inlined at build time, so setting one in a hosting dashboard changes nothing until the next deploy — production kept rendering the "it arrives by email" fallback while the variable sat in the dashboard looking perfectly correct, and every check of the configuration confirmed it was set. DOWNLOAD_URL, unprefixed, is read per request. That is why the route is force-dynamic; without it the per-request read is pointless.

503, never 404, and never HTML. A client that asked for a binary and got an error page cannot tell the difference, and some download managers will happily save it under the product's name. 404 says "there is no such thing here", which is false — there is, it is just not resolvable right now. 503 says "ask again later", which is both true and the correct instruction for an updater.

latest-version must never answer { version: null } at 200. A client has to be able to distinguish "could not check" from "you are up to date", because only one of those should ever be shown to a person. As a 200 with a null, a broken lookup on your side becomes an app confidently telling every user they are current — for however long the breakage lasts, with nobody reporting it, because nothing looked wrong.

Binaries do not live in site/public/. A hosting platform's static directory is deployed with the site: a binary there is re-uploaded on every deploy, counted against bandwidth on every download, and silently replaced the first time somebody deploys from a branch that does not have it. Releases are immutable and served from a CDN built for the job.

An empty appcast channel is the truthful answer. Before the first release, "no updates available" is correct. Anything invented there to make the file look finished is an update offer pointing at nothing, and Sparkle will refuse an unsigned or mismatched item anyway.


3. Switching PRICING to "live"

Where you already are. PRICING.mode is "static": the amounts live in the LICENSE_TIERS table in site/lib/pricing.ts, one row per licence rung. getPrice() and getTierPrices() throw where a page sells; the *OrNull twins degrade; site/lib/pricing.test.ts covers both.

What you change. One line in site/lib/pricing.ts:

export const PRICING: PricingConfig = { mode: "live" };

Then set each rung's CREEM_PRODUCT_ID_n, or point its NEXT_PUBLIC_CHECKOUT_URL_n at a /payment/prod_… link and let the id be derived from it — the link is public already, so there is no new secret. The amountCents on each row stops being read; the rows stay, because the device counts, the copy and the env var names all live there too.

Set every rung before you deploy. getTierPrices() is all-or-nothing: one rung whose product cannot be resolved rejects the whole ladder, so the visible symptom of a half-finished switch is the entire pricing section missing rather than two of three cards. That is deliberate — a price list that has silently stopped offering something is worse than one that is visibly absent — but it does mean "the pricing section vanished" reads as some rung, and the thrown message names which.

The contract

The split stops being theoretical the moment you flip this. Until now getPrice() and getPriceOrNull() differ only in what they do with a config that was never going to answer. In "live" mode they differ in what happens during a provider outage, and the difference is the whole design:

  • Pages that sell call getPrice(), which throws. Next's ISR contract is that a revalidation which throws keeps serving the last successfully generated page. So a two-second blip costs nothing at all. On a cold build there is nothing to keep and the build fails — which is right for a deploy that cannot determine what it is charging.
  • The root layout, the receipt page and diagnostics call getPriceOrNull(), which degrades and logs. The layout wraps every route, including the one where a customer recovers a lost licence key, and a price lookup must never stand between a paying customer and that.

Never add a compiled fallback price. It looks like a safety net and is a bug. A fallback swallows the error, so revalidation succeeds with the wrong number and overwrites a correct cached page — turning a two-second blip into an hour of advertising the wrong price to everyone. DEV_PRICE_CENTS is the sanctioned version of this and is refused in production for exactly that reason.

Keep the three revalidate windows equal. The in-process memo (OK_TTL_MS), the fetch layer's next.revalidate, and the revalidate exported by any page that shows a price. The worst-case gap between what the page says and what the card is charged is then one window rather than the sum of three.

Read the price once per render and pass it down. A page that calls getPrice() for the card and again for the JSON-LD can straddle a cache boundary and tell a crawler one number while showing a reader another. site/app/page.tsx already does this correctly; keep it that way.

Adding or removing a rung

One row in LICENSE_TIERS, and the rest follows: the card grid, the FAQ's "how many Macs" answer, the JSON-LD offers array, /buy?devices=, the per-rung checkout_click property and pnpm run creem:setup all read the table. Three things are worth knowing before you do it.

The rows must ascend in both devices and price, and the module throws at import if they do not. A ladder that rewards buying fewer Macs renders as a perfectly plausible page, so the check is at load rather than in review.

Exactly one row is primary. It is what getPrice() answers with, where a bare /buy sends a buyer, and which card is spotlit — and it is the only row carrying the pre-tier CREEM_PRODUCT_ID / NEXT_PUBLIC_CHECKOUT_URL at the end of its candidate lists. Moving the flag to another row moves that fallback with it, which is a live checkout link changing hands. Set the new row's own variables first.

Do not add a discount claim to make a new rung look better. No struck price, no "save 40%", no "most popular". The only comparison the cards draw is perDevice, which is arithmetic on the figure printed directly above it and therefore checkable by the reader — see AGENTS.md §9 and the note on the table itself.


4. Phased pricing — the rising ramp

What it is. The first N buyers pay less, then the price steps up. Each phase is its own product in the provider's catalogue, so the amount a customer is charged is always a real listed price rather than a discount computed by us.

export const PRICING: PricingConfig = {
  mode: "phased",
  phases: [
    { label: "First 50", seats: 50, productIdEnv: "CREEM_PRODUCT_ID_P1" },
    { label: "Next 150", seats: 150, productIdEnv: "CREEM_PRODUCT_ID_P2" },
    { label: "Standard", seats: Infinity, productIdEnv: "CREEM_PRODUCT_ID_P3" },
  ],
};

getPrice() throws for "phased" today, on purpose and with a message saying so: resolving the active phase needs a count of seats already sold, which is a different question from "what does this product cost". Answer that question first — a provider query for settled orders, or a count from your own orders table — then read the price of the phase it lands in.

The contract

The count has to be authoritative and cheap. Counting from your own database is right once you have one; counting by paging the provider's order API on every render is not. Cache the phase, not the count, and keep the window equal to the price window.

The ramp must never run backwards. A phase resolved from a stale count can step down — a customer who arrives during a cache miss pays less than one who arrived a minute earlier, and the one who paid more finds out. Resolve to the highest phase any recent read has seen and let it only ever advance.

The checkout link follows the phase. A hosted link pinned to phase one keeps selling phase one after the ramp moved, and the card is charged what the link says, not what the page says. Either mint the checkout server-side per buyer, or re-derive the link from the active phase's product id on every render.

Say what the ramp is on the page. "First 50 at this price" is honest and converts; the same mechanism unstated is a price that appears to change at random. And never state a remaining-seat count you cannot back with the same number the checkout uses.


5. Adopting a database

The template has none, on purpose: licensing is the payment provider's end to end, and the only durable licence state anywhere is the Keychain item on the customer's Mac. That keeps "the server stores nothing" true by inspection. The likeliest legitimate reason to add one is a waitlist you want to keep, or a "manage my Macs" roster — the provider exposes no endpoint listing a key's activations.

docs/modules/database.md is the recipe, including the two things that are easy to get subtly wrong: nothing may import the database at module scope, and the build must tell an absent database (skip) from a broken one (fail).

The contract

With no database configured, every route behaves exactly as it does today. Any adoption has to preserve that, and adding a table is a privacy-policy edit in the same commit.

6. The counted redirect

What it is. Outbound links go through a route on your own domain, which records the click and then redirects. Useful for an affiliate link, a directory of tools, a sponsor slot — anywhere the count is something you or somebody else makes a decision from.

Counted server-side rather than with a client beacon, because a beacon is stripped by every content blocker, and a count that is systematically wrong for technical readers is worse than no count at all.

export async function GET(request: Request, context: RouteContext<"/go/[slug]">) {
  const { slug } = await context.params;
  const target = await resolveTarget(slug);
  if (!target) redirect("/");

  try {
    await recordClick(slug, viewerHash(request));
  } catch (error) {
    // A counting failure must never stop somebody reaching what they clicked.
    console.error("[go] click tracking failed", error);
  }

  redirect(withUtm(target, SITE.utmSource));
}

The contract

The salt must fail loudly rather than fall back to a literal. This is the warning the section exists for. The version that ships in most codebases is:

// WRONG. Do not write this.
const salt = process.env.CLICK_SALT ?? "unsalted";

It is one character of convenience and it silently destroys the property the hash was for. A salt of "unsalted" is a published constant: the hash is then a plain, precomputable digest of an IP address and a user agent, so anybody with the source — which is everybody, it is in your repository — can confirm whether a given address clicked a given link. You did not store an IP, you stored something functionally equivalent, and nothing anywhere reports a problem. The site keeps working, the numbers keep looking right, and the privacy claim in your own policy page is now false.

Write it so an unset variable is impossible to miss:

function clickSalt(): string {
  const salt = process.env.CLICK_SALT?.trim();
  if (!salt) {
    throw new Error(
      "CLICK_SALT is not set. Outbound-click de-duplication hashes visitor " +
        "addresses, and an unsalted hash is a precomputable one — which makes " +
        "it an identity we are storing rather than a de-duplication key. " +
        "Set any long random string.",
    );
  }
  return salt;
}

Throwing inside the try above is the right place: the click is still redirected, the count is skipped, and the error is in the log on the first request rather than never.

The general rule, and it is worth carrying past this one route: a secret with a default is not a secret. A fallback is correct for a feature — a formatting option, a page size — and wrong for anything whose value is the only thing making the mechanism work.

Counting never blocks the redirect. Wrapped in try/catch, always. A database hiccup must not stand between somebody and the link they clicked.

De-duplicate, and prune what you de-duplicate with. A visitor double-clicking is one click. The hash exists only to notice that, so the rows behind it are pruned on a schedule; it is not an identity you keep.

The route is noindex and excluded from the sitemap. It is a redirect for humans arriving from a page, not a page. Use an x-robots-tag header rather than a robots.txt disallow — a disallowed URL's directive is never fetched, and the URL can still be indexed from links alone.


7. User-generated public content lives on a different domain

When this applies. The day the product lets a customer publish something at a hostname of yours: a share link, a preview URL, a hosted page, a tunnel.

The posture. Provision those hostnames under a separate domain, on a separate zone, with credentials scoped to that zone and no access to your primary one.

The contract

The primary domain must not be able to be taken down by a customer. A public share link puts a hostname of yours in a stranger's URL bar. If one customer ever serves something abusive there and the domain gets flagged by Safe Browsing or a corporate DNS filter, that must not take down your checkout, your licence API, or your marketing site. Those are the three things that cannot be unavailable, and none of them should share a registrable domain with content you do not control.

Scope the credentials to exactly that zone. A DNS token that can also write your primary zone re-couples what the separate domain was meant to decouple. Two permissions, on one zone, and nothing else.

One label deep, and no deeper. A wildcard certificate covers *.example.com one level down and no further, so a two-level hostname hands the recipient a certificate warning — which, on a link somebody was sent, reads as an attack. Generate single-label names.

Half-configured sharing is worse than none. If the feature needs four environment variables, the route answers 502 and names which are missing rather than provisioning something broken. A share link that resolves to nothing has already been sent.

Log nothing about it. Structured logs of share hostnames and who requested them defeat the privacy posture the feature is built around. This is the one place to decline the well-meaning suggestion to add request instrumentation — on purpose, in a comment, so the next reader knows it was a decision.


Where the rules live

Nothing in this document is a rule that exists only here. Each one is written at the code that implements it, which is the copy that stays true:

ContractWritten at
502 not 401, and never caching a licence answersite/lib/payments/licence-routes.ts, each adapter's client.ts
Neutral answers, and the key only to its ownersite/app/api/license/recover/route.ts
503 not 404, and the build-time inlining trapsite/lib/download.ts, site/app/api/download/route.ts
"Could not check" versus "you are up to date"site/app/api/latest-version/route.ts
Throw versus degrade, and no compiled fallbacksite/lib/pricing.ts
Idempotency, and what a webhook is toldsite/lib/payments/webhook.ts
Absent versus broken, for the databasedocs/modules/database.md, site/scripts/bootstrap.ts
Why binaries are not in site/public/site/public/appcast.xml

And the conventions all of it depends on: AGENTS.md.

This page is docs/growing.md in the repository, copied 2026-09-25.