Register plug-in installations in the plug-in backend

Until now our plug-in consisted only of some html, css and javascript files - a pure frontend plug-in.
But sometimes you would want to do more, like keeping track of all the installations of your plug-in, or making sure that only trustworthy Haiilo Home setups install the plug-in.

πŸ“˜

Demo project setup

Introducing a backend and database makes our demo project more complex. For keeping it as simple as possible and have you seeing results quickly we focus on demonstrating the 'good path', and keep most things happening in our already existing index.js.
For a production ready plug-in you would of course have to take things further, properly split up your project and handle all possible errors.

Register the install lifecycle event in the plug-in backend

When defined in the manifest of the plug-in Haiilo Home attempts to notify the plug-in of certain lifecycle events.
Whereas all other notifications are sent to the plug-in in a fire-and-forget manner, the install event is an exception: With an error response the plug-in can prevent the installation of the plug-in in Haiilo Home's admin area. We will use this mechanism to only allow installations at 'coyo' domains.

To receive lifecycle event notifications the plug-in has to implement Haiilo Home's plug-in lifecycle API.
Haiilo Home sends all data within the request's payload as signed JWT token. We need to decode the token, extract the data we are interested in, and react to it. The detailed content of the token is described in the lifecycle events web hook API.

We will use the demo project's node backend, which we only used to serve static files with Heroku so far, to implement the install endpoint.
And, of course, we will need to adapt our manifest once more.

Here are our final files:

{
  "manifestVersion": "2.0.0",
  "pluginVersion": "0.4.0",
  "name": {
    "en": "Demo Plugin - Step 4",
    "de": "Demo Plugin - Step 4"
  },
  "description": {
    "en": "A simple demo plug-in",
    "de": "Ein einfaches Beispiel-Plug-in"
  },
  "lifecycle": {
    "url": "/lifecycle/",
    "events": [
      "install"
    ]
  },
  ...
}
const jwt = require('jsonwebtoken');

module.exports = {
    decode: (token) => {
        let decodedToken = jwt.decode(token, {complete: true});
        return decodedToken;
    }
}
const express = require('express')
const app = express()
const path = require('path')
const jwt = require('./jwt')
const PORT = process.env.PORT || 5000

// use the express-static middleware
app.use(express.static(path.join(__dirname, 'dist')));
app.use(express.json());
app.use(express.urlencoded({extended: true}));

// lifecycle API implementation
app.post('/lifecycle/install', (req, res) => {
    console.log('Received life cycle event: install %s', req.body.token);
    let decodedToken = jwt.decode(req.body.token);
    console.log('decoded header: %j', decodedToken.header);
    console.log('decoded payload: %j', decodedToken.payload);
    if (decodedToken.payload.iss.indexOf('coyo') >= 0) {
        console.log('Successful installation');
        res.status(201).json({code: 100, message: 'ok'});
    } else {
        console.log('Unsupported COYO instance');
        res.status(400).json({code: 101, message: 'Unsupported COYO instance'})
    }
})

// start the server listening for requests
app.listen(PORT,() => console.log(`Server is running on ${ PORT }`));

Deploy your project to Heroku, run heroku logs --tail to watch the detailed logs when installing the new version of your plug-in.

πŸ‘

Demo project

The result of this step can be seen at branch 4-lifecycle of our demo project

Verify the JWT's information

How can we make sure that the value for iss sent within the JWT is the real Haiilo Home (sub)domain? Any Haiilo Home backend could just send an arbitrary value...

🚧

Security notice

Make sure to not only decode but always verify the JWT with its signature.
Haiilo Home provides all means to have a secure Haiilo Home - plug-in exchange of information. It is in the plug-in developer's responsibility to use those measures.

Haiilo Home signs its JWT tokens. So when you verify the token's signature with a public key you received from a trusted party, you can be sure the token information comes from a trusted Haiilo Home instance and wasn't tempered with. The public keys for tenants hosted by Haiilo Home can be found at https://certificates.plugins.coyoapp.com/ - they are provided in jwk format.
When properly configured the Haiilo Home backend will send the URL of the used public key in the jku header field of the JWT token. We can read this and make sure to only use and trust keys provided under https://certificates.plugins.coyoapp.com/.

πŸ‘

Demo project

The result of this step can be seen at branch 4a-lifecycle of our demo project

You now have all the basics dealing with Haiilo Home plug-in lifecycle events. The next step is only about applying them again and showing a possible use case.

Keep track of plug-in installations

Looking at the lifecycle API you'll find more web hooks being offered. In combination with some persistent data storage on the plug-in side these can be used to keep track of all your plug-in installations, their Haiilo Home tenants, the plug-in instances/ widgets used per plug-in and further more.

Let's got with the obvious next step: Tracking the uninstall of our plug-in, so that we can keep a list of currently installed plug-in's.

πŸ‘

Demo project

The result of this step can be seen at branch 4b-lifecycle of our demo project

Now you can do whatever you wish in your lifecycle API implementations.

