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..510ca5b 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 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()`, 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 hidden or unreachable inside the app, by the mechanism named in the file list above. + +**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. + +**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, 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. 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 **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..aa1a5d2 100644 --- a/docs/store/STORE_PATH.md +++ b/docs/store/STORE_PATH.md @@ -110,19 +110,19 @@ 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. - -## 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** | 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 | + **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:** `/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 | | 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..d3386bc --- /dev/null +++ b/scripts/verify-platform-gate.mjs @@ -0,0 +1,305 @@ +/** + * 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, 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 + * 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). 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. + * + * 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'), 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.rewrite === null, `web request for ${path} must pass through, got ${web.status} ${web.location} ${web.rewrite}`); + } + 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.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 +// 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'; +// 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 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'); +{ + 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 (/ 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) { + console.log('PASS: the marker is read from the request, only removes, and every gated surface is covered.'); + process.exit(0); +} +console.log(`FAIL: ${failures.length} problem(s)`); +for (const f of failures) console.log(' ! ' + f); +process.exit(1); diff --git a/scripts/verify-store-purchase-links.mjs b/scripts/verify-store-purchase-links.mjs index fbbf3f4..1b99bc7 100644 --- a/scripts/verify-store-purchase-links.mjs +++ b/scripts/verify-store-purchase-links.mjs @@ -70,6 +70,11 @@ if (platformMod) { check(platformMod.platformFromRequest(req('native')) === 'native', 'x-kf-platform: native is not read as native'); check(platformMod.platformFromRequest(req(null)) === 'web', 'a request with no header must be web'); check(platformMod.platformFromRequest(req('anything')) === 'web', 'an unknown header value must be web'); + // The app's own marker, the user-agent token (STORE_PATH.md step a; the full + // reading and the middleware are scripts/verify-platform-gate.mjs). + const ua = (v) => ({ 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..a329a91 100644 --- a/src/app/[locale]/(site)/layout.tsx +++ b/src/app/[locale]/(site)/layout.tsx @@ -1,20 +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 { 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, @@ -24,41 +23,5 @@ export default async function SiteLayout({ params: Promise<{ locale: Locale }>; }) { const { locale } = await params; - 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}
- -
- ); + return {children}; } diff --git a/src/app/[locale]/dashboard/knowledge/new/page.tsx b/src/app/[locale]/dashboard/knowledge/new/page.tsx index f0614c2..d37bf9f 100644 --- a/src/app/[locale]/dashboard/knowledge/new/page.tsx +++ b/src/app/[locale]/dashboard/knowledge/new/page.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'; import { Input, buttonVariants } from '@/components/ui'; import { useTranslation, Locale, resolveLocale } from '@/lib/i18n'; import { purchaseLinksAllowed } from '@/lib/platform'; +import { usePlatform } from '@/components/platform/PlatformProvider'; import { KB_LANGUAGES, type KbLanguage } from '@/types'; const fieldClass = @@ -36,6 +37,10 @@ export default function NewKnowledgeBasePage({ const { locale } = use(params); const safeLocale: Locale = resolveLocale(locale); const t = useTranslation(safeLocale); + // Which shell this page is in, from the request through the dashboard + // layout's PlatformProvider: inside the app the limit refusal carries no + // upgrade sentence (Apple 3.1.1(a)). + const platform = usePlatform(); const router = useRouter(); const supabase = createClient(); const [name, setName] = useState(''); @@ -63,7 +68,7 @@ export default function NewKnowledgeBasePage({ // the copy can never drift from the enforced number. A Pro user never sees // the free-plan / upgrade wording. const template = tier === 'pro' ? t.dashboard.newKb.errorLimitPro : t.dashboard.newKb.errorLimitFree; - const upgrade = tier !== 'pro' && purchaseLinksAllowed() ? ' ' + t.dashboard.newKb.errorLimitUpgrade : ''; + const upgrade = tier !== 'pro' && purchaseLinksAllowed(platform) ? ' ' + t.dashboard.newKb.errorLimitUpgrade : ''; setError(template.replace('{limit}', String(limit)) + upgrade); setLoading(false); return; diff --git a/src/app/[locale]/dashboard/layout.tsx b/src/app/[locale]/dashboard/layout.tsx index 677fba8..1498e54 100644 --- a/src/app/[locale]/dashboard/layout.tsx +++ b/src/app/[locale]/dashboard/layout.tsx @@ -7,6 +7,8 @@ import { getEntitlement } from '@/lib/entitlement'; import { PASSWORD_REPLACED_COOKIE } from '@/lib/auth/password-replaced'; import { redirect } from 'next/navigation'; import { Locale, useTranslation, resolveLocale } from '@/lib/i18n'; +import { currentPlatform } from '@/lib/platform-server'; +import { PlatformProvider } from '@/components/platform/PlatformProvider'; export default async function DashboardLayout({ children, @@ -43,6 +45,11 @@ export default async function DashboardLayout({ const passwordReplaced = cookieStore.get(PASSWORD_REPLACED_COOKIE)?.value === '1'; + // Which shell asked, handed to the client components below (the new-subject + // page's refusal sentence) through context, so server markup and hydration + // agree. Apple 3.1.1(a); src/lib/platform.ts. + const platform = await currentPlatform(); + const labels = { dashboard: t.dashboard.nav.dashboard, knowledge: t.dashboard.nav.knowledge, @@ -55,6 +62,7 @@ export default async function DashboardLayout({ }; return ( + {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]/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]/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({