diff --git a/client/packages/cli/__tests__/authClientAddGithub.test.ts b/client/packages/cli/__tests__/authClientAddGithub.test.ts index ebf7682106..d3008e1264 100644 --- a/client/packages/cli/__tests__/authClientAddGithub.test.ts +++ b/client/packages/cli/__tests__/authClientAddGithub.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe, vi, beforeEach } from 'vitest'; +import { test, expect, describe, vi, beforeEach, afterEach } from 'vitest'; import { Effect, Layer, Logger } from 'effect'; import * as NodeContext from '@effect/platform-node/NodeContext'; import { GlobalOpts } from '../src/context/globalOpts.ts'; @@ -90,12 +90,17 @@ const withEntry = (flags: Map, key: string, value: string) => new Map([...flags, [key, value]]); beforeEach(() => { + vi.stubEnv('INSTANT_CLI_API_URI', undefined); prompts = []; addedClients = []; logs = []; mockPromptReturn = ''; }); +afterEach(() => { + vi.unstubAllEnvs(); +}); + // -- flag sets -- const webFlags = new Map([ @@ -186,6 +191,16 @@ describe('interactive prompts for each missing flag', () => { // -- success cases -- describe('success', () => { + test('uses the configured API URI for the callback URL', async () => { + vi.stubEnv('INSTANT_CLI_API_URI', 'https://api.instant.example'); + + await run(webFlags, { yes: true }); + + expect(addedClients[0].redirectTo).toBe( + 'https://api.instant.example/runtime/oauth/callback', + ); + }); + test('all required flags → creates client and prints callback URL', async () => { await run(webFlags, { yes: true }); expect(addedClients).toHaveLength(1); diff --git a/client/packages/cli/__tests__/config.test.ts b/client/packages/cli/__tests__/config.test.ts index a25e935241..83a726f5fe 100644 --- a/client/packages/cli/__tests__/config.test.ts +++ b/client/packages/cli/__tests__/config.test.ts @@ -9,7 +9,7 @@ vi.mock('../src/util/instantConfig.ts', () => ({ readInstantConfigFile: mocks.readInstantConfigFile, })); -import { getDashUrl } from '../src/lib/config.ts'; +import { getDashUrl, getOAuthCallbackUrl } from '../src/lib/config.ts'; beforeEach(() => { vi.stubEnv('INSTANT_CLI_API_URI', undefined); @@ -45,3 +45,13 @@ describe('dashboard URL configuration', () => { ); }); }); + +describe('OAuth callback URL configuration', () => { + it('uses the configured API URL', async () => { + vi.stubEnv('INSTANT_CLI_API_URI', 'https://api.instant.example/'); + + await expect(Effect.runPromise(getOAuthCallbackUrl)).resolves.toBe( + 'https://api.instant.example/runtime/oauth/callback', + ); + }); +}); diff --git a/client/packages/cli/__tests__/redirectUriPrompt.test.ts b/client/packages/cli/__tests__/redirectUriPrompt.test.ts index 2078e46326..b80507f94f 100644 --- a/client/packages/cli/__tests__/redirectUriPrompt.test.ts +++ b/client/packages/cli/__tests__/redirectUriPrompt.test.ts @@ -2,9 +2,21 @@ import { expect, test } from 'vitest'; import stripAnsi from 'strip-ansi'; import { redirectUriPrompt } from '../src/commands/auth/client/shared.ts'; +test('redirectUriPrompt shows the configured callback URL', () => { + const prompt = redirectUriPrompt({ + heading: 'Custom redirect URI (optional):', + oauthCallbackURL: 'https://api.example.com/runtime/oauth/callback', + }); + + const output = stripAnsi(prompt.modifyOutput!('\n', 'idle')); + + expect(output).toContain('https://api.example.com/runtime/oauth/callback'); +}); + test('redirectUriPrompt shows skipped when submitted empty', () => { const prompt = redirectUriPrompt({ heading: 'Custom redirect URI (optional):', + oauthCallbackURL: 'https://api.example.com/runtime/oauth/callback', }); const output = stripAnsi(prompt.modifyOutput!('\n', 'submitted')); @@ -15,6 +27,7 @@ test('redirectUriPrompt shows skipped when submitted empty', () => { test('redirectUriPrompt shows submitted custom redirect URI', () => { const prompt = redirectUriPrompt({ heading: 'Custom redirect URI (optional):', + oauthCallbackURL: 'https://api.example.com/runtime/oauth/callback', }); const output = stripAnsi( diff --git a/client/packages/cli/src/commands/auth/client/add.ts b/client/packages/cli/src/commands/auth/client/add.ts index d6ccec0217..9a9149d75b 100644 --- a/client/packages/cli/src/commands/auth/client/add.ts +++ b/client/packages/cli/src/commands/auth/client/add.ts @@ -13,7 +13,6 @@ import { OAuthClient, } from '../../../lib/oauth.ts'; import { - DEFAULT_OAUTH_CALLBACK_URL, GOOGLE_AUTHORIZATION_ENDPOINT, GOOGLE_DISCOVERY_ENDPOINT, GOOGLE_TOKEN_ENDPOINT, @@ -25,6 +24,7 @@ import { LINKEDIN_DISCOVERY_ENDPOINT, LINKEDIN_TOKEN_ENDPOINT, } from '@instantdb/platform'; +import { getOAuthCallbackUrl } from '../../../lib/config.ts'; import { UI } from '../../../ui/index.ts'; import chalk from 'chalk'; import boxen from 'boxen'; @@ -58,9 +58,11 @@ const googleConsoleUrl = 'https://console.developers.google.com/apis/credentials'; const githubDeveloperUrl = 'https://github.com/settings/developers'; const linkedinDeveloperUrl = 'https://www.linkedin.com/developers/apps'; -const optionalRedirectPrompt = redirectUriPrompt({ - heading: 'Custom redirect URI (optional):', -}); +const optionalRedirectPrompt = (oauthCallbackURL: string) => + redirectUriPrompt({ + heading: 'Custom redirect URI (optional):', + oauthCallbackURL, + }); const selectGoogleAppType = (value: unknown) => Effect.gen(function* () { @@ -220,12 +222,14 @@ const printGoogleCustomCredentialsClient = Effect.fn(function* ({ clientId, customRedirectUri, redirectUri, + oauthCallbackURL, }: { appType: typeof GoogleAppTypeSchema.Type; client: typeof OAuthClient.Type; clientId: string | undefined; customRedirectUri: string | undefined; redirectUri: string | undefined; + oauthCallbackURL: string; }) { const redirectMessages: string[] = []; if (appType === 'web' && redirectUri) { @@ -233,6 +237,7 @@ const printGoogleCustomCredentialsClient = Effect.fn(function* ({ ...redirectSetupMessages({ prompt: 'Add this redirect URI in Google Console', redirectUri, + oauthCallbackURL, showCustomRedirectInstructions: Boolean(customRedirectUri), }), ); @@ -252,7 +257,10 @@ const printGoogleCustomCredentialsClient = Effect.fn(function* ({ ); }); -const handleGoogleClient = Effect.fn(function* (opts: Record) { +const handleGoogleClient = Effect.fn(function* ( + opts: Record, + oauthCallbackURL: string, +) { // This one requires special logic for getting client name // because the suggested name includes the app type const appType = yield* selectGoogleAppType(opts['app-type']); @@ -314,13 +322,13 @@ const handleGoogleClient = Effect.fn(function* (opts: Record) { ? '--custom-redirect-uri is not compatible with --dev-credentials.' : 'Provided custom redirect URI when not using web app type.', }), - Args.prompt(optionalRedirectPrompt), + Args.prompt(optionalRedirectPrompt(oauthCallbackURL)), Args.optional(), ); const redirectUri = useSharedCredentials ? undefined - : customRedirectUri || DEFAULT_OAUTH_CALLBACK_URL; + : customRedirectUri || oauthCallbackURL; const response = yield* addOAuthClient({ providerId: provider.id, @@ -352,10 +360,14 @@ const handleGoogleClient = Effect.fn(function* (opts: Record) { clientId, customRedirectUri, redirectUri, + oauthCallbackURL, }); }); -const handleGithubClient = Effect.fn(function* (opts: Record) { +const handleGithubClient = Effect.fn(function* ( + opts: Record, + oauthCallbackURL: string, +) { const { clientName, provider } = yield* getClientNameAndProvider( 'github', opts, @@ -372,11 +384,11 @@ const handleGithubClient = Effect.fn(function* (opts: Record) { ); const customRedirectUri = yield* Args.text(opts, 'custom-redirect-uri').pipe( - Args.prompt(optionalRedirectPrompt), + Args.prompt(optionalRedirectPrompt(oauthCallbackURL)), Args.optional(), ); - const redirectUri = customRedirectUri || DEFAULT_OAUTH_CALLBACK_URL; + const redirectUri = customRedirectUri || oauthCallbackURL; // The backend infers GitHub's authorization/token endpoints from // meta.providerName === 'github', so we don't pass them here. @@ -392,6 +404,7 @@ const handleGithubClient = Effect.fn(function* (opts: Record) { const redirectMessages = redirectSetupMessages({ prompt: 'Add this callback URL in your GitHub OAuth App settings', redirectUri, + oauthCallbackURL, showCustomRedirectInstructions: Boolean(customRedirectUri), }); @@ -410,6 +423,7 @@ const handleGithubClient = Effect.fn(function* (opts: Record) { const handleLinkedInClient = Effect.fn(function* ( opts: Record, + oauthCallbackURL: string, ) { const { clientName, provider } = yield* getClientNameAndProvider( 'linkedin', @@ -427,11 +441,11 @@ const handleLinkedInClient = Effect.fn(function* ( ); const customRedirectUri = yield* Args.text(opts, 'custom-redirect-uri').pipe( - Args.prompt(optionalRedirectPrompt), + Args.prompt(optionalRedirectPrompt(oauthCallbackURL)), Args.optional(), ); - const redirectUri = customRedirectUri || DEFAULT_OAUTH_CALLBACK_URL; + const redirectUri = customRedirectUri || oauthCallbackURL; const response = yield* addOAuthClient({ providerId: provider.id, @@ -447,6 +461,7 @@ const handleLinkedInClient = Effect.fn(function* ( const redirectMessages = redirectSetupMessages({ prompt: 'Add this redirect URI in your LinkedIn app settings', redirectUri, + oauthCallbackURL, showCustomRedirectInstructions: Boolean(customRedirectUri), }); @@ -463,7 +478,10 @@ const handleLinkedInClient = Effect.fn(function* ( ); }); -const handleAppleClient = Effect.fn(function* (opts: Record) { +const handleAppleClient = Effect.fn(function* ( + opts: Record, + oauthCallbackURL: string, +) { const { clientName, provider } = yield* getClientNameAndProvider( 'apple', opts, @@ -529,12 +547,12 @@ const handleAppleClient = Effect.fn(function* (opts: Record) { Args.availableWhen(!skipWeb, { message: `--custom-redirect-uri ${webSkipMessage}`, }), - Args.prompt(optionalRedirectPrompt), + Args.prompt(optionalRedirectPrompt(oauthCallbackURL)), Args.optional(), ); const redirectUri = privateKey - ? customRedirectUri || DEFAULT_OAUTH_CALLBACK_URL + ? customRedirectUri || oauthCallbackURL : undefined; const meta: { teamId?: string; keyId?: string } = {}; @@ -566,6 +584,7 @@ const handleAppleClient = Effect.fn(function* (opts: Record) { ...redirectSetupMessages({ prompt: `Add this return URL under your Services ID on ${link('https://developer.apple.com', 'developer.apple.com')}`, redirectUri, + oauthCallbackURL, showCustomRedirectInstructions: Boolean(customRedirectUri), }), ); @@ -697,13 +716,16 @@ export const authClientAddCmd = Effect.fn( }), ), ); + const oauthCallbackURL = yield* getOAuthCallbackUrl; yield* Match.value(clientType).pipe( Match.withReturnType>(), - Match.when('google', () => handleGoogleClient(opts)), - Match.when('github', () => handleGithubClient(opts)), - Match.when('apple', () => handleAppleClient(opts)), - Match.when('linkedin', () => handleLinkedInClient(opts)), + Match.when('google', () => handleGoogleClient(opts, oauthCallbackURL)), + Match.when('github', () => handleGithubClient(opts, oauthCallbackURL)), + Match.when('apple', () => handleAppleClient(opts, oauthCallbackURL)), + Match.when('linkedin', () => + handleLinkedInClient(opts, oauthCallbackURL), + ), Match.when('clerk', () => handleClerkClient(opts)), Match.when('firebase', () => handleFirebaseClient(opts)), Match.exhaustive, diff --git a/client/packages/cli/src/commands/auth/client/shared.ts b/client/packages/cli/src/commands/auth/client/shared.ts index 26a01c407a..1c1c658dcb 100644 --- a/client/packages/cli/src/commands/auth/client/shared.ts +++ b/client/packages/cli/src/commands/auth/client/shared.ts @@ -1,6 +1,5 @@ import { FileSystem } from '@effect/platform'; import { Effect } from 'effect'; -import { DEFAULT_OAUTH_CALLBACK_URL } from '@instantdb/platform'; import chalk from 'chalk'; import { BadArgsError } from '../../../errors.ts'; import { link } from '../../../logging.ts'; @@ -38,7 +37,13 @@ export const clientSecretPrompt = ({ ]), }); -export const redirectUriPrompt = ({ heading }: { heading: string }) => ({ +export const redirectUriPrompt = ({ + heading, + oauthCallbackURL, +}: { + heading: string; + oauthCallbackURL: string; +}) => ({ prompt: '', placeholder: 'https://yoursite.com/oauth/callback', modifyOutput: UI.modifiers.piped([ @@ -47,7 +52,7 @@ export const redirectUriPrompt = ({ heading }: { heading: string }) => ({ return ( `\n${heading} ${chalk.dim('With a custom redirect URI, users will see "Redirecting to yoursite.com..." for a more branded experience.')} -${chalk.dim(`Your URI must forward to ${DEFAULT_OAUTH_CALLBACK_URL} with all query parameters preserved.`)}\n\n` + +${chalk.dim(`Your URI must forward to ${oauthCallbackURL} with all query parameters preserved.`)}\n\n` + stripFirstBlankLine(output) ); } @@ -64,10 +69,12 @@ ${chalk.dim(`Your URI must forward to ${DEFAULT_OAUTH_CALLBACK_URL} with all que export const redirectSetupMessages = ({ prompt, redirectUri, + oauthCallbackURL, showCustomRedirectInstructions, }: { prompt: string; redirectUri: string; + oauthCallbackURL: string; showCustomRedirectInstructions?: boolean; }) => { const messages = ['', chalk.bold(`${prompt}:`), chalk.bold(redirectUri)]; @@ -75,7 +82,7 @@ export const redirectSetupMessages = ({ if (showCustomRedirectInstructions) { messages.push( '', - `Your custom redirect must forward to ${chalk.bold(DEFAULT_OAUTH_CALLBACK_URL)} with all query parameters preserved.`, + `Your custom redirect must forward to ${chalk.bold(oauthCallbackURL)} with all query parameters preserved.`, ); messages.push( `You can test it by visiting: ${chalk.bold(redirectUri + '?test-redirect=true')}`, diff --git a/client/packages/cli/src/commands/auth/client/update.ts b/client/packages/cli/src/commands/auth/client/update.ts index b92aedba80..7a5a46bfdf 100644 --- a/client/packages/cli/src/commands/auth/client/update.ts +++ b/client/packages/cli/src/commands/auth/client/update.ts @@ -11,10 +11,8 @@ import { updateOAuthClient, } from '../../../lib/oauth.ts'; import { UI } from '../../../ui/index.ts'; -import { - clerkDomainFromPublishableKey, - DEFAULT_OAUTH_CALLBACK_URL, -} from '@instantdb/platform'; +import { clerkDomainFromPublishableKey } from '@instantdb/platform'; +import { getOAuthCallbackUrl } from '../../../lib/config.ts'; import chalk from 'chalk'; import boxen from 'boxen'; import { @@ -42,10 +40,13 @@ type ProviderRow = { provider_name: string; }; -const redirectPrompt = redirectUriPrompt({ - heading: 'Custom redirect URI (optional):', -}); -const newRedirectPrompt = redirectUriPrompt({ heading: 'New redirect URI:' }); +const redirectPrompt = (oauthCallbackURL: string) => + redirectUriPrompt({ + heading: 'Custom redirect URI (optional):', + oauthCallbackURL, + }); +const newRedirectPrompt = (oauthCallbackURL: string) => + redirectUriPrompt({ heading: 'New redirect URI:', oauthCallbackURL }); const googleConsoleUrl = 'https://console.developers.google.com/apis/credentials'; @@ -267,12 +268,14 @@ const resolveGoogleUpdateMode = Effect.fn(function* ({ const updateGoogleRedirect = Effect.fn(function* ({ opts, client, + oauthCallbackURL, }: { opts: Record; client: OAuthClientRow; + oauthCallbackURL: string; }) { const redirectTo = yield* Args.text(opts, 'custom-redirect-uri').pipe( - Args.prompt(newRedirectPrompt), + Args.prompt(newRedirectPrompt(oauthCallbackURL)), Args.required(), ); @@ -288,6 +291,7 @@ const updateGoogleRedirect = Effect.fn(function* ({ ...redirectSetupMessages({ prompt: 'Add this redirect URI in Google Console', redirectUri: redirectTo, + oauthCallbackURL, showCustomRedirectInstructions: true, }), ].join('\n'), @@ -302,12 +306,14 @@ const updateGoogleCustomCredentials = Effect.fn(function* ({ isWeb, switchingFromShared, promptCredentials, + oauthCallbackURL, }: { opts: Record; client: OAuthClientRow; isWeb: boolean; switchingFromShared: boolean; promptCredentials: boolean; + oauthCallbackURL: string; }) { const mustCollectCredentials = promptCredentials || switchingFromShared; const shouldPromptRedirectUri = @@ -330,13 +336,13 @@ const updateGoogleCustomCredentials = Effect.fn(function* ({ Args.availableWhen( shouldPromptRedirectUri || Args.has(opts, 'custom-redirect-uri'), ), - Args.prompt(redirectPrompt), + Args.prompt(redirectPrompt(oauthCallbackURL)), Args.optional(), ) : undefined; const redirectTo = switchingFromShared - ? customRedirectUri || client.redirect_to || DEFAULT_OAUTH_CALLBACK_URL + ? customRedirectUri || client.redirect_to || oauthCallbackURL : customRedirectUri; const response = yield* updateOAuthClient({ @@ -361,6 +367,7 @@ const updateGoogleCustomCredentials = Effect.fn(function* ({ ...redirectSetupMessages({ prompt: 'Add this redirect URI in Google Console', redirectUri: redirectTo, + oauthCallbackURL, showCustomRedirectInstructions: Boolean(customRedirectUri), }), ); @@ -377,6 +384,7 @@ const updateGoogleCustomCredentials = Effect.fn(function* ({ const handleGoogleUpdate = Effect.fn(function* ( opts: Record, client: OAuthClientRow, + oauthCallbackURL: string, ) { const { yes } = yield* GlobalOpts; const appType = getMetaString(client.meta, 'appType'); @@ -410,7 +418,7 @@ const handleGoogleUpdate = Effect.fn(function* ( } if (updateMode === 'redirect') { - return yield* updateGoogleRedirect({ opts, client }); + return yield* updateGoogleRedirect({ opts, client, oauthCallbackURL }); } return yield* updateGoogleCustomCredentials({ @@ -419,6 +427,7 @@ const handleGoogleUpdate = Effect.fn(function* ( isWeb, switchingFromShared, promptCredentials: !hasAnyUpdateFlag && !yes, + oauthCallbackURL, }); }); @@ -428,6 +437,7 @@ const handleClientIdSecretUpdate = Effect.fn(function* (params: { providerLabel: string; providerUrl: string; redirectSetupPrompt: string; + oauthCallbackURL: string; }) { const { yes } = yield* GlobalOpts; const hasAnyUpdateFlag = Args.hasAny(params.opts, [ @@ -473,7 +483,11 @@ const handleClientIdSecretUpdate = Effect.fn(function* (params: { Args.availableWhen( promptRedirect || Args.has(params.opts, 'custom-redirect-uri'), ), - Args.prompt(promptRedirect ? newRedirectPrompt : redirectPrompt), + Args.prompt( + promptRedirect + ? newRedirectPrompt(params.oauthCallbackURL) + : redirectPrompt(params.oauthCallbackURL), + ), Args.required(), ); @@ -494,6 +508,7 @@ const handleClientIdSecretUpdate = Effect.fn(function* (params: { ...redirectSetupMessages({ prompt: params.redirectSetupPrompt, redirectUri: customRedirectUri, + oauthCallbackURL: params.oauthCallbackURL, showCustomRedirectInstructions: true, }), ); @@ -580,10 +595,12 @@ const readAppleWebUpdate = Effect.fn(function* ({ opts, client, promptAll, + oauthCallbackURL, }: { opts: Record; client: OAuthClientRow; promptAll: boolean; + oauthCallbackURL: string; }) { const teamId = yield* Args.text(opts, 'team-id').pipe( Args.availableWhen(promptAll || Args.has(opts, 'team-id')), @@ -605,7 +622,7 @@ const readAppleWebUpdate = Effect.fn(function* ({ : undefined; const customRedirectUri = yield* Args.text(opts, 'custom-redirect-uri').pipe( Args.availableWhen(promptAll || Args.has(opts, 'custom-redirect-uri')), - Args.prompt(redirectPrompt), + Args.prompt(redirectPrompt(oauthCallbackURL)), Args.optional(), ); @@ -616,7 +633,7 @@ const readAppleWebUpdate = Effect.fn(function* ({ return { privateKey, redirectTo: privateKey - ? customRedirectUri || client.redirect_to || DEFAULT_OAUTH_CALLBACK_URL + ? customRedirectUri || client.redirect_to || oauthCallbackURL : customRedirectUri, customRedirectUri, meta: Object.keys(meta).length ? meta : undefined, @@ -626,6 +643,7 @@ const readAppleWebUpdate = Effect.fn(function* ({ const handleAppleUpdate = Effect.fn(function* ( opts: Record, client: OAuthClientRow, + oauthCallbackURL: string, ) { const { yes } = yield* GlobalOpts; const { promptAll, configureWeb } = yield* resolveAppleUpdateConfig({ @@ -640,7 +658,12 @@ const handleAppleUpdate = Effect.fn(function* ( Args.required(), ); const webUpdate: AppleWebUpdate = configureWeb - ? yield* readAppleWebUpdate({ opts, client, promptAll }) + ? yield* readAppleWebUpdate({ + opts, + client, + promptAll, + oauthCallbackURL, + }) : {}; const response = yield* updateOAuthClient({ @@ -661,6 +684,7 @@ const handleAppleUpdate = Effect.fn(function* ( ...redirectSetupMessages({ prompt: `Add this return URL under your Services ID on ${link('https://developer.apple.com', 'developer.apple.com')}`, redirectUri: webUpdate.redirectTo, + oauthCallbackURL, showCustomRedirectInstructions: Boolean(webUpdate.customRedirectUri), }), ); @@ -752,10 +776,13 @@ export const authClientUpdateCmd = Effect.fn( message: `OAuth provider not found for client: ${resolvedClient.client_name}`, }); } + const oauthCallbackURL = yield* getOAuthCallbackUrl; yield* Match.value(provider.provider_name).pipe( Match.withReturnType>(), - Match.when('google', () => handleGoogleUpdate(opts, resolvedClient)), + Match.when('google', () => + handleGoogleUpdate(opts, resolvedClient, oauthCallbackURL), + ), Match.when('github', () => handleClientIdSecretUpdate({ opts, @@ -764,6 +791,7 @@ export const authClientUpdateCmd = Effect.fn( providerUrl: 'https://github.com/settings/developers', redirectSetupPrompt: 'Add this callback URL in your GitHub OAuth App settings', + oauthCallbackURL, }), ), Match.when('linkedin', () => @@ -774,9 +802,12 @@ export const authClientUpdateCmd = Effect.fn( providerUrl: 'https://www.linkedin.com/developers/apps', redirectSetupPrompt: 'Add this redirect URI in your LinkedIn app settings', + oauthCallbackURL, }), ), - Match.when('apple', () => handleAppleUpdate(opts, resolvedClient)), + Match.when('apple', () => + handleAppleUpdate(opts, resolvedClient, oauthCallbackURL), + ), Match.when('clerk', () => handleClerkUpdate(opts, resolvedClient)), Match.when('firebase', () => handleFirebaseUpdate(opts, resolvedClient)), Match.orElse((providerName) => diff --git a/client/packages/cli/src/lib/config.ts b/client/packages/cli/src/lib/config.ts index 41c3d60dd7..3ed8e6691a 100644 --- a/client/packages/cli/src/lib/config.ts +++ b/client/packages/cli/src/lib/config.ts @@ -1,4 +1,5 @@ import { Config, Effect, Option, Schema } from 'effect'; +import { oauthCallbackURL } from '@instantdb/platform'; import { BadArgsError } from '../errors.ts'; import { readInstantConfigFile } from '../util/instantConfig.ts'; @@ -39,6 +40,10 @@ export const getBaseUrl = Effect.gen(function* () { return dev ? 'http://localhost:8888' : 'https://api.instantdb.com'; }); +export const getOAuthCallbackUrl = getBaseUrl.pipe( + Effect.map(oauthCallbackURL), +); + export const getDashUrl = Effect.gen(function* () { const setEnv = yield* Config.string('INSTANT_CLI_DASH_URI').pipe( Config.option, diff --git a/client/packages/cli/src/lib/oauth.ts b/client/packages/cli/src/lib/oauth.ts index 0d70106969..d1dc7e92ba 100644 --- a/client/packages/cli/src/lib/oauth.ts +++ b/client/packages/cli/src/lib/oauth.ts @@ -2,11 +2,9 @@ import { HttpBody, HttpClientResponse } from '@effect/platform'; import { Effect, Schema } from 'effect'; import { CurrentApp } from '../context/currentApp.ts'; import { InstantHttpAuthed, withCommand } from './http.ts'; -import chalk from 'chalk'; -import { runUIEffect, stripFirstBlankLine, validateRequired } from './ui.ts'; +import { validateRequired } from './ui.ts'; import { UI } from '../ui/index.ts'; import { BadArgsError } from '../errors.ts'; -import { link } from '../logging.ts'; import type { ClientTypeSchema } from '../commands/auth/client/add.ts'; import { Args } from './args.ts'; @@ -181,43 +179,6 @@ export const updateOAuthClient = Effect.fn(function* (params: { ); }); -// Due to the long prompt text, we use modifiers to manually create the prompt so we can -// change it after submission. -export const promptForRedirectURI = Effect.fn(function* ( - existingValue?: string, -) { - if (existingValue) return existingValue; - - const result = yield* runUIEffect( - new UI.TextInput({ - prompt: '', - placeholder: 'https://yoursite.com/oauth/callback', - modifyOutput: UI.modifiers.piped([ - (output, status) => { - if (status === 'idle') { - return ( - `\nCustom redirect URL (optional): -${chalk.dim('With a custom redirect URL, users will see "Redirecting to yoursite.com..." for a more branded experience.')} -${chalk.dim(`Your URL must forward to ${link('https://api.instantdb.com/runtime/oauth/callback')} with all query parameters preserved.`)}\n\n` + - stripFirstBlankLine(output) - ); - } - return `\nCustom redirect URL (optional):\n${stripFirstBlankLine(output)}`; - }, - UI.modifiers.dimOnComplete, - ]), - }), - ).pipe( - Effect.catchTag('UIError', (e) => - BadArgsError.make({ - message: `UI error for redirect URI: ${e.message}`, - }), - ), - ); - - return result === '' ? undefined : result; -}); - export const getOrCreateProvider = Effect.fn(function* ( type: typeof ClientTypeSchema.Type, ) { diff --git a/client/packages/platform/__tests__/src/consts.test.ts b/client/packages/platform/__tests__/src/consts.test.ts new file mode 100644 index 0000000000..a4fa4cdb11 --- /dev/null +++ b/client/packages/platform/__tests__/src/consts.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from 'vitest'; +import { oauthCallbackURL } from '../../src/consts.ts'; + +test('builds an OAuth callback URL from the API origin', () => { + expect(oauthCallbackURL('https://api.example.com')).toBe( + 'https://api.example.com/runtime/oauth/callback', + ); + expect(oauthCallbackURL('https://api.example.com/')).toBe( + 'https://api.example.com/runtime/oauth/callback', + ); +}); diff --git a/client/packages/platform/src/consts.ts b/client/packages/platform/src/consts.ts index a29d27a9a8..d6843d757e 100644 --- a/client/packages/platform/src/consts.ts +++ b/client/packages/platform/src/consts.ts @@ -1,5 +1,10 @@ -export const DEFAULT_OAUTH_CALLBACK_URL = - 'https://api.instantdb.com/runtime/oauth/callback'; +export function oauthCallbackURL(apiURI: string) { + return `${apiURI.replace(/\/+$/, '')}/runtime/oauth/callback`; +} + +export const DEFAULT_OAUTH_CALLBACK_URL = oauthCallbackURL( + 'https://api.instantdb.com', +); export const GOOGLE_AUTHORIZATION_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'; diff --git a/client/packages/platform/src/index.ts b/client/packages/platform/src/index.ts index dc6410c562..3181916c50 100644 --- a/client/packages/platform/src/index.ts +++ b/client/packages/platform/src/index.ts @@ -117,6 +117,7 @@ export { export { DEFAULT_OAUTH_CALLBACK_URL, + oauthCallbackURL, GOOGLE_AUTHORIZATION_ENDPOINT, GOOGLE_DISCOVERY_ENDPOINT, GOOGLE_TOKEN_ENDPOINT, diff --git a/client/packages/version/src/version.ts b/client/packages/version/src/version.ts index f52f0f2e2c..349d86c571 100644 --- a/client/packages/version/src/version.ts +++ b/client/packages/version/src/version.ts @@ -2,6 +2,6 @@ // Update the version here and merge your code to main to // publish a new version of all of the packages to npm. -const version = 'v1.0.57'; +const version = 'v1.0.58'; export { version }; diff --git a/client/www/components/dash/auth/GitHub.tsx b/client/www/components/dash/auth/GitHub.tsx index 9d0a4b5402..f9d2fe09dd 100644 --- a/client/www/components/dash/auth/GitHub.tsx +++ b/client/www/components/dash/auth/GitHub.tsx @@ -27,8 +27,8 @@ import { import { errorToast } from '@/lib/toast'; import { messageFromInstantError } from '@/lib/errors'; -import { DEFAULT_OAUTH_CALLBACK_URL } from '@instantdb/platform'; import { useDarkMode } from '../DarkModeToggle'; +import { defaultOAuthCallbackURL } from '@/lib/config'; function exampleCode({ clientName }: { clientName: string }) { return /* js */ `// Create the authorization URL: @@ -169,8 +169,8 @@ export function AddGitHubClientForm({

