Skip to content
David Morton edited this page Mar 15, 2021 · 5 revisions

TLDR; We have a demo application that uses Bowhead deployed on Netlify that you can review here https://github.com/daithimorton/bowhead/tree/master/packages/test-app. You can see how Bowhead is configured and everything else that is required to get up and running.

Bowhead setup guide

To use Bowhead you will need to create a project on Google Firebase, provision a Firestore database for the application to use, create a Stripe account and project for your Saas product, create an account with a hosting provider for you frontend application and backend API endpoint, and finally provide authentication credentials for each of these services. This setup is specific to every user which is why you must complete these steps yourself, if there was an easy way for me to do this for you I would. If your still interested in using Bowhead read on.

Deploying Bowhead functions

In the demo Bowhead deployment https://sad-tereshkova-9a21f9.netlify.app/ we use Netlify to host our application and to host the API endpoints required for Bowhead to work correctly. For this demo the endpoints are written as server-less functions but you can deploy your own functions on a NodeJS backend or something similar if you like. Here I will describe the logic of each of the required endpoints, how you deploy them is your choice, Bowhead only requires the API endpoint urls during configuration.

  • Bowhead functions utility file
import BowheadFunctions from '@mortond/bowhead-functions'

const config = {
    firebase: {
        projectId: process.env.FIREBASE_PROJECT_ID,
        privateKey: process.env.FIREBASE_PRIVATE_KEY,
        clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
        databaseProductionUrl: process.env.FIREBASE_FIRESTORE_PROD_DATABASE_URL,
    },
    stripe: {
        stripeWebhookSigningSecret: process.env.STRIPE_WEBHOOK_SIGNING_SECRET,
        stripeSecretKey: process.env.STRIPE_SECRET_KEY
    }
}

const bowhead = new BowheadFunctions(config);
export { bowhead };

This file encapsulates creating a new instance of BowheadFunctions passing in the required configuration details. This instance of BowheadFunctions is used across each of our API endpoints.

  • webhook-stripe.js
import { bowhead } from '../utils/bowhead'

exports.handler = async (event, context, callback) => {
  const stripeSignature = event.headers['stripe-signature'];

  return await bowhead.webhookStripe({ stripeSignature, rawBody: event.body })
    .then(() => {
      callback(null, { statusCode: 200 })
    }).catch(error => {
      callback(error, { statusCode: 400 })
    });
}

This API endpoint receives updates from the Stripe service when a users subscription status changes. The BowheadFunctions instance then handles the logic to update the Firestore database which will update the users UI as required.

  • create-stripe-checkout-session.js
import { bowhead } from '../utils/bowhead'

exports.handler = async (event, context, callback) => {
    const data = JSON.parse(event.body);
    return await bowhead.createStripeCheckoutSession({ token: event.queryStringParameters.token, data })
        .then((result) => {
            callback(null, { statusCode: 200, body: JSON.stringify(result) })
        }).catch(error => {
            callback(error, { statusCode: 400 })
        });
}

This API endpoint is used by Bowhead to create a Stripe checkout session when a user is signing up to a specific subscription plan.

  • create-stripe-customer-portal-session.js
import { bowhead } from '../utils/bowhead'

exports.handler = async (event, context, callback) => {
    const data = JSON.parse(event.body);
    return await bowhead.createStripeCustomerPortalSession({ token: event.queryStringParameters.token, data })
        .then((result) => {
            callback(null, { statusCode: 200, body: JSON.stringify(result) })
        }).catch(error => {
            callback(error, { statusCode: 400 })
        });
}

This API endpoint handles the customer session portal where users can modify their Stripe subscription, for example they can move from a Basic plan to a Pro plan. Stripe manages this portal and any changes made will be picked up the webhook endpoint above.

  • delete-stripe-customer.js
import { bowhead } from '../utils/bowhead'

exports.handler = async (event, context, callback) => {
    const data = JSON.parse(event.body);
    return await bowhead.deleteStripeCustomer({ token: event.queryStringParameters.token, data })
        .then(() => {
            callback(null, { statusCode: 200 })
        }).catch(error => {
            callback(error, { statusCode: 400 })
        });
}

Users have the option to delete their account which means that Bowhead must handle deleting their Stripe subscription data and update the Firestore database.

Once each of these API endpoints are deployed you must provide the details to Bowhead through the configuration object.

  const bowheadConfig = {   
    // These APIs are required for Bowhead to manage a users stripe subscription
    api: {
      deleteStripeCustomer: process.env.REACT_APP_BOWHEAD_API_DELETE_STRIPE_CUSTOMER,
      createStripeCustomerPortalSession: process.env.REACT_APP_BOWHEAD_API_CREATE_STRIPE_CUSTOMER_PORTAL_SESSION,
      createStripeCheckoutSession: process.env.REACT_APP_BOWHEAD_API_CREATE_STRIPE_CHECKOUT_SESSION
    },    
  }

  pluginRegistry.register('configuration-bowhead', {
    type: PLUGIN_TYPES.CONFIGURATION_BOWHEAD,
    config: bowheadConfig
  })