As this is highly individual, the implementation greatly depends on your technology stack used, and also leaves the focus of explaining the Haiilo Home plug-in universe to you, we won't go into details of the implementation here. Let's just look at the quick example code to see what could be done.

On the install lifecycle hook we will add a database entry saving the Haiilo Home tenant's URL, id and plug-in id.
On the uninstall lifecycle hook we will remove the database entry again.
We also add a simple /installations endpoint to render the database content into an html page.

πŸ“˜

Demo project database setup

We continue to use the setup proposed by the Getting Started on Heroku with Node.js.

We will assume that you only use the Heroku database. If you want to test the locally running application with a local database, more configuration would be needed (especially for establishing a secure connection).

Please refer to the Getting Started on Heroku with Node.js Guide for a setup and detailed implementation explanation.

Install postgres npm install pg --save for database access and ejs npm install ejs --save as view engine to display the database content.

When you set up your database table, you may use this sql

create table installation (
tenant_id varchar(255) not null,
plugin_id varchar(255) not null,
tenant_url varchar(2000) not null,
constraint installation_pkey primary key (tenant_id, plugin_id)
);

Create a module to establish the database connection and send queries:

const { Pool } = require('pg');
const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    rejectUnauthorized: false
});

module.exports = {
    query: (text, params, callback) => pool.query(text, params, callback)
}

Adapt the main index.js to save the data, and render it in a new view:

const express = require('express')
const app = express()
const path = require('path')
const jwt = require('./jwt')
const db = require('./db')
// ===========> require the database connection module

const PORT = process.env.PORT || 5000

// use the express-static middleware
app.use(express.static(path.join(__dirname, 'dist')))
    .set('views', path.join(__dirname, 'views'))
    .set('view engine', 'ejs');
// ===========> use the ejs view engine

app.use(express.json());
app.use(express.urlencoded({extended: true}));

// lifecycle API implementation
app.post('/lifecycle/install', (req, res) => {
    console.log('Received life cycle event: install %s', req.body.token);
    jwt.verify(req.body.token, (err, result) => {
        if (result) {
            console.log('verified payload: %j', result.payload);
            if (result.payload.iss.indexOf('coyo') >= 0) {
                console.log('Successful installation');
                db.query('INSERT INTO installation VALUES ($1, $2, $3)',
                    [result.payload.tenantId, result.payload.pluginId, result.payload.iss]);
                // ===========> save the install information to the database
              
              	res.status(201).json({code: 100, message: 'ok'});
            } else {
                console.log('Unsupported COYO instance');
                res.status(400).json({code: 101, message: 'Unsupported COYO instance'})
            }
        } else {
            console.log('Error validating JWT: %s', err.message);
            res.status(400).json({code: 401, message: 'Token signature invalid'})
        }
    });
})

app.post('/lifecycle/uninstall', (req, res) => {
    console.log('Received life cycle event: uninstall %s', req.body.token);
    jwt.verify(req.body.token, (err, result) => {
        if (result) {
            console.log('verified payload: %j', result.payload);
            db.query('DELETE FROM installation WHERE tenant_id=$1 AND plugin_id=$2',
                [result.payload.tenantId, result.payload.pluginId]);
            // ===========> delete database entry on uninstall
          
          res.status(201).json({code: 100, message: 'ok'});
        } else {
            console.log('Error validating JWT: %s', err.message);
            res.status(400).json({code: 401, message: 'Token signature invalid'})
        }
    });
})

// view rendering
app.get('/installations', async (req, res) => {
    try {
        const installations = await db.query('SELECT * FROM installation');
        const totalInstallations = await db.query('SELECT COUNT(DISTINCT tenant_id) FROM installation');
        // ===========> fetch database entries
      	const results = {
            'installations': (installations) ? installations.rows : null,
            'total': totalInstallations.rows[0].count
        };
        res.render('pages/installations', results);
        // ===========> render the database entries in our new view template
    } catch (err) {
        console.error(err);
        res.send("Error " + err);
    }
})

// start the server listening for requests
app.listen(PORT,() => console.log(`Server is running on ${ PORT }`));

Define the simple view template at views/pages/installations.ejs

<!DOCTYPE html>
<html>
<head>
</head>

<body>

<div class="container">
<h2>Plug-in installations</h2>
<p>Your plug-in has been installed in <%= total %> Haiilo Home social intranets.</p>
<table>
<tr><th>Tenant Id</th><th>Tenant URL</th><th>Plugin Id</th></tr>
    <% installations.forEach(function(r) { %>
        <tr>
        <td><%= r.tenant_id %></td>
        <td><%= r.tenant_url %></td>
        <td><%= r.plugin_id %></td>
        </tr>
    <% }); %>
</table>

</div>

</body>
</html>

Redeploy to Heroku and install your plug-in again.

Can you now see who uses your plug-in at https://your-plugins-getting-started.herokuapp.com/installations

πŸ‘

Demo project

The result of this step can be seen at branch 4c-lifecycle of our demo project

Aaaaand, you are done.
We are excited to see where you take things from here πŸ˜ƒ