feat: show a clear error when trying to use CAAPI untunnelled in localhost#3504
feat: show a clear error when trying to use CAAPI untunnelled in localhost#3504fredericoo wants to merge 7 commits intomainfrom
Conversation
|
Oxygen deployed a preview of your
Learn more about Hydrogen's GitHub integration. |
| it('Throws guidance error in development when request origin is not a tunnel', async () => { | ||
| process.env.NODE_ENV = 'development'; | ||
|
|
||
| const customer = createCustomerAccountClient({ | ||
| session, | ||
| customerAccountId: 'customerAccountId', | ||
| shopId: '1', | ||
| request: new Request('https://localhost'), | ||
| waitUntil: vi.fn(), | ||
| }); | ||
|
|
||
| try { | ||
| await customer.login(); | ||
| } catch (error) { | ||
| const response = error as Response & {message?: string}; | ||
|
|
||
| expect(response.status).toBe(400); | ||
| expect(response.message).toContain('--customer-account-push'); | ||
| expect(response.message).toContain('.tryhydrogen.dev'); | ||
| } |
There was a problem hiding this comment.
I think we should add expect.assertions(3) at the top of this test as a defensive measure. If the code under test were refactored to no longer throw, the catch block would never execute and the test would silently pass with zero assertions.
| it('Throws guidance error in development when request origin is not a tunnel', async () => { | |
| process.env.NODE_ENV = 'development'; | |
| const customer = createCustomerAccountClient({ | |
| session, | |
| customerAccountId: 'customerAccountId', | |
| shopId: '1', | |
| request: new Request('https://localhost'), | |
| waitUntil: vi.fn(), | |
| }); | |
| try { | |
| await customer.login(); | |
| } catch (error) { | |
| const response = error as Response & {message?: string}; | |
| expect(response.status).toBe(400); | |
| expect(response.message).toContain('--customer-account-push'); | |
| expect(response.message).toContain('.tryhydrogen.dev'); | |
| } | |
| it('Throws guidance error in development when request origin is not a tunnel', async () => { | |
| expect.assertions(3); | |
| process.env.NODE_ENV = 'development'; | |
| const customer = createCustomerAccountClient({ | |
| session, | |
| customerAccountId: 'customerAccountId', | |
| shopId: '1', | |
| request: new Request('https://localhost'), | |
| waitUntil: vi.fn(), | |
| }); | |
| try { | |
| await customer.login(); | |
| } catch (error) { | |
| const response = error as Response & {message?: string}; | |
| expect(response.status).toBe(400); | |
| expect(response.message).toContain('--customer-account-push'); | |
| expect(response.message).toContain('.tryhydrogen.dev'); | |
| } | |
| }); |
Same applies to the handleAuthStatus() test at line 1304. This is a pre-existing pattern throughout the file (~6 other tests use the same try/catch without expect.assertions()), so not unique to this PR - I'm fine with this being a follow-up as long as it gets done :)
| if (process.env.NODE_ENV === 'development') { | ||
| if (!requestUrl.hostname.endsWith('.tryhydrogen.dev')) { | ||
| throw new Response( | ||
| [ | ||
| 'Customer Account API OAuth requires a Hydrogen tunnel in local development.', | ||
| 'Run `shopify hydrogen dev --customer-account-push`.', | ||
| 'Then open the tunnel URL shown in your terminal (`https://*.tryhydrogen.dev`) instead of localhost.', | ||
| ].join('\n\n'), | ||
| { | ||
| status: 400, | ||
| headers: { | ||
| 'Content-Type': 'text/plain; charset=utf-8', | ||
| }, | ||
| }, | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
(threading) Re: the customAuthStatusHandler asymmetry (lines 137-139) - this check in login() always fires regardless of whether a customAuthStatusHandler is provided, but the check in defaultAuthStatusHandler is bypassed when a custom handler is set.
non-blocking: This asymmetry is actually correct - login() initiates an OAuth redirect that fundamentally requires a publicly reachable URL, so the tunnel check is always relevant. A customAuthStatusHandler might intentionally handle the non-tunnel case differently (e.g., a mock or test double). I think a brief code comment explaining this rationale would help future readers.
There was a problem hiding this comment.
i’ve added this check whenever ifInvalidCredentialThrowError() is placed. this seems to be the most reliable way: nowhere that needs a valid credential will work without it being tunnelled
do you think this is a good assumption?
There was a problem hiding this comment.
yeah that sounds reasonable to me!
| throw new Response( | ||
| [ | ||
| 'Customer Account API OAuth requires a Hydrogen tunnel in local development.', | ||
| 'Run `shopify hydrogen dev --customer-account-push`.', |
There was a problem hiding this comment.
note: these commands can also be aliased as just h2 dev rather than shopify hydrogen dev. i don't think the message needs to be updated but if you can think of a good way to communicate that either one works, go for it
There was a problem hiding this comment.
thats very fair
i made it more generic just mentioning to use the flag. if they got to this screen it means they know how to start the dev server, and however they did, they can just add the flag to the end
There was a problem hiding this comment.
yep exactly, i think that's super reasonable!
|
🤖 Code Review · #projects-dev-ai for questions ✅ Complete - No issues 📋 History✅ No issues → ✅ No issues → ✅ No issues |
| function throwIfNotTunnelled(hostname: string) { | ||
| if (process.env.NODE_ENV === 'development') { |
There was a problem hiding this comment.
i’ve opted to place all the logic in this function instead of lots of ifs
when vite bundles this it’ll become a noop function so it is fine, although it adds a few bytes to the bundle we will survive
| process.env.NODE_ENV === 'production' | ||
| ? 'Internal Server Error' | ||
| : 'You do not have the valid credential to use Customer Account API (/account).'; | ||
| : 'You do not have a valid credential to use Customer Account API (/account).'; |
There was a problem hiding this comment.
this read a little broken english, right?
There was a problem hiding this comment.
agreed. "...have valid credentials..." reads even better to me. the singular "credential" feels a bit off to me
a681ba2 to
dceae08
Compare
| isLoggedIn: () => Promise<boolean>; | ||
| /** Check for a not logged in customer and redirect customer to login page. The redirect can be overwritten with `customAuthStatusHandler` option. */ | ||
| handleAuthStatus: () => void | DataFunctionValue; | ||
| handleAuthStatus: () => Promise<void>; |
There was a problem hiding this comment.
conversation starter:
this type was wrong. this function is async, and it does not return DataFunctionValue (which includes Response). those things are thrown today
I think i should leave this alone in this PR (revert this change) and we fix it later, with a changelog.
this also is impacting whenever we call handleAuthStatus() which is in many loaders within skeletons. they have a race condition: if they get processed in due time, the response (redirect) will be thrown, otherwise not
There was a problem hiding this comment.
I think i should leave this alone in this PR (revert this change) and we fix it later, with a changelog.
nice catch and that sounds great! i'd definitely vote to fix it. i'm fine either way - whether you want to fix it in this PR or as a follow up. but if this gets fixed in this PR the changeset should definitely be updated to mention it
| await customer.login(); | ||
| } catch (error) { | ||
| const response = error as Response & {message?: string}; | ||
|
|
There was a problem hiding this comment.
non-blocking: these tests work correctly because the test file globally stubs Response with a mock class at lines 26-38 that stores the constructor's body argument as .message. This is a pre-existing test pattern, not something this PR introduced.
The tests are correct and pass as intended. However, .message is a non-standard property that only exists due to this mock - the standard Response Web API doesn't have it. If the test environment or mock strategy ever changes, these assertions would silently break. Using await response.text() would be the spec-compliant approach, but since this follows the existing pattern throughout the file, I'm fine with this shipping as-is.
|
|
||
| function throwIfNotTunnelled(hostname: string) { | ||
| if (process.env.NODE_ENV === 'development') { | ||
| if (!hostname.endsWith('.tryhydrogen.dev')) { |
There was a problem hiding this comment.
non-blocking: I think a brief comment here would help future maintainers understand the coupling between this check and the tunnel infrastructure:
| if (!hostname.endsWith('.tryhydrogen.dev')) { | |
| // This domain must match the tunnel domain provisioned by --customer-account-push. | |
| // If the tunnel infrastructure changes domains, update this check. | |
| if (!hostname.endsWith('.tryhydrogen.dev')) { |
Extracting to a named constant (e.g., TUNNEL_DOMAIN_SUFFIX) would also reduce the magic string, but a comment is sufficient for a single-use constant imo
| if (!hostname.endsWith('.tryhydrogen.dev')) { | ||
| throw new Response( | ||
| [ | ||
| 'Customer Account API OAuth requires a Hydrogen tunnel in local development.', |
There was a problem hiding this comment.
non-blocking, very minor polish: a developer seeing this error has NOT yet started with the --customer-account-push flag. The sequencing might read slightly more naturally as "Restart your development server with the --customer-account-push flag..." since the implicit step is "stop your current server, restart with the flag."
| throw new Response( | ||
| [ | ||
| 'Customer Account API OAuth requires a Hydrogen tunnel in local development.', | ||
| 'Run `shopify hydrogen dev --customer-account-push`.', |
There was a problem hiding this comment.
yep exactly, i think that's super reasonable!
| if (process.env.NODE_ENV === 'development') { | ||
| if (!requestUrl.hostname.endsWith('.tryhydrogen.dev')) { | ||
| throw new Response( | ||
| [ | ||
| 'Customer Account API OAuth requires a Hydrogen tunnel in local development.', | ||
| 'Run `shopify hydrogen dev --customer-account-push`.', | ||
| 'Then open the tunnel URL shown in your terminal (`https://*.tryhydrogen.dev`) instead of localhost.', | ||
| ].join('\n\n'), | ||
| { | ||
| status: 400, | ||
| headers: { | ||
| 'Content-Type': 'text/plain; charset=utf-8', | ||
| }, | ||
| }, | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
yeah that sounds reasonable to me!
| isLoggedIn: () => Promise<boolean>; | ||
| /** Check for a not logged in customer and redirect customer to login page. The redirect can be overwritten with `customAuthStatusHandler` option. */ | ||
| handleAuthStatus: () => void | DataFunctionValue; | ||
| handleAuthStatus: () => Promise<void>; |
There was a problem hiding this comment.
I think i should leave this alone in this PR (revert this change) and we fix it later, with a changelog.
nice catch and that sounds great! i'd definitely vote to fix it. i'm fine either way - whether you want to fix it in this PR or as a follow up. but if this gets fixed in this PR the changeset should definitely be updated to mention it


WHY are these changes introduced?
Local development has a bad failure mode for Customer Account OAuth:
When a developer opens
/accountonlocalhostwithout tunneling, the app redirects into the OAuth flow and eventually fails with a redirect URI error from Shopify. The error is late and unclear, so it does not tell developers what they need to do next.WHAT is this pull request doing?
HOW to test your changes?
pnpm --dir packages/hydrogen buildpnpm --dir packages/hydrogen test -- src/customer/customer.test.tshttp://localhost:3000/account400error page with guidance to use--customer-account-pushand a*.tryhydrogen.devURL.--customer-account-push) and open the provided*.tryhydrogen.devURL./accountcontinues into normal Customer Account login flow.Post-merge steps
None.
Checklist