Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<n>). 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)
Expand Down
14 changes: 14 additions & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<n>` 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 `/<locale>` or `/<locale>/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 `/<locale>/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.**
Expand Down
26 changes: 13 additions & 13 deletions docs/store/STORE_PATH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<n>`,
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 `/<locale>/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/<n>` 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 `/<locale>/{login,signup,about,contact,privacy,terms,refund}` to `/<locale>/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/<digit>`; 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 |
Expand Down
Loading
Loading