You may have noticed that all of the configuration details for Firebase, Firestore, and Stripe are stored as environment variables. Here is an example of the env file for the demo application:

REACT_APP_BOWHEAD_API_DELETE_STRIPE_CUSTOMER=/.netlify/functions/delete-stripe-customer
REACT_APP_BOWHEAD_API_CREATE_STRIPE_CUSTOMER_PORTAL_SESSION=/.netlify/functions/create-stripe-customer-portal-session
REACT_APP_BOWHEAD_API_CREATE_STRIPE_CHECKOUT_SESSION=/.netlify/functions/create-stripe-checkout-session

There is no need to specify the Stripe webhook API endpoint here because the application itself does not use it, Stripe will send requests to the deployed webhook which will then connect directly to Firebase and Firestore to update user details.

It is important that these environment variables are not exposed in your GitHub repos or delivered to a users browsers. These are backend API endpoints and so these environment variables should be configured on your deployment environments console for production.

Once you have deployed these Bowhead API endpoints and provided the required configuration details when intialising Bowhead in your application you no longer need to worry about them. These endpoints are only used by Bowhead and do not require any interaction from you going forward

Configuring application name and production URL

const bowheadConfig = {
    app: {
      name: "Bowhead",
      productionUrl: process.env.REACT_APP_PRODUCTION_URL
    },    
  }

  pluginRegistry.register('configuration-bowhead', {
    type: PLUGIN_TYPES.CONFIGURATION_BOWHEAD,
    config: bowheadConfig
  })

This part of the Bowhead configuration is straight forward, you must provide a name for your application so it can be displayed in the header of the landing page. And you must also provide a productionUrl.

  • productionUrl

This URL is used by Bowhead as part of the user signin/registration verification feature. Users when they login will be sent a login email link, when this link is clicked the user is redirected to /verify of your application. Bowhead handles all of the verification steps but it needs to know where you have deployed your application in order to find the /verify endpoint. For example, https://your-awesome-app.com/verify.

You can deploy your application where-ever you like, we deployed to Netlify for the demo. And so our .env file contains this entry

REACT_APP_PRODUCTION_URL=https://sad-tereshkova-9a21f9.netlify.app

Configuring Bowhead Stripe subscription plans

const bowheadConfig = {   
    // Provide information for each Stripe subscription
    plans: [
      {
        title: "Basic",
        price: "10",
        priceId: process.env.REACT_APP_STRIPE_SUBSCRIPTION_PLAN_BASIC,
        featureList: [
          "1 Workspace",
          "1 Project/pw"
        ],
      },
      {
        title: "Pro",
        subheader: "Most popular",
        price: "50",
        priceId: process.env.REACT_APP_STRIPE_SUBSCRIPTION_PLAN_PRO,
        featureList: [
          "5 Workspaces",
          "5 Projects/pw"
        ],
      },
      {
        title: "Enterprise",
        price: "250",
        priceId: process.env.REACT_APP_STRIPE_SUBSCRIPTION_PLAN_ENTERPRISE,
        featureList: [
          "25 Workspaces",
          "25 Projects/pw"
        ],
      },
    ],  
  }

  pluginRegistry.register('configuration-bowhead', {
    type: PLUGIN_TYPES.CONFIGURATION_BOWHEAD,
    config: bowheadConfig
  })

Bowhead provides some subscription management features that allow users to sign up to 3 difference plans, Basic, Pro, and Enterprise. You can name this however you want, but you must provide the priceId for each plan. You can create plans in the Stripe console and set your prices.

Configuring Firebase and Firestore

Bowhead uses Google Firebase for user authentication, you must create a Firebase project for your Saas product and provide Bowhead with an instance.

import firebase from "firebase/app";
import "firebase/firestore";
import "firebase/auth";
import "firebase/functions";

const firebaseConfig = {
  apiKey: "AIzaSyAW3V551b53_Q4MIuWbhv_2c8dX4xnjraY",
  authDomain: "bowhead-d9522.firebaseapp.com",
  databaseURL: "https://bowhead-d9522.firebaseio.com",
  projectId: "bowhead-d9522",
  storageBucket: "bowhead-d9522.appspot.com",
  messagingSenderId: "839446933691",
  appId: "1:839446933691:web:6a1f97543a66c0dee8f99e"
};

firebase.initializeApp(firebaseConfig);

const firestore = firebase.firestore();

// this will connect the browser to the local emulator for firestore, avoiding using the quota during development.
// IMPORTANT: when viewing the dev version of the app from a remote device using this dev machines IP, the app will
// connect to the live database.
if (process.env.NODE_ENV === "development") {
  // Note that the Firebase Web SDK must connect to the WebChannel port
  firestore.settings({
    host: "localhost:8080",
    ssl: false
  });
}

export { firebase, firestore };

