feat(mobile): a native iOS + Android app, as a shell over the existing web app - #616
Draft
an1va wants to merge 17 commits into
Draft
feat(mobile): a native iOS + Android app, as a shell over the existing web app#616an1va wants to merge 17 commits into
an1va wants to merge 17 commits into
Conversation
Phase 1 of the mobile plan, shipping on its own ahead of any native shell. Both halves live in apps/web, so they reach the app, an installed Home Screen PWA, and plain mobile Safari at once, and they ship on deploy. PWA. A web app manifest (standalone, two shortcuts), the icon set generated from the opaque brand tile, and the installed-app metas. apple-touch-icon moves off favicon.png, which is a transparent white glyph and disappears against the light tile iOS composites it onto. theme-color is rendered as raw tags in RootDocument rather than through the route's head(): head management dedupes meta by name, so the light/dark pair collapsed to the dark one and the light variant never reached the served HTML. Caught by reading the response, not the source. Open-in-app bar. A Derive link tapped in Slack on a phone opens in Slack's own web view, where a universal link CANNOT reach a native app (Apple opens the site instead), so the page offers the hop itself on a custom scheme, which does escape a web view. Detection-gated and dismissible: nothing can tell whether the app is installed, so an automatic redirect would strand everyone without it. Renders in flow above the mobile top bar, never over it, so it cannot suppress navigation. No-ops on desktop, in a real mobile browser, and once installed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 2, partial. The shell boots, hosts apps/web in a web view, routes deep links (cold start included), keeps off-origin navigations out of the frame, and makes failure legible instead of white. Runs on a real phone through Expo Go, so it needs no Xcode to try. Not a workspace member. React Native wants a hoisted node_modules and the usual fix is repo-wide, so apps/mobile is excluded from pnpm-workspace.yaml and keeps its own lockfile. It needs nothing from the workspace: HTTP to the API, HTTPS to the web app. Biome and the repo gate still cover it. SDK 57, not the 55 the plan said. 55 was two releases stale; the versions here come from the official template rather than from guessing. Deep-link resolution is pure and Expo-free (src/links.ts) because it is the one security-relevant thing in the shell: a deep link is untrusted input that decides what the frame shows. Off-origin targets, javascript: payloads and suffix lookalikes (derive.to.evil.example) are all refused; verified against 11 cases. ALLOWED_ORIGINS is likewise a boundary, not a convenience. A native web view has no iframe sandbox attribute, so hosting the SPA is what preserves the containment the web already has: an artifact still renders inside the web app's own sandboxed iframe. Raw-bytes origins must never join that list. Deliberately unbuilt, and why, in apps/mobile/README.md: the native tab bar (needs a device to get the feel right) and the auth handoff (Google blocks OAuth in embedded web views, so it needs a real browser plus a cookie transfer). Verified: tsc clean, Metro bundles for iOS, the repo's 20 guardrails still pass. NOT verified: anything on a device. No Xcode or Android SDK on this machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Google refuses OAuth from an embedded web view (disallowed_useragent), and Derive has Google enabled in production, so "Continue with Google" inside the shell was a dead end. A sign-in navigation is now recognised and handed to a real browser via openAuthSessionAsync, which Google accepts and which closes itself on derive://auth-callback. NOT fixed by spoofing the user agent. That policy protects the person signing in and defeating it violates Google's terms. Detection is pure and separately exercised: 15 cases covering the flow starts, the provider hosts, and the things it must NOT hijack. Ordinary pages, including /login itself, stay in the web view; accounts.google.com.evil.example, an off-origin /api/auth/... path, and a /api/authx prefix are all refused. HALF A FIX, on purpose. The browser that completes the flow has its own cookie jar, so the session lands there and not in the web view's; on iOS those are genuinely separate stores and no client-side trick bridges them. The remaining piece is a server endpoint that exchanges a single-use token for a Set-Cookie on a request the WEB VIEW makes. It is security-sensitive, belongs in apps/api with tests, and is documented rather than guessed at. See apps/mobile/README.md. Also adds the web target, which is how the shell was tested locally: it boots, routes, themes and renders its own loading state. react-native-webview has no web implementation, and derive.to sends frame-ancestors 'none', so the hosted content itself cannot be exercised without a device. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…'s web view Closes the half of native sign-in that was missing. Google refuses OAuth from an embedded web view, so the shell runs sign-in in a REAL browser — which has its own cookie jar, so the session landed there and never reached the web view. On iOS those are genuinely separate stores, so no client-side trick bridges them. Better Auth ships the right primitive: the one-time-token plugin. The browser mints a token for the session it just created, the app carries it across, and the WEB VIEW spends it — so the Set-Cookie lands in the jar that needs it. Works for every sign-in method (Google, password, passkey, enterprise OIDC), not just the one that forced the design. No new endpoint, no new table. Two properties carry the safety, both tested rather than asserted in a comment: SINGLE-USE. Verify goes through consumeVerificationValue, so a replay finds nothing; storeToken:"hashed" means a leaked verification row is not a credential. Six API tests cover the hand-off, replay, forgery, anonymous minting, minting for another user, and that the plaintext is not in the table. NONCE-BOUND. Any web page can fire a deep link, so without binding the round trip to a value the app generated, a crafted derive://auth-callback could sign the app into someone else's account. The app refuses a callback that does not echo its nonce; 13 cases cover that and the injected script's escaping. Residual risk documented, not hidden: a custom scheme is not exclusively ours, so another app registering derive:// could intercept the callback. Single-use plus a two-minute expiry bounds it; the real fix is an https callback on a verified associated domain, which needs the app-association files Phase 4 will serve. auth-config.test.ts asserted the plugin set by length; it now names the set, so a future break says which plugin moved rather than that the number did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the app The two documents iOS and Android fetch at install time to decide whether a derive.to link opens the native app or a browser tab. Without them a universal link is just a web link, so this is what "tap a Derive link in Slack and land in the app" actually needs. OFF BY DEFAULT, and that is the important part. An instance with no app of its own must not publish an association: naming a bundle id hands that app the right to claim this domain's links. Each file appears only once its identifiers are configured and 404s otherwise, which is the honest answer for a self-host. Content type is set explicitly because iOS rejects an AASA served as anything but application/json and says nothing about it — the only symptom is that links quietly stay web links. /raw/* and /api/* are excluded: they are fetched, not navigated to, and claiming them would route background asset requests into the app. Six tests cover both files, the single-sided cases, that blank or comma-only config counts as unset rather than as an association that verifies nothing, and that no session is needed. Does NOT fix the Slack in-app browser: iOS ignores universal links from inside a web view, whatever the association says. That path stays covered by the web-side "Open in Derive" bar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… edit EXPO_PUBLIC_DERIVE_WEB_ORIGIN now wins over app.json's extra.webOrigin. The value is a LAN address, so it must never reach a commit — telling people to edit a tracked file to run locally was an invitation to do exactly that. Verified over the LAN before handing it to a phone: the dev bundle compiles and carries the override, and the full session hand-off works against a LAN origin (mint, spend from a clean cookie jar, that jar comes back signed in, replay refused). The README now also names the DERIVE_WEB_ORIGIN the API needs, since without it Better Auth's CSRF check rejects sign-in with INVALID_ORIGIN — which is the first wall anyone hits pointing a phone at their laptop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app failed on a real phone with "Project is incompatible with this version of Expo Go." The project was on SDK 57 because npm's `latest` tag says 57.0.9 — but npm's latest is the wrong source of truth when Expo Go is the delivery mechanism. The App Store ships Expo Go 54.0.2, released September 2025, so 57 cannot run there and no amount of updating the app fixes it. Caught only by putting it on a handset, which is exactly the class of thing the bundle compiling and the types checking cannot tell you. Every version here now comes from `expo install` against SDK 54 rather than from a template or a guess. Manifest reports exposdk:54.0.0, tsc is clean, and Metro serves a 6.4MB dev bundle carrying the configured origin. README documents the constraint and the one-line check against the App Store, so the next SDK bump starts from what the store ships instead of what npm tags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Expo stopped shipping new Expo Go builds to the App Store: SDK 55 stalled in Apple review and Expo pivoted to eas go, simulators and dev builds. SDK 54 is the last Expo Go on the store, so on a physical phone with no Apple Developer account it is the only SDK that can run, and waiting for an update is waiting for something that will not ship. The previous note called this a lag, which would have sent the next person looking for a store update that does not exist. It also misses the useful part: the SDK bump and the Apple enrolment are the same unlock, and dev builds are where this app is headed regardless, since push and the share extension cannot run in Expo Go at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…back Two things a phone said that the bundle compiling could not. The top strip had no background, so it rendered as the platform default grey: a dead band sitting above chrome that is trying to look continuous with it, visible before any content loads. The web app pads safe-area-inset-BOTTOM itself (comments sheet, selection bar) but has no top handling, so this strip really is the shell's to paint, and it has to follow the colour scheme. Pull to refresh was off because I guessed a bounce would fight the artifact viewer's locked scroll regions. On a device the absence just reads as a page that ignores you, so the guess loses to the handset: bounces and pullToRefreshEnabled are on, plus overScrollMode/nestedScrollEnabled for Android. WebKit only fires the refresh at the top of the document, so a scrolled list is unaffected. Also keyboardDisplayRequiresUserAction=false so the comment composer can raise the keyboard itself, and webviewDebuggingEnabled in dev only, which is the only way to inspect the hosted app from a device. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from a phone as "nothing happens on post", and it was real: the comment vanished, no request went out, no error appeared. crypto.randomUUID is SECURE-CONTEXT ONLY. On https and on localhost it exists; on plain http to a hostname or a LAN IP it is undefined and calling it throws. addComment built its optimistic row's temp id with it, unguarded, AFTER the composer had already closed — so the throw landed mid-handler and took the whole send with it, leaving nothing on screen to explain the loss. Not hypothetical. It is how Derive is reached when a self-host runs over http on an internal network, and when a phone is pointed at a laptop's dev server. lib/guest-id already guarded this exact hazard; addComment simply missed it. Both now share lib/random-id, so there is one place to be right. The ids label optimistic rows and anonymous presence, which the server re-derives or replaces, so the Math.random fallback weakens nothing. Proven rather than argued. Before: no POST, comment not visible. After: POST fires, comment renders. Same Playwright script, same LAN origin, and a unit test pins the non-secure branch that has no coverage from a normal test run, since vitest and localhost are both secure contexts. Worth noting the diagnosis path: this looked like a WebView bug, then a mobile bug, then an auth bug. It was none of those, and it reproduced identically at desktop width in plain Chrome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The strip came back wrong a second time, and the second reason was more interesting than the first. Painting it from the device colour scheme assumes the app and the OS agree about the theme. They do not: the web app resolves its own (a stored choice first, the OS only as a fallback), so a phone in light mode showing a dark-themed app got a white band sitting above black chrome. The page is the only honest source, so ask it. An injected probe reports the computed background and re-reports on change, which also covers the in-app theme toggle — that swaps a class on <html> with no navigation for the shell to hook. Device tokens remain the fallback until the first message, so the strip is never unpainted. Reads the computed colour rather than a token name, so it cannot drift when the theme system changes. Verified against the running app: stored dark reports rgb(10,11,13), stored light rgb(247,248,250) — both exactly the --background tokens, and both following the STORED theme rather than the OS, which is the disagreement that caused this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every check I ran on this shell during development was a throwaway script in /tmp. The README even admitted it: "There is no test runner in this package yet." So the deep-link refusals and the auth nonce binding — the security-relevant parts — were verified once and then guarded by nothing. 32 tests now cover what can be checked without a device: deep-link resolution and each refusal (off-origin, javascript:, the suffix lookalike derive.to.evil.example, the auth callback as a navigation), the nonce binding that stops a crafted deep link signing the app into someone else's account, the claim script's escaping, and the background probe. The probe runs in jsdom rather than being asserted as a string, because it is a script that executes in a document. That is the code that broke twice, both times visibly on launch, and both times invisible to every other gate. CI matters more than the tests here. apps/mobile is outside the pnpm workspace, so `pnpm -r` never reaches it and these would have passed locally and run nowhere. The workflow now installs and tests it on a lane that was already up. README states the ceiling plainly: no web view is exercised, so scroll, cookies, the auth hand-off, the keyboard and safe areas stay invisible here. Every bug this shell actually shipped lived in that set. A green suite means the logic holds, not that the app works. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by dogfooding the app at phone size against a plain-http origin, which is
where a phone pointed at a laptop actually lives — and where a self-host on an
internal network lives too.
navigator.clipboard is secure-context only, so on http every copy in the app
either showed "Couldn't copy to clipboard" or did nothing. Doing nothing was the
worse one, and it hid in plain sight:
navigator.clipboard?.writeText(url).then(ok).catch(fallback)
reads as graceful degradation, but optional chaining short-circuits the WHOLE
chain: with no clipboard the .catch never runs. The fallback written for exactly
this case was unreachable, so copying a comment link was a tap that did nothing.
copyText() does not just report failure better, it succeeds. execCommand("copy")
is deprecated but is not secure-context gated, so the artifact's copy link, share
link and embed snippet now work on http instead of erroring. Verified against the
running app: navigator.clipboard undefined, and the copy reports "Link copied"
rather than the error it used to.
Six tests pin both branches, which no ordinary run can reach: vitest and localhost
are both secure contexts, so the fallback would otherwise be dead code that only
executes for users.
Settings and context surfaces still use the direct API. They fail VISIBLY with an
error toast rather than silently, so they are honest today; converting them is
mechanical and belongs with someone who can click through those screens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e load
Measured while dogfooding at phone size: a hydration mismatch on EVERY route at
390px, and none at 1280px. React could not reconcile the tree it was hydrating,
so it discarded the prerendered shell and rebuilt it client-side — a full
re-render and a visible pop, on the device least able to afford either.
useIsMobile's initializer was the cause, and its own comment said the opposite:
useState(() => typeof window !== "undefined" && matchMedia(q).matches)
That is SSR-safe but not hydration-safe. A lazy initializer runs on the client's
FIRST render, not after mount, so the prerender said desktop and a phone said
mobile before hydration had a chance to agree. The useEffect underneath was
already doing the right thing; the initial value undercut it.
useSyncExternalStore is the API for this: getServerSnapshot is what hydration
compares against, so returning a viewport-independent value there makes the first
paint match by construction. The real answer still lands on the next tick, as an
ordinary update instead of a failed hydration.
Verified rather than assumed, since this drives every mobile branch in the app:
mismatches 3 -> 0 at 390px, still 0 at 1280px; the mobile top bar, nav drawer and
comments sheet all still render and post; and a fresh desktop context still gets
the rail and not the sheet.
useCoarsePointer had the identical bug and is fixed with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
THE TAB BAR is the shell's main answer to Guideline 4.2, which names a native tab bar and push as what separates an app from a repackaged website. It was deferred as "needs a device to get the feel right", but the risky part was never the feel — it was whether a tab could move the hosted app WITHOUT reloading it, since setting the web view's source re-boots the SPA and reads as a page load. That is now proven rather than assumed: history.pushState plus a popstate event drives the client-side router, verified against the running app across all four tabs with ZERO full page loads. The command goes through an injection handle rather than a prop, so a tap never re-renders the frame. Two mapping rules are pinned by tests because both fail silently. /settings redirects to /settings/profile, so tabs own their sub-routes or Settings goes dark the instant you tap it — caught by integration-checking against the real app, not by reading routes. And an artifact selects NOTHING, because lighting Library there would say you are somewhere you are not. READINESS PASS, checked live at phone size rather than assumed: - 5.1.1(v) account deletion: present (Settings > Security) - 1.2 report user content: present (artifact menu) - 2.1 dead ends: missing artifact and unknown route both show real states - 2.1 sign out: present (drawer > user menu) Two earlier "needs confirming" items are now confirmed, not carried. usesNonExemptEncryption is declared so every submission stops asking, and answering it wrong blocks a build. NSAllowsLocalNetworking lets a dev build reach a plain-http laptop without opening arbitrary http loads, which is what review looks at. Tokens widened from the `as const` literals: the background is overridden at runtime with the colour the PAGE reports, and a literal type rejected the very value that makes the strip correct. Still blocked on an Apple Developer account, not on code: push (the other half of 4.2) and Sign in with Apple (required because Google is offered). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous pass confirmed the delete-account button existed and that "Report" appeared in a menu. Neither had been clicked. App Review clicks them, so that gap mattered more than the checkmarks suggested. Both now run end to end: ACCOUNT DELETION (5.1.1(v)). Created an account, deleted it through the UI, confirmed the user row is gone, re-sign-in is refused, and there are ZERO orphaned sessions, accounts, passkeys or memberships left behind. Deleting the account and its associated data is the actual requirement, not showing a button. REPORT (1.2). Submitted a real one. The dialog confirms, and the row lands in the database carrying its reason. The distinction earned its keep immediately. Completing deletion needs a password AND the word "delete" typed, and a brand-new account is held on /welcome until onboarding is skipped — so the control is unreachable on a fresh sign-up until that is cleared. A presence check reported all of that as fine. Also exercised: the comment lifecycle on the mobile sheet, sign-out, and the missing-artifact and unknown-route states. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Main moved 8 commits ahead (chat, the boot-fetch head start, derived facts). One real conflict, in __root.tsx: main added BOOT_START_URLS + DATA_BOOT exactly where the mobile branch added THEME_COLOR. Both are additions, so both stay, and both still render — the two theme-color metas and the boot-fetch script. Merging before opening the PR rather than after: a conflicting PR gets ZERO CI runs on this repo, so it would have looked untested rather than failing. Verified after merging, not before: 21 guardrails, tsgo clean across the workspace, and the full suite green including main's new tests.
Previewhttps://derive-pr-616.derive-to.workers.dev Deployed from It shares production's database — sign in with your real account, and treat anything you change here as changed for real. It has no routes, no cron, no queue consumer and no OG renderer, so it cannot serve derive.to, run scheduled work, or write images onto real artifacts. Unlike production it serves artifact HTML on its own origin (that is what makes frame-side changes visible here). Storage is still sandboxed away, but untrusted HTML and the sign-in form share a hostname — treat this URL as you would any link: don't type a password into it because a page asked you to. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft. A native iOS + Android app for Derive, built as a native shell hosting the existing web app rather than a second front end.
Why this shape
apps/webis not merely responsive, it is deliberately designed for phones — nine files carry mobile branches, and the artifact page alone has twenty-one, including a0.28anchor-scroll bias so a quoted highlight clears the comments sheet. Rebuilding that in React Native would mean re-deriving every one of those judgements and maintaining two copies forever.So the shell owns only what a browser tab cannot do. A web deploy reaches phones with no app release.
A security consequence worth stating: a native web view has no equivalent of the iframe
sandboxattribute. Hosting the SPA preserves the containment the web already has, because an artifact still renders inside the web app's own sandboxed iframe. The cheaper architecture is also the safer one.What's here
apps/mobile— the Expo shell (SDK 54). Hosts the web app, native tab bar, deep links from cold start, off-origin taps to the system browser, real failure state with retry. Outside the pnpm workspace with its own lockfile; RN wants hoisting and the repo-wide fix would change resolution for the API and web app to suit Metro.apps/web— PWA (manifest, icons, installed-app metas) and the "Open in Derive" bar, plus three fixes found by running it (below).apps/api— the session hand-off and the app-association files.Native sign-in, the one real blocker
Google refuses OAuth from an embedded web view (
disallowed_useragent), and Derive has Google enabled in production. Not fixed by spoofing the user agent — that policy protects the person signing in.Sign-in runs in a real browser, then Better Auth's
oneTimeTokenplugin hands the session into the web view's cookie jar, where the cookie actually belongs. Works for every method, not just the one that forced it. No new endpoint, no new table.Two properties carry the safety, both tested:
consumeVerificationValue;storeToken: "hashed"means a leaked row is not a credential.derive://auth-callbackwould otherwise sign the app into someone else's account.Residual risk documented rather than hidden: a custom scheme is not exclusively ours. Single-use plus a two-minute expiry bounds it; the real fix is an https callback on a verified domain, which the association files now make possible.
Three bugs in
apps/webthat only a phone foundEach was invisible to types, tests and the bundler.
crypto.randomUUIDis secure-context only;addCommentused it unguarded after closing the composer, so the throw took the send with it — no request, no error. Real for a self-host over http on an internal network.lib/guest-idalready guarded this;addCommentmissed it.navigator.clipboard?.writeText(url).then().catch(fallback)reads as graceful degradation, but optional chaining short-circuits the whole chain — the fallback was unreachable. Now succeeds viaexecCommandinstead of only reporting failure.useIsMobile's lazy initializer runs on the client's first render, so the prerender said desktop and a phone said mobile.useSyncExternalStore'sgetServerSnapshotfixes it. 3 → 0.App Review status
Every row exercised end to end, not confirmed by finding a button — a distinction that earned its keep, since completing deletion needs a password and the word "delete", and a new account is held on
/welcomeuntil onboarding is skipped.Reviewing this
~11.7k of the ~14k insertions are
apps/mobile/package-lock.json. Actual code is ~2,600 lines.Worth a close look:
apps/mobile/src/links.ts,src/auth.ts— the security-relevant logicapps/api/src/auth-config.ts— theoneTimeTokenwiringapps/web/src/lib/use-is-mobile.ts— touches every mobile branch in the appNot done, and honestly
Nothing has run on a device by me — no Xcode, simulator or Android SDK on the machine this was built on. Logic is covered by 44 shell tests in CI; scroll feel, cookie jars, the keyboard, safe areas and the tab bar's feel are not. Tests are not a phone.
Push, the share extension, Sign in with Apple and turning on universal links are all gated on the Apple Developer account, not on code.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.