From c72086550d49373396daaa82bf75001bafd792d1 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 14:35:00 +0200 Subject: [PATCH] docs: extract port-worthy reference from legacy code before full cleanup --- docs/reference/legacy-backend.md | 543 ++++++++++++++++++ docs/reference/legacy-lessons-and-gotchas.md | 290 ++++++++++ docs/reference/legacy-mobile-app.md | 551 +++++++++++++++++++ 3 files changed, 1384 insertions(+) create mode 100644 docs/reference/legacy-backend.md create mode 100644 docs/reference/legacy-lessons-and-gotchas.md create mode 100644 docs/reference/legacy-mobile-app.md diff --git a/docs/reference/legacy-backend.md b/docs/reference/legacy-backend.md new file mode 100644 index 0000000..efce7e6 --- /dev/null +++ b/docs/reference/legacy-backend.md @@ -0,0 +1,543 @@ +# Legacy backend reference (`apps/web`) + +> Captured from the inherited Create / Anything export before the legacy code is removed. +> Source of truth at capture time: `apps/web/src/app/api/*`, `apps/web/src/app/api/utils/sql.ts`, +> `apps/web/src/lib/auth.ts`, `apps/web/db/schema.sql`. +> +> This is a **port reference** for the new `apps/api` (Hono + better-auth + Drizzle + libsql/SQLite) +> backend. It records what the old Next.js 16 backend actually did, with real SQL/code quoted where +> the wording is load-bearing. Nothing here prescribes the new implementation; it documents the +> contract the mobile app (`apps/mobile`) was built against, so behaviour can be preserved. + +--- + +## 1. Overview + +The legacy backend was a **Next.js 16 App Router** app whose only real job was to serve the +`/api/*` HTTP surface that `apps/mobile` calls. The DB was **Neon serverless Postgres**, reached +**only** from `apps/web` via `DATABASE_URL`. Auth was **better-auth** (cookie sessions for web, +`Authorization: Bearer ` for mobile). + +Two app tables: `production_tasks` and `time_logs`. Auth tables (better-auth user/session/account/ +verification) were managed separately by the better-auth CLI and are **not** in the repo schema. + +A worker picks an insole type (`Type zool`: Kurk / Berk / 3D), a handling/task, and a count +(`Aantal zolen`, default 2), runs a stopwatch, and on stop the session is POSTed to `/api/logs`. +History reads `GET /api/logs`; CSV comes from `GET /api/export`; settings manage tasks via +`/api/tasks*`. + +--- + +## 2. Data model + +Reverse-engineered schema (`apps/web/db/schema.sql` — there was no shipped migration). Postgres +dialect. Note the new backend is SQLite, so `text[]`, `GENERATED ALWAYS AS IDENTITY`, and +`timestamptz` will need equivalents (e.g. JSON/CSV text column for `insole_types`, integer PK +autoincrement, ISO-8601 text or epoch for timestamps). + +### `production_tasks` — the handelingen (tasks) per zooltype + +| Column | Type | Notes | +|---|---|---| +| `id` | `integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY` | auto PK | +| `name` | `text NOT NULL` | task / handeling name | +| `insole_types` | `text[] NOT NULL DEFAULT ARRAY['Kurk','Berk','3D']::text[]` | subset of `Kurk` / `Berk` / `3D`; which zooltypes this task applies to | +| `created_at` | `timestamptz NOT NULL DEFAULT now()` | | + +```sql +CREATE TABLE IF NOT EXISTS production_tasks ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL, + insole_types text[] NOT NULL DEFAULT ARRAY['Kurk', 'Berk', '3D']::text[], + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +### `time_logs` — one finished stopwatch session + +| Column | Type | Notes | +|---|---|---| +| `id` | `integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY` | auto PK | +| `task_id` | `integer NOT NULL REFERENCES production_tasks(id) ON DELETE CASCADE` | FK → `production_tasks` | +| `start_time` | `timestamptz NOT NULL` | stopwatch start (ISO from client) | +| `end_time` | `timestamptz` | nullable; stopwatch stop | +| `duration_seconds` | `integer NOT NULL DEFAULT 0` | elapsed seconds (client-counted, not derived from start/end) | +| `pair_count` | `integer NOT NULL DEFAULT 2` | "number of insoles" for the session (`Aantal zolen`) | +| `insole_type` | `text` | nullable; `'Kurk' \| 'Berk' \| '3D'` | +| `notes` | `text` | nullable; never written by the mobile app | +| `created_at` | `timestamptz NOT NULL DEFAULT now()` | | + +```sql +CREATE TABLE IF NOT EXISTS time_logs ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_id integer NOT NULL REFERENCES production_tasks(id) ON DELETE CASCADE, + start_time timestamptz NOT NULL, + end_time timestamptz, + duration_seconds integer NOT NULL DEFAULT 0, + pair_count integer NOT NULL DEFAULT 2, + insole_type text, + notes text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS time_logs_task_id_idx ON time_logs (task_id); +CREATE INDEX IF NOT EXISTS time_logs_start_time_idx ON time_logs (start_time DESC); +``` + +### Relationships & cascade behaviour + +- `time_logs.task_id → production_tasks.id`, `ON DELETE CASCADE` in the schema. +- The legacy `DELETE /api/tasks/:id` route **does not rely on the cascade** — it deletes the logs + first, explicitly, then the task (see §3). The new backend should reproduce this *behaviour* + (deleting a task removes its logs) whether via FK cascade, an explicit delete, or both. +- `duration_seconds` is authoritative for elapsed time — it is the client's stopwatch counter, not + `end_time - start_time`. Pause time means the two can differ. Preserve this: the CSV "Total + Duration" formats `duration_seconds`, not the wall-clock delta. + +### Default-value notes worth porting + +- A task with no/empty `insole_types` defaults to all three: `['Kurk','Berk','3D']` — enforced both + in the DB default **and** in the route code (POST/PUT tasks). +- On `POST /api/logs`, missing `pair_count` defaults to `2`, missing `insole_type` defaults to + `'Kurk'`, missing `notes` defaults to `null` (route-level coalescing, see §3). + +--- + +## 3. API surface + +All routes are Next.js App Router handlers under `apps/web/src/app/api/`. They return +`Response.json(...)` (the export route returns `text/csv`). **None of the data routes check a +session** — tasks/logs/export are open; only `/api/session` and `/api/auth/token` require auth. + +### `GET /api/tasks` — list tasks + +- File: `api/tasks/route.ts` +- Request: none. +- Response: JSON array of full `production_tasks` rows (`SELECT *`), ordered by `name ASC`. +- SQL: + ```sql + SELECT * FROM production_tasks ORDER BY name ASC + ``` + +### `POST /api/tasks` — create task + +- File: `api/tasks/route.ts` +- Request body: `{ name: string, insole_types?: string[] }`. +- Validation: `name` required → `400 { error: 'Name is required' }` if missing. +- Default: empty/non-array `insole_types` → `['Kurk','Berk','3D']`. +- Response: the inserted row (`RETURNING *`). +- SQL: + ```sql + INSERT INTO production_tasks (name, insole_types) + VALUES (${name}, ${types}) + RETURNING * + ``` + +### `PUT /api/tasks/:id` — update task + +- File: `api/tasks/[id]/route.ts` +- Request body: `{ name: string, insole_types?: string[] }`. +- Validation: `name` required → `400`. Not found (no row updated) → `404 { error: 'Task not found' }`. +- Default: same `insole_types` fallback as POST. +- Response: the updated row (`RETURNING *`). +- SQL: + ```sql + UPDATE production_tasks + SET name = ${name}, insole_types = ${types} + WHERE id = ${id} + RETURNING * + ``` + +### `DELETE /api/tasks/:id` — delete task (and its logs) + +- File: `api/tasks/[id]/route.ts` +- Request: none. +- Response: `{ success: true }`. +- Deletes logs first, then the task (two statements): + ```sql + DELETE FROM time_logs WHERE task_id = ${id} + DELETE FROM production_tasks WHERE id = ${id} + ``` + +### `GET /api/logs` — history list + +- File: `api/logs/route.ts` +- Request: none. +- Response: JSON array, joined to task name, **newest first** (`start_time DESC`). +- Fields returned: `id, task_name, task_id, start_time, end_time, duration_seconds, pair_count, insole_type, notes, created_at`. +- SQL: + ```sql + SELECT + tl.id, + pt.name AS task_name, + tl.task_id, + tl.start_time, + tl.end_time, + tl.duration_seconds, + tl.pair_count, + tl.insole_type, + tl.notes, + tl.created_at + FROM time_logs tl + JOIN production_tasks pt ON tl.task_id = pt.id + ORDER BY tl.start_time DESC + ``` + +### `POST /api/logs` — save a finished session + +- File: `api/logs/route.ts` +- Request body (this is exactly what `apps/mobile` `(tabs)/index.tsx` sends on Stop & Save): + ```json + { + "task_id": "", + "start_time": "", + "end_time": "", + "duration_seconds": "", + "pair_count": "", + "insole_type": "<'Kurk'|'Berk'|'3D', optional, default 'Kurk'>", + "notes": "" + } + ``` + Mobile call site (`apps/mobile/src/app/(tabs)/index.tsx`) sends `task_id, start_time, end_time, + duration_seconds, pair_count, insole_type` — `notes` is never sent by the client. +- Validation: requires `task_id`, `start_time`, `end_time`, and `duration_seconds !== undefined`, + else `400 { error: 'Missing required fields' }`. (Note: `duration_seconds: 0` is valid — the check + is `=== undefined`, not falsy.) +- Defaults applied in the route: `pair_count ?? 2`, `insole_type ?? 'Kurk'`, `notes ?? null`. +- Response: the inserted row (`RETURNING *`). +- SQL: + ```sql + INSERT INTO time_logs (task_id, start_time, end_time, duration_seconds, pair_count, insole_type, notes) + VALUES (${task_id}, ${start_time}, ${end_time}, ${duration_seconds}, ${pair_count ?? 2}, ${insole_type ?? 'Kurk'}, ${notes ?? null}) + RETURNING * + ``` + +### `GET /api/export` — CSV download + +- File: `api/export/route.ts` +- Detailed in §4 below. + +### `GET /api/session` — current session (auth) + +- File: `api/session/route.ts` +- Reads the better-auth session from request headers; `401 { error: 'Unauthorized' }` if none. +- Response: `{ user: session.user, session: session.session }`. +- Code: + ```ts + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ user: session.user, session: session.session }); + ``` + +### Auth routes (`/api/auth/*`) — platform-managed + +These carry `⚠ ANYTHING PLATFORM — DO NOT REWRITE` headers; their contracts are consumed by the +mobile `AuthWebView`. Summarised for porting; see §5 for the better-auth config. + +- **`GET|POST /api/auth/[...all]`** — better-auth catch-all via `toNextJsHandler(auth)`. Wires every + better-auth endpoint (`/sign-up/email`, `/sign-in/email`, `/get-session`, …). Do not hand-roll + these paths. +- **`GET /api/auth/token`** — mobile token exchange. `401` if no session; else returns the exact + shape the mobile `AuthWebView.tsx` parses: + ```json + { "jwt": "", "user": { "id": "...", "email": "...", "name": "..." } } + ``` +- **`GET /api/auth/expo-web-success`** — Expo-web postMessage bridge. Returns an **HTML page** (not + JSON) whose inline script posts to `window.parent`: + - success: `{ type: 'AUTH_SUCCESS', jwt: , user: { id, email, name } }` + - failure: `{ type: 'AUTH_ERROR', error: 'Unauthorized' }` + The mobile iframe (`apps/mobile/src/utils/auth/AuthWebView.tsx`) loads + `${proxyURL}/account/?callbackUrl=/api/auth/expo-web-success` and listens for exactly that + message shape. +- **`GET /api/__create/check-social-secrets?provider=google|apple`** — dev-only helper. `404` + unless `NEXT_PUBLIC_CREATE_ENV === 'DEVELOPMENT'`; `400` on unknown provider; else + `{ provider, missing: string[] }` listing which OAuth env vars are unset. Builder-preview only, + almost certainly not worth porting. + +### Error handling pattern (all data routes) + +Every route wraps its body in `try/catch`, `console.error(error)`, and returns a `500` with a +route-specific message (`'Failed to fetch tasks'`, `'Failed to create task'`, +`'Failed to update task'`, `'Failed to delete task'`, `'Failed to fetch logs'`, +`'Failed to save log'`, `'Failed to export data'`). + +--- + +## 4. CSV export format (`GET /api/export`) + +Returns `text/csv; charset=utf-8` with +`Content-Disposition: attachment; filename="insole-production-report.csv"`. + +Source query — same join as `/api/logs` but ordered **`start_time ASC`** (oldest first, opposite of +the history list): + +```sql +SELECT + tl.id, + pt.name as task_name, + tl.start_time, + tl.end_time, + tl.duration_seconds, + tl.pair_count, + tl.insole_type, + tl.notes +FROM time_logs tl +JOIN production_tasks pt ON tl.task_id = pt.id +ORDER BY tl.start_time ASC +``` + +### Columns (in order) + +| # | Header | Source / formatting | +|---|---|---| +| 1 | `ID` | `log.id` | +| 2 | `Task` | `log.task_name` | +| 3 | `Insole Type` | `log.insole_type ?? 'Kurk'` | +| 4 | `No. of Insoles` | `log.pair_count ?? 2` | +| 5 | `Date` | `start_time` → `toLocaleDateString('nl-BE', { day:'2-digit', month:'2-digit', year:'numeric' })` | +| 6 | `Total Duration` | `duration_seconds` formatted `HH:MM:SS` (zero-padded; hours can exceed 99) | +| 7 | `Start Time` | `start_time` → `toLocaleTimeString('nl-BE', { hour:'2-digit', minute:'2-digit', second:'2-digit' })` | +| 8 | `End Time` | `end_time` → same time format, or `''` if `end_time` is null | + +Formatting details to preserve: + +- **Locale `nl-BE`** for both date and time. Date is `dd/mm/yyyy`, time is 24-hour `HH:MM:SS`. + (Server-side `toLocale*` uses the server's timezone — a known fragility worth noting when porting; + the new backend should decide explicitly which timezone to format in.) +- **Duration** is computed from `duration_seconds`, **not** from `end_time - start_time`: + ```ts + const totalSeconds = log.duration_seconds || 0; + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const totalFormatted = + `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(seconds).padStart(2,'0')}`; + ``` +- **Quoting:** every cell (and every header) is wrapped with a `quote()` that double-quotes the + value and escapes embedded `"` by doubling it — standard CSV escaping: + ```ts + const quote = (value) => `"${String(value).replace(/"/g, '""')}"`; + ``` +- **Line endings:** rows joined with `\n` (LF, not CRLF). Header row first, then data rows. +- No UTF-8 BOM is emitted (relevant if Excel mangles accented characters — consider adding one in the + port if needed). + +--- + +## 5. better-auth configuration (`apps/web/src/lib/auth.ts`) + +⚠ Platform-managed file. The new backend uses better-auth too, so these specifics are the most +directly portable. Each piece below is documented as load-bearing by the file's own header comment. + +### DB connection + +Postgres via a `@neondatabase/serverless` `Pool` with the WebSocket constructor set to `ws`: + +```ts +neonConfig.webSocketConstructor = ws; +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +// ... +export const auth = betterAuth({ database: pool, /* ... */ }); +``` +In the SQLite port this becomes the libsql/Drizzle adapter instead of a Neon `Pool`. + +### Email + password, with argon2 compatibility + +```ts +emailAndPassword: { + enabled: true, + requireEmailVerification: false, + password: { verify: verifyCompatiblePassword }, +} +``` +`verifyCompatiblePassword` falls back to **argon2** verification when a stored hash starts with +`$argon2`, otherwise uses better-auth's default `verifyPassword`. This exists so users created under +an older argon2-based scheme still authenticate: + +```ts +async function verifyCompatiblePassword({ hash, password }) { + if (hash.startsWith('$argon2')) return argon2Verify({ hash, password }); + return verifyPassword({ hash, password }); +} +``` +(Only relevant if migrating existing user rows; a greenfield user table can use better-auth's +default scrypt hashing and drop this shim.) + +### `hooks.before` — name backfill on signup (load-bearing) + +better-auth's `/sign-up/email` schema requires `name`. The mobile signup form collects only +email+password, so a middleware backfills `name` from the email local-part. Removing this broke +**every** signup with `[body.name]` validation errors (per the header comment): + +```ts +hooks: { + before: createAuthMiddleware(async (ctx) => { + if (ctx.path !== '/sign-up/email') return; + const body = ctx.body as { email?: unknown; name?: unknown } | undefined; + if (!body || typeof body.email !== 'string') return; + if (typeof body.name === 'string' && body.name.trim().length > 0) return; + const derived = body.email.split('@')[0]; + body.name = derived && derived.length > 0 ? derived : 'User'; + }), +} +``` +**Port this** if the new signup UI likewise omits a name field. + +### `bearer()` plugin — mobile token auth (load-bearing) + +```ts +plugins: [bearer()], +``` +Enables `Authorization: Bearer ` so the mobile WebView (which can't carry cookies) +authenticates API calls with the token from `/api/auth/token`. Required for mobile. + +### Cookies — `sameSite:'none'` for iframes (load-bearing) + +Required for the Create preview / mobile iframe; do not change without understanding the iframe +constraint: + +```ts +advanced: { + cookiePrefix: 'better-auth', + defaultCookieAttributes: { + sameSite: 'none', // Required for iframes + secure: true, + httpOnly: true, + path: '/', + }, + cookies: { + sessionToken: { + attributes: { sameSite: 'none', secure: true }, + }, + }, +} +``` +Note: `secure: true` + `sameSite:'none'` means cookies only flow over HTTPS. For local-only dev the +new backend may need to relax this; weigh against the iframe requirement. + +### Session cookie cache + +```ts +session: { + cookieCache: { enabled: true, maxAge: 60 * 60 * 24 * 7 }, // 7 days +} +``` + +### `user.additionalFields` + +Adds an optional `image` string field to the user: +```ts +user: { additionalFields: { image: { type: 'string', required: false } } } +``` + +### `trustedOrigins` — CSRF allow-list (load-bearing) + +Built from env at startup, filtering out falsy entries. Every URL the app may be served under must +be present or better-auth rejects the request as "Invalid origin": + +```ts +const trustedOrigins = [ + process.env.BETTER_AUTH_URL, + process.env.EXPO_PUBLIC_PROXY_BASE_URL, + process.env.NEXT_PUBLIC_CREATE_BASE_URL, + process.env.NEXT_PUBLIC_CREATE_HOST ? `https://${process.env.NEXT_PUBLIC_CREATE_HOST}` : null, +].filter((v): v is string => Boolean(v)); +``` + +### `socialProviders` — self-activating Google / Apple + +Providers register only when their OAuth env vars are present (platform injects them when enabled in +project settings). A provider with missing credentials is simply not registered, so its sign-in +button never hits a half-configured backend. Apple additionally wires `appBundleIdentifier` (for +native "Sign in with Apple") when `APPLE_APP_BUNDLE_IDENTIFIER` is set: + +```ts +const socialProviders = { + ...(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET ? { google: { clientId, clientSecret } } : {}), + ...(APPLE_CLIENT_ID && APPLE_CLIENT_SECRET ? { + apple: { clientId, clientSecret, ...(APPLE_APP_BUNDLE_IDENTIFIER ? { appBundleIdentifier } : {}) }, + } : {}), +}; +``` +(Probably not needed for a local SoleLog rebuild unless social login is a requirement.) + +### Session type export + +```ts +export type Session = typeof auth.$Infer.Session; +``` + +### Auth tables + +The better-auth user/session/account/verification tables are **not** in `db/schema.sql`. The legacy +comment says to generate them with `npx @better-auth/cli migrate`. The new backend uses Drizzle, so +these come from better-auth's Drizzle schema generation instead. + +--- + +## 6. Parameterised tagged-template SQL pattern (`apps/web/src/app/api/utils/sql.ts`) + +The DB layer is a single default-exported Neon tagged template. **All interpolations in +`` sql`...` `` are bind parameters, not string concatenation** — i.e. it is parameterised and safe +against injection. Routes import it as `import sql from '@/app/api/utils/sql'`. + +```ts +import { neon, NeonQueryFunction } from '@neondatabase/serverless'; + +type SqlQueryFunction = NeonQueryFunction & { + query: NeonQueryFunction; +}; + +const NullishQueryFunction = (() => { + throw new Error( + 'No database connection string was provided to `neon()`. Perhaps process.env.DATABASE_URL has not been set' + ); +}) as any as SqlQueryFunction; + +NullishQueryFunction.transaction = (() => { + throw new Error(/* same message */); +}) as any as NeonQueryFunction['transaction']; +NullishQueryFunction.query = NullishQueryFunction; + +const sql = ( + process.env.DATABASE_URL ? neon(process.env.DATABASE_URL) : NullishQueryFunction +) as SqlQueryFunction; +sql.query = sql; + +export default sql; +``` + +Key behaviours to preserve in the port: + +- **Lazy / forgiving init:** if `DATABASE_URL` is unset, `sql` is a stub that throws a clear, + specific error *only when actually invoked* — importing the module never crashes at load time. + Both `sql()` and `sql.transaction()` throw the same explanatory message. +- **`sql.query = sql`:** the same function is exposed under `.query`, so call sites can use either + `` sql`...` `` (tagged template) or `sql.query(...)`. Both forms are bind-parameterised. +- **Array binding:** Postgres `text[]` columns are written by binding a JS `string[]` directly + (e.g. `VALUES (${name}, ${types})` where `types: string[]`). The Neon driver maps the array to a + Postgres array param. The SQLite/Drizzle port has no native array type — `insole_types` must be + stored as JSON/CSV text (or a join table) and serialised at the boundary. +- **Row access:** single-row inserts/updates use array destructuring on the result — + `const [task] = await sql\`... RETURNING *\``; a missing row yields `undefined` (the basis for the + `404` in `PUT /api/tasks/:id`). + +--- + +## 7. Mobile consumer contract (for parity verification) + +What `apps/mobile` actually calls, so the port can be verified against real callers: + +- `GET /api/tasks` — Stopwatch (`(tabs)/index.tsx`) and Settings (`(tabs)/tasks.tsx`); Stopwatch + filters by `t.insole_types.includes(insoleType)`. +- `POST /api/tasks` body `{ name, insole_types }` — Settings add. +- `PUT /api/tasks/:id` body `{ name, insole_types }` — Settings edit (id is a number). +- `DELETE /api/tasks/:id` — Settings delete (also invalidates the `logs` query client-side). +- `GET /api/logs` — History (`(tabs)/history.tsx`). +- `POST /api/logs` body `{ task_id, start_time, end_time, duration_seconds, pair_count, insole_type }` + — Stopwatch Stop & Save (`notes` omitted). +- `GET /api/export` — History CSV download/share. +- `GET /api/auth/token`, `GET /api/auth/expo-web-success`, `/api/auth/[...all]` — auth plumbing. + +All client calls go through the monkey-patched global `fetch` that rewrites `/api/...` onto +`EXPO_PUBLIC_BASE_URL` and attaches the Bearer JWT (see mobile `src/__create/fetch.ts`). diff --git a/docs/reference/legacy-lessons-and-gotchas.md b/docs/reference/legacy-lessons-and-gotchas.md new file mode 100644 index 0000000..f2a9202 --- /dev/null +++ b/docs/reference/legacy-lessons-and-gotchas.md @@ -0,0 +1,290 @@ +# Legacy lessons & gotchas (Create/Anything export) + +Hard-won knowledge mined from the **legacy** code (`apps/mobile`, `apps/web`) and +`.yarn/patches/` before the full clean. The legacy apps are being removed, but the +*reasons* behind these workarounds are worth keeping for the rebuild (`apps/api`, +`packages/shared`, and any future mobile client). + +Everything below is reconstructed from real source — each lesson cites the file path +where it lived. None of this code is part of the new stack; it is reference only. + +--- + +## 1. Android font / freeze fixes & platform workarounds + +### Font loading must never block render on Android +- **`apps/mobile/src/app/(tabs)/index.tsx`** (Stopwatch screen) + - Uses `const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold })`. + - Gate is `if (!fontsLoaded && !fontError) return null;` — i.e. it returns `null` + only while fonts are *still loading*. If font loading **errors**, it renders + anyway. The inline comments call this out explicitly: *"if fonts fail to load on + Android we still render (no freeze)"* and *"Wait for fonts — but if font loading + errored, render anyway (prevents Android freeze)"*. + - When `fontError` is truthy it falls back to `fontFamily: undefined` (system font) + instead of the named Inter family: `const regular = fontError ? undefined : 'Inter_400Regular'`. + - **Lesson for rebuild:** never block the whole tree on `fontsLoaded` alone; always + treat `fontError` as "render with system font". A hard wait on fonts is the classic + way to get a permanently blank/frozen screen on Android. + +### Splash screen has a hard timeout so a stuck session check can't freeze launch +- **`apps/mobile/src/app/_layout.tsx`** + - `SplashScreen.preventAutoHideAsync()` is called, then the root is gated on + `useAuth().initiate()` + `isReady`. To stop a hung session restore from freezing on + the splash forever, there is a `SPLASH_TIMEOUT_MS = 10_000` fallback: after 10s it + sets `timedOut` and hides the splash / renders regardless of `isReady`. + - **Lesson:** any "load persisted session before first render" gate needs a timeout + escape hatch. + +### Crash reporter is silenced in dev to avoid the RN red-box/freeze loop +- **`apps/mobile/index.tsx`** (native entry) + - In `__DEV__` it replaces `react-native/Libraries/Core/ExceptionsManager.handleException` + with a no-op, and calls `LogBox.ignoreAllLogs()` + `LogBox.uninstall()` (also gated on + `EXPO_PUBLIC_CREATE_ENV === 'DEVELOPMENT'`). It also installs an empty + `AppRegistry.setWrapperComponentProvider`. This keeps the Create in-builder preview + from getting stuck behind RN's error overlay. + +### New Architecture is on +- **`apps/mobile/app.json`**: `"newArchEnabled": true`. iOS uses + `expo-build-properties` with `useFrameworks: "static"`. Worth noting for any native + module compatibility decisions in the rebuild. + +--- + +## 2. expo-secure-store / SecureStore quirks (token storage) + +The richest source of SecureStore wisdom is **`apps/mobile/src/utils/auth/store.ts`** +(a platform-managed `⚠ DO NOT REWRITE` file). Token is the better-auth JWT, stored under +key `` `${EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt` ``. + +Explicit `secureStoreOptions` are passed on **every** SecureStore call, and each option +exists for a concrete failure it prevents: + +- **`keychainService: 'anything-auth'`** — pinned to a stable, hard-coded service name. + Without it, SecureStore derives the service name from the bundle, which can **drift + between Classic and EAS builds**, so reads silently miss writes made by a previous + build (token "disappears" after a rebuild). +- **`keychainAccessible: AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY`** — lets the token be read + on every cold launch after the device has been unlocked once since boot. The default + (`WHEN_UNLOCKED`) refuses access during the first-unlock window — described as *"the + most common TestFlight failure mode"* (user opens app from a notification before + unlocking → auth read fails → appears signed out). +- **`requireAuthentication: false`** — keeps SecureStore on its non-biometric code path, + so it never reads `NSFaceIDUsageDescription` or builds a biometry access-control object. + Both can throw an `NSException` and trip **iOS 26's unhandled async-void TurboModule + rethrow**, crashing the app. + +Other token-storage lessons: +- **Keychain writes are wrapped in `.catch(() => {})`** (in `setAuth`). A failed Keychain + write must NOT throw — throwing propagates into the unhandled-rejection / TurboModule + rethrow path and crashes on iOS 26.x. On write failure the app stays *in-memory authed* + for the session and re-auths via the WebView next launch. +- **Web has no Keychain** — `expo-secure-store` is aliased to a `localStorage` shim on web + (`apps/mobile/polyfills/web/secureStore.web.ts`). It namespaces keys with + `_create_secure_store_`, warns (but does not fail) when a value exceeds a 2048-byte + limit, `isAvailableAsync()` probes localStorage, and `canUseBiometricAuthentication()` + returns `false`. **Lesson:** if the rebuild stores tokens on a web client, plan a + non-Keychain fallback — localStorage is not secure storage. +- **Where the token gets read for requests:** `apps/mobile/src/__create/fetch.ts` + (`SecureStore.getItemAsync(authKey)` → `JSON.parse` → `Bearer `), with a + `.catch(() => null)` so a read failure just sends an unauthenticated request rather than + crashing. + +--- + +## 3. The global `fetch` monkey-patch (`src/__create/fetch.ts`) + +Installed by **`apps/mobile/src/__create/polyfills.ts`**, which does +`global.fetch = updatedFetch` (the default export of `fetch.ts`). App code everywhere just +calls `fetch('/api/...')` and relies on this rewrite. + +What `fetchToWeb` (in `apps/mobile/src/__create/fetch.ts`) actually does, in order: + +1. **Bails out unchanged** if `EXPO_PUBLIC_BASE_URL` (first-party) or + `EXPO_PUBLIC_PROXY_BASE_URL` (second-party) is unset — falls back to `expo/fetch`. +2. **Extracts the URL** from whatever form the call used — string, `Request`, or `URL` + (via a `getURLFromArgs` helper, because the `URL` type isn't always in the fetch TS + signature). +3. **Passes through static/file URLs untouched** via the *original* native `fetch` + (not `expo/fetch`): `file://`, `data:`, and static assets matched by extension + (`.wasm/.png/.jpg/.svg/.woff2/.ttf/...`). These must not get auth headers or rewriting. +4. **Leaves external (third-party) URLs alone** — headers/JWT are only ever added to + first-party requests. "First-party" = URL starts with `/` **or** with + `EXPO_PUBLIC_BASE_URL`. +5. **Rewrites first-party URLs onto a base.** A path starting with `/` is prefixed: + `/_create/...` paths use the **second-party** base (`EXPO_PUBLIC_PROXY_BASE_URL`), + everything else uses the **first-party** base (`EXPO_PUBLIC_BASE_URL`). Non-string + inputs (`Request`/`URL`) are *not* rewritten — they fall through to `expo/fetch`. +6. **Injects Create routing headers** on first-party calls: + `x-createxyz-project-group-id` (= `EXPO_PUBLIC_PROJECT_GROUP_ID`), plus `host`, + `x-forwarded-host`, and `x-createxyz-host` (all = `EXPO_PUBLIC_HOST`). Only set when the + env value is truthy. +7. **Attaches auth**: reads the JWT from SecureStore (key `-jwt`, + JSON-parsed), and if present sets `Authorization: Bearer `. Read errors are + swallowed (`→ null`), so the request still goes out unauthenticated. +8. Dispatches the rewritten request through **`expo/fetch`** (Expo's fetch, for + streaming/RN compatibility), not the global one (which it has replaced). + +**Lessons for rebuild:** the new mobile client should centralise (a) base-URL rewriting +and (b) auth-header attachment in one place. The legacy approach monkey-patches the global +`fetch`, which is invisible/magic to app code — a typed API client (the `packages/shared` +zod contracts make this easy) is a cleaner replacement. Note the alternative `authFetch` +in `apps/mobile/src/utils/auth/getSession.ts` does the Bearer-header part explicitly and +is the documented "use this instead of bare fetch" helper. + +--- + +## 4. Web sandbox / iframe plumbing + +The mobile app's **web** target runs inside the Create builder as an `