Skip to content

feat(preview): add the AN Preview lane for hardware validation - #24

Open
Aalv3 wants to merge 1 commit into
fix/auth-failure-classification-20260830from
feat/an-preview-lane-20260909
Open

Aalv3 wants to merge 1 commit into
fix/auth-failure-classification-20260830from
feat/an-preview-lane-20260909

Conversation

@Aalv3

@Aalv3 Aalv3 commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Implements the approved Preview lane. Two manual prerequisites remain (Apple portal + one server setting) — this cannot be installed until those are done.

Identity

Production AN Preview
Bundle ID org.adjusternetwork.app org.adjusternetwork.app.preview
Extension …app.ShareExtension …app.preview.ShareExtension
Display name Adjuster Network AN Preview
URL scheme adjusternetwork:// anpreview://
Auth redirect adjusternetwork://adjusternetwork.org/auth_redirect anpreview://adjusternetwork.org/auth_redirect
App group group.org.adjusternetwork.app group.org.adjusternetwork.preview
Channel production preview
Xcode config Release Preview
Server adjusternetwork.org adjusternetwork.org (same, by design)

Proof from the actual built binary

Not inference — this is plutil output from the compiled Preview app:

Expo.plist   expo-channel-name  = "preview"
             RuntimeVersion     = an-ios-android-1.0.0-native-2
Info.plist   CFBundleDisplayName = "AN Preview"
             CFBundleIdentifier  = org.adjusternetwork.app.preview
             CFBundleURLSchemes  = [ "anpreview" ]
             ANPushEnvironment   = "none"
PlugIns      CFBundleIdentifier  = org.adjusternetwork.app.preview.ShareExtension

xcodebuild -showBuildSettings confirms Release still resolves org.adjusternetwork.app, adjusternetwork, production, and the original entitlements file.

Isolation

Automatic from the bundle ID, because no keychain-access-groups entitlement is declared anywhere: Keychain (RSA keys, site tokens, push installation id), AsyncStorage, cookies, expo-updates state, @ClientId, push tokens. Preview mints its own User API client identity and gets its own key — revoking one leaves the other valid.

Explicitly separated because they cannot be: URL scheme (two apps claiming one scheme is undefined on iOS — the auth callback could reach the wrong app), auth redirect, app group.

V1 boundaries — deliberate

Preview entitlements carry no aps-environment and no associated-domains. Push is blocked by the server's pinned TOPIC = "org.adjusternetwork.app" (behind a raise), so Preview sets pushDelivery: false and registers no device rather than creating undeliverable registrations. AASA lists only the production app ID. Neither pin is touched. This also keeps the Preview App ID to a single capability (App Groups).

Two gates strengthened, not just updated

  • Callback scheme: the literal <string>adjusternetwork</string> became $(AN_URL_SCHEME), so the gate now verifies the resolved per-configuration values — shipping uses adjusternetwork, Preview uses anpreview.
  • iPhone-only: counted TARGETED_DEVICE_FAMILY = 1; occurrences (4). Adding configurations made it 6. Now asserts every occurrence is = 1;, so a future configuration can't slip through by keeping the count.

verify:ota gained four Preview assertions including that an unknown channel still fails the build.

Results

format/lint clean · Jest 799/799 across 97 suites (stable over 3 consecutive runs) · verify:ota, verify:ios-auth, verify:release-readiness, verify:backend, validate:ota all PASS · Preview simulator build succeeded.

Includes a one-line fix for a real-clock race in rateLimitResilience written for superseded PR #20 that never landed — Date.now() read twice in one assertion could straddle a millisecond, and it failed once during this work.

Not done here

ios/Discourse/PrivacyInfo.xcprivacy and ios/Podfile.lock were regenerated by a local pod install and reverted — unrelated to Preview, and the privacy manifest is governed by its own test.

Uncertified candidates have been going to the production OTA channel because
there was nowhere else to validate them on real hardware against real member
data. AN Preview is that place: a separately installable founder-only build
that sits beside Production on the same iPhone and talks to the same
production server.

Isolation is mostly free. No keychain-access-groups entitlement is declared
anywhere, so Keychain items land in the default per-bundle-ID access group and
RSA keys, site tokens and the push installation id separate automatically
despite sharing hardcoded service names. AsyncStorage, cookies, the
expo-updates database and @ClientId are all inside the app sandbox. Preview
therefore mints its own User API client identity and receives its own key;
revoking one leaves the other valid.

Three things had to differ explicitly and now do: the URL scheme, because two
installed apps claiming adjusternetwork:// is undefined on iOS and the auth
callback could reach the wrong app; the auth redirect, which follows it; and
the App Group, which is a genuinely shared container.

Channel isolation stays structural. The channel is compiled into Expo.plist by
the Xcode build phase and sent as the expo-channel-name header, so a binary can
request exactly one channel and an unrecognised channel still fails the build.

V1 carries neither aps-environment nor associated-domains. Push to Preview is
blocked by the server's pinned APNs topic, so Preview registers no device
rather than creating undeliverable registrations, and the server AASA lists
only the production app id. Both omissions are deliberate, and they keep the
Preview App ID to a single capability.

Two release gates were made stronger rather than merely updated: the callback
scheme check now verifies the resolved per-configuration values instead of one
literal, and the iPhone-only check asserts the property of every configuration
instead of counting them, so adding a configuration cannot pass by keeping the
number the same.

Also ports a one-line fix for a real-clock race in rateLimitResilience that
was written for the superseded PR #20 and never landed: Date.now() was read
twice inside one assertion and could straddle a millisecond.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant