diff --git a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx index a20dbbf4a2..2653357c53 100644 --- a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx +++ b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx @@ -29,6 +29,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({ experiments: { claudeCodeMockCliTraffic: false, editMessages: false, + mobileApp: false, newOnboarding: false, providerSessionReaping: false, }, diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx index 7ce87d84dc..1cf4144a9f 100644 --- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx +++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({ experiments: { claudeCodeMockCliTraffic: false, editMessages: false, + mobileApp: false, newOnboarding: false, providerSessionReaping: false, }, diff --git a/apps/app/src/lib/system-config-atoms.ts b/apps/app/src/lib/system-config-atoms.ts index 6ac9577d64..ddb09b3c26 100644 --- a/apps/app/src/lib/system-config-atoms.ts +++ b/apps/app/src/lib/system-config-atoms.ts @@ -23,6 +23,7 @@ const unavailableSystemConfig: SystemConfigResponse = { experiments: { claudeCodeMockCliTraffic: false, editMessages: false, + mobileApp: false, newOnboarding: false, providerSessionReaping: false, }, diff --git a/apps/app/src/views/SettingsView.experiments.test.tsx b/apps/app/src/views/SettingsView.experiments.test.tsx index c1443fabaa..fa62487062 100644 --- a/apps/app/src/views/SettingsView.experiments.test.tsx +++ b/apps/app/src/views/SettingsView.experiments.test.tsx @@ -6,6 +6,7 @@ import { ExperimentsSettingsSection } from "./SettingsView"; afterEach(cleanup); function renderSection(overrides?: { + onMobileAppEnabledChange?: (enabled: boolean) => void; onNewOnboardingEnabledChange?: (enabled: boolean) => void; onProviderSessionReapingEnabledChange?: (enabled: boolean) => void; }) { @@ -14,10 +15,12 @@ function renderSection(overrides?: { claudeCodeMockCliTrafficEnabled={false} disabled={false} editMessagesEnabled={false} + mobileAppEnabled={false} newOnboardingEnabled={false} providerSessionReapingEnabled={false} onClaudeCodeMockCliTrafficEnabledChange={vi.fn()} onEditMessagesEnabledChange={vi.fn()} + onMobileAppEnabledChange={overrides?.onMobileAppEnabledChange ?? vi.fn()} onNewOnboardingEnabledChange={ overrides?.onNewOnboardingEnabledChange ?? vi.fn() } @@ -36,6 +39,13 @@ describe("ExperimentsSettingsSection", () => { expect(onChange).toHaveBeenCalledWith(true); }); + it("reports mobile app changes", () => { + const onChange = vi.fn(); + renderSection({ onMobileAppEnabledChange: onChange }); + fireEvent.click(screen.getByLabelText("Mobile app")); + expect(onChange).toHaveBeenCalledWith(true); + }); + it("reports idle provider session release changes", () => { const onChange = vi.fn(); renderSection({ onProviderSessionReapingEnabledChange: onChange }); diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx index 8220393503..050cf6ef1d 100644 --- a/apps/app/src/views/SettingsView.stories.tsx +++ b/apps/app/src/views/SettingsView.stories.tsx @@ -335,6 +335,7 @@ function ExperimentsStory() { } disabled={false} editMessagesEnabled={state.experiments.editMessages} + mobileAppEnabled={state.experiments.mobileApp} newOnboardingEnabled={state.experiments.newOnboarding} providerSessionReapingEnabled={state.experiments.providerSessionReaping} onClaudeCodeMockCliTrafficEnabledChange={(enabled) => @@ -349,6 +350,12 @@ function ExperimentsStory() { editMessages: enabled, })) } + onMobileAppEnabledChange={(enabled) => + state.setExperiments((current) => ({ + ...current, + mobileApp: enabled, + })) + } onNewOnboardingEnabledChange={(enabled) => state.setExperiments((current) => ({ ...current, diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx index fb85521079..06c14b741f 100644 --- a/apps/app/src/views/SettingsView.tsx +++ b/apps/app/src/views/SettingsView.tsx @@ -212,10 +212,12 @@ export interface ExperimentsSettingsSectionProps { disabled: boolean; claudeCodeMockCliTrafficEnabled: boolean; editMessagesEnabled: boolean; + mobileAppEnabled: boolean; newOnboardingEnabled: boolean; providerSessionReapingEnabled: boolean; onClaudeCodeMockCliTrafficEnabledChange: (enabled: boolean) => void; onEditMessagesEnabledChange: (enabled: boolean) => void; + onMobileAppEnabledChange: (enabled: boolean) => void; onNewOnboardingEnabledChange: (enabled: boolean) => void; onProviderSessionReapingEnabledChange: (enabled: boolean) => void; } @@ -996,6 +998,7 @@ export function ProviderSettingsSection({ const CLAUDE_CODE_MOCK_CLI_TRAFFIC_EXPERIMENT_LABEL = "Mock CLI Traffic"; const EDIT_MESSAGES_EXPERIMENT_LABEL = "Edit messages"; +const MOBILE_APP_EXPERIMENT_LABEL = "Mobile app"; const NEW_ONBOARDING_EXPERIMENT_LABEL = "New onboarding"; const PROVIDER_SESSION_REAPING_EXPERIMENT_LABEL = "Idle provider session release"; @@ -1003,10 +1006,12 @@ export function ExperimentsSettingsSection({ claudeCodeMockCliTrafficEnabled, disabled, editMessagesEnabled, + mobileAppEnabled, newOnboardingEnabled, providerSessionReapingEnabled, onClaudeCodeMockCliTrafficEnabledChange, onEditMessagesEnabledChange, + onMobileAppEnabledChange, onNewOnboardingEnabledChange, onProviderSessionReapingEnabledChange, }: ExperimentsSettingsSectionProps) { @@ -1041,6 +1046,18 @@ export function ExperimentsSettingsSection({ /> + + + + + updateExperimentsMutation.mutate({ + ...experiments, + mobileApp: enabled, + }) + } newOnboardingEnabled={experiments.newOnboarding} onNewOnboardingEnabledChange={(enabled) => updateExperimentsMutation.mutate({ diff --git a/apps/connect/src/tunnel-do.ts b/apps/connect/src/tunnel-do.ts index 7eca8777af..895c329864 100644 --- a/apps/connect/src/tunnel-do.ts +++ b/apps/connect/src/tunnel-do.ts @@ -20,6 +20,12 @@ export interface Env { BETTER_AUTH_SECRET: string; ACCOUNT_APP_URL?: string; CLOUD_DEV?: string; + /** + * Android signing-cert SHA-256 fingerprints for `/.well-known/assetlinks.json` + * (comma-separated). Unset → the file serves an empty list (iOS universal + * links are unaffected). + */ + ASSETLINKS_SHA256_FINGERPRINTS?: string; } const TUNNEL_TAG = "tunnel"; diff --git a/apps/connect/src/worker.test.ts b/apps/connect/src/worker.test.ts index e6a9bdd078..ca536b6ce0 100644 --- a/apps/connect/src/worker.test.ts +++ b/apps/connect/src/worker.test.ts @@ -685,6 +685,118 @@ describe("machine gate auth", () => { ); }); +describe("bb mobile app-link association files", () => { + beforeEach(() => { + vi.clearAllMocks(); + // No cookie, no machine header: these must never reach the session gate. + mockParseCookie.mockReturnValue(null); + mockResolveLabel.mockResolvedValue(resolvedServer()); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + "/.well-known/apple-app-site-association", + "/.well-known/assetlinks.json", + ])( + "serves %s on a bare label without a session and without proxying", + async (path) => { + const { env, ctx, captured } = makeEnv(() => new Response("origin")); + const response = await worker.fetch( + visitorRequest("sawyer.getbb.app", path), + env as never, + ctx, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(captured).toHaveLength(0); + expect(mockResolveLabel).not.toHaveBeenCalled(); + expect(mockVerifySession).not.toHaveBeenCalled(); + }, + ); + + it("serves the AASA on bare labels that do not resolve yet (Apple fetches anonymously before a claim)", async () => { + mockResolveLabel.mockResolvedValue(null); + const { env, ctx, captured } = makeEnv(() => new Response("origin")); + const unknown = await worker.fetch( + visitorRequest( + "nobody-here.getbb.app", + "/.well-known/apple-app-site-association", + ), + env as never, + ctx, + ); + expect(unknown.status).toBe(200); + const body = (await unknown.json()) as { + applinks: { details: { appIDs: string[] }[] }; + }; + expect(body.applinks.details[0]?.appIDs).toEqual([ + "9QCU24SXK5.app.getbb.mobile", + ]); + expect(captured).toHaveLength(0); + }); + + it.each([ + "/.well-known/apple-app-site-association", + "/.well-known/assetlinks.json", + ])( + "does not claim %s on share hosts — they front arbitrary local apps, so the file falls through to the session gate", + async (path) => { + const { env, ctx, captured } = makeEnv(() => new Response("origin")); + const share = await worker.fetch( + visitorRequest("sawyer--8000.getbb.app", path), + env as never, + ctx, + ); + // Anonymous (Apple CDN / Android) fetch → 401 sign-in page, i.e. no + // association for `