From 7a0dc40a2118b460099079d4355daa86b0400018 Mon Sep 17 00:00:00 2001 From: tornidomaroc-web Date: Wed, 7 Oct 2026 10:00:11 +0000 Subject: [PATCH 1/2] feat(store): step a, the request-time platform marker: inside the app no purchase, upgrade or pricing surface and no Google button, read per request from KnowFlowApp/; the pages that change on it render per request; gated in CI by verify-platform-gate.mjs Co-Authored-By: Claude Fable 5.1 --- .github/workflows/typecheck.yml | 11 + docs/PROGRESS.md | 14 + docs/store/STORE_PATH.md | 13 +- scripts/verify-platform-gate.mjs | 255 ++++++++++++++++++ scripts/verify-store-purchase-links.mjs | 5 + src/app/[locale]/(site)/layout.tsx | 10 + .../[locale]/dashboard/knowledge/new/page.tsx | 7 +- src/app/[locale]/dashboard/layout.tsx | 9 + src/app/[locale]/dashboard/page.tsx | 6 +- src/app/[locale]/dashboard/settings/page.tsx | 8 +- src/app/[locale]/layout.tsx | 2 - src/app/[locale]/login/layout.tsx | 20 ++ .../[locale]/preview/student-home/page.tsx | 10 +- src/app/[locale]/signup/layout.tsx | 20 ++ src/components/auth/GoogleButton.tsx | 7 + src/components/layout/SiteHeader.tsx | 7 +- .../platform/NativePlatformHeader.tsx | 34 --- src/components/platform/PlatformProvider.tsx | 25 ++ src/lib/home-props.ts | 18 +- src/lib/platform-server.ts | 19 ++ src/lib/platform.ts | 101 +++++-- src/middleware.ts | 24 +- 22 files changed, 541 insertions(+), 84 deletions(-) create mode 100644 scripts/verify-platform-gate.mjs create mode 100644 src/app/[locale]/login/layout.tsx create mode 100644 src/app/[locale]/signup/layout.tsx delete mode 100644 src/components/platform/NativePlatformHeader.tsx create mode 100644 src/components/platform/PlatformProvider.tsx create mode 100644 src/lib/platform-server.ts diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 9f10a29..381f726 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -129,6 +129,17 @@ jobs: - name: Verify no purchase link reaches the store build (Apple 3.1.1(a), register #46) run: node --experimental-strip-types scripts/verify-store-purchase-links.mjs + # docs/store/STORE_PATH.md step a. The app is a shell over the live site, + # so the server tells the two apart per request (the user-agent token + # KnowFlowApp/). This proof renders every gated surface both ways from + # its real .tsx, drives the real middleware with real NextRequests, and + # scans src/ so a new pricing link, upgrade href or Google button that + # does not go through purchaseLinksAllowed / googleSignInAllowed fails + # here. Shown red on a deliberate break (the Google gate removed; an + # ungated /pricing link added) before it was merged. + - name: Verify the platform marker only removes, and every purchase link and Google button is gated (STORE_PATH.md step a) + run: node --experimental-strip-types scripts/verify-platform-gate.mjs + # Register #96, corrected: viewport-fit=cover is declared and every fixed bar # accounts for its safe-area inset. - name: Verify the iPhone safe areas are declared and used (register #96) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d7848e6..370e1c6 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -451,6 +451,20 @@ bodies total 37,095 bytes. **This retires the Option C frozen-tail invariant by existed only to police a boundary inside an unreviewable single line, and the append-only rule above supersedes it. No bespoke hash is needed for future updates: the diff is the proof. +### 2026-10-07 - Store path step a built: the request-time platform marker; inside the app no purchase, upgrade or pricing surface and no Google button, read per request from `KnowFlowApp/` in the user agent; the pages that change on it leave the CDN; gated in CI by `verify-platform-gate.mjs` + +**No row is edited. Outside this file: `src/lib/platform.ts` (rewritten: `platformFromHeaders`, `purchaseLinksAllowed(platform)`, `googleSignInAllowed(platform)`, the build-time `PLATFORM` and `NEXT_PUBLIC_KF_PLATFORM` gone), `src/lib/platform-server.ts` (new, `currentPlatform()` over `headers()`), `src/components/platform/PlatformProvider.tsx` (new, context for client components), `src/components/platform/NativePlatformHeader.tsx` (deleted), `src/middleware.ts` (an app request for `/` or `//pricing` → 307 to `//dashboard`), `src/app/[locale]/(site)/layout.tsx` and `src/components/layout/SiteHeader.tsx` (`showPricing`), `src/app/[locale]/login/layout.tsx` and `signup/layout.tsx` (new, the provider), `src/app/[locale]/dashboard/layout.tsx`, `dashboard/page.tsx`, `dashboard/settings/page.tsx`, `dashboard/knowledge/new/page.tsx`, `src/lib/home-props.ts` (`buildHrefs(locale, platform)`, `appHrefs`), `src/components/auth/GoogleButton.tsx` (returns nothing inside the app), `src/app/[locale]/preview/student-home/page.tsx` (`?platform=native`, like the settings preview), `scripts/verify-platform-gate.mjs` (new) as a step of the required `tsc` job, `scripts/verify-store-purchase-links.mjs` (the user-agent reading added), and `docs/store/STORE_PATH.md` (T1 done, T2's exact scope, two corrections). No schema, grant, auth config, template or env change. Rows 42, 69 and 81 are not touched, and Section 7 takes no deletions.** + +**THE SURFACES, LISTED FROM THE CODE FIRST, THEN COVERED.** `grep` over `src/` for `/pricing`, `upgradeHref`, `checkout`, `Upgrade`, `GoogleButton` and `startOAuth` at `399106e`: (1) the site header's Pricing link (`SiteHeader.tsx:69`, on the landing and the six marketing and legal pages); (2) Settings' Upgrade (`dashboard/settings/page.tsx:65`); (3) the home's Upgrade (`home-props.ts:134` → `StudentHome`); (4) the new-subject refusal's upgrade sentence (`knowledge/new/page.tsx:66`, client side); (5) the three API refusals' upgrade lines (`limit-messages.ts:178`, already request-based); (6) `/pricing` itself with its Paddle checkout; (7) the landing's calls to action, which lead to `/pricing` through the header; (8) the Google button on `/login` and `/signup` (`GoogleButton.tsx`, the only caller of `startOAuth('google')`). The cancel-subscription card and the refund page stay: management and policy, not calls to action. Each of the eight is now hidden or unreachable inside the app, by the mechanism named in the file list above. + +**THE MARKER ONLY REMOVES, BY CONSTRUCTION AND BY PROOF.** The reading is consumed by two predicates that each hide something, and by the middleware's redirect of two marketing pages to the dashboard, which `updateSession` sends to `/login` without a session. No entitlement, limit, auth or data path reads it (`grep platformFrom\|usePlatform\|currentPlatform`: only the files above). The proof renders every gated surface twice from its real .tsx (SiteHeader, GoogleButton, the login and signup pages, Settings, the student home, both locales) and asserts that every `href` in the native markup is also in the web markup, that the native markup names no `/pricing` and no Google, and that the privacy, terms, about and sign-in links stay; it drives the real `middleware.ts` with real `NextRequest`s (app: `/en`, `/ar`, `/en/pricing` → 307 to the dashboard; `/en/privacy`, `/en/terms`, `/en/login`, `/en/dashboard/settings` served; web: never redirected; a forged `KnowFlowApp/9` behaves exactly like the app); and it scans `src/` so that a `/pricing` reference, an `upgradeHref` or a Google start outside a gated line fails, that neither predicate is ever called with a literal or with no argument, that no client code sniffs `navigator.userAgent`, and that each page or layout rendering a gated surface still reads `currentPlatform()`. **Green locally: PASS, 9 surfaces x 2 locales. Red on two deliberate breaks: the Google gate removed (4 failures named) and an ungated `/pricing` link added to the footer (1 failure named). Every other proof step of the `tsc` job re-run locally: 19 of 19 pass.** + +**CACHING, THE PART THAT WAS WRONG BEFORE THIS PR AND THE READING THAT FOUND IT.** On production before the change, `/en/login` answered `X-Nextjs-Prerender: 1`, `X-Vercel-Cache: HIT`, `Age: 3889`, `Cache-Control: public, max-age=0, must-revalidate`: a prerendered page, built once with no request, held at the CDN, carrying the Google button in its static HTML. A client-side hide would have left that HTML, and a server-side hide on a static page would have cached whichever variant was built. Vercel's CDN is not asked to vary on the marker; instead every page whose markup depends on it reads `headers()` through `currentPlatform()`, which makes the route render per request, and Next answers such a route with `Cache-Control: private, no-store`. The Next route table cannot show this: it printed `●` for every page before and after (the dashboard, which reads cookies, included). `.next/prerender-manifest.json` can: before, the landing, the legal pages, login and signup were prerendered; after, 17 routes remain prerendered and none of them is a page that reads the marker; the only HTML files emitted under `app/en/` are `forgot-password.html` and `reset-password.html`. The cost, named: the landing and the six marketing and legal pages, login and signup are served by a function instead of the CDN (TTFB on production before: `/en` 0.82 s, `/en/login` 0.24 s, `/en/privacy` 0.21 s; the after values go in the next block). Router prefetches and RSC payloads of dynamic routes are fetched per request with the same user agent, and the router cache lives in each client, so the app and a browser never share one. **The production proof after deploy is the one that counts and is recorded in the next block: both variants, both locales, the cache headers of each, and a web request made right after an app request.** + +**WEB VISITORS SEE WHAT THEY SAW.** Baseline HTML of eleven public pages in both locales captured from production before the change (`/`, `/login`, `/signup`, `/pricing`, `/about`, `/privacy`, `/forgot-password`); the comparison after deploy, with build hashes masked, goes in the next block. Nothing in the web variant's markup is touched by this PR except the removal of a client component that rendered nothing (`NativePlatformHeader`). + +**STEP B'S EXACT SCOPE, FIXED BY THIS STEP.** `capacitor.config.ts` with `appId: 'com.knowflow.app'`, `server.url: 'https://tryknowflow.com'`, `server.allowNavigation: ['tryknowflow.com']`, `ios.appendUserAgent` and `android.appendUserAgent` both `'KnowFlowApp/1'`, an offline page through `server.errorPath`; the `ios/` and `android/` projects. Nothing on the server has to change for the app to be recognised. + ### 2026-10-07 - `docs/store/STORE_PATH.md` amended from Scan & Action's record: the build job and native sign-in are copied, not written; EU trader status is required even outside the EU; Android follows the iOS submission; three sessions to TestFlight and about nine to the first submission **No row is edited. Outside this file: `docs/store/STORE_PATH.md` (amended in place, each correction marked "Corrected by §8", and a new §8). The owner's other app, Scan & Action (`tornidomaroc-web/scan-and-action`, same Apple team), was read only, through the GitHub API; nothing in it was changed. Nothing is built, nothing in production changed. Rows 42, 69 and 81 are not touched, and Section 7 takes no deletions.** diff --git a/docs/store/STORE_PATH.md b/docs/store/STORE_PATH.md index b0d0894..2471e8e 100644 --- a/docs/store/STORE_PATH.md +++ b/docs/store/STORE_PATH.md @@ -110,6 +110,15 @@ every native piece built for the shell carries over. navigations included, which only a user-agent marker does (Capacitor `appendUserAgent`), and `resolvePlatform` must read it. `scripts/verify-store-purchase-links.mjs` extends to the request path. + **Built as step a (2026-10-07, below):** the token is `KnowFlowApp/`, + read by `platformFromHeaders` in `src/lib/platform.ts`; the build-time + flag and `NativePlatformHeader` are deleted. **A correction found while + building:** the Next route table was not enough to see the caching risk. + `/en/login` was prerendered and served from Vercel's CDN with the Google + button in its static HTML, and the table marks every page `●` whether or + not it reads the request. The pages that change on the marker now read + `headers()` and are absent from `.next/prerender-manifest.json`, which is + the reading that counts. ## 2. Distance, item by item, in order @@ -121,8 +130,8 @@ agent does not take because they need the owner's credentials. | # | Item | What it actually requires | Who | Size | |---|---|---|---|---| -| T1 | **Platform at request time** | A user-agent marker read by `resolvePlatform`; the middleware sends a native request for a marketing page (`/`, `/pricing`, `/refund`) to the app's entry; the Google button hidden in native until S1; proof extended to server-rendered pages. Web behaviour unchanged. | Agent | 1 session | -| T2 | **Capacitor project** | `@capacitor/core`, `@capacitor/ios`, `@capacitor/android`; `capacitor.config.ts` with `server.url`, `appendUserAgent`, `allowNavigation` limited to the site, an offline page bundled through `server.errorPath`; the `ios/` project with `TARGETED_DEVICE_FAMILY = 1` (`STORE_ASSETS.md` §5), `ITSAppUsesNonExemptEncryption = NO` (HTTPS only), bundle id `com.knowflow.app` (row #119 (iii), the owner's convention); a temporary icon from the in-app mark. `android/` generated in the same PR. | Agent | 1 session | +| T1 | **Platform at request time. DONE 2026-10-07 (step a).** | *Corrected while building:* the marker is the user-agent token `KnowFlowApp/` or `x-kf-platform: native`, read per request by `platformFromHeaders`; server components read it through `currentPlatform()` (`headers()`), client components through `PlatformProvider` from the nearest layout, never from `navigator.userAgent`. The middleware sends an app request for `/` and `//pricing` to the dashboard; **`/refund` is not redirected**: it is a policy page that names Paddle as the merchant of record, not a call to action, and the app needs its legal pages (5.1.1(i)). Hidden in the app: the header's Pricing link, Settings' Upgrade, the home's Upgrade, the new-subject refusal's upgrade sentence, the API refusals' upgrade line, the Google button on login and signup. The cost: every page that changes on the marker renders per request and leaves the CDN (the landing, the six marketing and legal pages, login, signup; the dashboard already did), because a cached variant would be served to both shells. Proof: `scripts/verify-platform-gate.mjs` (a step of the required `tsc` job) renders each surface both ways, drives the real middleware, and scans `src/` for an ungated link or button. | Agent | 1 session, spent | +| T2 | **Capacitor project (step b)** | **Exact scope, fixed by step a:** `capacitor.config.ts` with `appId: 'com.knowflow.app'`, `server.url: 'https://tryknowflow.com'`, `server.allowNavigation: ['tryknowflow.com']`, `ios.appendUserAgent` and `android.appendUserAgent` both `'KnowFlowApp/1'` (the server reads `KnowFlowApp/`; nothing else on the server changes), an offline page through `server.errorPath`; `@capacitor/core`, `@capacitor/ios`, `@capacitor/android`; the `ios/` project with `TARGETED_DEVICE_FAMILY = 1` (`STORE_ASSETS.md` §5), `ITSAppUsesNonExemptEncryption = NO` (HTTPS only), bundle id `com.knowflow.app` (row #119 (iii), the owner's convention); a temporary icon from the in-app mark. `android/` generated in the same PR. | Agent | 1 session | | T3 | **Owner console steps** | In App Store Connect: **Apps → +** (name, primary language Arabic, bundle id from T2, SKU); **Users and Access → Integrations → App Store Connect API → generate a new key for KnowFlow** (not Scan & Action's key, §8.4); in GitHub, create the environment `testflight` limited to deployments from `main` and put the Issuer ID, Key ID and the `.p8` contents in it as three environment secrets. The agent never sees the key. If "KnowFlow" is taken as an App Store name, choose another display name here; the bundle id is unaffected. *Corrected by §8:* the owner's iPhone is already a registered device on this team (Scan & Action, 2026-09-28), which development signing needs; nothing to do for it. | Owner | ~30 min | | T4 | **The iOS build job** | *Corrected by §8:* **copy Scan & Action's `.github/workflows/ios-testflight.yml`** and adapt it (§8.4): two jobs so the key never sits on a runner that ran npm; `macos-26`; API-key automatic signing that archives for development and re-signs for the App Store at export (Scan & Action's PRs #261 and #262 are the dead end of forcing a Distribution identity); the stale-certificate sweep, because Apple caps a team at ten Development certificates and every hosted run makes one; `testFlightInternalTestingOnly` until the submission build; `CFBundleVersion` from the run number; `Package.resolved` committed. Triggered by `workflow_dispatch` and by a push to `main` that touches the native project only; never by a pull request. | Agent | 1 session | | T5 | **Install** | Add the owner to an internal testing group in TestFlight; install the TestFlight app on the iPhone; install the build. Apple: internal testers are App Store Connect users, up to 100, and a build stays testable for 90 days ([TestFlight overview](https://developer.apple.com/help/app-store-connect/test-a-beta-version/testflight-overview/)). Internal testing needs no App Review. | Owner | ~10 min | diff --git a/scripts/verify-platform-gate.mjs b/scripts/verify-platform-gate.mjs new file mode 100644 index 0000000..f28c5b8 --- /dev/null +++ b/scripts/verify-platform-gate.mjs @@ -0,0 +1,255 @@ +/** + * Executable proof for the request-time platform marker (docs/store/STORE_PATH.md + * step a; Apple 3.1.1(a) and 4.8; src/lib/platform.ts). + * + * THREE CLAIMS, EACH HELD AGAINST THE REAL CODE: + * + * 1. THE READING. `platformFromHeaders` answers `native` to the user-agent + * token the shell appends (`KnowFlowApp/`) and to `x-kf-platform: + * native`, and `web` to everything else, including an iPhone Safari user + * agent and an empty request. The middleware, driven with real + * NextRequests, sends an app request for `/` and `//pricing` + * to the dashboard (307) and lets every other path through; a web request + * is never redirected. + * + * 2. THE MARKER ONLY REMOVES. Every gated surface is RENDERED twice from its + * real .tsx (react-dom/server through the project's own TypeScript, the + * way the other proofs do): the site header, the Google button, the login + * and signup pages, Settings, the student home. For each, the native + * markup carries no purchase link, no upgrade word and no Google button, + * the web markup carries them as today, and EVERY href in the native + * markup is also in the web markup. A forged marker can hide; it cannot + * show, grant or redirect anywhere the web could not go. + * + * 3. NOTHING ROTS. A source scan over src/: every line that links /pricing, + * passes an upgrade href, or starts a Google sign-in is in a file this + * script knows and gates, and the gating expression is on the line or in + * the file. A new upgrade link, pricing link or Google button that does + * not go through `purchaseLinksAllowed(platform)` / `googleSignInAllowed` + * fails CI here. The build-time flag is gone: no `NEXT_PUBLIC_KF_PLATFORM` + * anywhere in src/, and the two predicates take a platform argument + * (`tsc` enforces the signature; this script enforces that nobody + * hard-codes 'web' into them). + * + * Tier 0: no network, no credential, no database, no app. + * + * Usage: node --experimental-strip-types scripts/verify-platform-gate.mjs + */ +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { dirname, resolve as resolvePath, relative } from 'node:path'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { installTsxHooks } from './lib/tsx-hooks.mjs'; + +const ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), '..'); +const failures = []; +const check = (ok, msg) => { if (!ok) failures.push(msg); }; +const load = async (p) => import(pathToFileURL(resolvePath(ROOT, p)).href); + +installTsxHooks(ROOT, { + // `src/middleware.ts` imports the bare specifier; node's resolver wants the file. + 'next/server': `export * from 'next/server.js';`, + '@/lib/supabase/client': `export function createClient() { return { auth: { signOut: async () => {}, signInWithPassword: async () => ({ error: null }), signUp: async () => ({ data: {}, error: null }), getUser: async () => ({ data: { user: null } }) } }; }`, + // The middleware's session half is register #135's (verify-session-cookies); + // here it is a pass-through so the platform redirect is what is under test. + '@/lib/supabase/middleware': `import { NextResponse } from 'next/server'; export async function updateSession(request) { return NextResponse.next({ request }); }`, +}); + +const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; +const APP_UA = `${IPHONE_UA} KnowFlowApp/1`; + +// ── 1. The reading, and the middleware ───────────────────────────────────────── +const platform = await load('src/lib/platform.ts'); +const hdr = (map) => (name) => map[name.toLowerCase()] ?? null; +check(platform.platformFromHeaders(hdr({ 'user-agent': APP_UA })) === 'native', 'the app user agent is not read as native'); +check(platform.platformFromHeaders(hdr({ 'user-agent': IPHONE_UA })) === 'web', 'a plain iPhone Safari user agent must be web'); +check(platform.platformFromHeaders(hdr({ 'user-agent': 'KnowFlowApp' })) === 'web', 'the bare token without a version must not count'); +check(platform.platformFromHeaders(hdr({ 'x-kf-platform': 'native' })) === 'native', 'x-kf-platform: native is not read as native'); +check(platform.platformFromHeaders(hdr({ 'x-kf-platform': 'anything' })) === 'web', 'an unknown header value must be web'); +check(platform.platformFromHeaders(hdr({})) === 'web', 'an empty request must be web'); +check(platform.platformFromRequest({}) === 'web' && platform.platformFromRequest({ headers: new Headers({ 'user-agent': APP_UA }) }) === 'native', 'platformFromRequest does not read the user agent'); +check(platform.purchaseLinksAllowed('web') === true && platform.purchaseLinksAllowed('native') === false, 'purchaseLinksAllowed is wrong'); +check(platform.googleSignInAllowed('web') === true && platform.googleSignInAllowed('native') === false, 'googleSignInAllowed is wrong'); +check(!('PLATFORM' in platform), 'the build-time PLATFORM constant is back; the marker is request-time only'); +check(platform.NATIVE_USER_AGENT_TOKEN === 'KnowFlowApp', `the token step b must append changed: ${platform.NATIVE_USER_AGENT_TOKEN}`); + +const { NextRequest } = await import('next/server.js'); +const { middleware } = await load('src/middleware.ts'); +const ORIGIN = 'https://tryknowflow.com'; +async function mw(path, ua) { + const res = await middleware(new NextRequest(`${ORIGIN}${path}`, { headers: { 'user-agent': ua, accept: 'text/html' } })); + return { status: res.status, location: res.headers.get('location') }; +} +for (const locale of ['en', 'ar']) { + for (const path of [`/${locale}`, `/${locale}/pricing`]) { + const app = await mw(path, APP_UA); + check(app.status === 307 && app.location === `${ORIGIN}/${locale}/dashboard`, `app request for ${path}: expected 307 to /${locale}/dashboard, got ${app.status} ${app.location}`); + const web = await mw(path, IPHONE_UA); + check(web.status === 200 && web.location === null, `web request for ${path} must pass through, got ${web.status} ${web.location}`); + } + for (const path of [`/${locale}/privacy`, `/${locale}/terms`, `/${locale}/login`, `/${locale}/dashboard/settings`, `/${locale}/about`]) { + const app = await mw(path, APP_UA); + check(app.status === 200 && app.location === null, `app request for ${path} must be served, got ${app.status} ${app.location}`); + } +} +// The redirect target is the one page the web can also reach, and it is behind +// the session check: a forged marker buys a web browser nothing but a bounce. +{ + const forged = await mw('/en/pricing', 'Mozilla/5.0 KnowFlowApp/9 (forged)'); + check(forged.status === 307 && forged.location === `${ORIGIN}/en/dashboard`, 'a forged marker must behave exactly like the app: hidden, never granted'); +} + +// ── 2. The marker only removes: every gated surface rendered both ways ───────── +const React = (await import('react')).default; +const { renderToStaticMarkup } = await import('react-dom/server'); +const { PlatformProvider } = await load('src/components/platform/PlatformProvider.tsx'); +const under = (p, el) => renderToStaticMarkup(React.createElement(PlatformProvider, { platform: p }, el)); +const hrefs = (html) => new Set([...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1])); +const UPGRADE_WORDS = /\bPro\b|\bupgrade|الاحترافي|ترقية/i; +const GOOGLE = /Google|fill="#4285F4"/; +function onlyRemoves(name, web, native, { mustKeep = [] } = {}) { + const w = hrefs(web), n = hrefs(native); + for (const h of n) check(w.has(h), `${name}: the native markup links ${h}, which the web markup does not: the marker added something`); + check(![...n].some((h) => /\/pricing/.test(h)), `${name}: native markup links /pricing`); + check(!/\/pricing/.test(native), `${name}: native markup mentions /pricing`); + check(!GOOGLE.test(native), `${name}: native markup carries the Google button`); + for (const h of mustKeep) check(n.has(h), `${name}: native markup lost ${h}, which must stay`); + console.error(`${name}: web ${web.length} chars, ${w.size} hrefs; native ${native.length} chars, ${n.size} hrefs`); +} + +globalThis.__tsxHooksPathname = '/en/privacy'; +const { SiteHeader } = await load('src/components/layout/SiteHeader.tsx'); +const headerLabels = { home: 'KnowFlow', howItWorks: 'How', pricing: 'Pricing', about: 'About', signIn: 'Sign in', getStarted: 'Start', menu: 'Menu', appearance: 'Appearance', themeDark: 'Dark', themeLight: 'Light' }; +for (const locale of ['en', 'ar']) { + const web = renderToStaticMarkup(React.createElement(SiteHeader, { locale, labels: headerLabels, showPricing: true })); + const native = renderToStaticMarkup(React.createElement(SiteHeader, { locale, labels: headerLabels, showPricing: false })); + check(web.includes(`href="/${locale}/pricing"`), `${locale} header on the web lost its Pricing link`); + onlyRemoves(`SiteHeader ${locale}`, web, native, { mustKeep: [`/${locale}/about`, `/${locale}/login`] }); +} + +const { GoogleButton } = await load('src/components/auth/GoogleButton.tsx'); +{ + const web = under('web', React.createElement(GoogleButton, { label: 'Continue with Google', errorLabel: 'failed' })); + const native = under('native', React.createElement(GoogleButton, { label: 'Continue with Google', errorLabel: 'failed' })); + check(GOOGLE.test(web) && web.includes('Continue with Google'), 'GoogleButton on the web does not render'); + check(native === '', `GoogleButton inside the app must render nothing, got ${native.length} chars`); + const outside = renderToStaticMarkup(React.createElement(GoogleButton, { label: 'Continue with Google', errorLabel: 'failed' })); + check(GOOGLE.test(outside), 'GoogleButton with no provider must fail toward the web (shown)'); +} + +for (const [name, path, file] of [['login', '/en/login', 'src/app/[locale]/login/page.tsx'], ['signup', '/en/signup', 'src/app/[locale]/signup/page.tsx']]) { + globalThis.__tsxHooksPathname = path; + const Page = (await load(file)).default; + for (const locale of ['en', 'ar']) { + // `use(params)` reads a fulfilled thenable synchronously; a real Promise + // would suspend renderToStaticMarkup (the same shape the auth proofs use). + const params = { status: 'fulfilled', value: { locale }, then() {} }; + const props = { params }; + const web = under('web', React.createElement(Page, props)); + const native = under('native', React.createElement(Page, props)); + check(GOOGLE.test(web), `${name} ${locale} on the web lost its Google button`); + check(web.includes('type="password"') && native.includes('type="password"'), `${name} ${locale}: the email and password form must stay in both shells`); + onlyRemoves(`${name} page ${locale}`, web, native); + } +} + +globalThis.__tsxHooksPathname = '/en/dashboard/settings'; +const { SettingsPanel } = await load('src/components/dashboard/SettingsPanel.tsx'); +const settingsLabels = Object.fromEntries(['title','subtitle','account','email','plan','free','pro','freePlanDesc','proPlanDesc','renews','cancels','activeSubscription','preferences','language','appearance','themeDark','themeLight','helpLegal','privacyPolicy','terms','support','supportDesc'].map((k) => [k, k])); +settingsLabels.upgrade = 'Upgrade to Pro'; +const { purchaseLinksAllowed } = platform; +for (const locale of ['en', 'ar']) { + const render = (p) => renderToStaticMarkup(React.createElement(SettingsPanel, { + email: 'student@example.com', isPro: false, renewsOn: null, cancelsOn: null, + upgradeHref: purchaseLinksAllowed(p) ? `/${locale}/pricing` : null, + locale, pathname: `/${locale}/dashboard/settings`, privacyHref: `/${locale}/privacy`, termsHref: `/${locale}/terms`, + supportEmail: 'support@example.com', labels: settingsLabels, deleteCard: null, + })); + const web = render('web'), native = render('native'); + check(web.includes(`href="/${locale}/pricing"`), `Settings ${locale} on the web lost Upgrade`); + check(!UPGRADE_WORDS.test(native.replace(/freePlanDesc|proPlanDesc/g, '')) || !native.includes('Upgrade to Pro'), `Settings ${locale} in the app still offers Upgrade`); + onlyRemoves(`Settings ${locale}`, web, native, { mustKeep: [`/${locale}/privacy`, `/${locale}/terms`] }); +} + +const { StudentHome } = await load('src/components/dashboard/StudentHome.tsx'); +const { buildHrefs } = await load('src/lib/home-props.ts'); +const homeLabels = Object.fromEntries(['welcome','askTitle','askDesc','newSubject','newSubjectDesc','subjects','streakLabel','streakUnit','streakZoneHint','recentActivity','planTitle','planName','ofWord','subjectsUsed','allSubjects','materialsWord','noSubjects','noSubjectsDesc','startTitle','whatTitle','upgradeCta','noActivity','conversation','showLess','viewAll','unknownKb'].map((k) => [k, k])); +homeLabels.whatLines = ['a', 'b', 'c']; +homeLabels.activity = { noActivity: 'n', conversation: 'c', showLess: 's', viewAll: 'v', unknownKb: 'u' }; +for (const locale of ['en', 'ar']) { + const render = (p) => renderToStaticMarkup(React.createElement(StudentHome, { + stats: [], streak: null, ...buildHrefs(locale, p), isPro: false, quotas: [], subjects: [], subjectsUsed: 0, subjectsLimit: 5, onboarding: [], + labels: homeLabels, recentActivity: [], + })); + const web = render('web'), native = render('native'); + check(web.includes(`href="/${locale}/pricing"`), `StudentHome ${locale} on the web lost Upgrade`); + onlyRemoves(`StudentHome ${locale}`, web, native, { mustKeep: [`/${locale}/dashboard/agent`, `/${locale}/dashboard/knowledge/new`] }); +} + +// ── 3. Nothing rots: the source scan ─────────────────────────────────────────── +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + const p = resolvePath(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else if (/\.(ts|tsx)$/.test(name)) out.push(p); + } + return out; +} +const files = walk(resolvePath(ROOT, 'src')).map((p) => [relative(ROOT, p).replace(/\\/g, '/'), readFileSync(p, 'utf8')]); +const strip = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/\{\/\*[\s\S]*?\*\/\}/g, ''); + +// (a) Every pricing link, and the gate on its line. +const PRICING_OK = { + 'src/app/sitemap.ts': () => true, // the public sitemap: the web's, never served to the app's user + 'src/app/[locale]/dashboard/settings/page.tsx': (line) => /purchaseLinksAllowed\(await currentPlatform\(\)\)/.test(line), + 'src/app/[locale]/preview/settings/page.tsx': (line) => /native \? null :/.test(line), + 'src/components/layout/SiteHeader.tsx': (line) => /showPricing \?/.test(line), + 'src/lib/home-props.ts': (line) => /purchaseLinksAllowed\(platform\)/.test(line), + 'src/middleware.ts': (line) => /rest === '\/pricing'/.test(line), +}; +for (const [file, src] of files) { + for (const line of strip(src).split('\n')) { + if (!/\/pricing/.test(line)) continue; + const ok = PRICING_OK[file]; + check(ok && ok(line), `${file}: a /pricing reference without the platform gate on its line: ${line.trim().slice(0, 100)}`); + } +} +// (b) Every upgrade href handed to a screen comes from the gate. +for (const [file, src] of files) { + for (const line of strip(src).split('\n')) { + if (!/upgradeHref[=:]/.test(line) || /upgradeHref: string|upgradeHref,$|upgradeHref\?/.test(line.trim())) continue; + const gated = /purchaseLinksAllowed\(/.test(line) || /native \? null/.test(line); + check(gated, `${file}: upgradeHref set without purchaseLinksAllowed(...): ${line.trim().slice(0, 100)}`); + } +} +// (c) Google sign-in starts in exactly one component, and that component is gated. +for (const [file, src] of files) { + const s = strip(src); + if (/startOAuth\(\s*'google'/.test(s)) check(file === 'src/components/auth/GoogleButton.tsx', `${file}: starts a Google sign-in outside GoogleButton`); + if (/ ({ headers: { get: (n) => (n.toLowerCase() === 'user-agent' ? v : null) } }); + check(platformMod.platformFromRequest(ua('Mozilla/5.0 (iPhone) KnowFlowApp/1')) === 'native', 'the KnowFlowApp/ user agent is not read as native'); + check(platformMod.platformFromRequest(ua('Mozilla/5.0 (iPhone) Safari')) === 'web', 'a plain user agent must be web'); } // ── 2. The two screens, rendered. diff --git a/src/app/[locale]/(site)/layout.tsx b/src/app/[locale]/(site)/layout.tsx index 174d7c4..9a415b2 100644 --- a/src/app/[locale]/(site)/layout.tsx +++ b/src/app/[locale]/(site)/layout.tsx @@ -2,6 +2,8 @@ import type { ReactNode } from 'react'; import { SiteHeader } from '@/components/layout/SiteHeader'; import { SiteFooter } from '@/components/layout/SiteFooter'; import { useTranslation, type Locale } from '@/lib/i18n'; +import { purchaseLinksAllowed } from '@/lib/platform'; +import { currentPlatform } from '@/lib/platform-server'; /** * The public site's shell (#107): the header on the landing and on the six @@ -25,6 +27,13 @@ export default async function SiteLayout({ }) { const { locale } = await params; const t = useTranslation(locale); + // Which shell asked. Inside the app the header carries no Pricing link + // (Apple 3.1.1(a)). Reading the request here makes every page under this + // layout render per request instead of from the CDN, which is the cost of + // serving two variants from one build without ever caching either + // (src/lib/platform.ts, CACHING). The landing and /pricing themselves never + // reach the app: the middleware sends it to the dashboard. + const showPricing = purchaseLinksAllowed(await currentPlatform()); return ( // `min-h-screen` lives HERE and nowhere below it. Each page used to carry @@ -33,6 +42,7 @@ export default async function SiteLayout({
{passwordReplaced && ( + ); } diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index c9ed49a..1cdf449 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -12,6 +12,7 @@ import { FREE_LIMITS, PRO_LIMITS } from '@/lib/limits' import { DAILY_CAPS } from '@/lib/rate-limit' import { formatDate } from '@/lib/format-date' import { buildHomeLabels, buildHomeProgress, buildHrefs, buildOnboarding, buildQuotas } from '@/lib/home-props' +import { currentPlatform } from '@/lib/platform-server' // Thin server wrapper: auth + data only. Presentation lives in // (dumb, prop-driven) so it can be reused/storybooked in Phase 8. @@ -31,6 +32,9 @@ export default async function DashboardPage({ const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) redirect(`/${safeLocale}/login`) + // Which shell asked: inside the app the home carries no Upgrade link + // (Apple 3.1.1(a); src/lib/platform.ts). + const platform = await currentPlatform() // P5.3: the student's IANA zone, written by below. Read with the // same `next/headers` machinery `createClient()` already uses, which is what lets @@ -117,7 +121,7 @@ export default async function DashboardPage({ - {children} diff --git a/src/app/[locale]/login/layout.tsx b/src/app/[locale]/login/layout.tsx new file mode 100644 index 0000000..745fc29 --- /dev/null +++ b/src/app/[locale]/login/layout.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import { currentPlatform } from '@/lib/platform-server'; +import { PlatformProvider } from '@/components/platform/PlatformProvider'; + +/** + * This layout exists for one reason: the page below is a client component + * and cannot read the request, but its Google button must know which shell + * it is in (hidden inside the app; STORE_PATH.md step a, src/lib/platform.ts). + * The platform is read from the request here and handed down through + * context, so server markup and hydration agree. + * + * Reading the request also turns this route from a prerendered page into one + * rendered per request. That is deliberate and necessary: a prerendered page + * is built once as the web variant and served from the CDN to everyone, the + * app included. The cost is one small page no longer served from cache. + */ +export default async function PlatformLayout({ children }: { children: ReactNode }) { + const platform = await currentPlatform(); + return {children}; +} diff --git a/src/app/[locale]/preview/student-home/page.tsx b/src/app/[locale]/preview/student-home/page.tsx index 3afb4ce..3a63781 100644 --- a/src/app/[locale]/preview/student-home/page.tsx +++ b/src/app/[locale]/preview/student-home/page.tsx @@ -8,6 +8,7 @@ import { pluralize } from '@/lib/i18n/plural' import { FREE_LIMITS } from '@/lib/limits' import { DAILY_CAPS } from '@/lib/rate-limit' import { buildHomeLabels, buildHomeProgress, buildHrefs, buildOnboarding, buildQuotas } from '@/lib/home-props' +import { resolvePlatform } from '@/lib/platform' import { DashboardShell } from '@/components/layout/DashboardShell' import { formatDate } from '@/lib/format-date' @@ -51,7 +52,7 @@ export default async function StudentHomePreview({ searchParams, }: { params: Promise<{ locale: string }> - searchParams: Promise<{ state?: string; theme?: string }> + searchParams: Promise<{ state?: string; theme?: string; platform?: string }> }) { // ── THE GATE. A `noindex` tag is not access control: it asks crawlers not to // list the path, and does nothing about anyone who has the URL. This route @@ -76,7 +77,10 @@ export default async function StudentHomePreview({ const t = useTranslation(safeLocale) const home = t.dashboard.home - const { state: rawState, theme: rawTheme } = await searchParams + const { state: rawState, theme: rawTheme, platform: rawPlatform } = await searchParams + // `?platform=native` shows the home as the app renders it (no Upgrade link), + // the same switch the settings preview has; the store screenshots use it. + const platform = resolvePlatform(rawPlatform) const state: State = rawState === 'full' ? 'full' : 'zero' const full = state === 'full' // `?theme=` is now honoured by the boot script on (`src/lib/theme.ts`), @@ -196,7 +200,7 @@ export default async function StudentHomePreview({ {children}; +} diff --git a/src/components/auth/GoogleButton.tsx b/src/components/auth/GoogleButton.tsx index 54e5635..7bae090 100644 --- a/src/components/auth/GoogleButton.tsx +++ b/src/components/auth/GoogleButton.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { startOAuth } from '@/lib/auth/oauth'; +import { googleSignInAllowed } from '@/lib/platform'; +import { usePlatform } from '@/components/platform/PlatformProvider'; /** * The one way into a Google sign-in. Used by both /login and /signup, which @@ -48,6 +50,11 @@ export function GoogleButton({ }) { const [failed, setFailed] = useState(false); const [loading, setLoading] = useState(false); + // Hidden inside the app (STORE_PATH.md step a): Google refuses OAuth in an + // embedded web view, and native sign-in is step S1. The platform comes from + // the request through the page's PlatformProvider, never from the browser. + const platform = usePlatform(); + if (!googleSignInAllowed(platform)) return null; const start = async () => { setFailed(false); diff --git a/src/components/layout/SiteHeader.tsx b/src/components/layout/SiteHeader.tsx index 8498b25..0affb86 100644 --- a/src/components/layout/SiteHeader.tsx +++ b/src/components/layout/SiteHeader.tsx @@ -35,7 +35,7 @@ export interface SiteHeaderLabels { * The dictionary is NOT imported here: labels arrive as props from the server * layout, so neither locale's dictionary is pulled into the client bundle. */ -export function SiteHeader({ locale, labels }: { locale: Locale; labels: SiteHeaderLabels }) { +export function SiteHeader({ locale, labels, showPricing }: { locale: Locale; labels: SiteHeaderLabels; showPricing: boolean }) { const pathname = usePathname(); const [open, setOpen] = useState(false); const buttonRef = useRef(null); @@ -66,7 +66,10 @@ export function SiteHeader({ locale, labels }: { locale: Locale; labels: SiteHea // header now stands on seven pages, and on six of them a bare fragment // scrolls the page the student is already on to nothing. { href: `/${locale}#how-it-works`, label: labels.howItWorks }, - { href: `/${locale}/pricing`, label: labels.pricing }, + // Absent inside the app: a link to the pricing page is a call to action + // toward a purchase outside in-app purchase (Apple 3.1.1(a)). The layout + // decides from the request (`purchaseLinksAllowed(await currentPlatform())`). + ...(showPricing ? [{ href: `/${locale}/pricing`, label: labels.pricing }] : []), { href: `/${locale}/about`, label: labels.about }, ]; diff --git a/src/components/platform/NativePlatformHeader.tsx b/src/components/platform/NativePlatformHeader.tsx deleted file mode 100644 index cc22c2f..0000000 --- a/src/components/platform/NativePlatformHeader.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { PLATFORM, PLATFORM_HEADER } from '@/lib/platform'; - -/** - * In the native build, every same-origin `/api/` request carries - * `x-kf-platform: native`, so the server composes refusals without purchase - * sentences (Apple 3.1.1(a); `src/lib/platform.ts`). - * - * Done ONCE here, by wrapping `window.fetch`, rather than at each of the - * fourteen `fetch('/api/...')` call sites: a call site added later would - * otherwise ship the web sentence into the store build with nothing to catch - * it. In the web build (`PLATFORM === 'web'`) this renders nothing and touches - * nothing; the wrapper is installed only when the flag was set at build time. - */ -export function NativePlatformHeader() { - useEffect(() => { - if (PLATFORM !== 'native') return; - const w = window as Window & { __kfPlatformFetch?: boolean }; - if (w.__kfPlatformFetch) return; - w.__kfPlatformFetch = true; - const original = window.fetch.bind(window); - window.fetch = (input, init) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - const sameOriginApi = url.startsWith('/api/') || url.startsWith(`${window.location.origin}/api/`); - if (!sameOriginApi) return original(input, init); - const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); - headers.set(PLATFORM_HEADER, 'native'); - return original(input, { ...init, headers }); - }; - }, []); - return null; -} diff --git a/src/components/platform/PlatformProvider.tsx b/src/components/platform/PlatformProvider.tsx new file mode 100644 index 0000000..f93b45e --- /dev/null +++ b/src/components/platform/PlatformProvider.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { createContext, useContext, type ReactNode } from 'react'; +import type { Platform } from '@/lib/platform'; + +/** + * How a client component learns which shell it is in. The value is read on + * the server from the request (`currentPlatform()`) by the nearest layout or + * page that renders gated client components, and handed down here, so the + * server markup and the hydrated client agree by construction. No client + * code sniffs `navigator.userAgent`: that would be a second reading that can + * disagree with the first. + * + * The default is `'web'` (fail toward the web, `src/lib/platform.ts`): a + * gated component rendered outside a provider shows what the web shows. + */ +const PlatformContext = createContext('web'); + +export function PlatformProvider({ platform, children }: { platform: Platform; children: ReactNode }) { + return {children}; +} + +export function usePlatform(): Platform { + return useContext(PlatformContext); +} diff --git a/src/lib/home-props.ts b/src/lib/home-props.ts index 64482bd..e564277 100644 --- a/src/lib/home-props.ts +++ b/src/lib/home-props.ts @@ -1,5 +1,5 @@ import type { Locale } from '@/lib/i18n' -import { purchaseLinksAllowed } from '@/lib/platform' +import { purchaseLinksAllowed, type Platform } from '@/lib/platform' import type { PluralForms } from '@/lib/i18n/plural' import type { OnboardingStep, @@ -125,13 +125,21 @@ export function buildHomeLabels({ home, subjectsNavLabel, isPro, streakUnit, con } } -export function buildHrefs(locale: Locale) { +/** The three in-app destinations, the same in both shells. */ +export function appHrefs(locale: Locale) { return { askHref: `/${locale}/dashboard/agent`, newSubjectHref: `/${locale}/dashboard/knowledge/new`, subjectsHref: `/${locale}/dashboard/knowledge`, - // Null in the store build: no purchase link inside the app (Apple 3.1.1(a)). - upgradeHref: purchaseLinksAllowed() ? `/${locale}/pricing` : null, + } +} + +export function buildHrefs(locale: Locale, platform: Platform) { + return { + ...appHrefs(locale), + // Null inside the app: no purchase link there (Apple 3.1.1(a)); the page + // reads the platform from the request (`currentPlatform()`). + upgradeHref: purchaseLinksAllowed(platform) ? `/${locale}/pricing` : null, } } @@ -145,7 +153,7 @@ export function buildOnboarding( locale: Locale, counts: { subjects: number; materials: number; conversations: number }, ): OnboardingStep[] { - const h = buildHrefs(locale) + const h = appHrefs(locale) return [ { key: 'subject', title: home.step1Title, desc: home.step1Desc, done: counts.subjects > 0, href: h.newSubjectHref }, { key: 'upload', title: home.step2Title, desc: home.step2Desc, done: counts.materials > 0, href: h.subjectsHref }, diff --git a/src/lib/platform-server.ts b/src/lib/platform-server.ts new file mode 100644 index 0000000..593076c --- /dev/null +++ b/src/lib/platform-server.ts @@ -0,0 +1,19 @@ +import { headers } from 'next/headers'; +import { platformFromHeaders, type Platform } from '@/lib/platform'; + +/** + * The platform of the request a server component is rendering for. + * + * Reading `headers()` is what makes the route dynamic: a page that calls this + * is rendered per request and never prerendered, which is the only correct + * behaviour for a page whose markup depends on the marker (see the CACHING + * note in `src/lib/platform.ts`). Call it only in pages and layouts that + * really change on the marker; a page that does not should stay static. + * + * Kept apart from `platform.ts` so client components and the proof scripts, + * which have no `next/headers`, can import the pure module. + */ +export async function currentPlatform(): Promise { + const h = await headers(); + return platformFromHeaders((name) => h.get(name)); +} diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 513899d..b0d2b08 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -1,57 +1,100 @@ /** - * WHICH SHELL IS RUNNING, AND WHAT MAY BE SHOWN IN IT (Apple 3.1.1(a), a - * recorded Phase 8 gate; PIVOT_PLAN.md §8). + * WHICH SHELL IS ASKING, AND WHAT MAY BE SHOWN TO IT (Apple 3.1.1(a) and 4.8; + * `docs/store/STORE_PATH.md` step a; PIVOT_PLAN.md §8). * * Apple's rule, verbatim in §8: outside the US storefront an app "may not * include buttons, external links, or other calls to action that direct * customers to purchasing mechanisms other than in-app purchase". Morocco and - * the Gulf are not the US storefront. So inside the packaged app there is no - * Upgrade button, no link to /pricing, and no "Pro has higher limits" sentence - * in a refusal. The web app keeps all of them. + * the Gulf are not the US storefront. So inside the app there is no Upgrade + * button, no link to /pricing, and no "Pro has higher limits" sentence in a + * refusal. The Google sign-in button is hidden there too, because Google + * refuses OAuth inside an embedded web view and native sign-in is a later + * step. The web keeps all of them. * - * ONE FLAG, TWO HALVES, BECAUSE THE SERVER SERVES BOTH. + * ONE MARKER, READ AT REQUEST TIME. The app is a native shell that loads + * tryknowflow.com, so the server serves both shells from one build and can + * only tell them apart per request. The shell appends `KnowFlowApp/` to + * its web view's user agent (Capacitor `appendUserAgent`, step b), and every + * request the web view makes, document loads and fetches alike, carries it. + * `x-kf-platform: native` is honoured as well, for a client that sets it. * - * - THE BUILD. `NEXT_PUBLIC_KF_PLATFORM=native` is set when the Capacitor - * bundle is built (Phase 8) and inlined into the client. Everything rendered - * from that bundle reads `PLATFORM` and hides its purchase links. The web - * build never sets it and is `'web'`. - * - THE REQUEST. The native shell talks to the SAME `/api/*` on Vercel that - * the web app does, and four refusals are composed on the server - * (`dailyLimitMessage`, `monthlyConversationMessage`, - * `subjectMaterialsMessage`). A server cannot read a build-time flag of a - * client it did not build, so the native bundle sends `x-kf-platform: native` - * on every same-origin API call (`NativePlatformHeader`), and each route - * passes `platformFromRequest(request)` down to the sentence. + * THE MARKER ONLY REMOVES. Anyone can forge a user agent or a header, so the + * native answer must grant nothing: no entitlement, no limit, no auth, no + * different data. It is consumed by exactly two predicates below and by the + * middleware's redirect of the marketing root and /pricing to the dashboard, + * and every consumer HIDES something the web would show. A forged marker in + * a normal browser hides that browser's own upgrade links and Google button + * and nothing else. `scripts/verify-platform-gate.mjs` holds this: for every + * gated surface, what the native variant renders is a subset of the web + * variant. * - * FAIL SAFE IN THE STORE'S DIRECTION? No: fail toward the WEB. An absent or - * unknown value is `'web'`, which shows the links. That is deliberate: the - * web app is the product today, and a bug that hid Upgrade from every web - * student would cost revenue silently, while the native build sets the flag - * explicitly and is checked by `scripts/verify-store-purchase-links.mjs`. + * FAIL TOWARD THE WEB. An absent or unknown marker is `'web'`, which shows the + * links: the web app is the product today, and a bug that hid Upgrade from + * every web student would cost revenue silently, while the app sets its + * marker explicitly and the gate script checks the reading. + * + * CACHING. A page whose output depends on the marker must be rendered per + * request: a static page is built once, with no request and so as the web + * variant, and the CDN would serve it to the app. Every page that reads the + * marker does so through `currentPlatform()` (`src/lib/platform-server.ts`), + * which reads `headers()` and thereby opts the route out of static + * rendering; Next then answers with `Cache-Control: private, no-store`, so + * no shared cache ever holds either variant. The proof is on production, not + * in a unit test: `STORE_PATH.md` and Section 7 record the headers read. */ export type Platform = 'web' | 'native'; -export const PLATFORM: Platform = process.env.NEXT_PUBLIC_KF_PLATFORM === 'native' ? 'native' : 'web'; - -/** The request header the native bundle adds to its API calls. */ +/** The request header a client may set. */ export const PLATFORM_HEADER = 'x-kf-platform'; +/** + * The token the shell appends to its user agent, followed by a version: + * `KnowFlowApp/1`. Step b sets `appendUserAgent: 'KnowFlowApp/1'` in the + * Capacitor config and nothing else on the server has to change. + */ +export const NATIVE_USER_AGENT_TOKEN = 'KnowFlowApp'; +const NATIVE_USER_AGENT = /\bKnowFlowApp\/\d/; + export function resolvePlatform(value: unknown): Platform { return value === 'native' ? 'native' : 'web'; } +/** + * The one reading. `get` is `Headers.get` or anything shaped like it, so the + * middleware, a route handler, a server component (through `headers()`) and + * a proof script all read the same way. + */ +export function platformFromHeaders(get: (name: string) => string | null | undefined): Platform { + if (get(PLATFORM_HEADER) === 'native') return 'native'; + if (NATIVE_USER_AGENT.test(get('user-agent') ?? '')) return 'native'; + return 'web'; +} + export function platformFromRequest(request: { headers?: { get(name: string): string | null } }): Platform { // Defensive on purpose: a real Request always has headers, but the route // proofs (`scripts/verify-*.mjs`) drive the real handlers with a literal // `{ json() }` and must keep working; a request with no headers is simply // the web. - return resolvePlatform(request?.headers?.get?.(PLATFORM_HEADER) ?? null); + const headers = request?.headers; + if (!headers || typeof headers.get !== 'function') return 'web'; + return platformFromHeaders((name) => headers.get(name)); +} + +/** + * Whether a purchase link or an upgrade sentence may be shown. Every gated + * surface asks this and nothing else reads the platform for it. The argument + * is required: there is no build-time default any more, so a caller cannot + * pick up a stale answer by omission. + */ +export function purchaseLinksAllowed(platform: Platform): boolean { + return platform === 'web'; } /** - * Whether a purchase link or an upgrade sentence may be shown. The one - * question every gated surface asks; nothing else reads `PLATFORM` directly. + * Whether the Google sign-in button may be shown. Hidden in the app until + * native sign-in ships (STORE_PATH.md S1): Google refuses OAuth inside an + * embedded web view, so the button would only fail there. */ -export function purchaseLinksAllowed(platform: Platform = PLATFORM): boolean { +export function googleSignInAllowed(platform: Platform): boolean { return platform === 'web'; } diff --git a/src/middleware.ts b/src/middleware.ts index 716aa14..138570f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server'; import { updateSession } from '@/lib/supabase/middleware'; +import { platformFromHeaders } from '@/lib/platform'; import { LOCALE_COOKIE, LOCALE_COOKIE_MAX_AGE, @@ -80,10 +81,29 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(url, 307); } - // 2. Supabase session update second + // 2. The app never shows the marketing root or the pricing page: both are + // calls to action toward a purchase outside in-app purchase (Apple + // 3.1.1(a); docs/store/STORE_PATH.md step a). A request carrying the + // app's marker (src/lib/platform.ts) is sent to the dashboard, which + // `updateSession` below bounces to /login when there is no session; so + // this grants nothing, it only takes a page away. A forged marker in a + // normal browser costs that browser the landing and /pricing, nothing + // else. Every other page (privacy, terms, the app itself) is served as + // it is, with its own gated surfaces hidden. + if (platformFromHeaders((name) => request.headers.get(name)) === 'native') { + const rest = pathname.slice(`/${locale}`.length); + if (rest === '' || rest === '/' || rest === '/pricing' || rest === '/pricing/') { + const url = request.nextUrl.clone(); + url.pathname = `/${locale}/dashboard`; + url.search = ''; + return NextResponse.redirect(url, 307); + } + } + + // 3. Supabase session update const response = await updateSession(request); - // 3. Remember the language of the page being opened. Every page carries its + // 4. Remember the language of the page being opened. Every page carries its // locale in the path, so the switcher needs no client code and no route of // its own: following its link is the choice, and this line is the memory. // Written only when it changes, so an ordinary page view sets no cookie, From 13580735fb8ca7295b5d54f3764b8f24b489ea23 Mon Sep 17 00:00:00 2001 From: tornidomaroc-web Date: Wed, 7 Oct 2026 10:14:34 +0000 Subject: [PATCH 2/2] feat(store): step a rebuilt on the owner's objection: the web's pages stay prerendered on the CDN; the app is rewritten by the middleware to prerendered twins under //native/ without the Pricing link and the Google button; measurements and the gate's new rules recorded Co-Authored-By: Claude Fable 5.1 --- docs/PROGRESS.md | 14 +-- docs/store/STORE_PATH.md | 27 ++---- scripts/verify-platform-gate.mjs | 92 ++++++++++++++----- src/app/[locale]/(site)/layout.tsx | 73 +++------------ src/app/[locale]/login/layout.tsx | 20 ---- src/app/[locale]/native/(site)/about/page.tsx | 4 + .../[locale]/native/(site)/contact/page.tsx | 4 + src/app/[locale]/native/(site)/layout.tsx | 19 ++++ .../[locale]/native/(site)/privacy/page.tsx | 4 + .../[locale]/native/(site)/refund/page.tsx | 4 + src/app/[locale]/native/(site)/terms/page.tsx | 4 + src/app/[locale]/native/layout.tsx | 36 ++++++++ src/app/[locale]/native/login/page.tsx | 4 + src/app/[locale]/native/signup/page.tsx | 4 + src/app/[locale]/signup/layout.tsx | 20 ---- src/components/layout/SiteChrome.tsx | 60 ++++++++++++ src/lib/platform.ts | 27 ++++-- src/middleware.ts | 64 +++++++++---- 18 files changed, 308 insertions(+), 172 deletions(-) delete mode 100644 src/app/[locale]/login/layout.tsx create mode 100644 src/app/[locale]/native/(site)/about/page.tsx create mode 100644 src/app/[locale]/native/(site)/contact/page.tsx create mode 100644 src/app/[locale]/native/(site)/layout.tsx create mode 100644 src/app/[locale]/native/(site)/privacy/page.tsx create mode 100644 src/app/[locale]/native/(site)/refund/page.tsx create mode 100644 src/app/[locale]/native/(site)/terms/page.tsx create mode 100644 src/app/[locale]/native/layout.tsx create mode 100644 src/app/[locale]/native/login/page.tsx create mode 100644 src/app/[locale]/native/signup/page.tsx delete mode 100644 src/app/[locale]/signup/layout.tsx create mode 100644 src/components/layout/SiteChrome.tsx diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 370e1c6..510ca5b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -451,19 +451,19 @@ bodies total 37,095 bytes. **This retires the Option C frozen-tail invariant by existed only to police a boundary inside an unreviewable single line, and the append-only rule above supersedes it. No bespoke hash is needed for future updates: the diff is the proof. -### 2026-10-07 - Store path step a built: the request-time platform marker; inside the app no purchase, upgrade or pricing surface and no Google button, read per request from `KnowFlowApp/` in the user agent; the pages that change on it leave the CDN; gated in CI by `verify-platform-gate.mjs` +### 2026-10-07 - Store path step a built: the request-time platform marker; inside the app no purchase, upgrade or pricing surface and no Google button, read per request from `KnowFlowApp/` in the user agent; the web's pages stay on the CDN and the app is rewritten to prerendered twins; gated in CI by `verify-platform-gate.mjs` -**No row is edited. Outside this file: `src/lib/platform.ts` (rewritten: `platformFromHeaders`, `purchaseLinksAllowed(platform)`, `googleSignInAllowed(platform)`, the build-time `PLATFORM` and `NEXT_PUBLIC_KF_PLATFORM` gone), `src/lib/platform-server.ts` (new, `currentPlatform()` over `headers()`), `src/components/platform/PlatformProvider.tsx` (new, context for client components), `src/components/platform/NativePlatformHeader.tsx` (deleted), `src/middleware.ts` (an app request for `/` or `//pricing` → 307 to `//dashboard`), `src/app/[locale]/(site)/layout.tsx` and `src/components/layout/SiteHeader.tsx` (`showPricing`), `src/app/[locale]/login/layout.tsx` and `signup/layout.tsx` (new, the provider), `src/app/[locale]/dashboard/layout.tsx`, `dashboard/page.tsx`, `dashboard/settings/page.tsx`, `dashboard/knowledge/new/page.tsx`, `src/lib/home-props.ts` (`buildHrefs(locale, platform)`, `appHrefs`), `src/components/auth/GoogleButton.tsx` (returns nothing inside the app), `src/app/[locale]/preview/student-home/page.tsx` (`?platform=native`, like the settings preview), `scripts/verify-platform-gate.mjs` (new) as a step of the required `tsc` job, `scripts/verify-store-purchase-links.mjs` (the user-agent reading added), and `docs/store/STORE_PATH.md` (T1 done, T2's exact scope, two corrections). No schema, grant, auth config, template or env change. Rows 42, 69 and 81 are not touched, and Section 7 takes no deletions.** +**No row is edited. Outside this file: `src/lib/platform.ts` (rewritten: `platformFromHeaders`, `purchaseLinksAllowed(platform)`, `googleSignInAllowed(platform)`, the build-time `PLATFORM` and `NEXT_PUBLIC_KF_PLATFORM` gone), `src/lib/platform-server.ts` (new, `currentPlatform()` over `headers()`, for the signed-in pages only), `src/components/platform/PlatformProvider.tsx` (new, context for client components), `src/components/platform/NativePlatformHeader.tsx` (deleted), `src/components/layout/SiteChrome.tsx` (new, the public site's chrome taken out of `(site)/layout.tsx` so two layouts can render it), `src/app/[locale]/native/` (new: `layout.tsx` with `robots: noindex` and `PlatformProvider platform="native"`, `(site)/layout.tsx` rendering the chrome without the Pricing link, and seven pages that are bare re-exports of `login`, `signup`, `about`, `contact`, `privacy`, `terms`, `refund`), `src/middleware.ts` (an app request for `/` or `//pricing` → 307 to the dashboard; for the seven pages above → rewrite to the twin; a web visit to a `/native/` path → the 404 page), `src/components/layout/SiteHeader.tsx` (`showPricing`), `src/app/[locale]/dashboard/layout.tsx`, `dashboard/page.tsx`, `dashboard/settings/page.tsx`, `dashboard/knowledge/new/page.tsx`, `src/lib/home-props.ts` (`buildHrefs(locale, platform)`, `appHrefs`), `src/components/auth/GoogleButton.tsx` (returns nothing inside the app), `src/app/[locale]/preview/student-home/page.tsx` (`?platform=native`), `scripts/verify-platform-gate.mjs` (new) as a step of the required `tsc` job, `scripts/verify-store-purchase-links.mjs` (the user-agent reading added), and `docs/store/STORE_PATH.md` (T1 done, T2's exact scope, corrections). No schema, grant, auth config, template or env change. Rows 42, 69 and 81 are not touched, and Section 7 takes no deletions.** -**THE SURFACES, LISTED FROM THE CODE FIRST, THEN COVERED.** `grep` over `src/` for `/pricing`, `upgradeHref`, `checkout`, `Upgrade`, `GoogleButton` and `startOAuth` at `399106e`: (1) the site header's Pricing link (`SiteHeader.tsx:69`, on the landing and the six marketing and legal pages); (2) Settings' Upgrade (`dashboard/settings/page.tsx:65`); (3) the home's Upgrade (`home-props.ts:134` → `StudentHome`); (4) the new-subject refusal's upgrade sentence (`knowledge/new/page.tsx:66`, client side); (5) the three API refusals' upgrade lines (`limit-messages.ts:178`, already request-based); (6) `/pricing` itself with its Paddle checkout; (7) the landing's calls to action, which lead to `/pricing` through the header; (8) the Google button on `/login` and `/signup` (`GoogleButton.tsx`, the only caller of `startOAuth('google')`). The cancel-subscription card and the refund page stay: management and policy, not calls to action. Each of the eight is now hidden or unreachable inside the app, by the mechanism named in the file list above. +**THE SURFACES, LISTED FROM THE CODE FIRST, THEN COVERED.** `grep` over `src/` for `/pricing`, `upgradeHref`, `checkout`, `Upgrade`, `GoogleButton` and `startOAuth` at `399106e`: (1) the site header's Pricing link (`SiteHeader.tsx:69`, on the landing and the six marketing and legal pages); (2) Settings' Upgrade (`dashboard/settings/page.tsx:65`); (3) the home's Upgrade (`home-props.ts:134` → `StudentHome`); (4) the new-subject refusal's upgrade sentence (`knowledge/new/page.tsx:66`, client side); (5) the three API refusals' upgrade lines (`limit-messages.ts:178`, already request-based); (6) `/pricing` itself with its Paddle checkout; (7) the landing's calls to action, which lead to `/pricing` through the header; (8) the Google button on `/login` and `/signup` (`GoogleButton.tsx`, the only caller of `startOAuth('google')`). The cancel-subscription card and the refund page stay: management and policy, not calls to action. Each of the eight is hidden or unreachable inside the app, by the mechanism named in the file list above. -**THE MARKER ONLY REMOVES, BY CONSTRUCTION AND BY PROOF.** The reading is consumed by two predicates that each hide something, and by the middleware's redirect of two marketing pages to the dashboard, which `updateSession` sends to `/login` without a session. No entitlement, limit, auth or data path reads it (`grep platformFrom\|usePlatform\|currentPlatform`: only the files above). The proof renders every gated surface twice from its real .tsx (SiteHeader, GoogleButton, the login and signup pages, Settings, the student home, both locales) and asserts that every `href` in the native markup is also in the web markup, that the native markup names no `/pricing` and no Google, and that the privacy, terms, about and sign-in links stay; it drives the real `middleware.ts` with real `NextRequest`s (app: `/en`, `/ar`, `/en/pricing` → 307 to the dashboard; `/en/privacy`, `/en/terms`, `/en/login`, `/en/dashboard/settings` served; web: never redirected; a forged `KnowFlowApp/9` behaves exactly like the app); and it scans `src/` so that a `/pricing` reference, an `upgradeHref` or a Google start outside a gated line fails, that neither predicate is ever called with a literal or with no argument, that no client code sniffs `navigator.userAgent`, and that each page or layout rendering a gated surface still reads `currentPlatform()`. **Green locally: PASS, 9 surfaces x 2 locales. Red on two deliberate breaks: the Google gate removed (4 failures named) and an ungated `/pricing` link added to the footer (1 failure named). Every other proof step of the `tsc` job re-run locally: 19 of 19 pass.** +**TWO DESIGNS, ONE MEASUREMENT, THE OWNER'S OBJECTION UPHELD.** The first build of this step made every page whose markup depends on the marker read `headers()` and render per request, which took the landing, the six marketing and legal pages, login and signup off the CDN for every visitor. The owner objected before merge: web visitors and crawlers would pay for an app-only need, and a Hobby plan has hard limits. **Measured on production, 2026-10-07, five samples each, before any change:** `/en`, `/en/login`, `/en/signup` from the CDN answer in 0.18–0.20 s (one cold outlier at 0.98 s); a function-rendered response (`/api/check-limit`, 401) answers in 0.26–0.31 s warm and 0.99 s cold; a middleware-only answer (`/en/dashboard` → 307) in 0.18–0.20 s. So the first design would have cost each web visit 0.1–0.8 s on the first page and a function invocation; the plan is owner-attested Hobby (register #94, `PIVOT_PLAN.md` §9; the Vercel dashboard needs a sign-in, so not re-read today), whose allowance is 1,000,000 invocations, 4 CPU-hours and 360 GB-hours a month with no overage billing: Vercel pauses rather than charges (its limits and fluid-compute pricing pages, read 2026-10-07). **The design kept:** every web page stays prerendered and on the CDN exactly as before; each page a signed-out student can meet has a prerendered twin under `//native/`, and the middleware, which already runs on every request in both designs (Vercel: *"Because it runs globally before the cache, Routing Middleware is an effective way of providing personalization to statically generated content"*), rewrites an app request to the twin. The URL stays; the response is cached at the twin's own path; the two variants never share a cache key, so the CDN is never asked to vary on a header, which it would not honour. Next: *"When you use `NextResponse.rewrite()`, Next.js automatically propagates the required RSC rewrite headers upstream"*, so the router's own fetches land on the twin too; and, the #226/#227 lesson re-applied, the flight headers Next strips before the middleware are not what this keys on: the user agent survives. `.next/prerender-manifest.json` after the build: 49 prerendered routes, the web's `/en/login`, `/en/signup`, `/en`, `/en/privacy` (and the Arabic ones) all back in it, and the seven twins per locale beside them; `app/en/login.html` and `app/en/native/login.html` both emitted. Cost to the web: nothing. Cost of the design: seven three-line re-export files and one shared chrome component. -**CACHING, THE PART THAT WAS WRONG BEFORE THIS PR AND THE READING THAT FOUND IT.** On production before the change, `/en/login` answered `X-Nextjs-Prerender: 1`, `X-Vercel-Cache: HIT`, `Age: 3889`, `Cache-Control: public, max-age=0, must-revalidate`: a prerendered page, built once with no request, held at the CDN, carrying the Google button in its static HTML. A client-side hide would have left that HTML, and a server-side hide on a static page would have cached whichever variant was built. Vercel's CDN is not asked to vary on the marker; instead every page whose markup depends on it reads `headers()` through `currentPlatform()`, which makes the route render per request, and Next answers such a route with `Cache-Control: private, no-store`. The Next route table cannot show this: it printed `●` for every page before and after (the dashboard, which reads cookies, included). `.next/prerender-manifest.json` can: before, the landing, the legal pages, login and signup were prerendered; after, 17 routes remain prerendered and none of them is a page that reads the marker; the only HTML files emitted under `app/en/` are `forgot-password.html` and `reset-password.html`. The cost, named: the landing and the six marketing and legal pages, login and signup are served by a function instead of the CDN (TTFB on production before: `/en` 0.82 s, `/en/login` 0.24 s, `/en/privacy` 0.21 s; the after values go in the next block). Router prefetches and RSC payloads of dynamic routes are fetched per request with the same user agent, and the router cache lives in each client, so the app and a browser never share one. **The production proof after deploy is the one that counts and is recorded in the next block: both variants, both locales, the cache headers of each, and a web request made right after an app request.** +**THE MARKER ONLY REMOVES, BY CONSTRUCTION AND BY PROOF.** The reading is consumed by two predicates that each hide something, by the middleware's redirect of two marketing pages to the dashboard (which `updateSession` sends to `/login` without a session), and by its rewrite to a twin that is the same page with less in it. No entitlement, limit, auth or data path reads it. The proof renders every gated surface twice from its real .tsx (the site chrome, GoogleButton, the login and signup pages, Settings, the student home; both locales) and asserts that every `href` in the native markup is also in the web markup, that the native markup names no `/pricing` and no Google, and that the privacy, terms, about and sign-in links stay; drives the real `middleware.ts` with real `NextRequest`s (app: `/en`, `/en/pricing` → 307; the seven pages → `x-middleware-rewrite` to the twin; the dashboard and `forgot-password` → pass-through; web: never redirected, never rewritten; a web visit to `/en/native/login` → the 404 path; a forged `KnowFlowApp/9` behaves exactly like the app); and scans `src/`: a `/pricing` reference, an `upgradeHref` or a Google start outside a gated line fails; neither predicate is ever fed a literal or nothing; no client code sniffs `navigator.userAgent`; the signed-in pages still read `currentPlatform()`; no static public page, twin or chrome reads the request; the twin tree equals the middleware's list; every twin is a bare re-export of a page that exists; the landing and `/pricing` have no twin. **Green locally: PASS, 10 renders x 2 locales. Red on five deliberate breaks, each restored: the Google gate removed (4 failures), an ungated `/pricing` link in the footer (1), a twin given `export const dynamic` (1), the web site layout importing `currentPlatform` (1), `/refund` dropped from the middleware's list (2). Every other proof step of the `tsc` job passes locally (19 of 19).** -**WEB VISITORS SEE WHAT THEY SAW.** Baseline HTML of eleven public pages in both locales captured from production before the change (`/`, `/login`, `/signup`, `/pricing`, `/about`, `/privacy`, `/forgot-password`); the comparison after deploy, with build hashes masked, goes in the next block. Nothing in the web variant's markup is touched by this PR except the removal of a client component that rendered nothing (`NativePlatformHeader`). +**WEB VISITORS SEE WHAT THEY SAW, AND THE PROOF THAT COUNTS IS ON PRODUCTION.** Baseline HTML of eleven public pages in both locales captured from production before the change; after deploy: the same pages compared with build hashes masked, the cache headers of each variant (`x-vercel-cache`, `x-nextjs-prerender`), the app variant in both locales without the Google button and the Pricing link, a web request made right after an app request answered from the cache with them, a direct web visit to a twin answered 404, the router prefetch both ways, and TTFB after against the figures above. Recorded in the next block. -**STEP B'S EXACT SCOPE, FIXED BY THIS STEP.** `capacitor.config.ts` with `appId: 'com.knowflow.app'`, `server.url: 'https://tryknowflow.com'`, `server.allowNavigation: ['tryknowflow.com']`, `ios.appendUserAgent` and `android.appendUserAgent` both `'KnowFlowApp/1'`, an offline page through `server.errorPath`; the `ios/` and `android/` projects. Nothing on the server has to change for the app to be recognised. +**STEP B'S EXACT SCOPE, FIXED BY THIS STEP.** `capacitor.config.ts` with `appId: 'com.knowflow.app'`, `server.url: 'https://tryknowflow.com'`, `server.allowNavigation: ['tryknowflow.com']`, `ios.appendUserAgent` and `android.appendUserAgent` both `'KnowFlowApp/1'`, an offline page through `server.errorPath`; the `ios/` and `android/` projects. Nothing on the server has to change for the app to be recognised. One thing only the phone can show: client-side navigation inside the app between two rewritten pages (login → signup) and from a twin to the dashboard; T5 checks it first. ### 2026-10-07 - `docs/store/STORE_PATH.md` amended from Scan & Action's record: the build job and native sign-in are copied, not written; EU trader status is required even outside the EU; Android follows the iOS submission; three sessions to TestFlight and about nine to the first submission diff --git a/docs/store/STORE_PATH.md b/docs/store/STORE_PATH.md index 2471e8e..aa1a5d2 100644 --- a/docs/store/STORE_PATH.md +++ b/docs/store/STORE_PATH.md @@ -113,24 +113,15 @@ every native piece built for the shell carries over. **Built as step a (2026-10-07, below):** the token is `KnowFlowApp/`, read by `platformFromHeaders` in `src/lib/platform.ts`; the build-time flag and `NativePlatformHeader` are deleted. **A correction found while - building:** the Next route table was not enough to see the caching risk. - `/en/login` was prerendered and served from Vercel's CDN with the Google - button in its static HTML, and the table marks every page `●` whether or - not it reads the request. The pages that change on the marker now read - `headers()` and are absent from `.next/prerender-manifest.json`, which is - the reading that counts. - -## 2. Distance, item by item, in order - -Agent sessions are estimates for one focused working session each, including -the proof script every PR here carries. "Owner" steps are console steps the -agent does not take because they need the owner's credentials. - -### 2.1 To a TestFlight build on the owner's iPhone - -| # | Item | What it actually requires | Who | Size | -|---|---|---|---|---| -| T1 | **Platform at request time. DONE 2026-10-07 (step a).** | *Corrected while building:* the marker is the user-agent token `KnowFlowApp/` or `x-kf-platform: native`, read per request by `platformFromHeaders`; server components read it through `currentPlatform()` (`headers()`), client components through `PlatformProvider` from the nearest layout, never from `navigator.userAgent`. The middleware sends an app request for `/` and `//pricing` to the dashboard; **`/refund` is not redirected**: it is a policy page that names Paddle as the merchant of record, not a call to action, and the app needs its legal pages (5.1.1(i)). Hidden in the app: the header's Pricing link, Settings' Upgrade, the home's Upgrade, the new-subject refusal's upgrade sentence, the API refusals' upgrade line, the Google button on login and signup. The cost: every page that changes on the marker renders per request and leaves the CDN (the landing, the six marketing and legal pages, login, signup; the dashboard already did), because a cached variant would be served to both shells. Proof: `scripts/verify-platform-gate.mjs` (a step of the required `tsc` job) renders each surface both ways, drives the real middleware, and scans `src/` for an ungated link or button. | Agent | 1 session, spent | + building:** `/en/login` was prerendered and served from Vercel's CDN with + the Google button in its static HTML, so a client-side hide would have left + it there. The first build of step a made those pages render per request; + the owner objected (web visitors would pay for an app-only need, and a + Hobby plan has hard limits), and the second build keeps every web page + static and gives each one an app TWIN under `//native/`, chosen + by the middleware. §2.1 T1 has the measurements that decided it. + +| T1 | **Platform at request time. DONE 2026-10-07 (step a).** | The marker is the user-agent token `KnowFlowApp/` or `x-kf-platform: native`, read per request by `platformFromHeaders`. **Signed-in pages** (rendered per request already) read it through `currentPlatform()`; their client components get it through `PlatformProvider`, never from `navigator.userAgent`. **Prerendered pages** stay prerendered and on the CDN for the web; each has a twin under `src/app/[locale]/native/` (a bare re-export of the same page under a layout that hides the Pricing link and the Google button), and the middleware **rewrites** an app request for `//{login,signup,about,contact,privacy,terms,refund}` to `//native/…`, the URL unchanged, each variant at its own cache key. The landing and `/pricing` are redirected to the dashboard; `/refund` is a policy page, not a call to action, and is rewritten like the other legal pages, not redirected. A web visit straight to a `/native/` path gets the 404 page. **Measured on production before building (2026-10-07, five samples each):** a static page from the CDN answers in 0.18–0.20 s; a function-rendered response in 0.26–0.31 s warm and about 1.0 s cold; so the per-request design would have cost every web visitor and crawler 0.1–0.8 s on the landing, and every visit a function invocation against the Hobby allowance (1,000,000 invocations, 4 CPU-hours and 360 GB-hours a month, with no overage billing: Vercel pauses, it does not charge). The twin design costs the web nothing: Routing Middleware already runs on every request in both designs ("it runs globally before the cache", Vercel), and Next "automatically propagates the required RSC rewrite headers upstream" on `NextResponse.rewrite`, so the router's own fetches land on the twin. Proof: `scripts/verify-platform-gate.mjs` (a step of the required `tsc` job) renders each surface both ways, drives the real middleware (redirects, rewrites, the 404 for a direct twin visit), and scans `src/` for an ungated link or button, a twin that is not a bare re-export, a twin missing from the middleware's list, or a static page that reads the request. | Agent | 2 sessions, spent | | T2 | **Capacitor project (step b)** | **Exact scope, fixed by step a:** `capacitor.config.ts` with `appId: 'com.knowflow.app'`, `server.url: 'https://tryknowflow.com'`, `server.allowNavigation: ['tryknowflow.com']`, `ios.appendUserAgent` and `android.appendUserAgent` both `'KnowFlowApp/1'` (the server reads `KnowFlowApp/`; nothing else on the server changes), an offline page through `server.errorPath`; `@capacitor/core`, `@capacitor/ios`, `@capacitor/android`; the `ios/` project with `TARGETED_DEVICE_FAMILY = 1` (`STORE_ASSETS.md` §5), `ITSAppUsesNonExemptEncryption = NO` (HTTPS only), bundle id `com.knowflow.app` (row #119 (iii), the owner's convention); a temporary icon from the in-app mark. `android/` generated in the same PR. | Agent | 1 session | | T3 | **Owner console steps** | In App Store Connect: **Apps → +** (name, primary language Arabic, bundle id from T2, SKU); **Users and Access → Integrations → App Store Connect API → generate a new key for KnowFlow** (not Scan & Action's key, §8.4); in GitHub, create the environment `testflight` limited to deployments from `main` and put the Issuer ID, Key ID and the `.p8` contents in it as three environment secrets. The agent never sees the key. If "KnowFlow" is taken as an App Store name, choose another display name here; the bundle id is unaffected. *Corrected by §8:* the owner's iPhone is already a registered device on this team (Scan & Action, 2026-09-28), which development signing needs; nothing to do for it. | Owner | ~30 min | | T4 | **The iOS build job** | *Corrected by §8:* **copy Scan & Action's `.github/workflows/ios-testflight.yml`** and adapt it (§8.4): two jobs so the key never sits on a runner that ran npm; `macos-26`; API-key automatic signing that archives for development and re-signs for the App Store at export (Scan & Action's PRs #261 and #262 are the dead end of forcing a Distribution identity); the stale-certificate sweep, because Apple caps a team at ten Development certificates and every hosted run makes one; `testFlightInternalTestingOnly` until the submission build; `CFBundleVersion` from the run number; `Package.resolved` committed. Triggered by `workflow_dispatch` and by a push to `main` that touches the native project only; never by a pull request. | Agent | 1 session | diff --git a/scripts/verify-platform-gate.mjs b/scripts/verify-platform-gate.mjs index f28c5b8..d3386bc 100644 --- a/scripts/verify-platform-gate.mjs +++ b/scripts/verify-platform-gate.mjs @@ -4,13 +4,17 @@ * * THREE CLAIMS, EACH HELD AGAINST THE REAL CODE: * - * 1. THE READING. `platformFromHeaders` answers `native` to the user-agent - * token the shell appends (`KnowFlowApp/`) and to `x-kf-platform: - * native`, and `web` to everything else, including an iPhone Safari user - * agent and an empty request. The middleware, driven with real - * NextRequests, sends an app request for `/` and `//pricing` - * to the dashboard (307) and lets every other path through; a web request - * is never redirected. + * 1. THE READING, AND THE ROUTING. `platformFromHeaders` answers `native` + * to the user-agent token the shell appends (`KnowFlowApp/`) and to + * `x-kf-platform: native`, and `web` to everything else, including an + * iPhone Safari user agent and an empty request. The middleware, driven + * with real NextRequests: an app request for `/` and + * `//pricing` goes to the dashboard (307); an app request for a + * prerendered page with a twin (login, signup, the legal and marketing + * pages) is REWRITTEN to `//native/`, the URL unchanged; a + * web request is never redirected and never rewritten, so the web's + * cached pages are untouched; a web request straight to a `/native/` path + * is rewritten to a path no page claims, which the catch-all answers 404. * * 2. THE MARKER ONLY REMOVES. Every gated surface is RENDERED twice from its * real .tsx (react-dom/server through the project's own TypeScript, the @@ -29,7 +33,11 @@ * fails CI here. The build-time flag is gone: no `NEXT_PUBLIC_KF_PLATFORM` * anywhere in src/, and the two predicates take a platform argument * (`tsc` enforces the signature; this script enforces that nobody - * hard-codes 'web' into them). + * hard-codes 'web' into them). The twin tree `src/app/[locale]/native/` + * holds exactly the pages the middleware rewrites, each a bare re-export + * of its web page, and nothing under it reads the request, so every twin + * stays prerendered; and no static web page or layout reads the request + * either, so the web stays on the CDN. * * Tier 0: no network, no credential, no database, no app. * @@ -77,18 +85,26 @@ const { middleware } = await load('src/middleware.ts'); const ORIGIN = 'https://tryknowflow.com'; async function mw(path, ua) { const res = await middleware(new NextRequest(`${ORIGIN}${path}`, { headers: { 'user-agent': ua, accept: 'text/html' } })); - return { status: res.status, location: res.headers.get('location') }; + return { status: res.status, location: res.headers.get('location'), rewrite: res.headers.get('x-middleware-rewrite') }; } for (const locale of ['en', 'ar']) { for (const path of [`/${locale}`, `/${locale}/pricing`]) { const app = await mw(path, APP_UA); check(app.status === 307 && app.location === `${ORIGIN}/${locale}/dashboard`, `app request for ${path}: expected 307 to /${locale}/dashboard, got ${app.status} ${app.location}`); const web = await mw(path, IPHONE_UA); - check(web.status === 200 && web.location === null, `web request for ${path} must pass through, got ${web.status} ${web.location}`); + check(web.status === 200 && web.location === null && web.rewrite === null, `web request for ${path} must pass through, got ${web.status} ${web.location} ${web.rewrite}`); } - for (const path of [`/${locale}/privacy`, `/${locale}/terms`, `/${locale}/login`, `/${locale}/dashboard/settings`, `/${locale}/about`]) { + for (const page of ['/login', '/signup', '/about', '/contact', '/privacy', '/terms', '/refund']) { + const app = await mw(`/${locale}${page}`, APP_UA); + check(app.status === 200 && app.rewrite === `${ORIGIN}/${locale}/native${page}`, `app request for /${locale}${page}: expected a rewrite to /${locale}/native${page}, got ${app.status} rewrite=${app.rewrite}`); + const web = await mw(`/${locale}${page}`, IPHONE_UA); + check(web.status === 200 && web.location === null && web.rewrite === null, `web request for /${locale}${page} must be served as it is (no rewrite, no redirect), got ${web.status} ${web.location} ${web.rewrite}`); + const direct = await mw(`/${locale}/native${page}`, IPHONE_UA); + check(direct.rewrite !== null && /native-is-not-a-page/.test(direct.rewrite), `a web visit to /${locale}/native${page} must be sent to the 404, got rewrite=${direct.rewrite}`); + } + for (const path of [`/${locale}/dashboard/settings`, `/${locale}/forgot-password`, `/${locale}/dashboard/knowledge/new`]) { const app = await mw(path, APP_UA); - check(app.status === 200 && app.location === null, `app request for ${path} must be served, got ${app.status} ${app.location}`); + check(app.status === 200 && app.location === null && app.rewrite === null, `app request for ${path} must pass through, got ${app.status} ${app.location} ${app.rewrite}`); } } // The redirect target is the one page the web can also reach, and it is behind @@ -117,13 +133,21 @@ function onlyRemoves(name, web, native, { mustKeep = [] } = {}) { } globalThis.__tsxHooksPathname = '/en/privacy'; -const { SiteHeader } = await load('src/components/layout/SiteHeader.tsx'); -const headerLabels = { home: 'KnowFlow', howItWorks: 'How', pricing: 'Pricing', about: 'About', signIn: 'Sign in', getStarted: 'Start', menu: 'Menu', appearance: 'Appearance', themeDark: 'Dark', themeLight: 'Light' }; +// The two static layouts, rendered through the chrome they share: the web's +// (`(site)/layout.tsx`, showPricing) and the app's (`native/(site)/layout.tsx`). +const { SiteChrome } = await load('src/components/layout/SiteChrome.tsx'); for (const locale of ['en', 'ar']) { - const web = renderToStaticMarkup(React.createElement(SiteHeader, { locale, labels: headerLabels, showPricing: true })); - const native = renderToStaticMarkup(React.createElement(SiteHeader, { locale, labels: headerLabels, showPricing: false })); - check(web.includes(`href="/${locale}/pricing"`), `${locale} header on the web lost its Pricing link`); - onlyRemoves(`SiteHeader ${locale}`, web, native, { mustKeep: [`/${locale}/about`, `/${locale}/login`] }); + const body = React.createElement('p', null, 'page'); + const web = renderToStaticMarkup(React.createElement(SiteChrome, { locale, showPricing: true }, body)); + const native = renderToStaticMarkup(React.createElement(SiteChrome, { locale, showPricing: false }, body)); + check(web.includes(`href="/${locale}/pricing"`), `${locale} site chrome on the web lost its Pricing link`); + onlyRemoves(`SiteChrome ${locale}`, web, native, { mustKeep: [`/${locale}/about`, `/${locale}/login`, `/${locale}/privacy`, `/${locale}/terms`] }); +} +{ + const site = readFileSync(resolvePath(ROOT, 'src/app/[locale]/(site)/layout.tsx'), 'utf8'); + const twin = readFileSync(resolvePath(ROOT, 'src/app/[locale]/native/(site)/layout.tsx'), 'utf8'); + check(//.test(site), '(site)/layout.tsx no longer renders SiteChrome with showPricing'); + check(//.test(twin), 'native/(site)/layout.tsx no longer renders SiteChrome without the Pricing link'); } const { GoogleButton } = await load('src/components/auth/GoogleButton.tsx'); @@ -240,10 +264,36 @@ for (const [file, src] of files) { if (/NEXT_PUBLIC_KF_PLATFORM/.test(s)) check(false, `${file}: the build-time flag is back`); if (/navigator\.userAgent/.test(s)) check(false, `${file}: client code sniffs the user agent; the platform comes from the request through PlatformProvider`); } -// (e) Every page or layout that renders a gated surface reads the request. -for (const file of ['src/app/[locale]/(site)/layout.tsx', 'src/app/[locale]/dashboard/layout.tsx', 'src/app/[locale]/dashboard/page.tsx', 'src/app/[locale]/dashboard/settings/page.tsx', 'src/app/[locale]/login/layout.tsx', 'src/app/[locale]/signup/layout.tsx']) { +// (e) The signed-in pages, rendered per request already, read the marker +// themselves; the prerendered pages must NOT (a request read would turn them +// dynamic and take the web off the CDN), and neither may their twins. +for (const file of ['src/app/[locale]/dashboard/layout.tsx', 'src/app/[locale]/dashboard/page.tsx', 'src/app/[locale]/dashboard/settings/page.tsx']) { const src = readFileSync(resolvePath(ROOT, file), 'utf8'); - check(/await currentPlatform\(\)/.test(src), `${file} no longer reads the platform from the request (it would be prerendered as the web variant and cached)`); + check(/await currentPlatform\(\)/.test(src), `${file} no longer reads the platform from the request`); +} +const REQUEST_READS = /currentPlatform|next\/headers|platform-server/; +for (const [file, src] of files) { + const isStaticPublic = /^src\/app\/\[locale\]\/(\(site\)|native|login|signup|forgot-password|reset-password)\//.test(file) || file === 'src/components/layout/SiteChrome.tsx'; + if (isStaticPublic && REQUEST_READS.test(strip(src))) check(false, `${file} reads the request: it must stay prerendered (the web on the CDN, the twin at its own cache key)`); +} +// (f) The twin tree is exactly the middleware's list, and every twin is a bare re-export. +{ + const mwSrc = readFileSync(resolvePath(ROOT, 'src/middleware.ts'), 'utf8'); + const listed = (mwSrc.match(/const NATIVE_TWINS = new Set\(\[([^\]]*)\]\)/) || [])[1]; + check(!!listed, 'middleware.ts no longer declares NATIVE_TWINS'); + const twins = listed ? [...listed.matchAll(/'([^']+)'/g)].map((m) => m[1]).sort() : []; + const onDisk = files.map(([f]) => f).filter((f) => /^src\/app\/\[locale\]\/native\/.*\/page\.tsx$/.test(f)) + .map((f) => '/' + f.replace(/^src\/app\/\[locale\]\/native\//, '').replace(/^\(site\)\//, '').replace(/\/page\.tsx$/, '')).sort(); + check(JSON.stringify(twins) === JSON.stringify(onDisk), `the middleware's twin list ${JSON.stringify(twins)} differs from the pages under native/ ${JSON.stringify(onDisk)}`); + for (const [file, src] of files) { + if (!/^src\/app\/\[locale\]\/native\/.*\/page\.tsx$/.test(file)) continue; + const code = strip(src).trim(); + const web = file.replace('/native/', '/'); + const expected = `export { default } from '@/app/${web.replace(/^src\/app\//, '').replace(/\/page\.tsx$/, '/page')}';`; + check(code === expected, `${file} is not a bare re-export of its web page: ${code.slice(0, 120)}`); + check(files.some(([f]) => f === web), `${file} has no web twin at ${web}`); + } + check(!/(\(site\)\/)?page\.tsx/.test(onDisk.join(' ')) && !onDisk.includes('/pricing') && !onDisk.includes('/'), 'the landing and /pricing must have no twin: the app is redirected away from them'); } if (failures.length === 0) { diff --git a/src/app/[locale]/(site)/layout.tsx b/src/app/[locale]/(site)/layout.tsx index 9a415b2..a329a91 100644 --- a/src/app/[locale]/(site)/layout.tsx +++ b/src/app/[locale]/(site)/layout.tsx @@ -1,22 +1,19 @@ import type { ReactNode } from 'react'; -import { SiteHeader } from '@/components/layout/SiteHeader'; -import { SiteFooter } from '@/components/layout/SiteFooter'; -import { useTranslation, type Locale } from '@/lib/i18n'; -import { purchaseLinksAllowed } from '@/lib/platform'; -import { currentPlatform } from '@/lib/platform-server'; +import { SiteChrome } from '@/components/layout/SiteChrome'; +import type { Locale } from '@/lib/i18n'; /** - * The public site's shell (#107): the header on the landing and on the six - * marketing and legal pages, and the footer the landing used to keep to itself. + * The web's public site. WHY A ROUTE GROUP AND NOT SEVEN EDITS. `(site)` + * changes no URL — every page under it keeps the path it had. It buys the one + * thing seven copies could not: a page CANNOT be added to this part of the + * app without the header, which is how the six ended up without one. The + * signed-in and auth routes stay outside it and keep their own chrome. * - * WHY A ROUTE GROUP AND NOT SEVEN EDITS. `(site)` changes no URL — every page - * under it keeps the path it had. It buys the one thing seven copies could not: - * a page CANNOT be added to this part of the app without the header, which is - * how the six ended up without one. The signed-in and auth routes stay outside - * it and keep their own chrome. - * - * The labels are read here, on the server, and handed to the header as props, - * so the client bundle never pulls in either dictionary. + * STATIC, ON PURPOSE. This layout reads nothing from the request, so every + * page under it stays prerendered and served from the CDN exactly as before + * STORE_PATH.md step a. The app's variant of these pages, without the Pricing + * link, is `native/(site)/layout.tsx`, which the middleware rewrites an app + * request to; the chrome itself is `SiteChrome`. */ export default async function SiteLayout({ children, @@ -26,49 +23,5 @@ export default async function SiteLayout({ params: Promise<{ locale: Locale }>; }) { const { locale } = await params; - const t = useTranslation(locale); - // Which shell asked. Inside the app the header carries no Pricing link - // (Apple 3.1.1(a)). Reading the request here makes every page under this - // layout render per request instead of from the CDN, which is the cost of - // serving two variants from one build without ever caching either - // (src/lib/platform.ts, CACHING). The landing and /pricing themselves never - // reach the app: the middleware sends it to the dashboard. - const showPricing = purchaseLinksAllowed(await currentPlatform()); - - return ( - // `min-h-screen` lives HERE and nowhere below it. Each page used to carry - // its own, which under a header and a footer would have guaranteed a scroll - // on every short page — the Refund policy is four paragraphs long. -
- -
{children}
- -
- ); + return {children}; } diff --git a/src/app/[locale]/login/layout.tsx b/src/app/[locale]/login/layout.tsx deleted file mode 100644 index 745fc29..0000000 --- a/src/app/[locale]/login/layout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from 'react'; -import { currentPlatform } from '@/lib/platform-server'; -import { PlatformProvider } from '@/components/platform/PlatformProvider'; - -/** - * This layout exists for one reason: the page below is a client component - * and cannot read the request, but its Google button must know which shell - * it is in (hidden inside the app; STORE_PATH.md step a, src/lib/platform.ts). - * The platform is read from the request here and handed down through - * context, so server markup and hydration agree. - * - * Reading the request also turns this route from a prerendered page into one - * rendered per request. That is deliberate and necessary: a prerendered page - * is built once as the web variant and served from the CDN to everyone, the - * app included. The cost is one small page no longer served from cache. - */ -export default async function PlatformLayout({ children }: { children: ReactNode }) { - const platform = await currentPlatform(); - return {children}; -} diff --git a/src/app/[locale]/native/(site)/about/page.tsx b/src/app/[locale]/native/(site)/about/page.tsx new file mode 100644 index 0000000..d9bc0cd --- /dev/null +++ b/src/app/[locale]/native/(site)/about/page.tsx @@ -0,0 +1,4 @@ +// The app's variant of /about is the web page itself, rendered under +// native/(site)/layout.tsx (no Pricing link). One page, two layouts; see +// native/layout.tsx. Re-export only: nothing may be added here. +export { default } from '@/app/[locale]/(site)/about/page'; diff --git a/src/app/[locale]/native/(site)/contact/page.tsx b/src/app/[locale]/native/(site)/contact/page.tsx new file mode 100644 index 0000000..7df5a48 --- /dev/null +++ b/src/app/[locale]/native/(site)/contact/page.tsx @@ -0,0 +1,4 @@ +// The app's variant of /contact is the web page itself, rendered under +// native/(site)/layout.tsx (no Pricing link). One page, two layouts; see +// native/layout.tsx. Re-export only: nothing may be added here. +export { default } from '@/app/[locale]/(site)/contact/page'; diff --git a/src/app/[locale]/native/(site)/layout.tsx b/src/app/[locale]/native/(site)/layout.tsx new file mode 100644 index 0000000..b5ab9f2 --- /dev/null +++ b/src/app/[locale]/native/(site)/layout.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; +import { SiteChrome } from '@/components/layout/SiteChrome'; +import type { Locale } from '@/lib/i18n'; + +/** + * The app's variant of the public site's chrome: the same `SiteChrome` as + * `(site)/layout.tsx`, without the Pricing link (Apple 3.1.1(a)). Static; + * see `native/layout.tsx` for how a request reaches it. + */ +export default async function NativeSiteLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ locale: Locale }>; +}) { + const { locale } = await params; + return {children}; +} diff --git a/src/app/[locale]/native/(site)/privacy/page.tsx b/src/app/[locale]/native/(site)/privacy/page.tsx new file mode 100644 index 0000000..730ceb3 --- /dev/null +++ b/src/app/[locale]/native/(site)/privacy/page.tsx @@ -0,0 +1,4 @@ +// The app's variant of /privacy is the web page itself, rendered under +// native/(site)/layout.tsx (no Pricing link). One page, two layouts; see +// native/layout.tsx. Re-export only: nothing may be added here. +export { default } from '@/app/[locale]/(site)/privacy/page'; diff --git a/src/app/[locale]/native/(site)/refund/page.tsx b/src/app/[locale]/native/(site)/refund/page.tsx new file mode 100644 index 0000000..9418a31 --- /dev/null +++ b/src/app/[locale]/native/(site)/refund/page.tsx @@ -0,0 +1,4 @@ +// The app's variant of /refund is the web page itself, rendered under +// native/(site)/layout.tsx (no Pricing link). One page, two layouts; see +// native/layout.tsx. Re-export only: nothing may be added here. +export { default } from '@/app/[locale]/(site)/refund/page'; diff --git a/src/app/[locale]/native/(site)/terms/page.tsx b/src/app/[locale]/native/(site)/terms/page.tsx new file mode 100644 index 0000000..05c4789 --- /dev/null +++ b/src/app/[locale]/native/(site)/terms/page.tsx @@ -0,0 +1,4 @@ +// The app's variant of /terms is the web page itself, rendered under +// native/(site)/layout.tsx (no Pricing link). One page, two layouts; see +// native/layout.tsx. Re-export only: nothing may be added here. +export { default } from '@/app/[locale]/(site)/terms/page'; diff --git a/src/app/[locale]/native/layout.tsx b/src/app/[locale]/native/layout.tsx new file mode 100644 index 0000000..8379f12 --- /dev/null +++ b/src/app/[locale]/native/layout.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react'; +import type { Metadata } from 'next'; +import { PlatformProvider } from '@/components/platform/PlatformProvider'; + +/** + * THE APP'S VARIANT OF THE STATIC PAGES (STORE_PATH.md step a). + * + * The app is a native shell over tryknowflow.com, and inside it the pages must + * carry no purchase link and no Google button (Apple 3.1.1(a); Google refuses + * OAuth in an embedded web view). The pages a signed-out student meets are + * prerendered and served from the CDN, so they cannot read the request; the + * middleware reads it instead and REWRITES an app request for `//login` + * to `//native/login`, and so on for signup and the legal pages. The + * browser URL stays `//login`; the response is the page under this + * folder, prerendered like its web twin and cached at its own path. The two + * variants never share a cache key, so neither can be served to the other + * shell, and the web's pages are untouched. + * + * Every page here RE-EXPORTS its web twin: there is one page, rendered under + * two layouts. This layout hands `'native'` to the client components through + * context (the Google button reads it); `native/(site)/layout.tsx` renders the + * site chrome without the Pricing link. Nothing under this folder may read the + * request (`currentPlatform()`, `headers()`, `cookies()`): that would turn the + * variant dynamic, and `scripts/verify-platform-gate.mjs` fails on it. + * + * A direct visit to a `/native/` path without the app's marker is answered + * with the 404 page by the middleware; with the marker it is the same content + * as the rewrite. Not indexed either way. + */ +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; + +export default function NativeVariantLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/[locale]/native/login/page.tsx b/src/app/[locale]/native/login/page.tsx new file mode 100644 index 0000000..f745bf1 --- /dev/null +++ b/src/app/[locale]/native/login/page.tsx @@ -0,0 +1,4 @@ +// The app's variant of /login is the web page itself, rendered under +// native/layout.tsx, whose PlatformProvider hides the Google button. One page, +// two layouts; re-export only: nothing may be added here. +export { default } from '@/app/[locale]/login/page'; diff --git a/src/app/[locale]/native/signup/page.tsx b/src/app/[locale]/native/signup/page.tsx new file mode 100644 index 0000000..60e6625 --- /dev/null +++ b/src/app/[locale]/native/signup/page.tsx @@ -0,0 +1,4 @@ +// The app's variant of /signup is the web page itself, rendered under +// native/layout.tsx, whose PlatformProvider hides the Google button. One page, +// two layouts; re-export only: nothing may be added here. +export { default } from '@/app/[locale]/signup/page'; diff --git a/src/app/[locale]/signup/layout.tsx b/src/app/[locale]/signup/layout.tsx deleted file mode 100644 index 745fc29..0000000 --- a/src/app/[locale]/signup/layout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from 'react'; -import { currentPlatform } from '@/lib/platform-server'; -import { PlatformProvider } from '@/components/platform/PlatformProvider'; - -/** - * This layout exists for one reason: the page below is a client component - * and cannot read the request, but its Google button must know which shell - * it is in (hidden inside the app; STORE_PATH.md step a, src/lib/platform.ts). - * The platform is read from the request here and handed down through - * context, so server markup and hydration agree. - * - * Reading the request also turns this route from a prerendered page into one - * rendered per request. That is deliberate and necessary: a prerendered page - * is built once as the web variant and served from the CDN to everyone, the - * app included. The cost is one small page no longer served from cache. - */ -export default async function PlatformLayout({ children }: { children: ReactNode }) { - const platform = await currentPlatform(); - return {children}; -} diff --git a/src/components/layout/SiteChrome.tsx b/src/components/layout/SiteChrome.tsx new file mode 100644 index 0000000..818b123 --- /dev/null +++ b/src/components/layout/SiteChrome.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; +import { SiteHeader } from '@/components/layout/SiteHeader'; +import { SiteFooter } from '@/components/layout/SiteFooter'; +import { useTranslation, type Locale } from '@/lib/i18n'; + +/** + * The public site's shell (#107): the header on the landing and on the six + * marketing and legal pages, and the footer the landing used to keep to itself. + * + * ONE COMPONENT, TWO LAYOUTS (STORE_PATH.md step a). `(site)/layout.tsx` + * renders it with `showPricing` for the web; `native/(site)/layout.tsx` + * renders it without, for the pages the app's shell is rewritten to. Both + * layouts are static and prerendered: the choice between them is the + * middleware's, made per request from the app's user-agent marker, and each + * variant lives at its own path and so at its own cache key + * (`src/lib/platform.ts`, CACHING). Nothing here reads the request. + * + * The labels are read here, on the server, and handed to the header as props, + * so the client bundle never pulls in either dictionary. + */ +export function SiteChrome({ locale, showPricing, children }: { locale: Locale; showPricing: boolean; children: ReactNode }) { + const t = useTranslation(locale); + + return ( + // `min-h-screen` lives HERE and nowhere below it. Each page used to carry + // its own, which under a header and a footer would have guaranteed a scroll + // on every short page — the Refund policy is four paragraphs long. +
+ +
{children}
+ +
+ ); +} diff --git a/src/lib/platform.ts b/src/lib/platform.ts index b0d2b08..1e447a6 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -33,14 +33,25 @@ * every web student would cost revenue silently, while the app sets its * marker explicitly and the gate script checks the reading. * - * CACHING. A page whose output depends on the marker must be rendered per - * request: a static page is built once, with no request and so as the web - * variant, and the CDN would serve it to the app. Every page that reads the - * marker does so through `currentPlatform()` (`src/lib/platform-server.ts`), - * which reads `headers()` and thereby opts the route out of static - * rendering; Next then answers with `Cache-Control: private, no-store`, so - * no shared cache ever holds either variant. The proof is on production, not - * in a unit test: `STORE_PATH.md` and Section 7 record the headers read. + * CACHING. A page whose output depends on the marker must never be cached + * under a key both shells share. Two cases, two mechanisms: + * + * - THE SIGNED-IN PAGES are rendered per request already (they read the + * session cookie), so they read the marker themselves through + * `currentPlatform()` (`src/lib/platform-server.ts`) and nothing is cached. + * - THE PRERENDERED PAGES a signed-out student meets (login, signup, the + * legal and marketing pages) stay prerendered and on the CDN for the web, + * untouched. Each has a TWIN under `src/app/[locale]/native/`, a second + * prerendered copy of the same page under a layout that hides the Pricing + * link and hands `'native'` to the Google button; the middleware rewrites + * an app request to the twin, so the two variants live at two paths and + * two cache keys, and the browser URL stays the same. No page there reads + * the request, which `scripts/verify-platform-gate.mjs` enforces, and the + * CDN is never asked to vary on a header, which Vercel would not honour. + * + * The proof is on production, not in a unit test: `STORE_PATH.md` and Section + * 7 record the headers read for both variants and a web request made right + * after an app request. */ export type Platform = 'web' | 'native'; diff --git a/src/middleware.ts b/src/middleware.ts index 138570f..81e2e8b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -63,6 +63,13 @@ function isPageLoad(request: NextRequest): boolean { return !/prefetch|prerender|preview/i.test(purpose); } +/** + * The prerendered pages that have an app twin under `//native/`. + * Exactly the files in `src/app/[locale]/native/`; `verify-platform-gate.mjs` + * holds the two lists equal. + */ +const NATIVE_TWINS = new Set(['/login', '/signup', '/about', '/contact', '/privacy', '/terms', '/refund']); + /** The locale a path carries, or null. */ function pathLocale(pathname: string): Locale | null { return locales.find((l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`) ?? null; @@ -81,27 +88,48 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(url, 307); } - // 2. The app never shows the marketing root or the pricing page: both are - // calls to action toward a purchase outside in-app purchase (Apple - // 3.1.1(a); docs/store/STORE_PATH.md step a). A request carrying the - // app's marker (src/lib/platform.ts) is sent to the dashboard, which - // `updateSession` below bounces to /login when there is no session; so - // this grants nothing, it only takes a page away. A forged marker in a - // normal browser costs that browser the landing and /pricing, nothing - // else. Every other page (privacy, terms, the app itself) is served as - // it is, with its own gated surfaces hidden. - if (platformFromHeaders((name) => request.headers.get(name)) === 'native') { - const rest = pathname.slice(`/${locale}`.length); - if (rest === '' || rest === '/' || rest === '/pricing' || rest === '/pricing/') { - const url = request.nextUrl.clone(); - url.pathname = `/${locale}/dashboard`; - url.search = ''; - return NextResponse.redirect(url, 307); - } + // 2. WHICH SHELL ASKED (src/lib/platform.ts; docs/store/STORE_PATH.md step a). + // The app is a native shell over this site and marks every request with + // `KnowFlowApp/` in its user agent. Three things follow, and each only + // TAKES something away, so a forged marker in a browser costs that + // browser the same and grants nothing: + // - the marketing root and /pricing, calls to action toward a purchase + // outside in-app purchase (Apple 3.1.1(a)), are sent to the dashboard, + // which `updateSession` bounces to /login without a session; + // - the prerendered pages a signed-out student meets (login, signup, the + // legal and marketing pages) are REWRITTEN to their `/native/` twin, a + // second prerendered copy without the Pricing link and the Google + // button. The URL stays; the response is cached at the twin's own + // path, so the web's cached pages are never touched and neither + // variant can be served to the other shell. Next forwards the RSC + // headers upstream on a rewrite, so the router's own fetches land on + // the twin too; + // - a direct visit to a `/native/` path without the marker gets the 404 + // page (the twin is not a second public URL); with the marker it is + // served as it is. + // The signed-in pages read the marker themselves (`currentPlatform()`): + // they are rendered per request already, so nothing is cached there. + const native = platformFromHeaders((name) => request.headers.get(name)) === 'native'; + const rest = pathname.slice(`/${locale}`.length).replace(/\/$/, ''); + if (native && (rest === '' || rest === '/pricing')) { + const url = request.nextUrl.clone(); + url.pathname = `/${locale}/dashboard`; + url.search = ''; + return NextResponse.redirect(url, 307); } + let rewriteTo: string | null = null; + if (native && NATIVE_TWINS.has(rest)) rewriteTo = `/${locale}/native${rest}`; + if (!native && (rest === '/native' || rest.startsWith('/native/'))) rewriteTo = `/${locale}/native-is-not-a-page${rest}`; // 3. Supabase session update - const response = await updateSession(request); + let response = await updateSession(request); + if (rewriteTo && !response.headers.has('location')) { + const url = request.nextUrl.clone(); + url.pathname = rewriteTo; + const rewritten = NextResponse.rewrite(url, { request: { headers: request.headers } }); + for (const c of response.cookies.getAll()) rewritten.cookies.set(c); + response = rewritten; + } // 4. Remember the language of the page being opened. Every page carries its // locale in the path, so the switcher needs no client code and no route of