Here is an example of creating an instance of Firebase and Firestore in our frontend demo application. These details are fine to be exposed to users because the Firebase platform handles security using rules that are configured on the backend.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {    
  	
    function isSignedIn(req) {
      return req.auth != null;
    }

    match /users/{userId} {
			allow read, write: if isSignedIn(request) && request.auth.uid == userId;
    }

    match /stripe/{stripeCustomerId} {
			allow read, write: if isSignedIn(request) &&
                              // allow listeners to mount even when resource is null
                              (resource == null || request.auth.uid == resource.data.uid);
    }

    function getRole(rsc, req) {
      return rsc.data.members[req.auth.uid].role;
    }

    function isOneOfRoles(rsc, req, array) {
      return getRole(rsc, req) in array;
    }

    function getStripeCustomerId(uid) {
      return get(/databases/$(database)/documents/users/$(uid)).data.stripeCustomerId;
    } 

    function getStripeData(uid) {
      return get(/databases/$(database)/documents/stripe/$(getStripeCustomerId(uid))).data
    }

    function isUserSubscribed(uid) {
      return getStripeData(uid).status == 'trialing' || getStripeData(uid).status == 'active';
    }

    // STRIPE SUBSCRIPTION LIMITS
    function getBasicPlanId() {
      return 'price_1H306XJF9YjhGgt0GNOy1IQS';
    }

    function getProPlanId() {
      return 'price_1H307CJF9YjhGgt0ZmRhLsNE';
    }

    function getEnterprisePlanId() {
      return 'price_1H307mJF9YjhGgt0SxEYD01h';
    }

    function getUserSubscriptionPlanId(uid) {
      return getStripeData(uid).planId;
    }

    function validateSubscriptionProjectLimits() {
      return (getUserSubscriptionPlanId(request.auth.uid) == getBasicPlanId() &&
                                        (("projects" in resource.data) == false || request.resource.data.projects.keys().size() <= 1)) ||
              (getUserSubscriptionPlanId(request.auth.uid) == getProPlanId() &&
                                        (("projects" in resource.data) == false || request.resource.data.projects.keys().size() <= 5)) ||
              (getUserSubscriptionPlanId(request.auth.uid) == getEnterprisePlanId() &&
                                        (("projects" in resource.data) == false || request.resource.data.projects.keys().size() <= 25))
    }

    function getNumberOfWorkspaces() {
      return exists(/databases/$(database)/documents/userWorkspaces/$(request.auth.uid)) &&
              "numberOfWorkspaces" in get(/databases/$(database)/documents/userWorkspaces/$(request.auth.uid)).data ?
                get(/databases/$(database)/documents/userWorkspaces/$(request.auth.uid)).data.numberOfWorkspaces :
                0      
    }

    function validateSubscriptionWorkspaceLimits() {
      return (getUserSubscriptionPlanId(request.auth.uid) == getBasicPlanId() && getNumberOfWorkspaces() < 1) ||
              (getUserSubscriptionPlanId(request.auth.uid) == getProPlanId() && getNumberOfWorkspaces() < 5) ||
              (getUserSubscriptionPlanId(request.auth.uid) == getEnterprisePlanId() && getNumberOfWorkspaces() < 25)
    }

    match /userWorkspaces/{userId} {                            
      allow read, write: if isSignedIn(request) && 
                              request.auth.uid == userId;            
    }

    match /workspaces/{workspaceId} {
 
      allow create: if isSignedIn(request) &&
                        // user is subscribed
                        isUserSubscribed(request.auth.uid) &&
                        // resource does not already exist
                        resource == null && 
                        // incoming resource has member 'owner'
                        isOneOfRoles(request.resource, request, ['owner']) && 
                        // user subscription limits
                        validateSubscriptionWorkspaceLimits();
                        
      allow read: if isSignedIn(request) &&
                          // user is subscribed
                          isUserSubscribed(request.auth.uid) &&
                          // listeners are allowed to mount even if resourse is null
                          (isOneOfRoles(resource, request, ['owner', 'member']) || resource == null)
      
      allow update: if isSignedIn(request) &&                          
                          // user is owner of workspace
                          // user is subscribed
                          // check user subscription limits on projects
                          ((isOneOfRoles(resource, request, ['owner']) &&                           
                          isUserSubscribed(request.auth.uid) &&                          
                          validateSubscriptionProjectLimits()) ||
                          // allow users to update if invite 'pending'
                          (("invites" in resource.data) && resource.data.invites[request.auth.token['email']] in ['pending']) ||
                          // allow users to remove themselves from the members list
                          (isOneOfRoles(resource, request, ['member']) &&
                           (request.auth.uid in request.resource.data.members.keys()) == false))
      
      allow delete: if isSignedIn(request) &&
                          // user is subscribed
                          // isUserSubscribed(request.auth.uid) &&
                          // user is owner of workspace
                          isOneOfRoles(resource, request, ['owner']);
    }
  }
}

Here is an example of the rules we use in the demo application. You might see some references to workspaces here, in the demo application we have implemented a workspaces/projects feature that also uses Firestore as a database.

Bowhead requires Firebase and Firestore to manage user data and Stripe subscription data. For you applications custom business logic you can use another database if you want, such as PostreSQL or MongoDB. It was just easier for us to use Firestore for the workspaces/projects features. But again Firebase and Firestore along with rules above are REQUIRED by Bowhead.