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, +)