From 2f09f41df3b1fd96ec3dd366563213440e1a8649 Mon Sep 17 00:00:00 2001 From: Aalv3 <120076079+Aalv3@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:30:17 -0400 Subject: [PATCH] feat(preview): add the AN Preview lane for hardware validation 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) Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR --- docs/NATIVE-PREVIEW-LANE.md | 120 +++++++++++ eas.json | 13 ++ ios/Discourse.xcodeproj/project.pbxproj | 158 +++++++++++++- ios/Discourse/Discourse.preview.entitlements | 15 ++ ios/Discourse/Info.plist | 6 +- .../ShareExtension.preview.entitlements | 10 + js/Discourse.js | 10 + js/__tests__/adjusterNetworkConfig.test.js | 18 +- js/__tests__/appStoreP1Readiness.test.js | 9 +- js/__tests__/authEphemeralSession.test.js | 3 +- js/__tests__/nativeMediaAttachments.test.js | 12 ++ js/__tests__/previewLane.test.js | 200 ++++++++++++++++++ js/__tests__/rateLimitResilience.test.js | 5 +- js/adjusterNetworkConfig.js | 38 +++- js/authorizationConsent.js | 13 +- js/channelIdentity.js | 37 ++++ js/product/PreviewBanner.js | 55 +++++ js/site_manager.js | 2 +- scripts/verify-native-release-readiness.mjs | 15 +- scripts/verify-ota-readiness.mjs | 24 +++ .../an-preview/apply-preview-auth-redirect.rb | 34 +++ .../rollback-preview-auth-redirect.rb | 21 ++ 22 files changed, 790 insertions(+), 28 deletions(-) create mode 100644 docs/NATIVE-PREVIEW-LANE.md create mode 100644 ios/Discourse/Discourse.preview.entitlements create mode 100644 ios/ShareExtension/ShareExtension.preview.entitlements create mode 100644 js/__tests__/previewLane.test.js create mode 100644 js/channelIdentity.js create mode 100644 js/product/PreviewBanner.js create mode 100644 testing/an-preview/apply-preview-auth-redirect.rb create mode 100644 testing/an-preview/rollback-preview-auth-redirect.rb diff --git a/docs/NATIVE-PREVIEW-LANE.md b/docs/NATIVE-PREVIEW-LANE.md new file mode 100644 index 000000000..adae95a8b --- /dev/null +++ b/docs/NATIVE-PREVIEW-LANE.md @@ -0,0 +1,120 @@ +# AN Preview lane + +A founder-only build that installs beside Production on the same iPhone and +talks to the **real production server**. Uncertified candidates are validated +here instead of on the production OTA channel. + +## Identity + +| | Production | AN Preview | Staging | +| --- | --- | --- | --- | +| Bundle ID | `org.adjusternetwork.app` | `org.adjusternetwork.app.preview` | `org.adjusternetwork.app` | +| Display name | Adjuster Network | **AN Preview** | Adjuster Network | +| URL scheme | `adjusternetwork://` | `anpreview://` | `adjusternetwork://` | +| Auth redirect | `adjusternetwork://adjusternetwork.org/auth_redirect` | `anpreview://adjusternetwork.org/auth_redirect` | `adjusternetwork://adjusternetwork.org/auth_redirect` | +| App group | `group.org.adjusternetwork.app` | `group.org.adjusternetwork.preview` | `group.org.adjusternetwork.app` | +| OTA channel | `production` | `preview` | `staging` | +| Xcode config | Release | **Preview** | Debug | +| Server | adjusternetwork.org | **adjusternetwork.org** | staging.adjusternetwork.org | + +The display name is `AN Preview`, not `Adjuster Network Preview`: iOS truncates +home-screen labels around twelve characters, and the long form renders as +"Adjuster Netw…" — indistinguishable from Production, which defeats the point. + +## Why the isolation holds + +Everything below is isolated because the bundle identifier differs. No +namespacing code was added, and none is needed. + +- **Keychain** — no `keychain-access-groups` entitlement is declared anywhere, + so items land in the default per-bundle-ID access group. RSA keys, site + tokens and the push installation ID separate automatically despite sharing + hardcoded service names. +- **AsyncStorage, cookies, Expo Updates state** — inside the app sandbox. +- **User API client ID** — `@ClientId` is per-sandbox, so Preview mints its own + 32-byte identity and receives its own User API key. Revoking one leaves the + other valid. +- **Push tokens** — issued by APNs per bundle ID. Preview registers no device + at all in V1 (see below). + +Three things had to differ explicitly, and do: + +- **URL scheme.** Two installed apps claiming `adjusternetwork://` is undefined + behaviour on iOS: the auth callback could be delivered to the wrong app. +- **Auth redirect.** Follows the scheme, and must be allowlisted server-side. +- **App group.** A genuinely shared container, so Preview gets its own. + +## Channel isolation is structural + +The channel is written into the built `Expo.plist` by an Xcode build phase and +sent as the `expo-channel-name` request header. There is no runtime switch. + +``` +case "$channel" in + staging) embedded=false ;; + preview) embedded=true ;; + production) embedded=true ;; + *) echo "error: invalid Adjuster Network OTA channel" >&2; exit 1 ;; +esac +``` + +A Production binary cannot request `preview` updates because it cannot request +anything but `production`, and vice versa. An unrecognised channel still fails +the build. `verify:ota` asserts all four arms. + +## V1 boundaries + +**Push is off.** The server pins `TOPIC = "org.adjusternetwork.app"` behind a +`raise`, and `apns-topic` must equal the receiving app's bundle ID. Rather than +weaken that pin, Preview sets `pushDelivery: false` and registers no device, so +it never creates registrations that could not be delivered to. Preview's +entitlements carry no `aps-environment`. **V2:** make the server topic +per-registration instead of a constant. + +**Universal links are off.** The server AASA lists only +`.org.adjusternetwork.app`. Preview's entitlements carry no +`associated-domains`, so it never appears in link disambiguation. **V2:** add a +second AASA entry. + +Both are deliberate omissions that also keep the Apple setup small: the Preview +App ID needs only the App Groups capability. + +## Workflow + +``` +feature branch → fast CI → preview OTA → iPhone validation in AN Preview + → founder approval → promote exact content to production +``` + +Publish a candidate: + +``` +AN_OTA_CHANNEL=preview AN_OTA_GIT_SHA=$(git rev-parse HEAD) \ + npx eas-cli@latest update --branch preview --platform all \ + --message "Preview $(git rev-parse --short=12 HEAD) " \ + --non-interactive +``` + +Verify what the phone actually runs: + +``` +yarn device:harness ota-status # update ID must match the publish output +``` + +After founder PASS, promote the **validated group** to production rather than +rebundling, so the artifact approved is the artifact that ships: + +``` +yarn ota:promote --group= +``` + +**Provenance tags are for production artifacts only.** A Preview publish gets +no `ota-*` tag; the tag is created at promotion, as today. A Preview candidate +is already identified by its PR head SHA. + +## Removal + +Delete AN Preview from the phone; remove the `preview` channel; revoke the +Preview User API key from the founder account; run +`testing/an-preview/rollback-preview-auth-redirect.rb`. Production shares no +storage, credential or channel with Preview and is unaffected at every step. diff --git a/eas.json b/eas.json index cdd800acf..f04c432d8 100644 --- a/eas.json +++ b/eas.json @@ -16,6 +16,19 @@ "AN_OTA_CHANNEL_OVERRIDE": "staging" } }, + "preview": { + "channel": "preview", + "distribution": "internal", + "ios": { + "image": "macos-sequoia-15.6-xcode-16.4", + "cocoapods": "1.17.0", + "buildConfiguration": "Preview" + }, + "env": { + "AN_OTA_CHANNEL": "preview", + "AN_OTA_CHANNEL_OVERRIDE": "preview" + } + }, "production": { "channel": "production", "env": { diff --git a/ios/Discourse.xcodeproj/project.pbxproj b/ios/Discourse.xcodeproj/project.pbxproj index d8722a631..ea48d7ba4 100644 --- a/ios/Discourse.xcodeproj/project.pbxproj +++ b/ios/Discourse.xcodeproj/project.pbxproj @@ -68,6 +68,7 @@ B52583FD1E5551D7001E9B7C /* nav-icon-gray@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "nav-icon-gray@3x.png"; path = "../img/nav-icon-gray@3x.png"; sourceTree = ""; }; B587A4F11E5549B8003AAE26 /* Discourse.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Discourse.entitlements; path = Discourse/Discourse.entitlements; sourceTree = ""; }; B587A4F11E5549B8003AAE27 /* Discourse.development.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Discourse.development.entitlements; path = Discourse/Discourse.development.entitlements; sourceTree = ""; }; + B828FC8BD24E02EC2879E1DF /* Pods-Discourse.preview.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Discourse.preview.xcconfig"; path = "Target Support Files/Pods-Discourse/Pods-Discourse.preview.xcconfig"; sourceTree = ""; }; BD34D9672ADEF17500AC757D /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; BD4A208027B54F6500574A58 /* DiscourseKeyboardShortcuts.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DiscourseKeyboardShortcuts.h; sourceTree = ""; }; BD4A208127B54FA300574A58 /* DiscourseKeyboardShortcuts.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DiscourseKeyboardShortcuts.m; sourceTree = ""; }; @@ -139,6 +140,7 @@ children = ( 94936B1821688FB21A88C81D /* Pods-Discourse.debug.xcconfig */, 2482A32288AB770A5EC3D04B /* Pods-Discourse.release.xcconfig */, + B828FC8BD24E02EC2879E1DF /* Pods-Discourse.preview.xcconfig */, ); path = Pods; sourceTree = ""; @@ -383,7 +385,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -eu\nchannel=\"${AN_OTA_CHANNEL_OVERRIDE:-$AN_OTA_CHANNEL}\"\ncase \"$channel\" in\n staging) embedded=false ;;\n production) embedded=true ;;\n *) echo \"error: invalid Adjuster Network OTA channel\" >&2; exit 1 ;;\nesac\nplist=\"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Expo.plist\"\n/usr/libexec/PlistBuddy -c \"Set :EXUpdatesRequestHeaders:expo-channel-name $channel\" \"$plist\"\n/usr/libexec/PlistBuddy -c \"Set :EXUpdatesHasEmbeddedUpdate $embedded\" \"$plist\"\nprintf 'channel=%s\\nembedded=%s\\n' \"$channel\" \"$embedded\" > \"$SCRIPT_OUTPUT_FILE_0\"\n"; + shellScript = "set -eu\nchannel=\"${AN_OTA_CHANNEL_OVERRIDE:-$AN_OTA_CHANNEL}\"\ncase \"$channel\" in\n staging) embedded=false ;;\n preview) embedded=true ;;\n production) embedded=true ;;\n *) echo \"error: invalid Adjuster Network OTA channel\" >&2; exit 1 ;;\nesac\nplist=\"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Expo.plist\"\n/usr/libexec/PlistBuddy -c \"Set :EXUpdatesRequestHeaders:expo-channel-name $channel\" \"$plist\"\n/usr/libexec/PlistBuddy -c \"Set :EXUpdatesHasEmbeddedUpdate $embedded\" \"$plist\"\nprintf 'channel=%s\\nembedded=%s\\n' \"$channel\" \"$embedded\" > \"$SCRIPT_OUTPUT_FILE_0\"\n"; }; A30A0A122E4B000100000001 /* Generate Hermes dSYM */ = { isa = PBXShellScriptBuildPhase; @@ -503,7 +505,6 @@ inputPaths = ( "$(SRCROOT)/.xcode.env", "$(SRCROOT)/.xcode.env.local", - "$(SRCROOT)/Discourse/Discourse.entitlements", "$(SRCROOT)/Discourse/Discourse.development.entitlements", "$(SRCROOT)/Pods/Target Support Files/Pods-Discourse/expo-configure-project.sh", ); @@ -574,8 +575,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 94936B1821688FB21A88C81D /* Pods-Discourse.debug.xcconfig */; buildSettings = { + AN_DISPLAY_NAME = "Adjuster Network"; AN_OTA_CHANNEL = staging; AN_PUSH_ENVIRONMENT = staging; + AN_URL_SCHEME = adjusternetwork; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Discourse/Discourse.development.entitlements; @@ -622,8 +625,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 2482A32288AB770A5EC3D04B /* Pods-Discourse.release.xcconfig */; buildSettings = { + AN_DISPLAY_NAME = "Adjuster Network"; AN_OTA_CHANNEL = production; AN_PUSH_ENVIRONMENT = production; + AN_URL_SCHEME = adjusternetwork; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Discourse/Discourse.entitlements; @@ -865,6 +870,152 @@ }; name = Release; }; + DDDDDDDD0000000000000001 /* Preview */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION, + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift$(inherited)"; + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Preview; + }; + DDDDDDDD0000000000000002 /* Preview */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B828FC8BD24E02EC2879E1DF /* Pods-Discourse.preview.xcconfig */; + buildSettings = { + AN_DISPLAY_NAME = "AN Preview"; + AN_OTA_CHANNEL = preview; + AN_PUSH_ENVIRONMENT = none; + AN_URL_SCHEME = anpreview; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Discourse/Discourse.preview.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 8; + DEVELOPMENT_TEAM = 2GB8G74L4H; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)", + ); + HEADER_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = Discourse/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "$(inherited)", + "\"$(SRCROOT)/Discourse\"/**", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = org.adjusternetwork.app.preview; + PRODUCT_MODULE_NAME = Discourse; + PRODUCT_NAME = AdjusterNetwork; + SWIFT_OBJC_BRIDGING_HEADER = "Discourse-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Preview; + }; + DDDDDDDD0000000000000003 /* Preview */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.preview.entitlements; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = YES; + CURRENT_PROJECT_VERSION = 8; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 2GB8G74L4H; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = ShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = org.adjusternetwork.app.preview.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Preview; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -873,6 +1024,7 @@ buildConfigurations = ( 13B07F941A680F5B00A75B9A /* Debug */, 13B07F951A680F5B00A75B9A /* Release */, + DDDDDDDD0000000000000002 /* Preview */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -882,6 +1034,7 @@ buildConfigurations = ( 83CBBA201A601CBA00E9B192 /* Debug */, 83CBBA211A601CBA00E9B192 /* Release */, + DDDDDDDD0000000000000001 /* Preview */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -891,6 +1044,7 @@ buildConfigurations = ( BD9F666923F5B02C001001B3 /* Debug */, BD9F666A23F5B02C001001B3 /* Release */, + DDDDDDDD0000000000000003 /* Preview */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/ios/Discourse/Discourse.preview.entitlements b/ios/Discourse/Discourse.preview.entitlements new file mode 100644 index 000000000..f739d7fe1 --- /dev/null +++ b/ios/Discourse/Discourse.preview.entitlements @@ -0,0 +1,15 @@ + + + + + + com.apple.security.application-groups + + group.org.adjusternetwork.preview + + + diff --git a/ios/Discourse/Info.plist b/ios/Discourse/Info.plist index 7b641826e..2bc407ccc 100644 --- a/ios/Discourse/Info.plist +++ b/ios/Discourse/Info.plist @@ -9,7 +9,7 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - Adjuster Network + $(AN_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -30,10 +30,10 @@ CFBundleTypeRole Editor CFBundleURLName - org.adjusternetwork.app + $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleURLSchemes - adjusternetwork + $(AN_URL_SCHEME) diff --git a/ios/ShareExtension/ShareExtension.preview.entitlements b/ios/ShareExtension/ShareExtension.preview.entitlements new file mode 100644 index 000000000..026f6d1f2 --- /dev/null +++ b/ios/ShareExtension/ShareExtension.preview.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.adjusternetwork.preview + + + diff --git a/js/Discourse.js b/js/Discourse.js index 301e1da9e..47d44f379 100644 --- a/js/Discourse.js +++ b/js/Discourse.js @@ -102,6 +102,7 @@ import NativeTopicScreen from './product/NativeTopicScreen'; import NativeCollectionScreen from './product/NativeCollectionScreen'; import NativeProfileScreen from './product/NativeProfileScreen'; import BadgeEarnedScreen from './product/BadgeEarnedScreen'; +import PreviewBanner from './product/PreviewBanner'; import { classifyFirstPartyMemberRoute } from './nativeMemberRouting'; import { notificationIntent } from './notificationIntent'; import { @@ -1153,6 +1154,15 @@ class Discourse extends React.Component { } render() { + return ( + + {this._renderApp()} + + + ); + } + + _renderApp() { // TODO: pass only relevant props to each screen component const screenProps = { openUrl: this.openUrl.bind(this), diff --git a/js/__tests__/adjusterNetworkConfig.test.js b/js/__tests__/adjusterNetworkConfig.test.js index a75516f3d..019b31802 100644 --- a/js/__tests__/adjusterNetworkConfig.test.js +++ b/js/__tests__/adjusterNetworkConfig.test.js @@ -18,7 +18,12 @@ describe('Adjuster Network product boundary', () => { expect(canonicalOriginForChannel('unknown')).toBeNull(); expect(trustedUpdateChannel('staging')).toBe('staging'); expect(trustedUpdateChannel('production')).toBe('production'); - expect(trustedUpdateChannel('preview')).toBeNull(); + // preview is now a governed founder-only channel pointing at production. + expect(trustedUpdateChannel('preview')).toBe('preview'); + expect(canonicalOriginForChannel('preview')).toBe( + 'https://adjusternetwork.org', + ); + expect(trustedUpdateChannel('qa')).toBeNull(); }); test('exposes only routes backed by the current application', () => { @@ -89,7 +94,16 @@ describe('push backend channel routing', () => { }); test('fails closed for an unknown update channel', () => { - expect(backendOriginForUpdatesChannel('preview')).toBeNull(); + expect(backendOriginForUpdatesChannel('qa')).toBeNull(); + expect(backendOriginForUpdatesChannel('PRODUCTION')).toBeNull(); expect(backendOriginForUpdatesChannel(undefined)).toBeNull(); }); + + test('preview does not register a push device, so it routes nowhere', () => { + // Push to Preview is blocked by the server's pinned APNs topic; Preview + // registers no device rather than creating undeliverable registrations. + expect(adjusterNetwork.features.pushDelivery).toBe( + adjusterNetwork.channel !== 'preview', + ); + }); }); diff --git a/js/__tests__/appStoreP1Readiness.test.js b/js/__tests__/appStoreP1Readiness.test.js index 772d11584..a02df283a 100644 --- a/js/__tests__/appStoreP1Readiness.test.js +++ b/js/__tests__/appStoreP1Readiness.test.js @@ -20,7 +20,14 @@ describe('App Store P1 source gates', () => { const project = read('ios/Discourse.xcodeproj/project.pbxproj'); const info = read('ios/Discourse/Info.plist'); expect(project).not.toContain('TARGETED_DEVICE_FAMILY = "1,2"'); - expect(project.match(/TARGETED_DEVICE_FAMILY = 1;/g)).toHaveLength(4); + // Every configuration must be iPhone-only. Asserting the property of all + // of them rather than a fixed count, so adding a build configuration + // cannot silently pass by keeping the number the same. + const families = project.match(/TARGETED_DEVICE_FAMILY = [^;]+;/g) || []; + expect(families.length).toBeGreaterThanOrEqual(4); + expect( + families.every(value => value === 'TARGETED_DEVICE_FAMILY = 1;'), + ).toBe(true); expect(info).not.toContain('NSMicrophoneUsageDescription'); }); diff --git a/js/__tests__/authEphemeralSession.test.js b/js/__tests__/authEphemeralSession.test.js index f8306038d..708185412 100644 --- a/js/__tests__/authEphemeralSession.test.js +++ b/js/__tests__/authEphemeralSession.test.js @@ -300,7 +300,8 @@ describe('environment resolution is unchanged and fail-closed', () => { expect(canonicalOriginForChannel('staging')).toBe( 'https://staging.adjusternetwork.org', ); - for (const untrusted of [null, undefined, '', 'preview', 'PRODUCTION']) { + // 'preview' is now governed; these remain genuinely unknown. + for (const untrusted of [null, undefined, '', 'qa', 'PRODUCTION']) { expect(trustedUpdateChannel(untrusted)).toBeNull(); expect(canonicalOriginForChannel(untrusted)).toBeNull(); } diff --git a/js/__tests__/nativeMediaAttachments.test.js b/js/__tests__/nativeMediaAttachments.test.js index 4996a933f..d624f8adc 100644 --- a/js/__tests__/nativeMediaAttachments.test.js +++ b/js/__tests__/nativeMediaAttachments.test.js @@ -463,10 +463,22 @@ describe('native Discourse media attachments', () => { url: 'https://staging.adjusternetwork.org', }), ).toBe(false); + // Preview points at the production origin on purpose, so media is + // enabled there - it must behave exactly like production. expect( mediaUploadsEnabledForChannelSite('preview', { url: 'https://adjusternetwork.org', }), + ).toBe(true); + expect( + mediaUploadsEnabledForChannelSite('preview', { + url: 'https://staging.adjusternetwork.org', + }), + ).toBe(false); + expect( + mediaUploadsEnabledForChannelSite('qa', { + url: 'https://adjusternetwork.org', + }), ).toBe(false); expect( mediaUploadsEnabledForChannelSite(null, { diff --git a/js/__tests__/previewLane.test.js b/js/__tests__/previewLane.test.js new file mode 100644 index 000000000..05fb910d6 --- /dev/null +++ b/js/__tests__/previewLane.test.js @@ -0,0 +1,200 @@ +jest.mock('@react-native-vector-icons/fontawesome5', () => 'FontAwesome5'); + +import React from 'react'; +import renderer from 'react-test-renderer'; +import { + authRedirectForChannel, + authSchemeForChannel, + canonicalOriginForChannel, + isPreviewChannel, + mediaUploadsEnabledForChannelSite, + trustedUpdateChannel, +} from '../adjusterNetworkConfig'; +import PreviewBanner, { PREVIEW_LABEL } from '../product/PreviewBanner'; + +const PRODUCTION = 'https://adjusternetwork.org'; +const STAGING = 'https://staging.adjusternetwork.org'; + +describe('channel to origin mapping', () => { + test('preview points at the real production origin', () => { + expect(canonicalOriginForChannel('preview')).toBe(PRODUCTION); + expect(canonicalOriginForChannel('production')).toBe(PRODUCTION); + expect(canonicalOriginForChannel('staging')).toBe(STAGING); + }); + + test('preview and production are the same server, deliberately', () => { + expect(canonicalOriginForChannel('preview')).toBe( + canonicalOriginForChannel('production'), + ); + }); + + test('an unknown channel still has no origin', () => { + for (const channel of [null, undefined, '', 'dev', 'PREVIEW', 'prod']) { + expect(trustedUpdateChannel(channel)).toBeNull(); + expect( + canonicalOriginForChannel(trustedUpdateChannel(channel)), + ).toBeNull(); + } + }); + + test('preview is a trusted channel so product features stay enabled', () => { + expect(trustedUpdateChannel('preview')).toBe('preview'); + // Preview must behave like production, including media, or it validates + // something other than what ships. + expect( + mediaUploadsEnabledForChannelSite('preview', { url: PRODUCTION }), + ).toBe(true); + expect(mediaUploadsEnabledForChannelSite('preview', { url: STAGING })).toBe( + false, + ); + }); +}); + +describe('preview cannot claim production identity', () => { + test('the URL scheme differs', () => { + expect(authSchemeForChannel('preview')).toBe('anpreview'); + expect(authSchemeForChannel('production')).toBe('adjusternetwork'); + expect(authSchemeForChannel('staging')).toBe('adjusternetwork'); + expect(authSchemeForChannel('preview')).not.toBe( + authSchemeForChannel('production'), + ); + }); + + test('the auth redirect differs and stays exact', () => { + expect(authRedirectForChannel('preview')).toBe( + 'anpreview://adjusternetwork.org/auth_redirect', + ); + expect(authRedirectForChannel('production')).toBe( + 'adjusternetwork://adjusternetwork.org/auth_redirect', + ); + for (const channel of ['preview', 'production', 'staging']) { + expect(authRedirectForChannel(channel)).not.toContain('*'); + } + }); + + test('production and staging redirects are unchanged by the preview work', () => { + // Regression guard: the shipped value the server already allows. + expect(authRedirectForChannel('production')).toBe( + 'adjusternetwork://adjusternetwork.org/auth_redirect', + ); + expect(authRedirectForChannel('staging')).toBe( + 'adjusternetwork://adjusternetwork.org/auth_redirect', + ); + }); + + test('isPreviewChannel is exact', () => { + expect(isPreviewChannel('preview')).toBe(true); + for (const channel of ['production', 'staging', null, 'Preview']) { + expect(isPreviewChannel(channel)).toBe(false); + } + }); +}); + +describe('the PREVIEW marker', () => { + const render = channel => { + let tree; + renderer.act(() => { + tree = renderer.create(); + }); + return tree; + }; + + test('shows on preview', () => { + expect(JSON.stringify(render('preview').toJSON())).toContain(PREVIEW_LABEL); + }); + + test('never shows on production or staging', () => { + for (const channel of ['production', 'staging', null, undefined]) { + expect(render(channel).toJSON()).toBeNull(); + } + }); + + test('cannot intercept touches or alter behaviour', () => { + const root = render('preview').root; + // The composite and its host node both carry the prop; what matters is + // that the marker is non-interactive, not how many nodes report it. + const strip = root.findAll(node => node.props.pointerEvents === 'none'); + expect(strip.length).toBeGreaterThanOrEqual(1); + // No interactive handler anywhere in the marker. + expect( + root.findAll(node => typeof node.props.onPress === 'function'), + ).toHaveLength(0); + }); +}); + +describe('native and config wiring', () => { + const fs = require('fs'); + const path = require('path'); + const root = path.join(__dirname, '..', '..'); + const read = f => fs.readFileSync(path.join(root, f), 'utf8'); + + test('the build phase compiles preview and still fails closed', () => { + const project = read('ios/Discourse.xcodeproj/project.pbxproj'); + expect(project).toContain('preview) embedded=true'); + expect(project).toContain('staging) embedded=false'); + expect(project).toContain('production) embedded=true'); + expect(project).toContain('invalid Adjuster Network OTA channel'); + }); + + test('preview has its own bundle id, scheme, group and entitlements', () => { + const project = read('ios/Discourse.xcodeproj/project.pbxproj'); + expect(project).toContain( + 'PRODUCT_BUNDLE_IDENTIFIER = org.adjusternetwork.app.preview;', + ); + expect(project).toContain( + 'PRODUCT_BUNDLE_IDENTIFIER = org.adjusternetwork.app.preview.ShareExtension;', + ); + expect(project).toContain('AN_URL_SCHEME = anpreview;'); + expect(project).toContain('AN_DISPLAY_NAME = "AN Preview";'); + expect(project).toContain('AN_OTA_CHANNEL = preview;'); + // Production identity is untouched. + expect(project).toContain( + 'PRODUCT_BUNDLE_IDENTIFIER = org.adjusternetwork.app;', + ); + expect(project).toContain('AN_URL_SCHEME = adjusternetwork;'); + }); + + test('preview uses a separate app group, so no shared container', () => { + const preview = read('ios/Discourse/Discourse.preview.entitlements'); + expect(preview).toContain('group.org.adjusternetwork.preview'); + expect(preview).not.toContain( + 'group.org.adjusternetwork.app', + ); + // V1 carries neither push nor associated domains. + // Match the declared keys, not the explanatory comment that names them. + expect(preview).not.toContain('aps-environment'); + expect(preview).not.toContain( + 'com.apple.developer.associated-domains', + ); + // Production entitlements are unchanged. + const production = read('ios/Discourse/Discourse.entitlements'); + expect(production).toContain('group.org.adjusternetwork.app'); + expect(production).toContain('aps-environment'); + expect(production).toContain('applinks:adjusternetwork.org'); + }); + + test('preview does not register a push device in V1', () => { + expect(read('js/adjusterNetworkConfig.js')).toContain( + 'pushDelivery: !isPreviewChannel(updateChannel)', + ); + }); + + test('the eas preview profile targets the preview channel only', () => { + const eas = JSON.parse(read('eas.json')); + expect(eas.build.preview.channel).toBe('preview'); + expect(eas.build.preview.env.AN_OTA_CHANNEL).toBe('preview'); + expect(eas.build.preview.ios.buildConfiguration).toBe('Preview'); + expect(eas.build.preview.distribution).toBe('internal'); + // The other lanes are untouched. + expect(eas.build.production.channel).toBe('production'); + expect(eas.build.staging.channel).toBe('staging'); + }); + + test('no runtime switch can change a binary channel', () => { + const config = read('js/adjusterNetworkConfig.js'); + // The channel comes from expo-updates, which reads the compiled-in header. + expect(config).toContain('trustedUpdateChannel(Updates.channel)'); + // Nothing writes it. + expect(config).not.toMatch(/setChannel|channel\s*=\s*['"]preview['"]/); + }); +}); diff --git a/js/__tests__/rateLimitResilience.test.js b/js/__tests__/rateLimitResilience.test.js index 2d84c7936..99776bac7 100644 --- a/js/__tests__/rateLimitResilience.test.js +++ b/js/__tests__/rateLimitResilience.test.js @@ -23,7 +23,10 @@ describe('P1: Retry-After is honored by the shared cooldown', () => { // returned null, and now() + null === now(), so the cooldown expired // immediately and every request sailed through an active limiter window. expect(retryAfterDelayMs(responseWith('30'))).toBeNull(); - expect(Date.now() + null).toBe(Date.now() + 0); + // One clock read: two Date.now() calls can straddle a millisecond, and the + // claim here is about null, not about the clock. + const now = Date.now(); + expect(now + null).toBe(now + 0); }); test('rateLimitDelayMs reads Retry-After off the response', () => { diff --git a/js/adjusterNetworkConfig.js b/js/adjusterNetworkConfig.js index 874b5b775..5c777f1da 100644 --- a/js/adjusterNetworkConfig.js +++ b/js/adjusterNetworkConfig.js @@ -4,6 +4,22 @@ import { NativeModules, Platform } from 'react-native'; import * as Updates from 'expo-updates'; import { nativeContracts } from './adjusterNetworkContracts'; +import { + authRedirectForChannel, + authSchemeForChannel, + canonicalOriginForChannel, + isPreviewChannel, + trustedUpdateChannel, +} from './channelIdentity'; + +// Re-exported so existing import sites keep working unchanged. +export { + authRedirectForChannel, + authSchemeForChannel, + canonicalOriginForChannel, + isPreviewChannel, + trustedUpdateChannel, +}; export function trustedPushEnvironment(platform, configured) { if (platform !== 'ios') return null; @@ -17,16 +33,6 @@ const pushEnvironment = trustedPushEnvironment( NativeModules.DiscourseKeyboardShortcuts?.pushEnvironment, ); -export const trustedUpdateChannel = channel => - channel === 'staging' || channel === 'production' ? channel : null; - -export const canonicalOriginForChannel = channel => - channel === 'staging' - ? 'https://staging.adjusternetwork.org' - : channel === 'production' - ? 'https://adjusternetwork.org' - : null; - const updateChannel = trustedUpdateChannel(Updates.channel); // Keep Adjuster Network product choices in one reversible boundary. Native @@ -34,6 +40,13 @@ const updateChannel = trustedUpdateChannel(Updates.channel); // untouched until their separate release gates are satisfied. export const adjusterNetwork = Object.freeze({ name: 'Adjuster Network', + channel: updateChannel, + // Founder-only validation build. Drives the persistent PREVIEW marker, and + // nothing else: Preview must behave exactly like Production so that what is + // validated is what ships. + preview: isPreviewChannel(updateChannel), + authScheme: authSchemeForChannel(updateChannel), + authRedirect: authRedirectForChannel(updateChannel), canonicalOrigin: canonicalOriginForChannel(updateChannel), features: Object.freeze({ analytics: false, @@ -42,7 +55,10 @@ export const adjusterNetwork = Object.freeze({ pushEducation: true, // Enables device registration with the A3-owned dark backend. Server-side // delivery switches remain authoritative and OFF during certification. - pushDelivery: true, + // Preview cannot receive push in V1 - the server pins the APNs topic to + // the production bundle id - so it does not register a device at all + // rather than creating registrations that can never be delivered to. + pushDelivery: !isPreviewChannel(updateChannel), // Media V1 is available only when the signed app supplies one of the two // approved OTA channels. The site must still match that channel's exact // canonical origin, and the Discourse upload allowlist remains the final diff --git a/js/authorizationConsent.js b/js/authorizationConsent.js index e160d774a..132ab3dad 100644 --- a/js/authorizationConsent.js +++ b/js/authorizationConsent.js @@ -1,8 +1,17 @@ /* @flow */ 'use strict'; -export const AUTH_REDIRECT = - 'adjusternetwork://adjusternetwork.org/auth_redirect'; +import * as Updates from 'expo-updates'; +import { + authRedirectForChannel, + trustedUpdateChannel, +} from './channelIdentity'; + +// Derived from the compiled-in channel so a Preview build never claims +// Production's callback. Scopes, groups and consent copy are unchanged. +export const AUTH_REDIRECT = authRedirectForChannel( + trustedUpdateChannel(Updates.channel), +); export const REQUESTED_USER_API_KEY_SCOPES = Object.freeze([ 'read', diff --git a/js/channelIdentity.js b/js/channelIdentity.js new file mode 100644 index 000000000..f53c532fa --- /dev/null +++ b/js/channelIdentity.js @@ -0,0 +1,37 @@ +/* @flow */ +'use strict'; + +// Pure channel-derived identity. Deliberately dependency-free so it can be +// imported by both the product config and the authorization module without +// creating a cycle between them. +// +// The channel is compiled into the binary by an Xcode build phase and sent as +// the expo-channel-name request header. It is never selectable at runtime: a +// Production binary cannot become a Preview one, and nothing here can let an +// installed app change which server it talks to. + +export const trustedUpdateChannel = channel => + channel === 'staging' || channel === 'production' || channel === 'preview' + ? channel + : null; + +// Preview is a founder-only validation lane that deliberately points at the +// REAL production origin, so what it exercises is real production behaviour +// with real member data. Only the app identity is separate; the server is not. +export const canonicalOriginForChannel = channel => + channel === 'staging' + ? 'https://staging.adjusternetwork.org' + : channel === 'production' || channel === 'preview' + ? 'https://adjusternetwork.org' + : null; + +// Preview must not claim Production's custom scheme. Two installed apps +// registering the same scheme is undefined on iOS, and the auth callback could +// be delivered to the wrong app. +export const authSchemeForChannel = channel => + channel === 'preview' ? 'anpreview' : 'adjusternetwork'; + +export const authRedirectForChannel = channel => + `${authSchemeForChannel(channel)}://adjusternetwork.org/auth_redirect`; + +export const isPreviewChannel = channel => channel === 'preview'; diff --git a/js/product/PreviewBanner.js b/js/product/PreviewBanner.js new file mode 100644 index 000000000..c1f318fc6 --- /dev/null +++ b/js/product/PreviewBanner.js @@ -0,0 +1,55 @@ +/* @flow */ +'use strict'; + +import React from 'react'; +import { Platform, StyleSheet, Text, View } from 'react-native'; +import { adjusterNetwork } from '../adjusterNetworkConfig'; + +// A persistent marker for the founder-only Preview build. It is derived from +// the compiled-in OTA channel, so it cannot appear on Production and cannot be +// switched off from inside the app. +// +// Deliberately inert: it renders nothing interactive, intercepts no touches, +// and changes no behaviour. Preview points at the real production server, and +// what is validated there has to be exactly what ships. +export const PREVIEW_LABEL = 'PREVIEW'; +export const PREVIEW_AMBER = '#D9891F'; + +const PreviewBanner = ({ channel = adjusterNetwork.channel }) => { + if (channel !== 'preview') return null; + return ( + + + {PREVIEW_LABEL} + + + ); +}; + +export default PreviewBanner; + +const styles = StyleSheet.create({ + strip: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + // Above every screen and modal, including the status bar area. + zIndex: 9999, + elevation: 9999, + height: Platform.OS === 'ios' ? 22 : 18, + backgroundColor: PREVIEW_AMBER, + alignItems: 'center', + justifyContent: 'center', + }, + label: { + color: '#1A1206', + fontSize: 10, + fontWeight: '800', + letterSpacing: 2, + }, +}); diff --git a/js/site_manager.js b/js/site_manager.js index 6d661a2a8..fb0944976 100644 --- a/js/site_manager.js +++ b/js/site_manager.js @@ -54,7 +54,7 @@ class SiteManager { _subscribers = []; sites = []; activeSite = null; - customScheme = 'adjusternetwork'; + customScheme = adjusterNetwork.authScheme; urlScheme = AUTH_REDIRECT; deviceName = 'Adjuster Network - Unknown Mobile Device'; hotTopicsHidden = false; diff --git a/scripts/verify-native-release-readiness.mjs b/scripts/verify-native-release-readiness.mjs index 5d37fbc42..ecfb89b97 100644 --- a/scripts/verify-native-release-readiness.mjs +++ b/scripts/verify-native-release-readiness.mjs @@ -75,8 +75,15 @@ check( ); check( 'iOS callback scheme', - iosInfo.includes('adjusternetwork') ? 'PASS' : 'FAIL', - 'Owner-approved callback scheme must be configured', + // The scheme is per-configuration now that AN Preview exists, so verify the + // resolved values rather than one literal: shipping builds must still use + // the owner-approved scheme, and Preview must not be able to claim it. + iosInfo.includes('$(AN_URL_SCHEME)') && + iosProject.includes('AN_URL_SCHEME = adjusternetwork;') && + iosProject.includes('AN_URL_SCHEME = anpreview;') + ? 'PASS' + : 'FAIL', + 'Owner-approved callback scheme must be configured, and Preview must use its own', ); check( 'iOS associated domains', @@ -91,12 +98,12 @@ check( !packageManifest.includes('@react-native-firebase') && !androidGradle.includes('google-services') && productConfig.includes('push: false') && - productConfig.includes('pushDelivery: true') && + productConfig.includes('pushDelivery: !isPreviewChannel(updateChannel)') && iosEntitlements.includes('aps-environment') && !iosInfo.includes('remote-notification') ? 'PASS' : 'FAIL', - 'Direct APNs registration must remain separate from the disabled legacy relay, Firebase and analytics', + 'Direct APNs registration must remain separate from the disabled legacy relay, Firebase and analytics, and off for Preview', ); check( 'iOS APNs build-channel separation', diff --git a/scripts/verify-ota-readiness.mjs b/scripts/verify-ota-readiness.mjs index 030a3b00b..7793cbe8a 100644 --- a/scripts/verify-ota-readiness.mjs +++ b/scripts/verify-ota-readiness.mjs @@ -65,6 +65,24 @@ const checks = [ iosProject.includes('production) embedded=true') && iosProject.includes('Set :EXUpdatesHasEmbeddedUpdate $embedded'), ], + [ + 'iOS preview channel compiles with recovery bundle', + iosProject.includes('preview) embedded=true'), + ], + [ + // The allowlist is what makes channel isolation structural. An unknown + // channel must still fail the build rather than produce a binary that + // asks for an unintended channel. + 'iOS unknown channel still fails closed', + iosProject.includes('invalid Adjuster Network OTA channel') && + iosProject.includes('exit 1'), + ], + [ + 'iOS Preview identity is separate from Production', + iosProject.includes('PRODUCT_BUNDLE_IDENTIFIER = org.adjusternetwork.app.preview;') && + iosProject.includes('AN_URL_SCHEME = anpreview;') && + iosProject.includes('AN_OTA_CHANNEL = preview;'), + ], [ 'iOS Debug defaults to staging', iosProject.includes('AN_OTA_CHANNEL = staging;'), @@ -87,6 +105,12 @@ const checks = [ 'Android anti-bricking enabled', android.includes('DISABLE_ANTI_BRICKING_MEASURES" android:value="false"'), ], + [ + 'preview channel configured', + readFileSync(resolve(root, 'eas.json'), 'utf8').includes( + '"channel": "preview"', + ), + ], [ 'staging channel configured', readFileSync(resolve(root, 'eas.json'), 'utf8').includes( diff --git a/testing/an-preview/apply-preview-auth-redirect.rb b/testing/an-preview/apply-preview-auth-redirect.rb new file mode 100644 index 000000000..6a6aaeb36 --- /dev/null +++ b/testing/an-preview/apply-preview-auth-redirect.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +# +# Appends the AN Preview callback to allowed_user_api_auth_redirects on the +# PRODUCTION Discourse. Run from the production rails console. +# +# Bounded by construction: it appends one exact URI, refuses wildcards, removes +# nothing, and touches no other setting. Scopes, allowed groups, admission, the +# member-photo credential boundary, revocation and private-network controls are +# all untouched. +# +# Modelled on testing/native-auth/apply-native-auth-setting.rb in the server +# repository, which certified the production callback on 2026-08-11. + +require "json" + +required_redirect = "anpreview://adjusternetwork.org/auth_redirect" + +before = SiteSetting.allowed_user_api_auth_redirects.split("|") +after = (before + [required_redirect]).uniq + +raise "refusing wildcard redirect" if after.any? { |value| value.include?("*") } +raise "refusing to remove an existing redirect" unless (before - after).empty? + +SiteSetting.allowed_user_api_auth_redirects = after.join("|") + +puts JSON.generate( + setting: "allowed_user_api_auth_redirects", + before: before, + after: SiteSetting.allowed_user_api_auth_redirects.split("|"), + changed: before != after, + preview_redirect_present: + SiteSetting.allowed_user_api_auth_redirects.split("|").include?(required_redirect), + wildcard_present: SiteSetting.allowed_user_api_auth_redirects.include?("*"), +) diff --git a/testing/an-preview/rollback-preview-auth-redirect.rb b/testing/an-preview/rollback-preview-auth-redirect.rb new file mode 100644 index 000000000..e0339f871 --- /dev/null +++ b/testing/an-preview/rollback-preview-auth-redirect.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# +# Removes the AN Preview callback. Leaves every other redirect in place. + +require "json" + +removed_redirect = "anpreview://adjusternetwork.org/auth_redirect" + +before = SiteSetting.allowed_user_api_auth_redirects.split("|") +after = before - [removed_redirect] + +raise "refusing to empty the redirect allowlist" if after.empty? + +SiteSetting.allowed_user_api_auth_redirects = after.join("|") + +puts JSON.generate( + setting: "allowed_user_api_auth_redirects", + before: before, + after: SiteSetting.allowed_user_api_auth_redirects.split("|"), + changed: before != after, +)