Unable to register addon with development host (ERROR 400 - Invalid URI Syntax)

So I’ve been trying to rework the Atlassian Connect Express tutorial to reuse the same Ngrok tunnel and just restart the server without having to reinstall the app, but I’ve run into a bit of a baffling issue.

Failed to register with host https://XXXXXX.atlassian.net/wiki (400)
{"subCode":"upm.pluginInstall.error.invalid.uri.syntax"}
Add-on not registered; no compatible hosts detected

It’s important to note that I am setting up the NGrok tunnel myself and passing in the local base URL using the AC_LOCAL_BASE_URL environment variable. It’s pretty insane that you have to reinstall the entire app anytime you want a change to be made to the server code, since the tunnel gets reset on every change (which breaks the app).

It’s trying to tell me that my base URL has invalid syntax, but can’t find out why. I’m able to access the atlassian-connect.json from my ngrok URL and it appears to be identical to a stock tutorial project (that works). Strangely, I don’t get a single GET request to my local server unless I navigate to the atlassian-connect.json file manually in a browser. So it seems that there is a weird networking thing going on that I am obviously missing here.

Here’s a bunch of info about my setup:

The NGrok start:

    const url = await ngrok.connect({
        proto: 'http',
        addr: '3000'
    });

Running Nodemon that will start the app with the right base URL:

        nodemon({
            script: "src/app.js",
            exec: `SET AC_LOCAL_BASE_URL=${url} && node -r esm` 
            
        }).on('start', function () {
            console.log('App has started');
        })

A copy of the atlassian-connect.json:

{
  "key": "my-app",
  "name": "My app",
  "description": "My very first app",
  "baseUrl": "https://d6e6a363ec3e.ngrok.io",
  "enableLicensing": true,
  "authentication": {
    "type": "jwt"
  },
  "lifecycle": {
    "installed": "/installed"
  },
  "scopes": [
    "READ"
  ],
  "modules": {
    "generalPages": [
      {
        "key": "hello-world-page-jira",
        "location": "system.top.navigation.bar",
        "name": {
          "value": "Hello World"
        },
        "url": "/hello-world",
        "conditions": [
          {
            "condition": "user_is_logged_in"
          }
        ]
      },
      {
        "key": "hello-world-page-confluence",
        "location": "system.header/left",
        "name": {
          "value": "Hello World"
        },
        "url": "/hello-world",
        "conditions": [
          {
            "condition": "user_is_logged_in"
          }
        ]
      }
    ]
  }
}

The app.js file itself is largely unchanged from the tutorial, but here is the whole file anyways:

// Entry point for the app

// Express is the underlying that atlassian-connect-express uses:
// https://expressjs.com
import express from 'express';

// https://expressjs.com/en/guide/using-middleware.html
import bodyParser from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import errorHandler from 'errorhandler';
import morgan from 'morgan';

// atlassian-connect-express also provides a middleware
import ace from 'atlassian-connect-express';

// Use Handlebars as view engine:
// https://npmjs.org/package/express-hbs
// http://handlebarsjs.com
import hbs from 'express-hbs';

// We also need a few stock Node modules
import http from 'http';
import path from 'path';
import os from 'os';
import helmet from 'helmet';
import nocache from 'nocache';

// Routes live here; this is the C in MVC
import routes from './routes';
import { addServerSideRendering } from './server-side-rendering';

//process.env.FIREBASE_CONFIG = 

const admin = require('firebase-admin');
const functions = require('firebase-functions');
const { factory } = require('atlassian-connect-firestore')


admin.initializeApp(functions.config().firebase)

const db = admin.firestore()

ace.store.register('firestore', factory(db))

// Bootstrap Express and atlassian-connect-express
const app = express();
const addon = ace(app);


// See config.json
const port = addon.config.port();
app.set('port', port);

// Log requests, using an appropriate formatter by env
const devEnv = app.get('env') === 'development';
app.use(morgan(devEnv ? 'dev' : 'combined'));