- Add as - the Authorization callback URL in your GitHub OAuth App settings. + Add as the + Authorization callback URL in your GitHub OAuth App settings.

{redirectTo && }

@@ -231,7 +231,7 @@ export function GitHubClient({

{client.redirect_to && ( diff --git a/client/www/components/dash/auth/Google.tsx b/client/www/components/dash/auth/Google.tsx index fb9fd20e45..a594a2434f 100644 --- a/client/www/components/dash/auth/Google.tsx +++ b/client/www/components/dash/auth/Google.tsx @@ -28,8 +28,8 @@ import { TextInput, ToggleGroup, } from '@/components/ui'; -import { DEFAULT_OAUTH_CALLBACK_URL } from '@instantdb/platform'; import { useDarkMode } from '../DarkModeToggle'; +import { defaultOAuthCallbackURL } from '@/lib/config'; type AppType = 'web' | 'ios' | 'android' | 'button-for-web'; function isNative(appType: AppType) { @@ -272,9 +272,8 @@ export function AddGoogleClientForm({ {appType === 'web' && (

- Add{' '} - to - the "Authorized redirect URIs" on your{' '} + Add {' '} + to the "Authorized redirect URIs" on your{' '} {client.redirect_to && ( diff --git a/client/www/components/dash/auth/LinkedIn.tsx b/client/www/components/dash/auth/LinkedIn.tsx index d8e86fc020..c21d7e22fe 100644 --- a/client/www/components/dash/auth/LinkedIn.tsx +++ b/client/www/components/dash/auth/LinkedIn.tsx @@ -17,7 +17,6 @@ import { OAuthServiceProvider, } from '@/lib/types'; import { - DEFAULT_OAUTH_CALLBACK_URL, LINKEDIN_AUTHORIZATION_ENDPOINT, LINKEDIN_TOKEN_ENDPOINT, LINKEDIN_DISCOVERY_ENDPOINT, @@ -33,6 +32,7 @@ import { errorToast } from '@/lib/toast'; import { messageFromInstantError } from '@/lib/errors'; import { useDarkMode } from '../DarkModeToggle'; +import { defaultOAuthCallbackURL } from '@/lib/config'; function exampleCode({ clientName }: { clientName: string }) { return /* js */ `// Create the authorization URL: @@ -176,7 +176,7 @@ export function AddLinkedInClientForm({

- Add as a + Add as a redirect URI for your LinkedIn app.

{redirectTo && } @@ -239,7 +239,7 @@ export function LinkedInClient({
{client.redirect_to && ( diff --git a/client/www/components/dash/auth/shared.tsx b/client/www/components/dash/auth/shared.tsx index a2daec07bf..9c98f75f4b 100644 --- a/client/www/components/dash/auth/shared.tsx +++ b/client/www/components/dash/auth/shared.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { jsonFetch } from '@/lib/fetch'; -import config from '@/lib/config'; +import config, { defaultOAuthCallbackURL } from '@/lib/config'; import { InstantApp, InstantIssue, @@ -19,7 +19,6 @@ import { } from '@/components/ui'; import { errorToast, successToast } from '@/lib/toast'; import { messageFromInstantError } from '@/lib/errors'; -import { DEFAULT_OAUTH_CALLBACK_URL } from '@instantdb/platform'; export function findName(prefix: string, used: Set): string { if (!used.has(prefix)) { @@ -220,7 +219,7 @@ function RedirectUrlLabel() {

Your URI must forward to{' '} - https://api.instantdb.com/runtime/oauth/callback + {defaultOAuthCallbackURL} {' '} with all query parameters preserved.

@@ -295,7 +294,7 @@ export function RedirectForwardingNote({ redirectTo }: { redirectTo: string }) { Your redirect URI must forward all query parameters to Instant's callback.

- +
diff --git a/client/www/lib/config.ts b/client/www/lib/config.ts index 6307428f7f..11695bb0cf 100644 --- a/client/www/lib/config.ts +++ b/client/www/lib/config.ts @@ -1,3 +1,5 @@ +import { oauthCallbackURL } from '@instantdb/platform'; + export const isBrowser = typeof window != 'undefined'; export const isDev = process.env.NODE_ENV === 'development'; @@ -72,6 +74,8 @@ export const config = ? getRuntimeConfig() || configFromApiURI(defaultApiURI)! : configFromApiURI(defaultApiURI)!; +export const defaultOAuthCallbackURL = oauthCallbackURL(config.apiURI); + // In dev mode, sync the devBackend flag to a cookie so server components // can resolve the same apiURI as the client. if (isDev && isBrowser) {