diff --git a/.github/workflows/deploy-mobile.yml b/.github/workflows/deploy-mobile.yml index e40eb572..4b1bf1dd 100644 --- a/.github/workflows/deploy-mobile.yml +++ b/.github/workflows/deploy-mobile.yml @@ -3,8 +3,10 @@ name: Deploy mobile (TestFlight) # Builds apps/community-mobile for iOS and uploads it to App Store Connect, where TestFlight hands it # to the internal tester group. Same shape as deploy-web.yml, one lane: # -# push to main -> the `testflight` profile -> staging API (api.civfix.dev) -# manual run, profile=production -> the `production` profile -> prod API (api.civfix.org) +# push to main -> the `testflight` profile -> staging API (api.civfix.dev), baked +# manual run, profile=production -> the `production` profile -> API chosen at runtime by install +# source: api.civfix.dev in TestFlight, api.civfix.org from the +# App Store (src/lib/apiUrl.ts + src/lib/nativeBetaInstall.ts) # # Nothing here submits for App Store review. A production build lands in App Store Connect exactly # like a staging one; attaching it to a version and submitting is the manual step, so a prod mobile @@ -145,7 +147,7 @@ jobs: if (api !== expectedApi) { throw new Error(`profile '${profileName}' bakes EXPO_PUBLIC_API_URL='${api}', expected '${expectedApi}'`) } - console.log(`profile ${profileName}: EXPO_PUBLIC_API_URL='${api || "(unset -> https://api.civfix.org)"}'`) + console.log(`profile ${profileName}: EXPO_PUBLIC_API_URL='${api || "(unset -> resolved at runtime: api.civfix.dev in TestFlight, api.civfix.org from the App Store)"}'`) NODE echo "TARGET=$target" >> "$GITHUB_ENV" diff --git a/README.md b/README.md index 54742bce..7dee7592 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,9 @@ build cannot ship the staging API URL to civfix.org or the production one to the The mobile app deploys through `.github/workflows/deploy-mobile.yml` on the same lane: a push to `main` that touches the app or the packages builds the `testflight` profile (staging API) on a GitHub-hosted Mac and uploads it to App Store Connect, where TestFlight hands it to the internal -testers; a manual run with `profile=production` uploads a prod-API build, and attaching that build to +testers; a manual run with `profile=production` uploads a build that bakes no API URL at all and +picks one at runtime from its iOS install source (`api.civfix.dev` while it is handed out through +TestFlight, `api.civfix.org` once it is downloaded from the App Store), and attaching that build to a version and submitting it for review stays a human step in App Store Connect. The runner executes the same `scripts/store-build.sh` a developer runs locally (`eas build --local`, then a direct `fastlane pilot upload` to App Store Connect — no EAS Submit queue), so CI and laptop builds share diff --git a/apps/community-mobile/APP-REVIEW-NOTES.md b/apps/community-mobile/APP-REVIEW-NOTES.md index f0f9728e..8116d28a 100644 --- a/apps/community-mobile/APP-REVIEW-NOTES.md +++ b/apps/community-mobile/APP-REVIEW-NOTES.md @@ -3,6 +3,48 @@ Paste the relevant section into **App Store Connect → App Review Information → Notes** (and into the Play Console reviewer notes where the equivalent field exists). +The **Environment** section below is the exception: it is an internal pre-submission checklist, not +reviewer-facing copy. Do not paste it into App Store Connect. + +--- + +## Environment — App Review runs against STAGING (internal; do not paste) + +**The binary a reviewer runs talks to `api.civfix.dev`, not `api.civfix.org`.** This is deliberate +and needs to be understood before every submission. + +The `production` EAS profile bakes no `EXPO_PUBLIC_API_URL`; the app picks its API at launch from its +iOS install source — a TestFlight/beta install (`StoreKit/sandboxReceipt`) resolves to +`https://api.civfix.dev`, an App Store download (`StoreKit/receipt`) to `https://api.civfix.org` +(`src/lib/apiUrl.ts`, `src/lib/nativeBetaInstall.ts`; the mechanism is written up in `README.md`). +App Review installs through the beta/sandbox path — the same fact behind StoreKit's 21007 sandbox +receipt status — so the reviewer's copy sees a sandbox receipt and runs against **staging**. + +Consequences, accepted knowingly: the approval verdict is rendered against a binary whose production +behaviour was never exercised by the reviewer, and anything the reviewer is asked to find has to exist +on staging. There is no OTA update channel in this project that could flip an override for review +only, and routing beta installs to staging is the property this app deliberately wants — testers must +never write to the live civic record. + +**Pre-submission checklist — all of these are about the STAGING environment:** + +- [ ] `api.civfix.dev` is up and healthy (`/readyz`), and staging is on the same commit as the build + being submitted. +- [ ] Staging carries reviewer-visible demo content: at least one event, organization or profile + that shows a **Donate** card, so the "Reviewer steps" under *External donation links* can + actually be followed. Without it the reviewer finds nothing and the note reads as false. +- [ ] Staging carries a demo event with a ticket QR, so the check-in scanner note can be followed. +- [ ] A working reviewer sign-in exists on staging and the credentials in App Store Connect → + App Review Information match it. **This is currently missing:** `REVIEWER_OTP_BYPASS` and + `REVIEWER_OTP_CODE` are absent from `civfix-infra/secrets/staging/api.sops.env` (and from the + prod one), so the reviewer-OTP path is fail-closed on staging today. Add both to the staging + SOPS file and redeploy before submitting, or give the reviewer an account whose OTP they can + actually receive. +- [ ] Share links opened from the reviewer's build point at `civfix.dev` and resolve there; iOS + universal links are pinned to `civfix.org` only, so those links open in the browser rather than + deep-linking back into the app. Harmless, but do not write a reviewer step that depends on a + share link re-entering the app. + --- ## External donation links diff --git a/apps/community-mobile/README.md b/apps/community-mobile/README.md index 6296879b..9e512509 100644 --- a/apps/community-mobile/README.md +++ b/apps/community-mobile/README.md @@ -69,7 +69,7 @@ pnpm --filter community-mobile exec expo run:ios # or run:android Then start the bundler with `pnpm --filter community-mobile start` (runs `expo start --dev-client`). -## Store builds: TestFlight (staging API) vs App Store (prod API) +## Store-distribution profiles: `testflight` vs `production` Two store-distribution profiles exist in `apps/community-mobile/eas.json`, differing only in the baked API base URL and their EAS Update channel: @@ -77,9 +77,81 @@ baked API base URL and their EAS Update channel: - `testflight` - dev/testing builds for TestFlight. Bakes `EXPO_PUBLIC_API_URL=https://api.civfix.dev`, so testers hit the staging API. Update channel `testflight`. -- `production` - official App Store releases. Sets no `EXPO_PUBLIC_API_URL`, - so release builds fall back to the prod API `https://api.civfix.org` - (`src/lib/apiUrl.ts`). Update channel `production`. +- `production` - official App Store releases. Sets no `EXPO_PUBLIC_API_URL`, so the base URL is + chosen at RUNTIME by install source: `https://api.civfix.dev` while that build is handed out + through TestFlight, `https://api.civfix.org` once the same build is downloaded from the App Store + (`src/lib/apiUrl.ts`, `src/lib/nativeBetaInstall.ts`). Update channel `production`. + +### How the runtime split is decided (iOS only) + +**This split exists on iOS and nowhere else.** `src/lib/nativeBetaInstall.ts` returns `false` for any +other platform, so an Android release build - including one handed to internal-track testers - always +resolves to the production API. Android's own testing tracks have no equivalent on-device marker. + +iOS ships the App Store and TestFlight copies of a build with different StoreKit receipts: a store +download gets `StoreKit/receipt` in the app's data container, a TestFlight install gets +`StoreKit/sandboxReceipt`. `src/lib/nativeBetaInstall.ts` reads those two paths synchronously through +`expo-file-system`, so `API_URL` is a plain module constant and one session can never straddle two +APIs. `expo-application`'s `getIosApplicationReleaseTypeAsync()` cannot make this call: it reads the +embedded provisioning profile, which reports `APP_STORE` for TestFlight and App Store alike. + +Precedence is `EXPO_PUBLIC_API_URL` (when baked) -> `__DEV__` localhost -> the receipt probe. + +**Both receipts can be on disk at once.** Moving between TestFlight and the App Store is an in-place +update and the previous receipt is not removed, so mere presence decides nothing: the **newer** file +wins (`src/lib/storeKitReceipt.ts`). Everything ambiguous resolves to production - no sandbox receipt, +a tie, an unreadable modification time, an unreadable container, a probe that throws: all `false`. A +store download therefore cannot be routed to staging by any failure mode of this probe. + +**The container path is an assumption.** The probe reconstructs `Bundle.main.appStoreReceiptURL` as +`/StoreKit/`, derived from `Paths.document.parentDirectory`. If Apple ever +changes that layout the probe goes permanently `false` - production for everyone, which is the safe +direction but silent. A dev build logs the resolved container and both receipt stats under +`[install-source]` so the assumption can be checked on a real device. + +**Identity-bearing state is scoped to the API it was written against** (`src/lib/storageScope.ts`). +Four ids carry the API host as a suffix: the app MMKV instance (`civfix.app` - cached user, last +identity, persisted query cache, prefs), the session token in the keychain +(`civfix.session.token`), the secure-blob MMKV instance (`civfix.secure`) and its keychain encryption +key (`civfix.secure-blobs.key`). Production deliberately keeps the legacy un-suffixed ids so existing +App Store users are not signed out by this change. + +Three stores are deliberately NOT scoped, because none of them holds identity or server state: the map +filter prefs including recent-search history (`civfix.ui.filters`, `@civfix/ui` +`map/filterStorage.native.ts`), the sidebar width (`civfix.ui.sidebar`, +`shell/sidebarStorage.native.ts`), and the push `device_id` (`civfix.device_id`, `src/lib/deviceId.ts`), +which must stay stable per install for the backend's push-token ownership guard to recognise a +same-device handoff. + +**Scoping alone does not protect the TestFlight -> App Store upgrade**, and that is the whole point of +`src/lib/storageEnvMarker.ts` + `src/lib/legacyStorageReset.ts`. Production resolves to the LEGACY ids, +so a prod build cannot tell its own leftovers from a pre-namespacing TestFlight install's staging +leftovers sitting under the same ids. Every run therefore records its environment in an unscoped +keychain item (`civfix.storage.env`), and `adoptStorageEnvironment()` runs at boot before the first +session read: on a production boot whose marker names a non-production environment, it clears the four +legacy ids (the blob encryption key is emptied through its own store rather than deleted, so an +already-open MMKV instance is never re-keyed mid-process) and the in-memory query cache. The marker +lives in the keychain, not MMKV, because the keychain is where the dangerous leftover survives an app +DELETE - so delete-and-reinstall into the App Store copy is covered too. + +*What it cannot detect:* the FIRST upgrade off a build that predates the marker. There the marker is +absent, and an absent marker beside a legacy session token is genuinely ambiguous - equally a +long-standing App Store user whose session is legitimately theirs. Signing all of those out is the +worse failure, so an absent marker keeps the state: that one upgrade still sends a staging token to +prod, is rejected with a 401 and signs out, after briefly painting the cached staging user. Every later +environment flip on that install is covered, because by then a marker exists. Note also that a purge +clears `civfix.app` wholesale, so the locale, theme and onboarding-seen prefs of the discarded +environment go with it. + +**Share links do not follow the split.** `app.config.js` `ios.associatedDomains` pins +`applinks:civfix.org` / `applinks:www.civfix.org` only, so a `civfix.dev` link produced by a +staging-resolved build opens in the browser rather than deep-linking into the app. Universal links +work only against the production domain; adding `civfix.dev` would need a new entitlement and a +matching apple-app-site-association file on that host. + +**App Review runs against staging.** A reviewer's copy is installed through the beta/sandbox path, so +the probe returns `true` and review sessions hit `api.civfix.dev`. That is a deliberate, recorded +property - see the "Environment" section of `APP-REVIEW-NOTES.md` and its pre-submission checklist. ### Hand-driven Xcode archive (`scripts/prep-archive.sh`) @@ -93,8 +165,8 @@ read `.env` at *archive* time. So prep the native project with the target's `.env` in place, then archive by hand: ```sh -pnpm --filter community-mobile prep:testflight # -> https://api.civfix.dev -pnpm --filter community-mobile prep:appstore # -> https://api.civfix.org +pnpm --filter community-mobile prep:testflight # -> bakes https://api.civfix.dev +pnpm --filter community-mobile prep:appstore # -> bakes NOTHING; resolved at runtime apps/community-mobile/scripts/prep-archive.sh appstore --platform android ``` @@ -116,9 +188,11 @@ for every third-party dependency. Then: open `ios/civfix.xcworkspace`, destinati Two things this script exists to stop: -- `.env` is gitignored and *persists*. A `testflight` prep silently governs every later local build - on that machine, so the `appstore` target writes `https://api.civfix.org` **explicitly** rather - than leaning on the fallback in `src/lib/apiUrl.ts`. Re-run the script before switching targets. +- `.env` is gitignored and *persists*. A `testflight` prep would otherwise silently govern every + later local build on that machine. The script **rewrites `.env` on every run**, so the `appstore` + target's omission of `EXPO_PUBLIC_API_URL` is a real omission and not a leftover - and the banner + refuses to let you archive unless the built config carries **no `apiUrl` at all**. Baking one there + would freeze the runtime split to a single API. Re-run the script before switching targets. - `expo prebuild` deletes `DEVELOPMENT_TEAM` from the pbxproj on every run unless `ios.appleTeamId` is set, so a team picked by hand in Xcode's Signing & Capabilities pane vanishes on the next prebuild. The script reads the existing team back out and feeds it in via `CIVFIX_APPLE_TEAM_ID`. @@ -143,7 +217,7 @@ Android ships by hand from this directory: prep the target, re-apply the machine Temurin 25). ```sh -scripts/prep-archive.sh testflight --platform android # or appstore -> https://api.civfix.org +scripts/prep-archive.sh testflight --platform android # or appstore; Android always resolves to https://api.civfix.org scripts/android-release-patches.sh export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-22.jdk/Contents/Home cd android && ./gradlew --no-daemon :app:assembleRelease # or :app:bundleRelease for the Play .aab @@ -312,7 +386,9 @@ to `eas build`), and never map the `testflight` channel onto a prod-published br Config plugins for the native modules (camera/mic/location permission strings, Google sign-in URL scheme, Apple auth, notifications) are declared in `apps/community-mobile/app.config.js`. The API base URL comes from `EXPO_PUBLIC_API_URL`, surfaced via `extra.apiUrl`; when unset, dev builds fall back -to `http://localhost:8080` and release builds to `https://api.civfix.org` (`src/lib/apiUrl.ts`). +to `http://localhost:8080` and release builds to whichever API the install source implies - +`https://api.civfix.dev` from TestFlight, `https://api.civfix.org` from the App Store +(`src/lib/apiUrl.ts`). ## pnpm + Expo + the shared packages diff --git a/apps/community-mobile/app.config.js b/apps/community-mobile/app.config.js index 3dabf028..dcfbd9d6 100644 --- a/apps/community-mobile/app.config.js +++ b/apps/community-mobile/app.config.js @@ -33,6 +33,14 @@ function resolveSourceCommit() { } } +const EAS_DEVELOPMENT_PROFILES = ["development"] + +function apsEnvironmentMode() { + const profile = process.env.EAS_BUILD_PROFILE + if (!profile) return "development" + return EAS_DEVELOPMENT_PROFILES.includes(profile) ? "development" : "production" +} + const SPLASH_BG_LIGHT = tokens.color.neutral.paper const APP_LINK_HOSTS = ["civfix.org", "www.civfix.org"] @@ -229,6 +237,7 @@ module.exports = ({ config }) => ({ "expo-notifications", { color: "#FF7A6B", + mode: apsEnvironmentMode(), }, ], [ diff --git a/apps/community-mobile/app/_layout.tsx b/apps/community-mobile/app/_layout.tsx index e7743612..ab889edb 100644 --- a/apps/community-mobile/app/_layout.tsx +++ b/apps/community-mobile/app/_layout.tsx @@ -48,11 +48,12 @@ import { ToastProvider, setBrandAboutPresenter, setOnboardingTourPresenter, + setApiHost, setScanPresenter, setSourceCommit, setWebOrigin, } from "@civfix/ui" -import { CARTO_API_KEY, DONATE_BROWSER_MODE, SOURCE_COMMIT, WEB_ORIGIN } from "@/config" +import { API_URL, CARTO_API_KEY, DONATE_BROWSER_MODE, SOURCE_COMMIT, WEB_ORIGIN } from "@/config" import { nativeCamera, setCameraNavigator } from "@/lib/nativeCamera" import { nativeCalendarFile } from "@/lib/nativeCalendarFile" import { nativeClipboard } from "@/lib/nativeClipboard" @@ -72,6 +73,7 @@ import { chatSocket } from "@/lib/ws" import { usePrefsStore } from "@/store/prefsStore" import { resolveActiveLocale } from "@/lib/locale" import { goHome } from "@/lib/goHome" +import { adoptStorageEnvironment } from "@/lib/legacyStorageReset" import { isExternalUrl } from "@/lib/links" import { codeScannerSupported } from "@/lib/scannerSupport" import { @@ -178,6 +180,7 @@ async function openInAppBrowser(url: string): Promise { setWebOrigin(WEB_ORIGIN) setSourceCommit(SOURCE_COMMIT) +setApiHost(API_URL) const mobileCapabilities: PlatformCapabilities = { ...makeFakeCapabilities(), @@ -330,7 +333,11 @@ function useBootstrap() { setUnauthorizedHandler(() => { useAuthStore.getState().markUnauthed() }) - void hydrate() + void adoptStorageEnvironment() + .catch(() => false) + .finally(() => { + void hydrate() + }) return () => setUnauthorizedHandler(null) }, [hydrate]) } @@ -628,7 +635,11 @@ export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) { {copy.title} {copy.body} {__DEV__ ? {String(error?.message ?? error)} : null} - + [crash.action, pressed ? crash.actionPressed : null]} + > {copy.action} @@ -686,6 +697,9 @@ function crashStyles(t: Theme) { borderRadius: t.radius.pill, backgroundColor: t.colors.brand.bloom, }, + actionPressed: { + opacity: 0.85, + }, actionLabel: { fontSize: t.fontSize["16"], fontWeight: "600", diff --git a/apps/community-mobile/app/auth/otp.tsx b/apps/community-mobile/app/auth/otp.tsx index 2abf889a..b85d004a 100644 --- a/apps/community-mobile/app/auth/otp.tsx +++ b/apps/community-mobile/app/auth/otp.tsx @@ -169,7 +169,7 @@ export default function OtpScreen() { > - + @@ -226,8 +226,8 @@ export default function OtpScreen() { {error ? ( - - + + {error} @@ -254,7 +254,15 @@ export default function OtpScreen() { {t("resend.prompt")} - 0 || resending} hitSlop={8}> + 0 || resending} + accessibilityRole="button" + accessibilityLabel={t("resend.action")} + accessibilityState={{ disabled: cooldown > 0 || resending }} + hitSlop={8} + style={({ pressed }) => (pressed && !(cooldown > 0 || resending) ? styles.resendPressed : null)} + > 0 ? th.colors.textSubtle : th.colors.brand.bloom} @@ -349,4 +357,7 @@ const useStyles = makeThemedStyles((t) => ({ resendText: { fontFamily: t.fontFamily.bodySemiBold, }, + resendPressed: { + opacity: 0.6, + }, })) diff --git a/apps/community-mobile/app/cleanups/[id]/analytics.tsx b/apps/community-mobile/app/cleanups/[id]/analytics.tsx new file mode 100644 index 00000000..c2fce836 --- /dev/null +++ b/apps/community-mobile/app/cleanups/[id]/analytics.tsx @@ -0,0 +1,8 @@ +import { useLocalSearchParams } from "expo-router" +import DetailRouteHost from "@/components/DetailRouteHost" + +export default function EventAnalyticsScreen() { + const { id } = useLocalSearchParams<{ id: string }>() + + return +} diff --git a/apps/community-mobile/app/cleanups/[id]/announcements/[announcementId].tsx b/apps/community-mobile/app/cleanups/[id]/announcements/[announcementId].tsx new file mode 100644 index 00000000..8ca1e329 --- /dev/null +++ b/apps/community-mobile/app/cleanups/[id]/announcements/[announcementId].tsx @@ -0,0 +1,12 @@ +import { useLocalSearchParams } from "expo-router" +import DetailRouteHost from "@/components/DetailRouteHost" + +export default function AnnouncementScreen() { + const { id, announcementId } = useLocalSearchParams<{ id: string; announcementId: string }>() + + return ( + + ) +} diff --git a/apps/community-mobile/app/cleanups/[id]/announcements/index.tsx b/apps/community-mobile/app/cleanups/[id]/announcements/index.tsx new file mode 100644 index 00000000..51ad28ab --- /dev/null +++ b/apps/community-mobile/app/cleanups/[id]/announcements/index.tsx @@ -0,0 +1,8 @@ +import { useLocalSearchParams } from "expo-router" +import DetailRouteHost from "@/components/DetailRouteHost" + +export default function AnnouncementsScreen() { + const { id } = useLocalSearchParams<{ id: string }>() + + return +} diff --git a/apps/community-mobile/app/host/analytics.tsx b/apps/community-mobile/app/host/analytics.tsx new file mode 100644 index 00000000..a980e615 --- /dev/null +++ b/apps/community-mobile/app/host/analytics.tsx @@ -0,0 +1,5 @@ +import DetailRouteHost from "@/components/DetailRouteHost" + +export default function HostAnalyticsScreen() { + return +} diff --git a/apps/community-mobile/app/index.tsx b/apps/community-mobile/app/index.tsx index 481c0dc9..d2d67404 100644 --- a/apps/community-mobile/app/index.tsx +++ b/apps/community-mobile/app/index.tsx @@ -26,13 +26,11 @@ import { shouldAdoptCenter, PRECISE_ZOOM, APPROX_ZOOM, - type DetailEntry, type MapCenterSource, type MapCenterTarget, type MapHandle, type MapProps, type RememberedCenter, - type View as NavView, } from "@civfix/ui" import { queryKeys, @@ -44,8 +42,9 @@ import { import { useHaptics } from "@civfix/ui/capabilities" import { mobileHostMapPlan } from "@/components/hostMapPlan" import { + ROOT_SHELL_ID, clearNestedShellHosts, - nestedShellBodyEntry, + shellStackBelow, useNestedShellStore, } from "@/lib/nestedShellSignal" import { LocationPrimerSheet } from "@/components/LocationPrimerSheet" @@ -520,19 +519,13 @@ export default function MapHomeScreen() { focusedCleanupId, ]) - const nestedShell = useNestedShellStore() - const renderRootBody = useCallback( - (bodyEntry: DetailEntry | null, view: NavView): React.ReactNode => { - const plan = nestedShellBodyEntry(nestedShell, bodyEntry) - return plan.render ? defaultRenderBody(plan.entry, view) : null - }, - [nestedShell], - ) + const ownedStack = useNestedShellStore((s) => shellStackBelow(s, ROOT_SHELL_ID)) return ( <> () + + return +} diff --git a/apps/community-mobile/eas.json b/apps/community-mobile/eas.json index 8c4020bd..781b6428 100644 --- a/apps/community-mobile/eas.json +++ b/apps/community-mobile/eas.json @@ -35,7 +35,7 @@ "channel": "testflight", "autoIncrement": true, "env": { - "//api": "Dev/TestFlight testing builds bake the staging API base URL. App Store releases use the production profile, which sets no EXPO_PUBLIC_API_URL and therefore falls back to https://api.civfix.org (src/lib/apiUrl.ts).", + "//api": "Dev/TestFlight testing builds bake the staging API base URL, pinning this profile to https://api.civfix.dev however it is installed. The production profile below bakes nothing, so src/lib/apiUrl.ts picks its API at RUNTIME from the active StoreKit receipt: https://api.civfix.dev while that build is handed out through TestFlight, https://api.civfix.org once it is downloaded from the App Store.", "//donate": "How a host's external donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. The link is the host's own third-party page; no payment happens anywhere in civfix. OTA-flippable with `eas update` if App Review ever objects to the in-app browser (see APP-REVIEW-NOTES.md).", "EXPO_PUBLIC_DONATE_BROWSER_MODE": "in-app", "EXPO_PUBLIC_API_URL": "https://api.civfix.dev", @@ -58,6 +58,7 @@ "channel": "production", "autoIncrement": true, "env": { + "//api": "Deliberately sets no EXPO_PUBLIC_API_URL. On iOS ONLY, src/lib/apiUrl.ts resolves the base URL at runtime from the active StoreKit receipt, so one binary talks to https://api.civfix.dev while it is under test in TestFlight and to https://api.civfix.org once the same build is downloaded from the App Store. Every other platform resolves to https://api.civfix.org, so an Android internal-track tester is on production. Baking a URL here would freeze iOS to one of the two.", "//dsym": "iOS links React Native's prebuilt core frameworks (buildReactNativeFromSource: false in app.config.js), so App Store Connect's 'Upload Symbols Failed' warning for React.framework / ReactNativeDependencies.framework (alongside hermes and MapLibre) is expected and non-blocking.", "//donate": "How a host's external donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. The link is the host's own third-party page; no payment happens anywhere in civfix. OTA-flippable with `eas update` if App Review ever objects to the in-app browser (see APP-REVIEW-NOTES.md).", "EXPO_PUBLIC_DONATE_BROWSER_MODE": "in-app", diff --git a/apps/community-mobile/scripts/prep-archive.sh b/apps/community-mobile/scripts/prep-archive.sh index 9e63a5a8..7792c894 100755 --- a/apps/community-mobile/scripts/prep-archive.sh +++ b/apps/community-mobile/scripts/prep-archive.sh @@ -2,8 +2,10 @@ # Prepare the native iOS project for a HAND-DRIVEN Xcode archive (Product > Archive), with the right # API base URL baked in. Does not build or upload anything. # -# scripts/prep-archive.sh testflight dev/testing build -> staging API https://api.civfix.dev -# scripts/prep-archive.sh appstore App Store release -> prod API https://api.civfix.org +# scripts/prep-archive.sh testflight dev/testing build -> staging API https://api.civfix.dev, baked +# scripts/prep-archive.sh appstore App Store release -> no baked API URL; the app resolves it at +# runtime from the install source (api.civfix.dev in TestFlight, +# api.civfix.org from the App Store) # scripts/prep-archive.sh --platform android same, for the gradle release build # # WHY THIS EXISTS. `eas.json` build profiles are read by `eas build` ONLY. A raw Xcode archive never @@ -15,9 +17,12 @@ # writes `.env`, then regenerates the native project from it. # # `.env` is gitignored and persists, so it silently governs every later local build too. That is -# exactly how a TestFlight prep would otherwise poison a subsequent App Store archive, which is why -# the appstore target writes the prod URL EXPLICITLY instead of leaning on the fallback in -# src/lib/apiUrl.ts, and why the banner below prints the baked URL read back off the built config. +# exactly how a TestFlight prep would otherwise poison a subsequent App Store archive. This script +# REWRITES `.env` on every run, so the appstore target's omission of EXPO_PUBLIC_API_URL is a real +# omission rather than a leftover: a prior testflight prep cannot survive into it. The banner below +# then reads the baked config back off the built app config and refuses the archive unless it matches +# the target - an appstore archive must carry NO apiUrl at all, or the runtime install-source split in +# src/lib/apiUrl.ts is frozen to whichever URL got baked. set -euo pipefail cd "$(dirname "$0")/.." @@ -30,7 +35,7 @@ usage() { target="${1:-}" case "$target" in testflight) api_url="https://api.civfix.dev"; channel="testflight" ;; - appstore) api_url="https://api.civfix.org"; channel="production" ;; + appstore) api_url=""; channel="production" ;; *) usage ;; esac @@ -62,18 +67,24 @@ if [ "$platform" = "ios" ]; then command -v xcodebuild >/dev/null 2>&1 || { echo "xcodebuild not found. Install Xcode + command-line tools." >&2; exit 1; } fi +if [ -n "$api_url" ]; then + api_line="EXPO_PUBLIC_API_URL=${api_url}" +else + api_line="# No EXPO_PUBLIC_API_URL on purpose: src/lib/apiUrl.ts resolves it at runtime from the install source." +fi + # Keep the Google values in lockstep with eas.json so a local archive and an EAS build of the same # target are byte-for-byte equivalent in configuration. These are public client ids, not secrets. cat > .env <}' but the ${profile} profile promises '${expected_api_url:-}'. Refusing to upload ${ipa}." >&2 + echo "Baked API URL is '${baked_api_url:-}' but the ${profile} profile promises '${expected_api_url:-}'. Refusing to upload ${ipa}." >&2 exit 1 fi build_version="$(unzip -p "$ipa" 'Payload/*.app/Info.plist' | plutil -convert json -o - - | node -e ' const plist = JSON.parse(require("fs").readFileSync(0, "utf8")) process.stdout.write(`${plist.CFBundleShortVersionString} (${plist.CFBundleVersion})`) ')" -echo "Built ${ipa}: version ${build_version}, profile ${profile}, API ${baked_api_url:-https://api.civfix.org (default)}" +echo "Built ${ipa}: version ${build_version}, profile ${profile}, API ${baked_api_url:-resolved at runtime (api.civfix.dev in TestFlight, api.civfix.org from the App Store)}" if [ "$submit" = 0 ]; then echo "Upload later with: scripts/store-upload.sh ${ipa}" diff --git a/apps/community-mobile/src/auth/storage.ts b/apps/community-mobile/src/auth/storage.ts index 2a9eac92..de7fadac 100644 --- a/apps/community-mobile/src/auth/storage.ts +++ b/apps/community-mobile/src/auth/storage.ts @@ -1,7 +1,9 @@ import * as SecureStore from "expo-secure-store" import { storage } from "@/lib/mmkv" +import { scopeStorageId } from "@/lib/storageScope" +import { API_URL } from "@/config" -const TOKEN_KEY = "civfix.session.token" +const TOKEN_KEY = scopeStorageId("civfix.session.token", API_URL) const OPTIONS: SecureStore.SecureStoreOptions = { keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK, diff --git a/apps/community-mobile/src/components/AuthOptions.tsx b/apps/community-mobile/src/components/AuthOptions.tsx index 5635c267..a742a613 100644 --- a/apps/community-mobile/src/components/AuthOptions.tsx +++ b/apps/community-mobile/src/components/AuthOptions.tsx @@ -214,8 +214,8 @@ export function AuthOptions({ {error ? ( - - + + {error} diff --git a/apps/community-mobile/src/components/DetailRouteHost.tsx b/apps/community-mobile/src/components/DetailRouteHost.tsx index 5dcbed99..193e36cd 100644 --- a/apps/community-mobile/src/components/DetailRouteHost.tsx +++ b/apps/community-mobile/src/components/DetailRouteHost.tsx @@ -12,8 +12,14 @@ import { } from "@civfix/ui" import { seedEntry } from "@/components/MobileNavAdapter" import { goHome, navTeardownEpoch } from "@/lib/goHome" -import { detailRestorePlan, nativeBridgeKey } from "@/lib/navBridge" -import { enterNestedShell, exitNestedShell, rootIsTopRoute } from "@/lib/nestedShellSignal" +import { detailRestorePlan, detailShellSnapshot, nativeBridgeKey } from "@/lib/navBridge" +import { + enterNestedShell, + exitNestedShell, + rootIsTopRoute, + shellStackBelow, + useNestedShellStore, +} from "@/lib/nestedShellSignal" import { secondaryShellBackAction } from "@/lib/secondaryShellBack" export interface DetailRouteHostProps { @@ -28,7 +34,9 @@ export default function DetailRouteHost({ entry }: DetailRouteHostProps): React. const router = useRouter() const navigationRef = useNavigationContainerRef() const hostId = useId() - const nativeGestureOwnsBack = useNavStore((s) => s.stack.length <= 1) + const ownedStack = useNestedShellStore((s) => shellStackBelow(s, hostId)) + const liveStackLength = useNavStore((s) => s.stack.length) + const nativeGestureOwnsBack = (ownedStack ? ownedStack.length : liveStackLength) <= 1 const entryRef = useRef(entry) entryRef.current = entry @@ -48,23 +56,36 @@ export default function DetailRouteHost({ entry }: DetailRouteHostProps): React. focusedBridgeKeyRef.current = focusedBridgeKey useLayoutEffect(() => { - if (!entry) return if (restoreRef.current === null) { - restoreRef.current = useNavStore - .getState() - .stack.filter((e) => entryIdentity(e) !== seedKey) + restoreRef.current = detailShellSnapshot(useNavStore.getState().stack, seedKey, entryIdentity) teardownEpochRef.current = navTeardownEpoch() } - seedEntry(entry) + enterNestedShell(hostId, restoreRef.current) + if (entry) seedEntry(entry) // eslint-disable-next-line react-hooks/exhaustive-deps }, [seedKey]) useEffect(() => { - const restore = restoreRef.current - enterNestedShell( - hostId, - restore && restore.length > 0 ? (restore[restore.length - 1] ?? null) : null, - ) + return () => { + const restore = restoreRef.current + restoreRef.current = null + if (restore === null) return + const state = useNavStore.getState() + const plan = detailRestorePlan({ + restore, + seedKey: seedKeyRef.current, + activeKey: entryIdentity(state.active), + stackLength: state.stack.length, + left: leftRef.current, + tornDown: navTeardownEpoch() !== teardownEpochRef.current, + focusedBridgeKey: focusedBridgeKeyRef.current(), + }) + if (plan.type === "restore") state.setStack(plan.stack) + else if (plan.type === "clear") state.setStack([]) + } + }, []) + + useEffect(() => { return () => exitNestedShell(hostId) }, [hostId]) @@ -118,31 +139,17 @@ export default function DetailRouteHost({ entry }: DetailRouteHostProps): React. }, [router]), ) - useEffect(() => { - return () => { - const restore = restoreRef.current - restoreRef.current = null - if (restore === null) return - const state = useNavStore.getState() - const plan = detailRestorePlan({ - restore, - seedKey: seedKeyRef.current, - activeKey: entryIdentity(state.active), - stackLength: state.stack.length, - left: leftRef.current, - tornDown: navTeardownEpoch() !== teardownEpochRef.current, - focusedBridgeKey: focusedBridgeKeyRef.current(), - }) - if (plan.type === "restore") state.setStack(plan.stack) - else if (plan.type === "clear") state.setStack([]) - } - }, []) - return ( <> - + ) diff --git a/apps/community-mobile/src/components/FirstRunGate.tsx b/apps/community-mobile/src/components/FirstRunGate.tsx index 507e9938..cc52bd09 100644 --- a/apps/community-mobile/src/components/FirstRunGate.tsx +++ b/apps/community-mobile/src/components/FirstRunGate.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react" -import { View, StyleSheet, ActivityIndicator } from "react-native" +import { View, StyleSheet, ActivityIndicator, Pressable } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" import { Ionicons } from "@expo/vector-icons" import { isValidHandle } from "@civfix/shared" @@ -161,8 +161,8 @@ function FirstRunForm() { {error ? ( - - + + {error} @@ -176,14 +176,17 @@ function FirstRunForm() { void onSubmit()} /> - void signOut()} + accessibilityRole="button" + accessibilityLabel={t("signout.action")} + hitSlop={8} + style={({ pressed }) => [styles.signOut, pressed ? styles.signOutPressed : null]} > - {t("signout.prompt")} {t("signout.action")} - + + {t("signout.prompt")} {t("signout.action")} + + @@ -216,7 +219,7 @@ function HandleHint({ content = t("handle.checking") } else if (available) { content = t("handle.available", { handle }) - color = th.colors.moss["700"] + color = th.colors.successInk } else if (taken) { content = t("handle.taken", { handle }) color = th.colors.brand.bloom @@ -260,5 +263,7 @@ const useStyles = makeThemedStyles((t) => ({ borderTopColor: t.colors.border, backgroundColor: t.colors.bg, }, - signOut: { textAlign: "center", marginTop: t.space["3"] }, + signOut: { alignSelf: "center", marginTop: t.space["3"] }, + signOutPressed: { opacity: 0.6 }, + signOutText: { textAlign: "center" }, })) diff --git a/apps/community-mobile/src/components/report/ReportViewfinder.tsx b/apps/community-mobile/src/components/report/ReportViewfinder.tsx index 70f8b385..64ade3f7 100644 --- a/apps/community-mobile/src/components/report/ReportViewfinder.tsx +++ b/apps/community-mobile/src/components/report/ReportViewfinder.tsx @@ -464,12 +464,28 @@ export function ReportViewfinder({ - !recording && setMode("photo")} hitSlop={8}> + setMode("photo")} + disabled={recording} + accessibilityRole="button" + accessibilityLabel={t("mode.photo")} + accessibilityState={{ selected: mode === "photo", disabled: recording }} + hitSlop={8} + style={({ pressed }) => (pressed && !recording ? styles.pressed : null)} + > {t("mode.photo")} - !recording && setMode("video")} hitSlop={8}> + setMode("video")} + disabled={recording} + accessibilityRole="button" + accessibilityLabel={t("mode.video")} + accessibilityState={{ selected: mode === "video", disabled: recording }} + hitSlop={8} + style={({ pressed }) => (pressed && !recording ? styles.pressed : null)} + > {t("mode.video")} @@ -482,7 +498,8 @@ export function ReportViewfinder({ disabled={busy || recording} accessibilityRole="button" accessibilityLabel={t("gate.choose_library")} - style={styles.sideBtn} + hitSlop={8} + style={({ pressed }) => [styles.sideBtn, pressed && !(busy || recording) ? styles.pressed : null]} > @@ -586,7 +603,7 @@ const cameraStyles = StyleSheet.create({ right: 60, bottom: 60, borderWidth: 1, - borderColor: "rgba(255,255,255,0.18)", + borderColor: stage.colors.lightboxControl, borderStyle: "dashed", borderRadius: 8, }, diff --git a/apps/community-mobile/src/components/scan/TicketScanner.tsx b/apps/community-mobile/src/components/scan/TicketScanner.tsx index 1100f685..7ecaed39 100644 --- a/apps/community-mobile/src/components/scan/TicketScanner.tsx +++ b/apps/community-mobile/src/components/scan/TicketScanner.tsx @@ -175,7 +175,7 @@ const scannerStyles = StyleSheet.create({ right: 48, bottom: "22%", borderWidth: 2, - borderColor: stage.colors.neutral.card, + borderColor: stage.colors.onScrim, borderRadius: radius.lg, }, }) diff --git a/apps/community-mobile/src/components/ui/ScreenHeader.tsx b/apps/community-mobile/src/components/ui/ScreenHeader.tsx index 1059b854..b1239d86 100644 --- a/apps/community-mobile/src/components/ui/ScreenHeader.tsx +++ b/apps/community-mobile/src/components/ui/ScreenHeader.tsx @@ -34,7 +34,7 @@ export function ScreenHeader({ else router.replace("/") } - const glyphColor = overlay ? th.colors.neutral.card : th.colors.text + const glyphColor = overlay ? th.colors.onScrim : th.colors.text return ( { @@ -379,3 +385,39 @@ test("every detail route hosts its entry in a nested shell instead of seeding an assert.match(source, new RegExp(`kind: "${kind}"`), `${file} must host the ${kind} entry`) } }) + +const cluster = { kind: "cluster", id: "cl1" } as DetailEntry +const dropPin = { kind: "drop-pin" } as DetailEntry +const bareView = { kind: "view" } as DetailEntry + +test("detailShellSnapshot excludes the entry the route seeds", () => { + assert.deepEqual(detailShellSnapshot([person, cleanup], identity(cleanup), identity), [person]) +}) + +test("a route with NO entry snapshots the whole stack instead of dropping its null-identity rows", () => { + const stack = [cluster, dropPin, bareView, person] + assert.deepEqual(detailShellSnapshot(stack, null, identity), stack) +}) + +test("an unaddressable entry survives a snapshot taken for an addressable seed", () => { + assert.deepEqual(detailShellSnapshot([cluster, dropPin, cleanup], identity(cleanup), identity), [ + cluster, + dropPin, + ]) +}) + +test("the snapshot is a copy, so a later store mutation cannot rewrite the frozen stack", () => { + const stack = [person] + const snapshot = detailShellSnapshot(stack, null, identity) + assert.notEqual(snapshot, stack) + assert.deepEqual(snapshot, stack) +}) + +test("DetailRouteHost takes its snapshot through detailShellSnapshot, not a raw identity filter", () => { + const host = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "..", "components", "DetailRouteHost.tsx"), + "utf8", + ) + assert.match(host, /detailShellSnapshot\(useNavStore\.getState\(\)\.stack, seedKey, entryIdentity\)/) + assert.doesNotMatch(host, /stack\.filter\(\(e\) => entryIdentity\(e\) !== seedKey\)/) +}) diff --git a/apps/community-mobile/src/lib/deviceId.ts b/apps/community-mobile/src/lib/deviceId.ts index c02f78cb..909506b6 100644 --- a/apps/community-mobile/src/lib/deviceId.ts +++ b/apps/community-mobile/src/lib/deviceId.ts @@ -1,13 +1,11 @@ /** * Stable per-install device identifier for push-token registration. * - * WHY: the backend `push_tokens` upsert carries an OWNERSHIP-STEAL guard (P1-3) — an Expo push token, - * which is stable per app-install (the SAME string regardless of which account is signed in), is only - * (re)assigned to the registering user when that user already owns it OR presents the SAME non-null - * `device_id` as proof of genuine same-device handoff. Without a device id every account after the FIRST - * one signed in on a device hits the guard, the upsert returns "conflict", and that account gets NO - * push-token row. Sending a stable device id lets the guard transfer the token to whoever is currently - * signed in on the device, which is the correct behaviour. + * The id is sent with every push registration and stored for diagnostics and bookkeeping only; it no + * longer proves anything to the backend. The `push_tokens` upsert reclaims a row for the registering + * user when that user already owns it or when the row was revoked by a sign-out — an Expo push token is + * stable per app-install (the SAME string regardless of which account is signed in), so a row that is + * still ACTIVE under another account is refused with "conflict". * * `resolveDeviceId` is the pure functional core (store + uuid generator injected) so it is unit-testable * without the native SecureStore/Crypto modules; `getDeviceId` (in src/push/register.ts) is the thin @@ -35,7 +33,7 @@ export async function resolveDeviceId( return fresh } catch { // SecureStore unavailable (e.g. unsigned simulator with no Keychain): fall back to no device id so - // registration still proceeds — same as the pre-fix behaviour, just without same-device handoff. + // registration still proceeds, just without the bookkeeping. return null } } diff --git a/apps/community-mobile/src/lib/errors.test.ts b/apps/community-mobile/src/lib/errors.test.ts index 1fca04e6..2b69bc24 100644 --- a/apps/community-mobile/src/lib/errors.test.ts +++ b/apps/community-mobile/src/lib/errors.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test" import assert from "node:assert/strict" import { AppError, ErrorCode } from "@civfix/shared" import { parseError } from "@civfix/shared/client" -import { isAppError, isRetryableError } from "./errors.ts" +import { isAppError, isConflict, isRetryableError } from "./errors.ts" function crossRealmAppError(code: string, message = "cross-realm"): unknown { const err = new Error(message) @@ -70,6 +70,37 @@ test("a transport failure is retryable - it is not an AppError at all", () => { assert.equal(isRetryableError("UNAUTHORIZED"), true) }) +async function conflictFromTheWire(body: string): Promise { + return parseError( + new Response(body, { status: 409, headers: { "content-type": "application/json" } }), + ) +} + +test("a push-token 409 is recognised as a conflict, enveloped or bare", async () => { + const enveloped = await conflictFromTheWire( + JSON.stringify({ error: { code: ErrorCode.CONFLICT, message: "token owned elsewhere" } }), + ) + assert.equal(isConflict(enveloped), true) + assert.equal(isConflict(await conflictFromTheWire("nginx")), true) +}) + +test("a conflict is recognised in BOTH realms and from the status alone", () => { + assert.equal(isConflict(new AppError(ErrorCode.CONFLICT, "taken")), true) + assert.equal(isConflict(crossRealmAppError(ErrorCode.CONFLICT)), true) + assert.equal( + isConflict(Object.assign(crossRealmAppError(ErrorCode.INTERNAL), { httpStatus: 409 })), + true, + ) +}) + +test("nothing else is a conflict - a 401 or a transport failure must stay retryable", () => { + assert.equal(isConflict(new AppError(ErrorCode.UNAUTHORIZED, "no")), false) + assert.equal(isConflict(new AppError(ErrorCode.INTERNAL, "boom")), false) + assert.equal(isConflict(crossRealmAppError(ErrorCode.RATE_LIMITED)), false) + assert.equal(isConflict(new TypeError("Network request failed")), false) + assert.equal(isConflict(undefined), false) +}) + test("an AppError-shaped object with a non-string code is not treated as an AppError", () => { const err = new Error("x") err.name = "AppError" diff --git a/apps/community-mobile/src/lib/errors.ts b/apps/community-mobile/src/lib/errors.ts index 2de462e1..c754f763 100644 --- a/apps/community-mobile/src/lib/errors.ts +++ b/apps/community-mobile/src/lib/errors.ts @@ -64,6 +64,11 @@ export function isRateLimited(err: unknown): boolean { return isAppError(err) && err.code === ErrorCode.RATE_LIMITED } +export function isConflict(err: unknown): boolean { + if (!isAppError(err)) return false + return err.code === ErrorCode.CONFLICT || err.httpStatus === 409 +} + const NON_RETRYABLE_CODES: ReadonlySet = new Set([ ErrorCode.UNAUTHORIZED, ErrorCode.FORBIDDEN, diff --git a/apps/community-mobile/src/lib/internalHref.ts b/apps/community-mobile/src/lib/internalHref.ts index 46493038..339e7ec6 100644 --- a/apps/community-mobile/src/lib/internalHref.ts +++ b/apps/community-mobile/src/lib/internalHref.ts @@ -3,6 +3,9 @@ import type { NativeRoute } from "./navBridge" export const SHELL_HOST_ROUTE_NAMES: readonly string[] = [ "index", "cleanups/[id]", + "cleanups/[id]/analytics", + "cleanups/[id]/announcements", + "cleanups/[id]/announcements/[announcementId]", "cleanups/[id]/checkin", "cleanups/[id]/host", "cleanups/[id]/hours", @@ -10,7 +13,9 @@ export const SHELL_HOST_ROUTE_NAMES: readonly string[] = [ "cleanups/[id]/ticket", "cleanups/[id]/ticket/[seatId]", "dashboard", + "host/analytics", "orgs/[slug]", + "orgs/[slug]/manage", "people/[id]", "pin/[id]", "profile", diff --git a/apps/community-mobile/src/lib/legacyStorageReset.ts b/apps/community-mobile/src/lib/legacyStorageReset.ts new file mode 100644 index 00000000..f9542280 --- /dev/null +++ b/apps/community-mobile/src/lib/legacyStorageReset.ts @@ -0,0 +1,59 @@ +import * as SecureStore from "expo-secure-store" +import { API_URL } from "@/config" +import { clearToken } from "@/auth/storage" +import { clearAppStorage } from "@/lib/mmkv" +import { clearSecureBlobs } from "@/lib/nativeSecureStore" +import { storageNamespace } from "@/lib/storageScope" +import { + PROD_STORAGE_ENV, + STORAGE_ENV_MARKER_KEY, + purgesLegacyStorage, + storageEnvFor, + writesStorageEnvMarker, +} from "@/lib/storageEnvMarker" +import { queryClient } from "@/query/client" +import { clearPersistedCache, resumeCachePersistence } from "@/query/mmkv-persister" + +const OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK, +} + +async function readMarker(): Promise { + try { + return await SecureStore.getItemAsync(STORAGE_ENV_MARKER_KEY, OPTIONS) + } catch { + return null + } +} + +async function writeMarker(value: string): Promise { + try { + await SecureStore.setItemAsync(STORAGE_ENV_MARKER_KEY, value, OPTIONS) + } catch { + return + } +} + +async function purge(): Promise { + await clearToken() + clearPersistedCache() + clearAppStorage() + await clearSecureBlobs() + queryClient.clear() + resumeCachePersistence() +} + +export async function adoptStorageEnvironment(): Promise { + const namespace = storageNamespace(API_URL) + const marker = await readMarker() + const purged = purgesLegacyStorage(namespace, marker) + if (purged) await purge() + if (writesStorageEnvMarker(namespace, marker)) await writeMarker(storageEnvFor(namespace)) + if (__DEV__) { + console.log( + `[storage-env] namespace="${namespace || PROD_STORAGE_ENV}" previous=${marker ?? ""} ` + + `purgedLegacyIds=${purged}`, + ) + } + return purged +} diff --git a/apps/community-mobile/src/lib/mmkv.ts b/apps/community-mobile/src/lib/mmkv.ts index f2154df5..5aca7c6e 100644 --- a/apps/community-mobile/src/lib/mmkv.ts +++ b/apps/community-mobile/src/lib/mmkv.ts @@ -1,5 +1,9 @@ import { MMKV } from "react-native-mmkv" import type { StateStorage } from "zustand/middleware" +import { API_URL } from "@/config" +import { scopeStorageId } from "@/lib/storageScope" + +const INSTANCE_ID = scopeStorageId("civfix.app", API_URL) export interface KeyValueStore { getString(key: string): string | undefined @@ -7,7 +11,11 @@ export interface KeyValueStore { delete(key: string): void } -function memoryStore(): KeyValueStore { +interface ClearableStore extends KeyValueStore { + clearAll(): void +} + +function memoryStore(): ClearableStore { const map = new Map() return { getString: (key) => map.get(key), @@ -17,18 +25,29 @@ function memoryStore(): KeyValueStore { delete: (key) => { map.delete(key) }, + clearAll: () => map.clear(), } } -function createStore(): KeyValueStore { +function createStore(): ClearableStore { try { - return new MMKV({ id: "civfix.app" }) + return new MMKV({ id: INSTANCE_ID }) } catch { return memoryStore() } } -export const storage: KeyValueStore = createStore() +const store = createStore() + +export const storage: KeyValueStore = store + +export function clearAppStorage(): void { + try { + store.clearAll() + } catch { + return + } +} export const mmkvStateStorage: StateStorage = { getItem: (name) => storage.getString(name) ?? null, diff --git a/apps/community-mobile/src/lib/nativeBetaInstall.ts b/apps/community-mobile/src/lib/nativeBetaInstall.ts new file mode 100644 index 00000000..00b2c95e --- /dev/null +++ b/apps/community-mobile/src/lib/nativeBetaInstall.ts @@ -0,0 +1,52 @@ +import { Platform } from "react-native" +import { + APP_STORE_RECEIPT, + RECEIPT_ABSENT, + SANDBOX_RECEIPT, + STORE_KIT_DIR, + betaInstallFromReceipts, + type ReceiptStat, +} from "@/lib/storeKitReceipt" + +type StoreKitProbe = { dir: string | null; store: ReceiptStat; sandbox: ReceiptStat } + +const PROBE_UNAVAILABLE: StoreKitProbe = { + dir: null, + store: RECEIPT_ABSENT, + sandbox: RECEIPT_ABSENT, +} + +function statReceipt(file: { exists: boolean; modificationTime: number | null }): ReceiptStat { + if (!file.exists) return RECEIPT_ABSENT + const modificationTime = file.modificationTime + return { present: true, modifiedAt: typeof modificationTime === "number" ? modificationTime : null } +} + +function probeStoreKit(): StoreKitProbe { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- loaded inside the guard so a resolution failure cannot break boot + const { File, Paths } = require("expo-file-system") as typeof import("expo-file-system") + const container = Paths.document.parentDirectory + return { + dir: container.uri, + store: statReceipt(new File(container, STORE_KIT_DIR, APP_STORE_RECEIPT)), + sandbox: statReceipt(new File(container, STORE_KIT_DIR, SANDBOX_RECEIPT)), + } + } catch { + return PROBE_UNAVAILABLE + } +} + +export function isBetaInstall(): boolean { + if (Platform.OS !== "ios") return false + const probe = probeStoreKit() + const beta = betaInstallFromReceipts(probe.store, probe.sandbox) + if (__DEV__) { + console.log( + `[install-source] StoreKit container ${probe.dir ?? ""}; ` + + `${APP_STORE_RECEIPT}=${JSON.stringify(probe.store)}, ` + + `${SANDBOX_RECEIPT}=${JSON.stringify(probe.sandbox)} -> beta=${beta}`, + ) + } + return beta +} diff --git a/apps/community-mobile/src/lib/nativeCamera.ts b/apps/community-mobile/src/lib/nativeCamera.ts index 62d98ddb..94fe226d 100644 --- a/apps/community-mobile/src/lib/nativeCamera.ts +++ b/apps/community-mobile/src/lib/nativeCamera.ts @@ -1,10 +1,10 @@ -import { InteractionManager } from "react-native" import { Directory, File, Paths } from "expo-file-system" import * as Crypto from "expo-crypto" import * as ImagePicker from "expo-image-picker" import { ImageManipulator, SaveFormat } from "expo-image-manipulator" import { Image as ImageCompressor, Video as VideoCompressor } from "react-native-compressor" import { AppError, ErrorCode } from "@civfix/shared" +import { motion } from "@civfix/ui/theme" import type { CameraCapability, CapturedMedia, @@ -40,6 +40,7 @@ const VIDEO_MIN_COMPRESS_MB = 0 const STALE_TEMP_AGE_MS = 60 * 60_000 const SWEEP_DELETE_LIMIT = 40 const SWEEP_SCAN_LIMIT = 400 +const SWEEP_DELAY_MS = motion.pagePop.duration const TEMP_OUTPUT_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|jpeg|png|mp4|mov|m4a)$/i @@ -95,14 +96,20 @@ function sweepDirectory(directory: Directory, now: number, budget: number): numb function sweepStaleMediaTempFiles(): void { if (sweptThisSession) return sweptThisSession = true - InteractionManager.runAfterInteractions(() => { + setTimeout(() => { const now = Date.now() + let removed = 0 try { - const removed = sweepDirectory(new Directory(Paths.cache, "ImageManipulator"), now, SWEEP_DELETE_LIMIT) - sweepDirectory(Paths.cache, now, SWEEP_DELETE_LIMIT - removed) + removed = sweepDirectory(new Directory(Paths.cache, "ImageManipulator"), now, SWEEP_DELETE_LIMIT) } catch { } - }) + setTimeout(() => { + try { + sweepDirectory(Paths.cache, now, SWEEP_DELETE_LIMIT - removed) + } catch { + } + }, 0) + }, SWEEP_DELAY_MS) } async function sha256Hex(bytes: Uint8Array): Promise { diff --git a/apps/community-mobile/src/lib/nativeSecureStore.ts b/apps/community-mobile/src/lib/nativeSecureStore.ts index 09fc5535..9c59deaa 100644 --- a/apps/community-mobile/src/lib/nativeSecureStore.ts +++ b/apps/community-mobile/src/lib/nativeSecureStore.ts @@ -4,9 +4,11 @@ import * as Crypto from "expo-crypto" import type { SecureStoreCapability } from "@civfix/ui/capabilities" import type { KeyValueStore } from "@/lib/mmkv" import { mintSecureBlobKey, normalizeSecureBlobKey } from "@/lib/secureBlobKey" +import { scopeStorageId } from "@/lib/storageScope" +import { API_URL } from "@/config" -const ENCRYPTION_KEY_ITEM = "civfix.secure-blobs.key" -const INSTANCE_ID = "civfix.secure" +const ENCRYPTION_KEY_ITEM = scopeStorageId("civfix.secure-blobs.key", API_URL) +const INSTANCE_ID = scopeStorageId("civfix.secure", API_URL) const OPTIONS: SecureStore.SecureStoreOptions = { keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK, diff --git a/apps/community-mobile/src/lib/navBridge.test.ts b/apps/community-mobile/src/lib/navBridge.test.ts index 5ad032ec..b9627ab8 100644 --- a/apps/community-mobile/src/lib/navBridge.test.ts +++ b/apps/community-mobile/src/lib/navBridge.test.ts @@ -334,9 +334,9 @@ test("a screen that renders its OWN body drops the entry when it is already on t } }) -test("the quick broadcast stays in the sheet so its draft guard applies", () => { - assert.equal(bridgeKey({ kind: "host-broadcast-quick", id: "c1" }), null) - assert.equal(bridgeRoute({ kind: "host-broadcast-quick", id: "c1" }), null) - const { actions } = run([{ active: { kind: "host-broadcast-quick", id: "c1" }, now: 0 }]) +test("the announcement composer stays in the sheet so its draft guard applies", () => { + assert.equal(bridgeKey({ kind: "host-announce", id: "c1" }), null) + assert.equal(bridgeRoute({ kind: "host-announce", id: "c1" }), null) + const { actions } = run([{ active: { kind: "host-announce", id: "c1" }, now: 0 }]) assert.equal(actions[0].type, "none") }) diff --git a/apps/community-mobile/src/lib/navBridge.ts b/apps/community-mobile/src/lib/navBridge.ts index 60cb6e1e..b2a9741f 100644 --- a/apps/community-mobile/src/lib/navBridge.ts +++ b/apps/community-mobile/src/lib/navBridge.ts @@ -161,6 +161,15 @@ export function restorableStack( }) } +export function detailShellSnapshot( + stack: readonly DetailEntry[], + seedKey: string | null, + identityOf: (entry: DetailEntry) => string | null, +): DetailEntry[] { + if (seedKey === null) return [...stack] + return stack.filter((entry) => identityOf(entry) !== seedKey) +} + export function detailRestorePlan(input: DetailRestoreInput): DetailRestorePlan { if (input.tornDown) { return input.activeKey !== null && input.activeKey === input.seedKey diff --git a/apps/community-mobile/src/lib/nestedShellSignal.test.ts b/apps/community-mobile/src/lib/nestedShellSignal.test.ts index 720c8303..233b0118 100644 --- a/apps/community-mobile/src/lib/nestedShellSignal.test.ts +++ b/apps/community-mobile/src/lib/nestedShellSignal.test.ts @@ -1,39 +1,79 @@ import assert from "node:assert/strict" import { test } from "node:test" +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" import type { DetailEntry } from "@civfix/ui" import { NO_NESTED_SHELL, - nestedShellBodyEntry, + ROOT_SHELL_ID, rootIsTopRoute, + shellStackBelow, withNestedShellHost, withoutNestedShellHost, withoutNestedShellHosts, } from "./nestedShellSignal.ts" +const SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), "..") +const APP_DIR = join(SRC_DIR, "..", "app") + const cleanup = { kind: "cleanup", id: "c1" } as DetailEntry const org = { kind: "org", slug: "acme" } as DetailEntry +const person = { kind: "person", id: "u1" } as DetailEntry + +test("with no host mounted every shell reads the live store", () => { + assert.equal(shellStackBelow(NO_NESTED_SHELL, ROOT_SHELL_ID), null) + assert.equal(shellStackBelow(NO_NESTED_SHELL, "h1"), null) +}) + +test("the root renders the stack it owned when the first host took over", () => { + const state = withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]) + assert.deepEqual(shellStackBelow(state, ROOT_SHELL_ID), [cleanup]) +}) + +test("a root that owned no detail renders an EMPTY stack, never the host's entry", () => { + const state = withNestedShellHost(NO_NESTED_SHELL, "h1", []) + assert.deepEqual(shellStackBelow(state, ROOT_SHELL_ID), []) +}) -test("a host ABOVE the root defers the root body to the entry the root held before it", () => { - const state = withNestedShellHost(NO_NESTED_SHELL, "h1", cleanup) - assert.deepEqual(nestedShellBodyEntry(state, org), { render: true, entry: cleanup }) +test("the top host reads the live store, and every host below it reads its own snapshot", () => { + const one = withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]) + const two = withNestedShellHost(one, "h2", [org]) + assert.deepEqual(shellStackBelow(two, ROOT_SHELL_ID), [cleanup]) + assert.deepEqual(shellStackBelow(two, "h1"), [org]) + assert.equal(shellStackBelow(two, "h2"), null) }) -test("a host above the root with an empty snapshot renders no root body", () => { - const state = withNestedShellHost(NO_NESTED_SHELL, "h1", null) - assert.deepEqual(nestedShellBodyEntry(state, cleanup), { render: false }) +test("a host that has not registered yet reads the live store", () => { + const state = withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]) + assert.equal(shellStackBelow(state, "unregistered"), null) }) -test("a host left BELOW the root by a POP_TO replace stops deferring the root body", () => { - const mounted = withNestedShellHost(NO_NESTED_SHELL, "h1", null) - assert.deepEqual(nestedShellBodyEntry(mounted, cleanup), { render: false }) - const rootFocused = withoutNestedShellHosts(mounted) - assert.deepEqual(rootFocused, NO_NESTED_SHELL) - assert.deepEqual(nestedShellBodyEntry(rootFocused, cleanup), { render: true, entry: cleanup }) +test("a snapshot is returned by reference, so a selector does not churn the shell", () => { + const snapshot = [cleanup] + const state = withNestedShellHost(NO_NESTED_SHELL, "h1", snapshot) + assert.equal(shellStackBelow(state, ROOT_SHELL_ID), snapshot) +}) + +test("hosts are counted by identity, so a double exit cannot unfreeze a shell still covered", () => { + const two = withNestedShellHost(withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]), "h2", [org]) + const once = withoutNestedShellHost(two, "h2") + assert.deepEqual( + once.hosts.map((host) => host.id), + ["h1"], + ) + assert.deepEqual(withoutNestedShellHost(once, "h2"), once) + assert.deepEqual(shellStackBelow(once, ROOT_SHELL_ID), [cleanup]) + assert.deepEqual(withoutNestedShellHost(once, "h1"), NO_NESTED_SHELL) +}) + +test("re-entering with the same host id keeps the first snapshot", () => { + const state = withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]) + assert.equal(withNestedShellHost(state, "h1", [person]), state) }) test("the root focusing clears every host, since nothing can sit above a focused root", () => { - const two = withNestedShellHost(withNestedShellHost(NO_NESTED_SHELL, "h1", cleanup), "h2", null) - assert.deepEqual(two.hosts, ["h1", "h2"]) + const two = withNestedShellHost(withNestedShellHost(NO_NESTED_SHELL, "h1", [cleanup]), "h2", [org]) assert.equal(withoutNestedShellHosts(two).hosts.length, 0) assert.equal(withoutNestedShellHosts(NO_NESTED_SHELL), NO_NESTED_SHELL) }) @@ -42,29 +82,26 @@ test("a blurred host only leaves the signal when the ROOT is the top route", () assert.equal(rootIsTopRoute({ name: "index" }), true) assert.equal(rootIsTopRoute({ name: "cleanups/[id]" }), false) assert.equal(rootIsTopRoute({ name: "cleanups/[id]/ticket/index" }), false) - assert.equal(rootIsTopRoute({ name: "auth" }), false) assert.equal(rootIsTopRoute({}), false) assert.equal(rootIsTopRoute(null), false) assert.equal(rootIsTopRoute(undefined), false) }) -test("hosts are counted by identity, so a double exit cannot unblank a shell still above the root", () => { - const two = withNestedShellHost(withNestedShellHost(NO_NESTED_SHELL, "h1", cleanup), "h2", null) - const once = withoutNestedShellHost(two, "h1") - assert.deepEqual(once.hosts, ["h2"]) - assert.deepEqual(withoutNestedShellHost(once, "h1"), once) - assert.deepEqual(nestedShellBodyEntry(once, org), { render: true, entry: cleanup }) - assert.deepEqual(withoutNestedShellHost(once, "h2"), NO_NESTED_SHELL) -}) - -test("re-entering with the same host id keeps the first snapshot", () => { - const state = withNestedShellHost(NO_NESTED_SHELL, "h1", cleanup) - assert.equal(withNestedShellHost(state, "h1", org), state) +test("the root shell hands its owned stack to AppShell instead of blanking its body", () => { + const rootScreen = readFileSync(join(APP_DIR, "index.tsx"), "utf8") + assert.match( + rootScreen, + /const ownedStack = useNestedShellStore\(\(s\) => shellStackBelow\(s, ROOT_SHELL_ID\)\)/, + ) + assert.match(rootScreen, /\{\.\.\.\(ownedStack \? \{ stack: ownedStack \} : \{\}\)\}/) + assert.doesNotMatch(rootScreen, /nestedShellBodyEntry/) }) -test("with no host mounted the root always renders its own entry", () => { - assert.deepEqual(nestedShellBodyEntry(NO_NESTED_SHELL, cleanup), { render: true, entry: cleanup }) - assert.deepEqual(nestedShellBodyEntry(NO_NESTED_SHELL, null), { render: true, entry: null }) - const state = withNestedShellHost(NO_NESTED_SHELL, "h1", cleanup) - assert.deepEqual(nestedShellBodyEntry(state, null), { render: true, entry: null }) +test("a detail host registers BEFORE it seeds, so the shell below never renders its entry", () => { + const host = readFileSync(join(SRC_DIR, "components", "DetailRouteHost.tsx"), "utf8") + const register = host.indexOf("enterNestedShell(hostId, restoreRef.current)") + const seed = host.indexOf("if (entry) seedEntry(entry)") + assert.ok(register > 0, "DetailRouteHost no longer registers its snapshot") + assert.ok(seed > register, "DetailRouteHost seeds the store before it registers its snapshot") + assert.match(host, /const ownedStack = useNestedShellStore\(\(s\) => shellStackBelow\(s, hostId\)\)/) }) diff --git a/apps/community-mobile/src/lib/nestedShellSignal.ts b/apps/community-mobile/src/lib/nestedShellSignal.ts index 049e9e1d..64b9ddfd 100644 --- a/apps/community-mobile/src/lib/nestedShellSignal.ts +++ b/apps/community-mobile/src/lib/nestedShellSignal.ts @@ -3,12 +3,20 @@ import type { DetailEntry } from "@civfix/ui" export const ROOT_ROUTE_NAME = "index" +export type ShellId = string | null + +export const ROOT_SHELL_ID: ShellId = null + +export interface NestedShellHost { + id: string + snapshot: readonly DetailEntry[] +} + export interface NestedShellState { - hosts: readonly string[] - rootEntry: DetailEntry | null + hosts: readonly NestedShellHost[] } -export const NO_NESTED_SHELL: NestedShellState = { hosts: [], rootEntry: null } +export const NO_NESTED_SHELL: NestedShellState = { hosts: [] } export function rootIsTopRoute(route: { name?: string } | null | undefined): boolean { return route?.name === ROOT_ROUTE_NAME @@ -17,28 +25,36 @@ export function rootIsTopRoute(route: { name?: string } | null | undefined): boo export function withNestedShellHost( state: NestedShellState, id: string, - rootEntry: DetailEntry | null, + snapshot: readonly DetailEntry[], ): NestedShellState { - if (state.hosts.includes(id)) return state - return state.hosts.length === 0 - ? { hosts: [id], rootEntry } - : { hosts: [...state.hosts, id], rootEntry: state.rootEntry } + if (state.hosts.some((host) => host.id === id)) return state + return { hosts: [...state.hosts, { id, snapshot }] } } export function withoutNestedShellHost(state: NestedShellState, id: string): NestedShellState { - if (!state.hosts.includes(id)) return state - const hosts = state.hosts.filter((host) => host !== id) - return hosts.length === 0 ? NO_NESTED_SHELL : { hosts, rootEntry: state.rootEntry } + if (!state.hosts.some((host) => host.id === id)) return state + const hosts = state.hosts.filter((host) => host.id !== id) + return hosts.length === 0 ? NO_NESTED_SHELL : { hosts } } export function withoutNestedShellHosts(state: NestedShellState): NestedShellState { return state.hosts.length === 0 ? state : NO_NESTED_SHELL } +export function shellStackBelow( + state: NestedShellState, + id: ShellId, +): readonly DetailEntry[] | null { + if (id === ROOT_SHELL_ID) return state.hosts[0]?.snapshot ?? null + const index = state.hosts.findIndex((host) => host.id === id) + if (index === -1) return null + return state.hosts[index + 1]?.snapshot ?? null +} + export const useNestedShellStore = create(() => NO_NESTED_SHELL) -export function enterNestedShell(id: string, rootEntry: DetailEntry | null): void { - useNestedShellStore.setState((state) => withNestedShellHost(state, id, rootEntry)) +export function enterNestedShell(id: string, snapshot: readonly DetailEntry[]): void { + useNestedShellStore.setState((state) => withNestedShellHost(state, id, snapshot)) } export function exitNestedShell(id: string): void { @@ -48,11 +64,3 @@ export function exitNestedShell(id: string): void { export function clearNestedShellHosts(): void { useNestedShellStore.setState(withoutNestedShellHosts) } - -export function nestedShellBodyEntry( - state: NestedShellState, - entry: DetailEntry | null, -): { render: false } | { render: true; entry: DetailEntry | null } { - if (state.hosts.length === 0 || entry === null) return { render: true, entry } - return state.rootEntry === null ? { render: false } : { render: true, entry: state.rootEntry } -} diff --git a/apps/community-mobile/src/lib/pushRegistration.test.ts b/apps/community-mobile/src/lib/pushRegistration.test.ts index 17425cab..840a040c 100644 --- a/apps/community-mobile/src/lib/pushRegistration.test.ts +++ b/apps/community-mobile/src/lib/pushRegistration.test.ts @@ -1,14 +1,18 @@ import { test, mock } from "node:test" import assert from "node:assert/strict" +import { readFileSync } from "node:fs" import { PUSH_REGISTRATION_KEY, PUSH_UNREGISTER_TIMEOUT_MS, + SESSION_REVOKE_TIMEOUT_MS, forgetPushRegistration, readPushRegistration, rememberPushRegistration, signOutUnregisteringPush, + unregisterLapsedSessionPush, type PersistedPushRegistration, type PushRegistrationStore, + type PushUnregisterDeps, type SignOutUnregisteringPushDeps, } from "./pushRegistration.ts" @@ -31,6 +35,7 @@ function signOutProbe(overrides: Partial = {}) { const store = overrides.store ?? memoryStore() const order: string[] = [] const sent: { registration: PersistedPushRegistration; bearer: string }[] = [] + const revoked: string[] = [] const deps: SignOutUnregisteringPushDeps = { store, readBearer: async () => "current-bearer", @@ -41,9 +46,13 @@ function signOutProbe(overrides: Partial = {}) { order.push("unregistered") sent.push({ registration, bearer }) }, + revokeSession: async (bearer) => { + order.push("revoked") + revoked.push(bearer) + }, ...overrides, } - return { deps, store, order, sent } + return { deps, store, order, sent, revoked } } test("a registration round-trips through the store", () => { @@ -79,7 +88,7 @@ test("a missing, malformed or half-written value never yields a registration", ( }) test("sign-out unregisters the EXACT values registration persisted, with the captured bearer", async () => { - const { deps, store, sent } = signOutProbe() + const { deps, store, sent, revoked } = signOutProbe() rememberPushRegistration(store, { platform: "android", token: "ExponentPushToken[xyz]" }) await signOutUnregisteringPush(deps) @@ -91,28 +100,115 @@ test("sign-out unregisters the EXACT values registration persisted, with the cap bearer: "current-bearer", }, ]) + assert.deepEqual(revoked, ["current-bearer"]) assert.equal(readPushRegistration(store), null) }) -test("the session is torn down BEFORE the unregister is ever attempted", async () => { +test("the push token is released BEFORE the session is revoked, and both before local teardown", async () => { const { deps, store, order } = signOutProbe() rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) await signOutUnregisteringPush(deps) await tick() - assert.deepEqual(order, ["signed-out", "unregistered"]) + assert.deepEqual(order, ["unregistered", "revoked", "signed-out"]) }) -test("an unregister that NEVER SETTLES still leaves sign-out complete", async () => { - const { deps, store, order } = signOutProbe({ unregister: () => new Promise(() => {}) }) +test("the unregister and the revoke carry the SAME bearer, captured once before it is cleared", async () => { + let reads = 0 + const { deps, store, sent, revoked } = signOutProbe({ + readBearer: async () => { + reads += 1 + return "live-bearer" + }, + }) rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) await signOutUnregisteringPush(deps) await tick() - assert.deepEqual(order, ["signed-out"]) - assert.equal(readPushRegistration(store), null) + assert.equal(reads, 1) + assert.equal(sent[0]?.bearer, "live-bearer") + assert.deepEqual(revoked, ["live-bearer"]) +}) + +test("an unregister that NEVER SETTLES still leaves sign-out complete, and never stops the revoke", async () => { + mock.timers.enable({ apis: ["setTimeout"] }) + try { + const { deps, store, order } = signOutProbe({ unregister: () => new Promise(() => {}) }) + rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) + + const signingOut = signOutUnregisteringPush(deps) + await tick() + assert.deepEqual(order, []) + + mock.timers.tick(PUSH_UNREGISTER_TIMEOUT_MS) + await signingOut + + assert.deepEqual(order, ["revoked", "signed-out"]) + assert.equal(readPushRegistration(store), null) + } finally { + mock.timers.reset() + } +}) + +test("a revoke that NEVER SETTLES cannot hold sign-out open either", async () => { + mock.timers.enable({ apis: ["setTimeout"] }) + try { + let signal: AbortSignal | null = null + const { deps, store, order } = signOutProbe({ + revokeSession: (_bearer, received) => { + signal = received + return new Promise(() => {}) + }, + }) + rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) + + const signingOut = signOutUnregisteringPush(deps) + await tick() + assert.deepEqual(order, ["unregistered"]) + assert.ok(signal) + assert.equal((signal as AbortSignal).aborted, false) + + mock.timers.tick(SESSION_REVOKE_TIMEOUT_MS) + await signingOut + + assert.equal((signal as AbortSignal).aborted, true) + assert.deepEqual(order, ["unregistered", "signed-out"]) + } finally { + mock.timers.reset() + } +}) + +test("a REJECTED revoke still completes sign-out and never escapes", async () => { + let attempts = 0 + const { deps, store, order } = signOutProbe({ + revokeSession: async () => { + attempts += 1 + throw new Error("503 service unavailable") + }, + }) + rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) + + await signOutUnregisteringPush(deps) + await tick() + + assert.equal(attempts, 1) + assert.deepEqual(order, ["unregistered", "signed-out"]) +}) + +test("a revoke that THROWS synchronously never escapes sign-out", async () => { + const { deps, store, order } = signOutProbe({ + revokeSession: () => { + throw new Error("client blew up") + }, + }) + rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) + + await signOutUnregisteringPush(deps) + await tick() + + assert.deepEqual(order, ["unregistered", "signed-out"]) }) test("a hung unregister is ABORTED once the bound lapses, not merely abandoned", async () => { @@ -127,12 +223,13 @@ test("a hung unregister is ABORTED once the bound lapses, not merely abandoned", }) rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) - await signOutUnregisteringPush(deps) + const signingOut = signOutUnregisteringPush(deps) await tick() assert.ok(signal) assert.equal((signal as AbortSignal).aborted, false) mock.timers.tick(PUSH_UNREGISTER_TIMEOUT_MS) + await signingOut assert.equal((signal as AbortSignal).aborted, true) } finally { mock.timers.reset() @@ -153,7 +250,7 @@ test("a REJECTED unregister still completes sign-out and never escapes", async ( await tick() assert.equal(attempts, 1) - assert.deepEqual(order, ["signed-out"]) + assert.deepEqual(order, ["revoked", "signed-out"]) assert.equal(readPushRegistration(store), null) }) @@ -168,16 +265,20 @@ test("an unregister that THROWS synchronously never escapes sign-out", async () await signOutUnregisteringPush(deps) await tick() - assert.deepEqual(order, ["signed-out"]) + assert.deepEqual(order, ["revoked", "signed-out"]) }) -test("an UNREADABLE keychain signs out without attempting an unauthenticated unregister", async () => { +test("an UNREADABLE keychain signs out without attempting an unauthenticated call of either kind", async () => { let attempts = 0 + let revokes = 0 const { deps, store, order } = signOutProbe({ readBearer: async () => null, unregister: async () => { attempts += 1 }, + revokeSession: async () => { + revokes += 1 + }, }) rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) @@ -185,6 +286,7 @@ test("an UNREADABLE keychain signs out without attempting an unauthenticated unr await tick() assert.equal(attempts, 0) + assert.equal(revokes, 0) assert.deepEqual(order, ["signed-out"]) assert.equal(readPushRegistration(store), null) }) @@ -203,10 +305,10 @@ test("a THROWING keychain read cannot block sign-out", async () => { assert.deepEqual(order, ["signed-out"]) }) -test("no persisted registration means no bearer read and no unregister call at all", async () => { +test("no persisted registration still revokes the session: the bearer is read for the logout", async () => { let reads = 0 let attempts = 0 - const { deps, order } = signOutProbe({ + const { deps, order, revoked } = signOutProbe({ readBearer: async () => { reads += 1 return "current-bearer" @@ -219,9 +321,188 @@ test("no persisted registration means no bearer read and no unregister call at a await signOutUnregisteringPush(deps) await tick() - assert.equal(reads, 0) + assert.equal(reads, 1) assert.equal(attempts, 0) - assert.deepEqual(order, ["signed-out"]) + assert.deepEqual(revoked, ["current-bearer"]) + assert.deepEqual(order, ["revoked", "signed-out"]) +}) + +function lapsedProbe(overrides: Partial = {}) { + const store = overrides.store ?? memoryStore() + const sent: { registration: PersistedPushRegistration; bearer: string }[] = [] + const deps: PushUnregisterDeps = { + store, + readBearer: async () => "stale-bearer", + unregister: async (registration, bearer) => { + sent.push({ registration, bearer }) + }, + ...overrides, + } + return { deps, store, sent } +} + +test("an EXPIRED session still tells the server to release the push token", async () => { + const { deps, store, sent } = lapsedProbe() + rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) + + unregisterLapsedSessionPush(deps) + await tick() + + assert.deepEqual(sent, [ + { + registration: { platform: "ios", token: "ExponentPushToken[abc]" }, + bearer: "stale-bearer", + }, + ]) + assert.equal(readPushRegistration(store), null) +}) + +test("the local registration is forgotten SYNCHRONOUSLY, before the call is even issued", () => { + const { deps, store, sent } = lapsedProbe() + rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) + + unregisterLapsedSessionPush(deps) + + assert.equal(readPushRegistration(store), null) + assert.deepEqual(sent, []) +}) + +test("the bearer read is issued SYNCHRONOUSLY, ahead of the token clear that follows teardown", () => { + let reads = 0 + const { deps, store } = lapsedProbe({ + readBearer: async () => { + reads += 1 + return "stale-bearer" + }, + }) + rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) + + unregisterLapsedSessionPush(deps) + + assert.equal(reads, 1) +}) + +test("teardown can AWAIT the bearer capture, so a slow keychain still beats the token clear", async () => { + const order: string[] = [] + let release = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + const { deps, store, sent } = lapsedProbe({ + readBearer: async () => { + await held + order.push("bearer-read") + return "still-valid-bearer" + }, + }) + rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) + + const captured = unregisterLapsedSessionPush(deps) + const clearToken = async () => { + await captured + order.push("token-cleared") + } + const clearing = clearToken() + release() + await clearing + await tick() + + assert.deepEqual(order, ["bearer-read", "token-cleared"]) + assert.deepEqual(sent, [ + { + registration: { platform: "ios", token: "ExponentPushToken[abc]" }, + bearer: "still-valid-bearer", + }, + ]) +}) + +test("the awaited handle stays fire-and-forget: it never rejects and never waits on the network", async () => { + const thrower = lapsedProbe({ + readBearer: async () => { + throw new Error("keychain gone") + }, + }) + rememberPushRegistration(thrower.store, { platform: "ios", token: "ExponentPushToken[abc]" }) + await assert.doesNotReject(() => unregisterLapsedSessionPush(thrower.deps)) + + let settled = false + const hanging = lapsedProbe({ + unregister: () => + new Promise(() => { + settled = true + }), + }) + rememberPushRegistration(hanging.store, { platform: "ios", token: "ExponentPushToken[abc]" }) + await unregisterLapsedSessionPush(hanging.deps) + assert.equal(settled, true) + + const none = lapsedProbe() + await assert.doesNotReject(() => unregisterLapsedSessionPush(none.deps)) +}) + +test("a REJECTED unregister on an expired session never escapes teardown", async () => { + let attempts = 0 + const { deps, store } = lapsedProbe({ + unregister: async () => { + attempts += 1 + throw new Error("401 unauthorized") + }, + }) + rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) + + unregisterLapsedSessionPush(deps) + await tick() + + assert.equal(attempts, 1) + assert.equal(readPushRegistration(store), null) +}) + +test("a THROWING or EMPTY keychain read leaves teardown complete and sends nothing", async () => { + const thrower = lapsedProbe({ + readBearer: () => { + throw new Error("keychain gone") + }, + }) + rememberPushRegistration(thrower.store, { platform: "ios", token: "ExponentPushToken[abc]" }) + unregisterLapsedSessionPush(thrower.deps) + + const empty = lapsedProbe({ readBearer: async () => null }) + rememberPushRegistration(empty.store, { platform: "ios", token: "ExponentPushToken[abc]" }) + unregisterLapsedSessionPush(empty.deps) + + await tick() + + assert.deepEqual(thrower.sent, []) + assert.deepEqual(empty.sent, []) + assert.equal(readPushRegistration(thrower.store), null) + assert.equal(readPushRegistration(empty.store), null) +}) + +test("no persisted registration means an expired session reads no bearer and calls nothing", async () => { + let reads = 0 + const { deps, sent } = lapsedProbe({ + readBearer: async () => { + reads += 1 + return "stale-bearer" + }, + }) + + unregisterLapsedSessionPush(deps) + await tick() + + assert.equal(reads, 0) + assert.deepEqual(sent, []) +}) + +test("sign-out's own teardown cannot double-unregister, the registration is already gone", async () => { + const { deps, store, sent } = lapsedProbe() + rememberPushRegistration(store, { platform: "ios", token: "ExponentPushToken[abc]" }) + + unregisterLapsedSessionPush(deps) + unregisterLapsedSessionPush(deps) + await tick() + + assert.equal(sent.length, 1) }) test("an UNREADABLE store never blocks sign-out", async () => { @@ -242,5 +523,18 @@ test("an UNREADABLE store never blocks sign-out", async () => { await signOutUnregisteringPush(deps) await tick() - assert.deepEqual(order, ["signed-out"]) + assert.deepEqual(order, ["revoked", "signed-out"]) +}) + +test("the store revokes on the EXPLICIT sign-out only, never on the lapsed or foreign paths", () => { + const source = readFileSync(new URL("../store/authStore.ts", import.meta.url), "utf8") + const signOut = source.slice(source.indexOf(" signOut: async () => {")) + const body = signOut.slice(0, signOut.indexOf("\n },")) + assert.match(body, /revokeSession: \(bearer, signal\) =>/) + assert.match(body, /api\.logout\(/) + assert.equal(source.match(/revokeSession:/g)?.length, 1) + assert.equal(source.match(/api\.logout\(/g)?.length, 1) + + const lapsed = source.slice(source.indexOf(" markUnauthed: () => {")) + assert.doesNotMatch(lapsed.slice(0, lapsed.indexOf("\n },")), /revokeSession|api\.logout/) }) diff --git a/apps/community-mobile/src/lib/pushRegistration.ts b/apps/community-mobile/src/lib/pushRegistration.ts index f8291004..c09b012c 100644 --- a/apps/community-mobile/src/lib/pushRegistration.ts +++ b/apps/community-mobile/src/lib/pushRegistration.ts @@ -6,6 +6,8 @@ export const PUSH_REGISTRATION_KEY = "civfix.push.registration" export const PUSH_UNREGISTER_TIMEOUT_MS = 3000 +export const SESSION_REVOKE_TIMEOUT_MS = 3000 + export interface PersistedPushRegistration { platform: PushPlatform token: string @@ -13,10 +15,9 @@ export interface PersistedPushRegistration { export type PushRegistrationStore = KeyValueStore -export interface SignOutUnregisteringPushDeps { +export interface PushUnregisterDeps { store: PushRegistrationStore readBearer: () => Promise - completeSignOut: () => Promise unregister: ( registration: PersistedPushRegistration, bearer: string, @@ -24,6 +25,11 @@ export interface SignOutUnregisteringPushDeps { ) => Promise } +export interface SignOutUnregisteringPushDeps extends PushUnregisterDeps { + revokeSession: (bearer: string, signal: AbortSignal) => Promise + completeSignOut: () => Promise +} + export function rememberPushRegistration( store: PushRegistrationStore, registration: PersistedPushRegistration, @@ -60,7 +66,7 @@ export function forgetPushRegistration(store: PushRegistrationStore): void { } function fireUnregister( - deps: SignOutUnregisteringPushDeps, + deps: PushUnregisterDeps, registration: PersistedPushRegistration, bearer: string, ): void { @@ -77,22 +83,74 @@ function fireUnregister( })() } +function boundedCall( + call: (signal: AbortSignal) => Promise, + timeoutMs: number, +): Promise { + const controller = new AbortController() + return new Promise((resolve) => { + const abort = setTimeout(() => { + controller.abort() + resolve(false) + }, timeoutMs) + const settle = (released: boolean) => { + clearTimeout(abort) + resolve(released) + } + try { + void call(controller.signal).then( + () => settle(true), + () => settle(false), + ) + } catch { + settle(false) + } + }) +} + export async function signOutUnregisteringPush( deps: SignOutUnregisteringPushDeps, ): Promise { const registration = readPushRegistration(deps.store) + let bearer: string | null = null - if (registration) { - try { - bearer = await deps.readBearer() - } catch { - bearer = null - } + try { + bearer = await deps.readBearer() + } catch { + bearer = null } forgetPushRegistration(deps.store) + + if (bearer !== null) { + const held = bearer + if (registration) { + await boundedCall( + (signal) => deps.unregister(registration, held, signal), + PUSH_UNREGISTER_TIMEOUT_MS, + ) + } + await boundedCall((signal) => deps.revokeSession(held, signal), SESSION_REVOKE_TIMEOUT_MS) + } + await deps.completeSignOut() +} + +export function unregisterLapsedSessionPush(deps: PushUnregisterDeps): Promise { + const registration = readPushRegistration(deps.store) + forgetPushRegistration(deps.store) + if (!registration) return Promise.resolve() + + let bearer: Promise + try { + bearer = Promise.resolve(deps.readBearer()) + } catch { + return Promise.resolve() + } - if (!registration || !bearer) return - fireUnregister(deps, registration, bearer) + return bearer + .then((value) => { + if (value) fireUnregister(deps, registration, value) + }) + .catch(() => undefined) } diff --git a/apps/community-mobile/src/lib/pushRegistrationRetry.test.ts b/apps/community-mobile/src/lib/pushRegistrationRetry.test.ts index 2e97f3ab..de1e1786 100644 --- a/apps/community-mobile/src/lib/pushRegistrationRetry.test.ts +++ b/apps/community-mobile/src/lib/pushRegistrationRetry.test.ts @@ -15,6 +15,20 @@ test("only a decided outcome is terminal - a transient error stays retryable", ( assert.equal(isPushOutcomeTerminal("error"), false) }) +test("a 409 conflict is a DECIDED outcome - retrying it cannot change the answer", () => { + assert.equal(isPushOutcomeTerminal("conflict"), true) +}) + +test("a conflict settles the session, so no foreground edge ever re-attempts it", () => { + const state = freshPushAttemptState() + state.attempts += 1 + assert.equal(isPushOutcomeTerminal("conflict"), true) + state.settled = true + isForegroundEdge(state, "background") + assert.equal(isForegroundEdge(state, "active"), true) + assert.equal(shouldAttemptPushRegistration(state), false) +}) + test("a fresh authed session attempts registration", () => { assert.equal( shouldAttemptPushRegistration({ ...freshPushAttemptState() }), diff --git a/apps/community-mobile/src/lib/pushRegistrationRetry.ts b/apps/community-mobile/src/lib/pushRegistrationRetry.ts index f2e3f911..83e8ace1 100644 --- a/apps/community-mobile/src/lib/pushRegistrationRetry.ts +++ b/apps/community-mobile/src/lib/pushRegistrationRetry.ts @@ -1,6 +1,6 @@ export const PUSH_ATTEMPT_LIMIT = 3 -export type PushOutcomeStatus = "registered" | "denied" | "unsupported" | "error" +export type PushOutcomeStatus = "registered" | "denied" | "unsupported" | "conflict" | "error" export interface PushAttemptState { attempts: number @@ -19,7 +19,12 @@ export function shouldAttemptPushRegistration(state: PushAttemptState): boolean } export function isPushOutcomeTerminal(status: PushOutcomeStatus): boolean { - return status === "registered" || status === "denied" || status === "unsupported" + return ( + status === "registered" || + status === "denied" || + status === "unsupported" || + status === "conflict" + ) } export function isForegroundEdge(state: PushAttemptState, next: string): boolean { diff --git a/apps/community-mobile/src/lib/storageEnvMarker.test.ts b/apps/community-mobile/src/lib/storageEnvMarker.test.ts new file mode 100644 index 00000000..14120ace --- /dev/null +++ b/apps/community-mobile/src/lib/storageEnvMarker.test.ts @@ -0,0 +1,116 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { + LEGACY_STORAGE_IDS, + PROD_STORAGE_ENV, + STORAGE_ENV_MARKER_KEY, + purgesLegacyStorage, + storageEnvFor, + writesStorageEnvMarker, +} from "./storageEnvMarker.ts" +import { scopeStorageId, storageNamespace } from "./storageScope.ts" +import { DEV_API_URL, PROD_API_URL, STAGING_API_URL } from "./apiUrl.ts" + +const PROD = storageNamespace(PROD_API_URL) +const STAGING = storageNamespace(STAGING_API_URL) +const DEV = storageNamespace(DEV_API_URL) + +function bootSequence(namespaces: readonly string[], seed: string | null = null) { + let marker = seed + const purges: boolean[] = [] + for (const namespace of namespaces) { + const purged = purgesLegacyStorage(namespace, marker) + purges.push(purged) + if (writesStorageEnvMarker(namespace, marker)) marker = storageEnvFor(namespace) + } + return { marker, purges } +} + +test("the marker names production explicitly, so an absent marker stays distinguishable from it", () => { + assert.equal(storageEnvFor(PROD), PROD_STORAGE_ENV) + assert.notEqual(storageEnvFor(PROD), "") + assert.equal(storageEnvFor(STAGING), STAGING) + assert.equal(storageEnvFor(DEV), DEV) +}) + +test("a production boot whose previous run was staging purges the legacy ids", () => { + assert.equal(purgesLegacyStorage(PROD, storageEnvFor(STAGING)), true) +}) + +test("a production boot whose previous run was a local dev build purges them too", () => { + assert.equal(purgesLegacyStorage(PROD, storageEnvFor(DEV)), true) +}) + +test("a production boot that follows another production run keeps the session", () => { + assert.equal(purgesLegacyStorage(PROD, PROD_STORAGE_ENV), false) +}) + +test("a production boot with NO marker keeps the session - that ambiguity is documented, not guessed", () => { + assert.equal(purgesLegacyStorage(PROD, null), false) + assert.equal(purgesLegacyStorage(PROD, ""), false) +}) + +test("a non-production boot never purges: it does not read the legacy ids in the first place", () => { + for (const namespace of [STAGING, DEV]) { + assert.equal(purgesLegacyStorage(namespace, PROD_STORAGE_ENV), false) + assert.equal(purgesLegacyStorage(namespace, storageEnvFor(STAGING)), false) + assert.equal(purgesLegacyStorage(namespace, null), false) + } +}) + +test("every listed legacy id is exactly what a production build resolves to", () => { + assert.deepEqual( + [...LEGACY_STORAGE_IDS].sort(), + ["civfix.app", "civfix.secure", "civfix.secure-blobs.key", "civfix.session.token"], + ) + for (const id of LEGACY_STORAGE_IDS) { + assert.equal(scopeStorageId(id, PROD_API_URL), id) + assert.notEqual(scopeStorageId(id, STAGING_API_URL), id) + } +}) + +test("a TestFlight detour between two App Store boots never wipes the production session", () => { + const { marker, purges } = bootSequence([PROD, STAGING, PROD]) + assert.deepEqual(purges, [false, false, false]) + assert.equal(marker, PROD_STORAGE_ENV) +}) + +test("a staging boot leaves an existing production marker alone, whatever it detours through", () => { + assert.equal(writesStorageEnvMarker(STAGING, PROD_STORAGE_ENV), false) + assert.equal(writesStorageEnvMarker(DEV, PROD_STORAGE_ENV), false) + assert.equal(writesStorageEnvMarker(STAGING, storageEnvFor(DEV)), false) + assert.deepEqual(bootSequence([PROD, STAGING, DEV, STAGING, PROD]).purges, [ + false, + false, + false, + false, + false, + ]) +}) + +test("old TestFlight residue still purges: no marker means the first staging boot claims it", () => { + assert.equal(writesStorageEnvMarker(STAGING, null), true) + assert.equal(writesStorageEnvMarker(STAGING, ""), true) + const { marker, purges } = bootSequence([STAGING, PROD]) + assert.equal(marker, PROD_STORAGE_ENV) + assert.deepEqual(purges, [false, true]) +}) + +test("a production boot always records itself, including the one that just purged", () => { + assert.equal(writesStorageEnvMarker(PROD, null), true) + assert.equal(writesStorageEnvMarker(PROD, storageEnvFor(STAGING)), true) + assert.equal(writesStorageEnvMarker(PROD, PROD_STORAGE_ENV), false) + assert.equal(writesStorageEnvMarker(STAGING, storageEnvFor(STAGING)), false) +}) + +test("the boot path decides the write through that policy, not through a bare marker comparison", () => { + const source = readFileSync(new URL("./legacyStorageReset.ts", import.meta.url), "utf8") + assert.match(source, /writesStorageEnvMarker\(namespace, marker\)/) + assert.doesNotMatch(source, /marker !== next/) +}) + +test("the marker key is never namespaced, so it survives the environment flip it reports", () => { + assert.equal(STORAGE_ENV_MARKER_KEY, "civfix.storage.env") + assert.ok(!STORAGE_ENV_MARKER_KEY.endsWith(STAGING)) +}) diff --git a/apps/community-mobile/src/lib/storageEnvMarker.ts b/apps/community-mobile/src/lib/storageEnvMarker.ts new file mode 100644 index 00000000..bc026be5 --- /dev/null +++ b/apps/community-mobile/src/lib/storageEnvMarker.ts @@ -0,0 +1,26 @@ +export const STORAGE_ENV_MARKER_KEY = "civfix.storage.env" + +export const PROD_STORAGE_ENV = "prod" + +export const LEGACY_STORAGE_IDS = [ + "civfix.session.token", + "civfix.app", + "civfix.secure", + "civfix.secure-blobs.key", +] as const + +export function storageEnvFor(namespace: string): string { + return namespace === "" ? PROD_STORAGE_ENV : namespace +} + +export function purgesLegacyStorage(namespace: string, marker: string | null): boolean { + if (namespace !== "") return false + if (marker === null || marker === "") return false + return marker !== PROD_STORAGE_ENV +} + +export function writesStorageEnvMarker(namespace: string, marker: string | null): boolean { + if (marker === storageEnvFor(namespace)) return false + if (namespace === "") return true + return marker === null || marker === "" +} diff --git a/apps/community-mobile/src/lib/storageScope.test.ts b/apps/community-mobile/src/lib/storageScope.test.ts new file mode 100644 index 00000000..8ca73f2f --- /dev/null +++ b/apps/community-mobile/src/lib/storageScope.test.ts @@ -0,0 +1,42 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { scopeStorageId, storageNamespace } from "./storageScope.ts" +import { DEV_API_URL, PROD_API_URL, STAGING_API_URL } from "./apiUrl.ts" + +test("a production build keeps the legacy ids, so an existing install is not signed out", () => { + assert.equal(storageNamespace(PROD_API_URL), "") + assert.equal(scopeStorageId("civfix.app", PROD_API_URL), "civfix.app") + assert.equal(scopeStorageId("civfix.session.token", PROD_API_URL), "civfix.session.token") + assert.equal(scopeStorageId("civfix.secure", PROD_API_URL), "civfix.secure") +}) + +test("a trailing slash or stray whitespace is still production", () => { + assert.equal(scopeStorageId("civfix.app", "https://api.civfix.org/"), "civfix.app") + assert.equal(scopeStorageId("civfix.app", " https://api.civfix.org "), "civfix.app") + assert.equal(scopeStorageId("civfix.app", "https://api.civfix.org/v1"), "civfix.app") +}) + +test("a staging build gets its own namespace", () => { + assert.equal(storageNamespace(STAGING_API_URL), "api-civfix-dev") + assert.equal(scopeStorageId("civfix.app", STAGING_API_URL), "civfix.app.api-civfix-dev") + assert.equal( + scopeStorageId("civfix.session.token", STAGING_API_URL), + "civfix.session.token.api-civfix-dev", + ) +}) + +test("a local dev build is namespaced too, port included", () => { + assert.equal(storageNamespace(DEV_API_URL), "localhost-8080") + assert.equal(scopeStorageId("civfix.app", DEV_API_URL), "civfix.app.localhost-8080") +}) + +test("every API base URL the resolver can choose maps to a distinct namespace", () => { + const namespaces = [DEV_API_URL, STAGING_API_URL, PROD_API_URL].map(storageNamespace) + assert.equal(new Set(namespaces).size, namespaces.length) +}) + +test("an unrecognised or unparseable base URL never borrows the production namespace", () => { + for (const bogus of ["", " ", "not a url", "https://api.evil.com", "https://api.civfix.org.evil.com"]) { + assert.notEqual(storageNamespace(bogus), "") + } +}) diff --git a/apps/community-mobile/src/lib/storageScope.ts b/apps/community-mobile/src/lib/storageScope.ts new file mode 100644 index 00000000..f7344349 --- /dev/null +++ b/apps/community-mobile/src/lib/storageScope.ts @@ -0,0 +1,30 @@ +const PROD_API_HOST = "api.civfix.org" + +const LEGACY_NAMESPACE = "" + +const UNRESOLVED_HOST = "unknown" + +function hostSlug(apiUrl: string): string { + const value = apiUrl.trim() + let host = value + try { + host = new URL(value).host + } catch { + host = value + } + const slug = host + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + return slug === "" ? UNRESOLVED_HOST : slug +} + +export function storageNamespace(apiUrl: string): string { + const slug = hostSlug(apiUrl) + return slug === hostSlug(PROD_API_HOST) ? LEGACY_NAMESPACE : slug +} + +export function scopeStorageId(id: string, apiUrl: string): string { + const namespace = storageNamespace(apiUrl) + return namespace === LEGACY_NAMESPACE ? id : `${id}.${namespace}` +} diff --git a/apps/community-mobile/src/lib/storeKitReceipt.test.ts b/apps/community-mobile/src/lib/storeKitReceipt.test.ts new file mode 100644 index 00000000..69d53220 --- /dev/null +++ b/apps/community-mobile/src/lib/storeKitReceipt.test.ts @@ -0,0 +1,31 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { RECEIPT_ABSENT, betaInstallFromReceipts, type ReceiptStat } from "./storeKitReceipt.ts" + +function present(modifiedAt: number | null): ReceiptStat { + return { present: true, modifiedAt } +} + +test("no sandbox receipt means the build was never handed out through TestFlight", () => { + assert.equal(betaInstallFromReceipts(RECEIPT_ABSENT, RECEIPT_ABSENT), false) + assert.equal(betaInstallFromReceipts(present(1_000), RECEIPT_ABSENT), false) +}) + +test("a sandbox receipt with no store receipt is a TestFlight install", () => { + assert.equal(betaInstallFromReceipts(RECEIPT_ABSENT, present(1_000)), true) +}) + +test("with both receipts on disk the newer one wins, in either direction", () => { + assert.equal(betaInstallFromReceipts(present(1_000), present(2_000)), true) + assert.equal(betaInstallFromReceipts(present(2_000), present(1_000)), false) +}) + +test("a tie resolves to the App Store, never to staging", () => { + assert.equal(betaInstallFromReceipts(present(1_000), present(1_000)), false) +}) + +test("an unreadable timestamp falls back to the store receipt rather than guessing staging", () => { + assert.equal(betaInstallFromReceipts(present(null), present(2_000)), false) + assert.equal(betaInstallFromReceipts(present(1_000), present(null)), false) + assert.equal(betaInstallFromReceipts(present(null), present(null)), false) +}) diff --git a/apps/community-mobile/src/lib/storeKitReceipt.ts b/apps/community-mobile/src/lib/storeKitReceipt.ts new file mode 100644 index 00000000..a9d79127 --- /dev/null +++ b/apps/community-mobile/src/lib/storeKitReceipt.ts @@ -0,0 +1,14 @@ +export const STORE_KIT_DIR = "StoreKit" +export const APP_STORE_RECEIPT = "receipt" +export const SANDBOX_RECEIPT = "sandboxReceipt" + +export type ReceiptStat = { present: boolean; modifiedAt: number | null } + +export const RECEIPT_ABSENT: ReceiptStat = { present: false, modifiedAt: null } + +export function betaInstallFromReceipts(store: ReceiptStat, sandbox: ReceiptStat): boolean { + if (!sandbox.present) return false + if (!store.present) return true + if (store.modifiedAt === null || sandbox.modifiedAt === null) return false + return sandbox.modifiedAt > store.modifiedAt +} diff --git a/apps/community-mobile/src/lib/threadEntryRoutes.test.ts b/apps/community-mobile/src/lib/threadEntryRoutes.test.ts index 6464c301..351a3cb7 100644 --- a/apps/community-mobile/src/lib/threadEntryRoutes.test.ts +++ b/apps/community-mobile/src/lib/threadEntryRoutes.test.ts @@ -39,6 +39,33 @@ test("maps every push-capable thread tap to a pushed route", () => { }) }) +test("the shell kinds a thread can now reach stack on top instead of tearing the stack down", () => { + assert.deepEqual(threadEntryRoute({ kind: "announcements", id: "c1" }), { + pathname: "/cleanups/[id]/announcements", + params: { id: "c1" }, + }) + assert.deepEqual(threadEntryRoute({ kind: "announcement", id: "c1", announcementId: "a1" }), { + pathname: "/cleanups/[id]/announcements/[announcementId]", + params: { id: "c1", announcementId: "a1" }, + }) + assert.deepEqual(threadEntryRoute({ kind: "event-analytics", id: "c1" }), { + pathname: "/cleanups/[id]/analytics", + params: { id: "c1" }, + }) + assert.deepEqual(threadEntryRoute({ kind: "org-manage", slug: "acme" }), { + pathname: "/orgs/[slug]/manage", + params: { slug: "acme" }, + }) +}) + +test("an announcement without its announcement id is refused rather than pushed half-addressed", () => { + assert.equal(threadEntryRoute({ kind: "announcement", id: "c1" }), null) + assert.equal(threadEntryRoute({ kind: "announcement", announcementId: "a1" }), null) + assert.equal(threadEntryRoute({ kind: "announcements" }), null) + assert.equal(threadEntryRoute({ kind: "event-analytics" }), null) + assert.equal(threadEntryRoute({ kind: "org-manage" }), null) +}) + test("an org byline on a post opens the organization instead of dropping the tap", () => { assert.notEqual(threadEntryRoute({ kind: "org", slug: "river-keepers" }), null) assert.equal(threadEntryRoute({ kind: "org" }), null) @@ -71,6 +98,11 @@ test("every mapped pathname has a real expo-router route file", () => { threadEntryRoute({ kind: "post-thread", id: "x" }), threadEntryRoute({ kind: "post", id: "x" }), threadEntryRoute({ kind: "composer", composerMode: "quote", targetPostId: "x" }), + threadEntryRoute({ kind: "announcements", id: "x" }), + threadEntryRoute({ kind: "announcement", id: "x", announcementId: "a" }), + threadEntryRoute({ kind: "event-analytics", id: "x" }), + threadEntryRoute({ kind: "org", slug: "x" }), + threadEntryRoute({ kind: "org-manage", slug: "x" }), ].map((route) => { assert.notEqual(route, null) return route!.pathname diff --git a/apps/community-mobile/src/lib/threadEntryRoutes.ts b/apps/community-mobile/src/lib/threadEntryRoutes.ts index 133c49a0..4b367180 100644 --- a/apps/community-mobile/src/lib/threadEntryRoutes.ts +++ b/apps/community-mobile/src/lib/threadEntryRoutes.ts @@ -16,8 +16,23 @@ export function threadEntryRoute(entry: DetailEntry): ThreadEntryRoute | null { case "post": case "post-thread": return entry.id ? { pathname: "/post/[id]", params: { id: entry.id } } : null + case "announcements": + return entry.id + ? { pathname: "/cleanups/[id]/announcements", params: { id: entry.id } } + : null + case "announcement": + return entry.id && entry.announcementId + ? { + pathname: "/cleanups/[id]/announcements/[announcementId]", + params: { id: entry.id, announcementId: entry.announcementId }, + } + : null + case "event-analytics": + return entry.id ? { pathname: "/cleanups/[id]/analytics", params: { id: entry.id } } : null case "org": return entry.slug ? { pathname: "/orgs/[slug]", params: { slug: entry.slug } } : null + case "org-manage": + return entry.slug ? { pathname: "/orgs/[slug]/manage", params: { slug: entry.slug } } : null case "composer": return entry.composerMode === "quote" && entry.targetPostId ? { pathname: "/compose", params: { mode: "quote", targetPostId: entry.targetPostId } } diff --git a/apps/community-mobile/src/lib/webOrigin.test.ts b/apps/community-mobile/src/lib/webOrigin.test.ts index 0ebe27e6..cc9fcf97 100644 --- a/apps/community-mobile/src/lib/webOrigin.test.ts +++ b/apps/community-mobile/src/lib/webOrigin.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test" import assert from "node:assert/strict" import { PROD_WEB_ORIGIN, resolveWebOrigin } from "./webOrigin.ts" +import { DEV_API_URL, PROD_API_URL, STAGING_API_URL } from "./apiUrl.ts" test("a build pointed at the staging API shares links to the staging site", () => { assert.equal(resolveWebOrigin("https://api.civfix.dev"), "https://civfix.dev") @@ -18,6 +19,17 @@ test("a local or unrecognised API falls back to the production site", () => { assert.equal(resolveWebOrigin("not a url"), PROD_WEB_ORIGIN) }) +test("every API base URL the resolver can choose has a mapped share origin", () => { + const originFor: Readonly> = { + [DEV_API_URL]: PROD_WEB_ORIGIN, + [STAGING_API_URL]: "https://civfix.dev", + [PROD_API_URL]: PROD_WEB_ORIGIN, + } + for (const [apiUrl, origin] of Object.entries(originFor)) { + assert.equal(resolveWebOrigin(apiUrl), origin) + } +}) + test("never reflects an arbitrary api.* host into a trusted link origin", () => { assert.equal(resolveWebOrigin("https://api.evil.com"), PROD_WEB_ORIGIN) assert.equal(resolveWebOrigin("https://api.civfix.org.evil.com"), PROD_WEB_ORIGIN) diff --git a/apps/community-mobile/src/push/register.ts b/apps/community-mobile/src/push/register.ts index 162b1f2b..9e7e03b2 100644 --- a/apps/community-mobile/src/push/register.ts +++ b/apps/community-mobile/src/push/register.ts @@ -7,6 +7,7 @@ import type { Href } from "expo-router" import { tokens } from "@civfix/shared/tokens" import { createI18n } from "@civfix/ui/i18n" import { api } from "@/api/client" +import { isConflict } from "@/lib/errors" import { toInternalHref } from "@/lib/links" import { resolveActiveLocale } from "@/lib/locale" import { resolveDeviceId, type DeviceIdStore } from "@/lib/deviceId" @@ -35,6 +36,7 @@ export type PushRegistrationResult = | { status: "registered"; token: string } | { status: "denied" } | { status: "unsupported"; reason: string } + | { status: "conflict"; reason: string } | { status: "error"; reason: string } function pushPlatform(): "ios" | "android" | "web" | null { @@ -170,6 +172,11 @@ export async function registerForPushNotifications( return { status: "registered", token } } catch (err) { + if (isConflict(err)) { + const reason = "this device's push token is still owned by another account" + console.warn(`[push] registration refused: ${reason}; not retrying`, err) + return { status: "conflict", reason } + } const reason = err instanceof Error ? err.message : "unknown error" return { status: "error", reason } } diff --git a/apps/community-mobile/src/store/authStore.ts b/apps/community-mobile/src/store/authStore.ts index b13a2e2c..2ec6f184 100644 --- a/apps/community-mobile/src/store/authStore.ts +++ b/apps/community-mobile/src/store/authStore.ts @@ -14,7 +14,11 @@ import { chatSocket } from "@/lib/ws" import { storage } from "@/lib/mmkv" import { clearSecureBlobs } from "@/lib/nativeSecureStore" import { CACHED_USER_KEY, LAST_IDENTITY_KEY } from "@/lib/mmkv-keys" -import { forgetPushRegistration, signOutUnregisteringPush } from "@/lib/pushRegistration" +import { + signOutUnregisteringPush, + unregisterLapsedSessionPush, + type PushUnregisterDeps, +} from "@/lib/pushRegistration" import { queryClient } from "@/query/client" import { clearPersistedCache, @@ -91,15 +95,31 @@ function refreshGuestCapabilities(set: SetAuthState): void { .catch((err) => set({ networkOutcome: reachabilityOutcome(err) })) } -function tearDownIdentity(set: SetAuthState): void { +function pushUnregisterDeps(): PushUnregisterDeps { + return { + store: storage, + readBearer: async () => { + const read = await readToken() + return read.ok ? read.token : null + }, + unregister: (registration, bearer, signal) => + api.pushUnregister(registration, { + headers: { Authorization: `Bearer ${bearer}` }, + signal, + }), + } +} + +function tearDownIdentity(set: SetAuthState): Promise { clearPersistedCache() chatSocket.disconnect() cacheUser(null) - forgetPushRegistration(storage) + const pushReleased = unregisterLapsedSessionPush(pushUnregisterDeps()) queryClient.clear() set({ status: "unauthed", user: null, sessionPresent: false }) resumeCachePersistence() identityTornDown = true + return pushReleased } function dropForeignIdentityState(): void { @@ -149,7 +169,7 @@ export const useAuthStore = create((set, get) => ({ } if (read.token === null) { - tearDownIdentity(set) + void tearDownIdentity(set) refreshGuestCapabilities(set) return } @@ -168,12 +188,12 @@ export const useAuthStore = create((set, get) => ({ adoptIdentity(session.user, set) return } - tearDownIdentity(set) + await tearDownIdentity(set) await clearToken() } catch (err) { if (isUnauthorized(err)) { set({ networkOutcome: "ok" }) - tearDownIdentity(set) + await tearDownIdentity(set) await clearToken() return } @@ -221,27 +241,23 @@ export const useAuthStore = create((set, get) => ({ signOut: async () => { await signOutUnregisteringPush({ - store: storage, - readBearer: async () => { - const read = await readToken() - return read.ok ? read.token : null - }, + ...pushUnregisterDeps(), + revokeSession: (bearer, signal) => + api.logout({ + headers: { Authorization: `Bearer ${bearer}` }, + signal, + }), completeSignOut: async () => { - tearDownIdentity(set) + await tearDownIdentity(set) rememberIdentity(null) await clearSecureBlobs() await clearToken() }, - unregister: (registration, bearer, signal) => - api.pushUnregister(registration, { - headers: { Authorization: `Bearer ${bearer}` }, - signal, - }), }) }, markUnauthed: () => { if (identityTornDown) return - tearDownIdentity(set) + void tearDownIdentity(set) }, })) diff --git a/apps/community-mobile/tests/apiUrl.test.ts b/apps/community-mobile/tests/apiUrl.test.ts index c434c4a4..8a81c7b7 100644 --- a/apps/community-mobile/tests/apiUrl.test.ts +++ b/apps/community-mobile/tests/apiUrl.test.ts @@ -20,40 +20,59 @@ import { resolveApiUrl, DEV_API_URL, PROD_API_URL } from "../src/lib/apiUrl.ts" test("an empty object from the baked config falls back - the shipped-outage case", () => { // `??` would have returned the object here. Everything else in this file is scaffolding; this is // the assertion that actually encodes the incident. - assert.equal(resolveApiUrl({}, false), PROD_API_URL) - assert.equal(resolveApiUrl({}, true), DEV_API_URL) + assert.equal(resolveApiUrl({}, false, false), PROD_API_URL) + assert.equal(resolveApiUrl({}, true, false), DEV_API_URL) }) test("a configured string wins in both environments", () => { - assert.equal(resolveApiUrl("https://staging.example.org", false), "https://staging.example.org") - assert.equal(resolveApiUrl("https://staging.example.org", true), "https://staging.example.org") + assert.equal(resolveApiUrl("https://staging.example.org", false, false), "https://staging.example.org") + assert.equal(resolveApiUrl("https://staging.example.org", true, false), "https://staging.example.org") }) test("null and undefined fall back to the environment default", () => { - assert.equal(resolveApiUrl(null, false), PROD_API_URL) - assert.equal(resolveApiUrl(undefined, false), PROD_API_URL) - assert.equal(resolveApiUrl(null, true), DEV_API_URL) - assert.equal(resolveApiUrl(undefined, true), DEV_API_URL) + assert.equal(resolveApiUrl(null, false, false), PROD_API_URL) + assert.equal(resolveApiUrl(undefined, false, false), PROD_API_URL) + assert.equal(resolveApiUrl(null, true, false), DEV_API_URL) + assert.equal(resolveApiUrl(undefined, true, false), DEV_API_URL) }) test("empty and whitespace-only strings fall back rather than producing a bare-path URL", () => { - assert.equal(resolveApiUrl("", false), PROD_API_URL) - assert.equal(resolveApiUrl(" ", false), PROD_API_URL) + assert.equal(resolveApiUrl("", false, false), PROD_API_URL) + assert.equal(resolveApiUrl(" ", false, false), PROD_API_URL) }) test("a non-string of any shape falls back", () => { for (const bogus of [0, 1, true, false, [], { url: "x" }, () => "x"]) { - assert.equal(resolveApiUrl(bogus, false), PROD_API_URL) + assert.equal(resolveApiUrl(bogus, false, false), PROD_API_URL) } }) test("surrounding whitespace is trimmed off a real value", () => { - assert.equal(resolveApiUrl(" https://api.civfix.org ", false), "https://api.civfix.org") + assert.equal(resolveApiUrl(" https://api.civfix.org ", false, false), "https://api.civfix.org") }) test("a release build never resolves to localhost, and a dev build never to production", () => { // The direction of the fallback is the other half of the contract: getting it backwards would ship // a TestFlight build pointed at localhost, which is the same user-visible failure. - assert.equal(resolveApiUrl(undefined, false), "https://api.civfix.org") - assert.equal(resolveApiUrl(undefined, true), "http://localhost:8080") + assert.equal(resolveApiUrl(undefined, false, false), "https://api.civfix.org") + assert.equal(resolveApiUrl(undefined, true, false), "http://localhost:8080") +}) + +test("an unbaked release installed from TestFlight resolves to the staging API", () => { + assert.equal(resolveApiUrl(undefined, false, true), "https://api.civfix.dev") +}) + +test("an unbaked release installed from the App Store resolves to the production API", () => { + assert.equal(resolveApiUrl(undefined, false, false), PROD_API_URL) +}) + +test("a dev build stays on localhost even when the install looks like a beta one", () => { + assert.equal(resolveApiUrl(undefined, true, true), DEV_API_URL) + assert.equal(resolveApiUrl(null, true, true), DEV_API_URL) + assert.equal(resolveApiUrl({}, true, true), DEV_API_URL) +}) + +test("a baked URL still wins over the runtime install signal", () => { + assert.equal(resolveApiUrl("https://api.civfix.dev", false, false), "https://api.civfix.dev") + assert.equal(resolveApiUrl("https://api.civfix.org", false, true), "https://api.civfix.org") }) diff --git a/apps/community-mobile/tests/bootShell.test.ts b/apps/community-mobile/tests/bootShell.test.ts index c460383a..5b8539d9 100644 --- a/apps/community-mobile/tests/bootShell.test.ts +++ b/apps/community-mobile/tests/bootShell.test.ts @@ -6,6 +6,17 @@ const layout = readFileSync(new URL("../app/_layout.tsx", import.meta.url), "utf const home = readFileSync(new URL("../app/index.tsx", import.meta.url), "utf8") const config = readFileSync(new URL("../src/config.ts", import.meta.url), "utf8") const apiUrlModule = readFileSync(new URL("../src/lib/apiUrl.ts", import.meta.url), "utf8") +const betaInstallModule = readFileSync( + new URL("../src/lib/nativeBetaInstall.ts", import.meta.url), + "utf8", +) +const storeKitModule = readFileSync(new URL("../src/lib/storeKitReceipt.ts", import.meta.url), "utf8") +const mmkvModule = readFileSync(new URL("../src/lib/mmkv.ts", import.meta.url), "utf8") +const secureStoreModule = readFileSync( + new URL("../src/lib/nativeSecureStore.ts", import.meta.url), + "utf8", +) +const authStorage = readFileSync(new URL("../src/auth/storage.ts", import.meta.url), "utf8") const appConfig = readFileSync(new URL("../app.config.js", import.meta.url), "utf8") test("the root layout exports an ErrorBoundary so expo-router can catch a boot crash", () => { @@ -57,15 +68,51 @@ test("every external URL is validated before it reaches Linking.openURL", () => test("a bare dev bundle points at localhost, never silently at production", () => { assert.match(apiUrlModule, /export const DEV_API_URL = "http:\/\/localhost:8080"/) + assert.match(apiUrlModule, /export const STAGING_API_URL = "https:\/\/api\.civfix\.dev"/) assert.match(apiUrlModule, /export const PROD_API_URL = "https:\/\/api\.civfix\.org"/) }) test("the base URL goes through the guarded resolver, never a bare ?? on the baked value", () => { - assert.match(config, /resolveApiUrl\(extra\.apiUrl, __DEV__\)/) + assert.match(config, /resolveApiUrl\(extra\.apiUrl, __DEV__, isBetaInstall\(\)\)/) assert.doesNotMatch(config, /extra\.apiUrl \?\?/) assert.match(apiUrlModule, /typeof configured === "string"/) }) +test("the install probe is iOS-only and reads the ACTIVE StoreKit receipt", () => { + assert.match(betaInstallModule, /if \(Platform\.OS !== "ios"\) return false/) + assert.match(betaInstallModule, /betaInstallFromReceipts\(probe\.store, probe\.sandbox\)/) + assert.match(betaInstallModule, /Paths\.document\.parentDirectory/) + assert.match(storeKitModule, /export const APP_STORE_RECEIPT = "receipt"/) + assert.match(storeKitModule, /export const SANDBOX_RECEIPT = "sandboxReceipt"/) +}) + +test("the receipt decision runs store-first, and only a NEWER sandbox receipt means staging", () => { + assert.match(storeKitModule, /if \(!sandbox\.present\) return false/) + assert.match(storeKitModule, /if \(!store\.present\) return true/) + assert.match( + storeKitModule, + /if \(store\.modifiedAt === null \|\| sandbox\.modifiedAt === null\) return false/, + ) + assert.match(storeKitModule, /return sandbox\.modifiedAt > store\.modifiedAt/) +}) + +test("expo-file-system is required inside the guarded probe, never imported into the boot chain", () => { + assert.doesNotMatch(betaInstallModule, /^import .*"expo-file-system"/m) + const probe = betaInstallModule.slice(betaInstallModule.indexOf("function probeStoreKit")) + const guard = probe.indexOf("try {") + const load = probe.indexOf('require("expo-file-system")') + assert.ok(guard > -1 && load > guard) + assert.match(probe, /catch \{\s*return PROBE_UNAVAILABLE\s*}/) + assert.match(betaInstallModule, /const PROBE_UNAVAILABLE: StoreKitProbe = \{\s*dir: null,/) +}) + +test("persisted state is scoped to the API environment, production keeping the legacy ids", () => { + assert.match(mmkvModule, /scopeStorageId\("civfix\.app", API_URL\)/) + assert.match(secureStoreModule, /scopeStorageId\("civfix\.secure-blobs\.key", API_URL\)/) + assert.match(secureStoreModule, /scopeStorageId\("civfix\.secure", API_URL\)/) + assert.match(authStorage, /scopeStorageId\("civfix\.session\.token", API_URL\)/) +}) + test("the app config OMITS apiUrl when unset rather than baking a null Expo turns into {}", () => { assert.match(appConfig, /\.\.\.\(API_URL \? \{ apiUrl: API_URL \} : \{\}\)/) assert.doesNotMatch(appConfig, /^\s+apiUrl: API_URL,\s*$/m) diff --git a/apps/community-mobile/tests/cameraWarmup.test.ts b/apps/community-mobile/tests/cameraWarmup.test.ts index 444c4a07..bea81d02 100644 --- a/apps/community-mobile/tests/cameraWarmup.test.ts +++ b/apps/community-mobile/tests/cameraWarmup.test.ts @@ -6,6 +6,7 @@ import { test } from "node:test" const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8") const mapHome = read("../app/index.tsx") const viewfinder = read("../src/components/report/ReportViewfinder.tsx") +const nativeCamera = read("../src/lib/nativeCamera.ts") function mapElementMemo(): string { const start = mapHome.indexOf("const mapElement = useMemo(") @@ -156,3 +157,19 @@ test("the photo shutter asks the library to stay silent", () => { assert.ok(take.includes("enableShutterSound: false")) assert.ok(take.includes('flash: "off"')) }) + +test("the stale-temp sweep waits out the pop on a real clock, not on runAfterInteractions", () => { + assert.ok( + !nativeCamera.includes("InteractionManager"), + "runAfterInteractions is a bare setImmediate under RN 0.81 - it defers nothing", + ) + assert.ok(nativeCamera.includes("const SWEEP_DELAY_MS = motion.pagePop.duration")) + assert.ok(nativeCamera.includes("}, SWEEP_DELAY_MS)")) +}) + +test("the sweep yields between directories, so one turn is never two full scans", () => { + const sweep = nativeCamera.slice(nativeCamera.indexOf("function sweepStaleMediaTempFiles()")) + const body = sweep.slice(0, sweep.indexOf("\n}\n")) + assert.equal((body.match(/setTimeout\(/g) ?? []).length, 2) + assert.ok(body.includes("}, 0)")) +}) diff --git a/apps/community-mobile/tests/notificationNav.test.ts b/apps/community-mobile/tests/notificationNav.test.ts index 86192a38..9848265c 100644 --- a/apps/community-mobile/tests/notificationNav.test.ts +++ b/apps/community-mobile/tests/notificationNav.test.ts @@ -4,6 +4,7 @@ import { test } from "node:test" import { isInternalLink } from "../src/lib/links.ts" import { bridgeKey, nativeBridgeKey, BRIDGE_ROUTE_NAMES } from "../src/lib/navBridge.ts" import { shellHostsEntries } from "../src/lib/internalHref.ts" +import { threadEntryRoute } from "../src/lib/threadEntryRoutes.ts" const adapter = readFileSync(new URL("../src/components/MobileNavAdapter.tsx", import.meta.url), "utf8") const layout = readFileSync(new URL("../app/_layout.tsx", import.meta.url), "utf8") @@ -17,7 +18,11 @@ const NOTIFICATION_LINKS = [ "/cleanups/c1/checkin", "/cleanups/c1/ticket", "/cleanups/c1/ticket/s1", + "/cleanups/c1/announcements", + "/cleanups/c1/announcements/a1", + "/cleanups/c1/analytics", "/orgs/acme", + "/orgs/acme/manage", "/people/u1", "/post/p1", "/reports", @@ -114,16 +119,48 @@ test("an event push that names a host surface lands in a shell, not on a native { entry: { kind: "host-team", id: "c1" } as const, route: "cleanups/[id]/team" }, { entry: { kind: "host-log-hours", id: "c1" } as const, route: "cleanups/[id]/hours" }, { entry: { kind: "my-ticket", id: "c1", seatId: "s1" } as const, route: "cleanups/[id]/ticket/[seatId]" }, - { entry: { kind: "org", slug: "acme" } as const, route: "orgs/[slug]" }, + { entry: { kind: "event-analytics", id: "c1" } as const, route: "cleanups/[id]/analytics" }, + { entry: { kind: "announcements", id: "c1" } as const, route: "cleanups/[id]/announcements" }, + { + entry: { kind: "announcement", id: "c1", announcementId: "a1" } as const, + route: "cleanups/[id]/announcements/[announcementId]", + }, + { entry: { kind: "org", slug: "acme" } as const, route: "orgs/[slug]/index" }, + { entry: { kind: "org-manage", slug: "acme" } as const, route: "orgs/[slug]/manage" }, ] for (const { entry, route } of SHELL_HOSTED) { assert.equal(bridgeKey(entry), null, `${entry.kind} still claims a bridge key`) - assert.equal(nativeBridgeKey({ name: route, params: { id: "c1", seatId: "s1", slug: "acme" } }), null, route) + assert.equal( + nativeBridgeKey({ + name: route, + params: { id: "c1", seatId: "s1", slug: "acme", announcementId: "a1" }, + }), + null, + route, + ) assert.equal(shellHostsEntries({ name: route }), true, route) } }) -test("a broadcast push that lands in the sheet claims no native bridge key", () => { - assert.equal(bridgeKey({ kind: "host-broadcast-quick", id: "c1" }), null) +test("those same shell surfaces are push-capable, so a tap from inside a thread stacks", () => { + const PUSHABLE = [ + { entry: { kind: "announcements", id: "c1" } as const, pathname: "/cleanups/[id]/announcements" }, + { + entry: { kind: "announcement", id: "c1", announcementId: "a1" } as const, + pathname: "/cleanups/[id]/announcements/[announcementId]", + }, + { entry: { kind: "event-analytics", id: "c1" } as const, pathname: "/cleanups/[id]/analytics" }, + { entry: { kind: "org-manage", slug: "acme" } as const, pathname: "/orgs/[slug]/manage" }, + ] + for (const { entry, pathname } of PUSHABLE) { + const route = threadEntryRoute(entry) + assert.notEqual(route, null, `${entry.kind} still tears the stack down`) + assert.equal(route!.pathname, pathname) + assert.equal(shellHostsEntries({ name: pathname.replace(/^\//, "") }), true, pathname) + } +}) + +test("the announcement composer lands in the sheet and claims no native bridge key", () => { + assert.equal(bridgeKey({ kind: "host-announce", id: "c1" }), null) assert.equal(shellHostsEntries({ name: "compose" }), false) }) diff --git a/apps/community-mobile/tests/notificationsMode.test.ts b/apps/community-mobile/tests/notificationsMode.test.ts new file mode 100644 index 00000000..96a59654 --- /dev/null +++ b/apps/community-mobile/tests/notificationsMode.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict" +import { createRequire } from "node:module" +import { test } from "node:test" + +const require = createRequire(import.meta.url) +const appConfigFactory = require("../app.config.js") as (input: { + config: Record +}) => Record + +function notificationsPlugin(profile?: string): Record { + const previous = process.env.EAS_BUILD_PROFILE + if (profile === undefined) delete process.env.EAS_BUILD_PROFILE + else process.env.EAS_BUILD_PROFILE = profile + try { + const entry = appConfigFactory({ config: {} }).plugins.find( + (plugin: unknown) => Array.isArray(plugin) && plugin[0] === "expo-notifications", + ) + assert.ok(Array.isArray(entry), "app.config.js declares no expo-notifications plugin tuple") + return entry[1] + } finally { + if (previous === undefined) delete process.env.EAS_BUILD_PROFILE + else process.env.EAS_BUILD_PROFILE = previous + } +} + +test("a store-bound EAS build bakes the PRODUCTION aps-environment entitlement", () => { + for (const profile of ["testflight", "production"]) { + assert.equal( + notificationsPlugin(profile).mode, + "production", + `${profile} must not ship a sandbox aps-environment - APNS drops every push`, + ) + } +}) + +test("the internally distributed EAS profiles are signed for production APNS too", () => { + for (const profile of ["preview", "staging"]) { + assert.equal(notificationsPlugin(profile).mode, "production", profile) + } +}) + +test("the EAS development-client profile keeps the sandbox, and so does a local prebuild", () => { + assert.equal(notificationsPlugin("development").mode, "development") + assert.equal(notificationsPlugin(undefined).mode, "development") +}) + +test("the mode is resolved per config call, never frozen at module load", () => { + assert.equal(notificationsPlugin("production").mode, "production") + assert.equal(notificationsPlugin("development").mode, "development") + assert.equal(notificationsPlugin("production").mode, "production") +}) diff --git a/apps/community-mobile/tests/storeBuildApiUrl.test.ts b/apps/community-mobile/tests/storeBuildApiUrl.test.ts new file mode 100644 index 00000000..d22cc1f6 --- /dev/null +++ b/apps/community-mobile/tests/storeBuildApiUrl.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { execFileSync } from "node:child_process" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, join } from "node:path" + +const SCRIPT = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "store-build.sh"), + "utf8", +) + +function bakedApiUrlReader(): string { + const at = SCRIPT.indexOf("baked_api_url=\"$(printf '%s' \"$baked_config\" | node -e '") + assert.ok(at > -1, "store-build.sh no longer reads the baked api url with an inline node program") + const open = SCRIPT.indexOf("node -e '", at) + "node -e '".length + const close = SCRIPT.indexOf("')\"", open) + assert.ok(close > open, "the inline node program is unterminated") + return SCRIPT.slice(open, close) +} + +function read(config: unknown): string { + return execFileSync("node", ["-e", bakedApiUrlReader()], { + input: JSON.stringify(config), + encoding: "utf8", + }) +} + +const APPSTORE_EXPECTATION = "" +const TESTFLIGHT_EXPECTATION = "https://api.civfix.dev" + +test("a testflight build reports the API URL that was baked in", () => { + assert.equal(read({ extra: { apiUrl: TESTFLIGHT_EXPECTATION } }), TESTFLIGHT_EXPECTATION) +}) + +test("only a genuinely ABSENT apiUrl satisfies the appstore expectation", () => { + assert.equal(read({ extra: {} }), APPSTORE_EXPECTATION) + assert.equal(read({}), APPSTORE_EXPECTATION) + assert.equal(read({ extra: null }), APPSTORE_EXPECTATION) +}) + +test("the {} Expo bakes for a null config value FAILS the appstore assertion", () => { + const baked = read({ extra: { apiUrl: {} } }) + assert.equal(baked, "{}") + assert.notEqual(baked, APPSTORE_EXPECTATION) +}) + +test("a null, a number and an object all stay distinguishable from absent", () => { + for (const value of [null, 0, 42, { a: 1 }, []]) { + assert.notEqual(read({ extra: { apiUrl: value } }), APPSTORE_EXPECTATION) + } +}) + +test("a staging URL in an appstore build is refused, and a prod URL in a testflight build too", () => { + assert.notEqual(read({ extra: { apiUrl: TESTFLIGHT_EXPECTATION } }), APPSTORE_EXPECTATION) + assert.notEqual(read({ extra: { apiUrl: "https://api.civfix.org" } }), TESTFLIGHT_EXPECTATION) +}) diff --git a/apps/community-web/src/app/e/signup.css b/apps/community-web/src/app/e/signup.css index e1d9c5d3..3912907a 100644 --- a/apps/community-web/src/app/e/signup.css +++ b/apps/community-web/src/app/e/signup.css @@ -384,7 +384,7 @@ .signup-page textarea:focus-visible, .signup-page a:focus-visible { outline: none; - box-shadow: var(--ring); + box-shadow: var(--focus-ring); } .signup-state { diff --git a/apps/community-web/src/app/globals.css b/apps/community-web/src/app/globals.css index d059272a..44eebe47 100644 --- a/apps/community-web/src/app/globals.css +++ b/apps/community-web/src/app/globals.css @@ -5,6 +5,12 @@ * a hex in @civfix/shared tokens (the source hex is named in the trailing comment so the mapping * back to the single source of truth is auditable). Tailwind utilities read these via * hsl(var(--token)) in tailwind.config.ts. We do not introduce any color that is not a token. + * + * Three of these names once collided with the ported handoff palette in styles/design.css, which is + * imported AFTER this file and unlayered, so its flat hex/rgba value won the cascade and the Tailwind + * utility emitted hsl(rgba(...)) - an invalid color the browser drops. Those three carry a shadcn- + * prefix here, the way design.css's own --ring became --focus-ring; design-css-tokens.test.ts asserts + * the two files' :root variable names stay disjoint. */ @import "maplibre-gl/dist/maplibre-gl.css"; @@ -15,39 +21,39 @@ @layer base { :root { - /* Surfaces. background = paper (#F4EFE6); card = white (#FFFFFF). */ + /* Surfaces. background = paper (#F4EFE6); card = the warm card white (#FFFDF8). */ --background: 38.6 38.9% 92.9%; /* neutral.paper #F4EFE6 */ - --foreground: 30.0 13.0% 9.0%; /* neutral.ink #1A1714 */ + --foreground: 34.3 26.9% 10.2%; /* neutral.ink #211B13 */ - --card: 0 0% 100%; /* neutral.card #FFFFFF */ - --card-foreground: 30.0 13.0% 9.0%; /* neutral.ink */ + --shadcn-card: 42.9 100% 98.6%; /* neutral.card #FFFDF8 */ + --shadcn-card-foreground: 34.3 26.9% 10.2%; /* neutral.ink */ - --popover: 0 0% 100%; /* neutral.card #FFFFFF */ - --popover-foreground: 30.0 13.0% 9.0%; /* neutral.ink */ + --popover: 42.9 100% 98.6%; /* neutral.card #FFFDF8 */ + --popover-foreground: 34.3 26.9% 10.2%; /* neutral.ink */ - /* Primary = bloom/coral (#FF7A6B) on white. */ - --primary: 6.1 100% 71%; /* bloom.500 #FF7A6B */ + /* Primary = bloom/coral (#F0685C) on white. */ + --primary: 4.9 83.1% 65.1%; /* bloom.500 #F0685C */ --primary-foreground: 0 0% 100%; /* white */ /* Secondary = paper2 surface with ink text. */ --secondary: 39.0 32.3% 87.8%; /* neutral.paper2 #EAE3D6 */ - --secondary-foreground: 30.0 13.0% 9.0%; /* neutral.ink */ + --secondary-foreground: 34.3 26.9% 10.2%; /* neutral.ink */ /* Muted = paper2 with ink3 text. */ --muted: 39.0 32.3% 87.8%; /* neutral.paper2 #EAE3D6 */ - --muted-foreground: 36.7 7.1% 50.6%; /* neutral.ink3 #8A8378 */ + --muted-foreground: 38.2 8.8% 51%; /* neutral.ink3 #8D8577 */ - /* Accent = sun (#FFCB47) with ink text (host-event accent). */ - --accent: 43.0 100% 63.9%; /* sun.500 #FFCB47 */ - --accent-foreground: 30.0 13.0% 9.0%; /* neutral.ink */ + /* Accent = sun (#D9A21B) with ink text (host-event accent). */ + --shadcn-accent: 42.6 77.9% 47.8%; /* sun.500 #D9A21B */ + --shadcn-accent-foreground: 34.3 26.9% 10.2%; /* neutral.ink */ - /* Destructive = bloom.600 (#E55F50). */ - --destructive: 6.0 74.1% 60.6%; /* bloom.600 #E55F50 */ + /* Destructive = bloom.600 (#E4574A). */ + --destructive: 5.1 74% 59.2%; /* bloom.600 #E4574A */ --destructive-foreground: 0 0% 100%; /* white */ - --border: 39.0 28.6% 86.3%; /* neutral.ink5 #E6DFD2 (hairlines) */ + --shadcn-border: 39.0 28.6% 86.3%; /* neutral.ink5 #E6DFD2 (hairlines) */ --input: 39.0 28.6% 86.3%; /* neutral.ink5 #E6DFD2 */ - --ring: 6.1 100% 71%; /* bloom.500 focus ring */ + --ring: 4.9 83.1% 65.1%; /* bloom.500 #F0685C focus ring */ /* Radius base for shadcn components; matches token radius.lg (20px). */ --radius: 20px; @@ -109,8 +115,8 @@ --background: 33.3 24.3% 7.3%; /* darkColor.neutral.paper #17130E */ --foreground: 35.3 37.8% 91.2%; /* darkColor.neutral.ink #F1EAE0 */ - --card: 34.3 20.0% 13.7%; /* darkColor.neutral.card #2A241C */ - --card-foreground: 35.3 37.8% 91.2%; /* darkColor.neutral.ink */ + --shadcn-card: 34.3 20.0% 13.7%; /* darkColor.neutral.card #2A241C */ + --shadcn-card-foreground: 35.3 37.8% 91.2%; /* darkColor.neutral.ink */ --popover: 34.3 20.0% 13.7%; /* darkColor.neutral.card #2A241C */ --popover-foreground: 35.3 37.8% 91.2%; /* darkColor.neutral.ink */ @@ -124,13 +130,13 @@ --muted: 35.0 22.2% 10.6%; /* darkColor.neutral.paper2 #211C15 */ --muted-foreground: 37.1 8.9% 53.9%; /* darkColor.neutral.ink3 #948C7F */ - --accent: 42.5 75.3% 55.5%; /* darkColor.sun.500 #E3B138 */ - --accent-foreground: 33.3 24.3% 7.3%; /* darkColor.neutral.paper */ + --shadcn-accent: 42.5 75.3% 55.5%; /* darkColor.sun.500 #E3B138 */ + --shadcn-accent-foreground: 33.3 24.3% 7.3%; /* darkColor.neutral.paper */ --destructive: 6.3 87.7% 74.5%; /* darkColor.bloom.600 #F79185 */ --destructive-foreground: 33.3 24.3% 7.3%; /* darkColor.neutral.paper */ - --border: 34.3 15.9% 17.3%; /* darkColor.neutral.ink5 #332D25 (hairlines) */ + --shadcn-border: 34.3 15.9% 17.3%; /* darkColor.neutral.ink5 #332D25 (hairlines) */ --input: 34.3 15.9% 17.3%; /* darkColor.neutral.ink5 #332D25 */ --ring: 6.3 87.7% 74.5%; /* darkColor.bloom.600 #F79185 = FOCUS_RING_COLOR_DARK */ diff --git a/apps/community-web/src/app/host/analytics/page.tsx b/apps/community-web/src/app/host/analytics/page.tsx new file mode 100644 index 00000000..0ba0c60d --- /dev/null +++ b/apps/community-web/src/app/host/analytics/page.tsx @@ -0,0 +1,5 @@ +import { HomeShell } from "@/components/home/home-shell" + +export default function HostAnalyticsPage() { + return +} diff --git a/apps/community-web/src/app/legal/legal.css b/apps/community-web/src/app/legal/legal.css index 2f23b358..07d29ed0 100644 --- a/apps/community-web/src/app/legal/legal.css +++ b/apps/community-web/src/app/legal/legal.css @@ -193,12 +193,17 @@ padding: var(--space-3) var(--space-4); white-space: nowrap; } -.legal-prose tbody td { +.legal-prose tbody td, +.legal-prose tbody th { padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border-soft); vertical-align: top; } -.legal-prose tbody tr:nth-child(even) td { +.legal-prose tbody th { + text-align: left; +} +.legal-prose tbody tr:nth-child(even) td, +.legal-prose tbody tr:nth-child(even) th { background: var(--card-tint); } diff --git a/apps/community-web/src/app/orgs/[...slug]/page.tsx b/apps/community-web/src/app/orgs/[...slug]/page.tsx index baf92208..1a6d97a5 100644 --- a/apps/community-web/src/app/orgs/[...slug]/page.tsx +++ b/apps/community-web/src/app/orgs/[...slug]/page.tsx @@ -1,4 +1,4 @@ -import { OrgPageView } from "@/features/org-page/org-page-view" +import { OrgRoute } from "@/features/org-page/org-route" /** * Catch-all public organization page. Like /e/[...slug]: the static export emits one placeholder @@ -13,5 +13,5 @@ export function generateStaticParams(): Array<{ slug: string[] }> { export const dynamicParams = false export default function OrgPage() { - return + return } diff --git a/apps/community-web/src/app/orgs/org-page.css b/apps/community-web/src/app/orgs/org-page.css index a253c6b6..ea0dd36f 100644 --- a/apps/community-web/src/app/orgs/org-page.css +++ b/apps/community-web/src/app/orgs/org-page.css @@ -69,11 +69,6 @@ letter-spacing: var(--tracking-snug); overflow-wrap: anywhere; } -.orgpage-verified { - color: var(--fg-on-color); - fill: var(--sky-600); - flex: none; -} .orgpage-handle { margin: var(--space-1) 0 0; color: var(--fg-3); @@ -179,6 +174,38 @@ background: var(--bg-2); } +.orgpage-socials { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} +.orgpage-links + .orgpage-socials { + margin-top: var(--space-3); +} +.orgpage-social { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background: var(--bg-2); + color: var(--fg-3); +} +.orgpage-social:hover { + border-color: var(--border-strong); + color: var(--fg-1); +} +.orgpage-social:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* ---- Footer / states ---- */ .orgpage-foot { color: var(--fg-3); diff --git a/apps/community-web/src/components/auth/auth-modal.tsx b/apps/community-web/src/components/auth/auth-modal.tsx index 0aae88bf..9d01776a 100644 --- a/apps/community-web/src/components/auth/auth-modal.tsx +++ b/apps/community-web/src/components/auth/auth-modal.tsx @@ -218,7 +218,7 @@ export function AuthModal({ oauthReturnPath = null }: AuthModalProps = {}) { return createPortal(
-
setOpen(false)} /> +