// Configure Handlebars
const viewsDir = path.join(__dirname, 'views');
const handlebarsEngine = hbs.express4();
app.engine('hbs', handlebarsEngine);
app.set('view engine', 'hbs');
//app.set('views', viewsDir);

// Configure jsx (jsx files should go in views/ and export the root component as the default export)
addServerSideRendering(app, handlebarsEngine);

// Atlassian security policy requirements
// http://go.atlassian.com/security-requirements-for-cloud-apps
// HSTS must be enabled with a minimum age of at least one year
app.use(helmet.hsts({
  maxAge: 31536000,
  includeSubDomains: false
}));
app.use(helmet.referrerPolicy({
  policy: ['origin']
}));

// Include request parsers
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false}));
app.use(cookieParser());

// Gzip responses when appropriate
app.use(compression());

// Include atlassian-connect-express middleware
app.use(addon.middleware());

// Mount the static files directory
const staticDir = path.join(__dirname, 'public');
app.use(express.static(staticDir));

// Atlassian security policy requirements
// http://go.atlassian.com/security-requirements-for-cloud-apps
app.use(nocache());

// Show nicer errors in dev mode
if (devEnv) app.use(errorHandler());

// Wire up routes
routes(app, addon);

// Boot the HTTP server
http.createServer(app).listen(port, () => {
  console.log('App server running at http://' + os.hostname() + ':' + port);
  console.log('ENV: AC_LOCAL_BASE_URL = ' + process.env.AC_LOCAL_BASE_URL);
  console.log(addon.config.localBaseUrl())
  console.log(addon.config.hosts())


  // Enables auto registration/de-registration of app into a host in dev mode
  if (devEnv) addon.register();
});

I have solved it. For some god-forsaken reason, URIs without a forward slash at the end are rejected.

Additionally, if you are starting the Ngrok tunnel yourself, ACE does not change the base URL in the atlassian-connect.json file which means you also have to do that yourself.

Here is the final script I wrote which allows you to restart the server without losing the Ngrok tunnel (why Atlassian decided to write it this way, I will never know).

#!/usr/bin/env node

if (process.env.NODE_ENV === 'production') {
    throw new Error("Do not use nodemon in production, run bin/www.js directly instead");
}
  
const nodemon = require('nodemon');
const ngrok = require('ngrok');
const fs = require('fs');


console.log("~~~INITIALIZING DEVELOPMENT SERVER~~~");

// We start an ngrok tunnel to ensure it stays the same for the entire process
(async function() {
    const url = await ngrok.connect({
        proto: 'http',
        addr: '3000'
    });

    //YOU NEED THE SLASH HERE, otherwise you will get very vague "invalid URI" errors
    var httpUrl = url + "/";

    console.log("Ngrok tunnel opened at " + httpUrl);

    var isWin = process.platform === "win32";
    if(isWin){ 

        // need to update the atlassian-connect baseUrl since ACE doesn't update it if it doesn't open the Ngrok tunnel
        let rawdata = fs.readFileSync('atlassian-connect.json');
        let atlasConnectData = JSON.parse(rawdata);
        atlasConnectData.baseUrl = httpUrl;

        fs.writeFileSync('atlassian-connect.json', JSON.stringify(atlasConnectData, null, '\t'))

        process.env.AC_OPTS = "force-reg"

        nodemon({
            script: "src/app.js",
            exec: `SET AC_LOCAL_BASE_URL=${httpUrl}&& node -r esm` 
            
        }).on('start', function () {
            console.log('App has started');
        }).on('quit', function () {
            console.log('App has quit');
        }).on('restart', function (files) {
            process.env.AC_OPTS = "no-reg"
            console.log('App restarted due to: ', files);
        });

    } else { //if running linux, WARNING THIS IS UNTESTED
        nodemon(`-x 'AC_LOCAL_BASE_URL=${httpUrl} node' src/app.js`);
    } 

})();