Consent is bound to the channel it was given for: approving a post on X says
nothing about Instagram. Publishing state moves from the SocialMilestone row
into SocialMilestonePost, one row per channel, where a missing row means no
consent. The dialog asks per channel, shows the text each one will publish and
keeps a separate handle for each; Instagram captions end in hashtags because a
link there is not clickable.
Also fixes three problems in the existing X path:
- A QR code already past several thresholds produced one prompt per threshold,
and since the post quotes the current scan count, every one of them would
have published the same number. Only the highest threshold is announced now.
- Detection ran after every unique scan and re-read the QR code's full scan
history just to hit skipDuplicates. Known milestones are filtered first.
- A failed post stayed failed forever because the consent dialog only opens
once. The queue now retries three times on its own, spaces first attempts by
SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per
channel with restart and revoke.
The worker no longer renders the card itself; it downloads the image the app
renders at /s/m/<token>/og, which also serves the new square and portrait
formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and
SOCIAL_WORKER_CHANNELS both name it.
Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before
deploying this version.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splits the two hostnames across one deployment. No files move: the Next app still
serves every route on both hosts, and the middleware decides per host which paths it
owns and 301s the rest. /login and /signup stay on www - all 82 marketing CTAs point
at /signup, which carries a hard canonical to www plus ad traffic.
src/lib/hosts.ts is the single source of truth for the boundary (APP_PATH_PREFIXES,
isAppPath, wwwUrl, appUrl, urlForPath). The middleware and every absolute-URL builder
read from it so they cannot drift apart.
- Split the overloaded NEXT_PUBLIC_APP_URL into a www and an app origin. It previously
fed both public URLs and in-app URLs, so any single value was wrong somewhere. Most
important: QRCodeCard encodes this origin into the QR code the user downloads and
prints, so it must stay on www.
- Route Stripe return URLs, email links and OAuth redirects per path rather than
against one origin, so /dashboard lands on app and /pricing on www.
- Cross the host boundary once, after a successful login: the router cannot push across
origins, so that jump needs a full load. The user arrives signed in because the
session cookie is scoped to COOKIE_DOMAIN.
- Keep the app host out of search indexes: X-Robots-Tag on every response plus a
Disallow-all robots.txt via rewrite, and /sitemap.xml redirects to www.
- Point the TikTok callback fallback at www explicitly. It used to read
NEXT_PUBLIC_APP_URL, whose meaning changed here, and only the apex domain is
verified with TikTok.
Host splitting is inert while both origins are equal, so development is unaffected.
Verified: tsc clean, production build succeeds including the Edge middleware bundle,
and the path-to-host mapping is unit-checked (prefix traps like /created and
/settings-guide stay on www, query strings do not break matching).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>