diff --git a/.env.test b/.env.test index 99805fd..623afd8 100644 --- a/.env.test +++ b/.env.test @@ -1 +1,7 @@ MONGODB_CONNECTION_STRING=mongodb://127.0.0.1:27017/mongoose_test +STRIPE_SECRET_KEY=sk_test_123 +STRIPE_WEBHOOK_SECRET=whsec_test_123 +GITHUB_REDIRECT_URI=http://localhost:7777/oauth-github-login +MAILGUN_FROM_EMAIL=noreply@example.com +MAILGUN_DOMAIN=example.com +MAILGUN_PRIVATE_KEY=key-test diff --git a/netlify/functions/getMyAccount.js b/netlify/functions/getMyAccount.js new file mode 100644 index 0000000..dfbb917 --- /dev/null +++ b/netlify/functions/getMyAccount.js @@ -0,0 +1,5 @@ +'use strict'; + +const extrovert = require('extrovert'); + +module.exports = extrovert.toNetlifyFunction(require('../../src/actions/getMyAccount')); diff --git a/netlify/functions/github.js b/netlify/functions/github.js index 533443c..3002021 100644 --- a/netlify/functions/github.js +++ b/netlify/functions/github.js @@ -30,7 +30,8 @@ module.exports = extrovert.toNetlifyFunction(async function github(params) { const { access_token: token } = await githubOAuth.getAccessToken(code); const userData = await githubOAuth.getUser(token); - const { id: githubUserId, notification_email: email, avatar_url: picture, name, login: githubUsername } = userData; + const { id: githubUserId, notification_email: notificationEmail, avatar_url: picture, name, login: githubUsername } = userData; + const email = notificationEmail || await githubOAuth.getPrimaryEmail(token) || `${githubUsername}@users.noreply.github.com`; const $set = { githubUserId, @@ -55,9 +56,12 @@ module.exports = extrovert.toNetlifyFunction(async function github(params) { let roles = null; if (member == null) { const invitation = await Invitation.findOne({ - githubUsername, workspaceId, - status: 'pending' + status: 'pending', + $or: [ + { githubUsername }, + { email: email.toLowerCase() } + ] }); if (invitation != null) { workspace.members.push({ userId: user._id, roles: invitation.roles }); @@ -73,6 +77,15 @@ module.exports = extrovert.toNetlifyFunction(async function github(params) { const seats = users.length; await stripe.updateSubscriptionSeats(workspace.stripeSubscriptionId, seats); } + } else if ( + workspace.members.length === 0 && + workspace.stripeCustomerEmail && + workspace.stripeCustomerEmail.toLowerCase() === email.toLowerCase() + ) { + workspace.members.push({ userId: user._id, roles: ['owner'] }); + workspace.stripeCustomerEmail = null; + await workspace.save(); + roles = ['owner']; } else if (workspace.subscriptionTier === 'free') { workspace.members.push({ userId: user._id, roles: ['readonly'] }); await workspace.save(); diff --git a/netlify/functions/google.js b/netlify/functions/google.js index 09db416..bef5426 100644 --- a/netlify/functions/google.js +++ b/netlify/functions/google.js @@ -73,6 +73,14 @@ module.exports = extrovert.toNetlifyFunction(async function googleLogin(params) const seats = users.length; await stripe.updateSubscriptionSeats(workspace.stripeSubscriptionId, seats); } + } else if ( + workspace.members.length === 0 && + workspace.stripeCustomerEmail && + workspace.stripeCustomerEmail.toLowerCase() === email.toLowerCase() + ) { + workspace.members.push({ userId: user._id, roles: ['owner'] }); + await workspace.save(); + roles = ['owner']; } else if (workspace.subscriptionTier === 'free') { workspace.members.push({ userId: user._id, roles: ['readonly'] }); await workspace.save(); diff --git a/netlify/functions/updateWorkspace.js b/netlify/functions/updateWorkspace.js new file mode 100644 index 0000000..7699823 --- /dev/null +++ b/netlify/functions/updateWorkspace.js @@ -0,0 +1,5 @@ +'use strict'; + +const extrovert = require('extrovert'); + +module.exports = extrovert.toNetlifyFunction(require('../../src/actions/updateWorkspace')); diff --git a/package.json b/package.json index f7607ff..02b9218 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,13 @@ "cheerio": "1.0.0", "extrovert": "0.2.0", "googleapis": "100.0.0", - "mongoose": "^9.0.0-0", + "mongoose": "9.x", "ramda": "0.28.0", + "random-word-slugs": "^0.1.7", "stripe": "9.9.0", "time-commando": "1.0.1", - "vue": "3.x" + "vue": "3.x", + "xss": "^1.0.15" }, "devDependencies": { "@masteringjs/eslint-config": "0.1.1", diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..58d82ad --- /dev/null +++ b/public/login.html @@ -0,0 +1,157 @@ + + + + + + Mongoose Studio Account Setup + + + +
+
+

Set up your account

+

Sign in to connect your account and access your API key.

+ +
+ + +
+ +

+ Get Help on Discord +

+ + +

Already signed in? Go to My Account

+
+
+ + + + diff --git a/public/my-account.html b/public/my-account.html new file mode 100644 index 0000000..e842c92 --- /dev/null +++ b/public/my-account.html @@ -0,0 +1,251 @@ + + + + + + My Account - Mongoose Studio + + + +
+
+
+
+

My Account

+

Loading account...

+
+ +
+ + + + +
+ +
+
+ + + + + + + + diff --git a/src/actions/getMyAccount.js b/src/actions/getMyAccount.js new file mode 100644 index 0000000..7d4a9f9 --- /dev/null +++ b/src/actions/getMyAccount.js @@ -0,0 +1,41 @@ +'use strict'; + +const Archetype = require('archetype'); +const connect = require('../../src/db'); + +const GetMyAccountParams = new Archetype({ + authorization: { + $type: 'string', + $required: true + } +}).compile('GetMyAccountParams'); + +module.exports = async function getMyAccount(params) { + const { authorization } = new GetMyAccountParams(params); + + const db = await connect(); + const { AccessToken, User, Workspace } = db.models; + + const accessToken = await AccessToken.findById(authorization).orFail(new Error('Invalid or expired access token')); + if (accessToken.expiresAt < new Date()) { + throw new Error('Access token has expired'); + } + + const user = await User.findById(accessToken.userId).orFail(new Error('User not found')); + const workspaces = await Workspace.find({ 'members.userId': user._id }).sort({ name: 1, createdAt: 1 }); + + const workspaceSummaries = workspaces.map(workspace => { + const member = workspace.members.find(member => member.userId.toString() === user._id.toString()); + return { + _id: workspace._id, + name: workspace.name, + apiKey: workspace.apiKey, + subscriptionTier: workspace.subscriptionTier, + stripeCustomerId: workspace.stripeCustomerId, + stripeSubscriptionId: workspace.stripeSubscriptionId, + roles: member?.roles ?? [] + }; + }); + + return { user, workspaces: workspaceSummaries }; +}; diff --git a/src/actions/inviteToWorkspace.js b/src/actions/inviteToWorkspace.js index 2fad8e2..82cee56 100644 --- a/src/actions/inviteToWorkspace.js +++ b/src/actions/inviteToWorkspace.js @@ -19,7 +19,8 @@ const InviteToWorkspaceParams = new Archetype({ $required: true }, email: { - $type: 'string' + $type: 'string', + $transform: v => v == null ? null : v.toLowerCase() }, roles: { $type: ['string'], diff --git a/src/actions/stripeWebhook.js b/src/actions/stripeWebhook.js index 616f65d..0147528 100644 --- a/src/actions/stripeWebhook.js +++ b/src/actions/stripeWebhook.js @@ -2,9 +2,16 @@ const Archetype = require('archetype'); const assert = require('assert'); +const cheerio = require('cheerio'); const connect = require('../../src/db'); +const crypto = require('crypto'); +const fs = require('fs'); const mongoose = require('mongoose'); +const mailgun = require('../integrations/mailgun'); +const path = require('path'); +const { generateSlug } = require('random-word-slugs'); const stripe = require('../integrations/stripe'); +const xss = require('xss'); const StripeWebhookParams = new Archetype({ type: { @@ -25,6 +32,8 @@ const StripeWebhookParams = new Archetype({ } }).compile('StripeWebhookParams'); +const newWorkspaceTemplate = fs.readFileSync(path.resolve(__dirname, '..', 'emailTemplates', 'newWorkspace.html'), 'utf8'); + module.exports = async function stripeWebhook(params, event) { try { stripe.client.webhooks.constructEvent(event.body, event.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET); @@ -41,31 +50,70 @@ module.exports = async function stripeWebhook(params, event) { if (type === 'checkout.session.completed') { const workspaceId = data?.object?.client_reference_id; - // If no workspace ID provided, create a new workspace with just API key and subscription details - if (!workspaceId && data?.object?.customer && data?.object?.subscription) { - // Get customer email from Stripe - const customer = await stripe.client.customers.retrieve(data.object.customer); - const customerEmail = customer.email ?? 'Auto-created workspace'; + const stripeCustomerId = data?.object?.customer; + const stripeSubscriptionId = data?.object?.subscription; + assert.ok(stripeCustomerId, 'no customer found in webhook'); + assert.ok(stripeSubscriptionId, 'no subscription found in webhook'); + const customer = await stripe.client.customers.retrieve(stripeCustomerId); + const customerEmail = customer?.email?.toLowerCase?.() ?? null; + const newApiKey = crypto.randomBytes(48).toString('hex'); + const publicAppBaseUrl = new URL(process.env.GITHUB_REDIRECT_URI).origin; + const randomWorkspaceName = generateSlug(2, { format: 'kebab' }); + + // If no workspace ID provided, create a new workspace with just API key and subscription details + if (!workspaceId) { const newWorkspace = new Workspace({ - name: customerEmail, - stripeCustomerId: data.object.customer, - stripeSubscriptionId: data.object.subscription, - subscriptionTier: 'pro' + name: randomWorkspaceName, + stripeCustomerId, + stripeSubscriptionId, + subscriptionTier: 'pro', + stripeCustomerEmail: customerEmail, + apiKey: newApiKey }); await newWorkspace.save(); + if (customerEmail) { + const setupUrl = new URL('/login.html', publicAppBaseUrl); + setupUrl.searchParams.set('workspaceId', newWorkspace._id.toString()); + const $ = cheerio.load(newWorkspaceTemplate); + $('#workspace-name').text(newWorkspace.name); + $('#setup-link').attr('href', setupUrl.toString()); + $('#setup-link-fallback').attr('href', setupUrl.toString()).text(setupUrl.toString()); + await mailgun.sendEmail({ + to: customerEmail, + from: process.env.MAILGUN_FROM_EMAIL, + subject: 'Set up your Mongoose Studio Workspace', + html: $.html() + }); + } return { workspace: newWorkspace }; } else { const workspace = await Workspace.findById(workspaceId).orFail(); assert.ok(!workspace.stripeSubscriptionId, 'workspace already has a subscription'); - assert.ok(data.object.customer, 'no customer found in webhook'); - assert.ok(data.object.subscription, 'no subscription found in webhook'); - workspace.stripeCustomerId = data.object.customer; - workspace.stripeSubscriptionId = data.object.subscription; + workspace.stripeCustomerId = stripeCustomerId; + workspace.stripeSubscriptionId = stripeSubscriptionId; workspace.subscriptionTier = 'pro'; + workspace.apiKey = newApiKey; + if (workspace.members.length === 0 && customerEmail) { + workspace.stripeCustomerEmail = customerEmail; + } await workspace.save(); + if (customerEmail) { + const setupUrl = new URL('/login.html', publicAppBaseUrl); + setupUrl.searchParams.set('workspaceId', workspace._id.toString()); + const $ = cheerio.load(newWorkspaceTemplate); + $('#workspace-name').text(xss(workspace.name || randomWorkspaceName)); + $('#setup-link').attr('href', setupUrl.toString()); + $('#setup-link-fallback').attr('href', setupUrl.toString()).text(setupUrl.toString()); + await mailgun.sendEmail({ + to: customerEmail, + from: process.env.MAILGUN_FROM_EMAIL, + subject: 'Set up your Mongoose Studio Workspace', + html: $.html() + }); + } return { workspace }; } diff --git a/src/actions/updateWorkspace.js b/src/actions/updateWorkspace.js new file mode 100644 index 0000000..a3a9060 --- /dev/null +++ b/src/actions/updateWorkspace.js @@ -0,0 +1,52 @@ +'use strict'; + +const Archetype = require('archetype'); +const connect = require('../../src/db'); +const mongoose = require('mongoose'); + +const UpdateWorkspaceParams = new Archetype({ + authorization: { + $type: 'string', + $required: true + }, + workspaceId: { + $type: mongoose.Types.ObjectId, + $required: true + }, + name: { + $type: 'string' + } +}).compile('UpdateWorkspaceParams'); + +module.exports = async function updateWorkspace(params) { + const { authorization, workspaceId, name } = new UpdateWorkspaceParams(params); + + const db = await connect(); + const { AccessToken, Workspace } = db.models; + + const accessToken = await AccessToken.findById(authorization).orFail(new Error('Invalid or expired access token')); + if (accessToken.expiresAt < new Date()) { + throw new Error('Access token has expired'); + } + + const workspace = await Workspace.findById(workspaceId).orFail(new Error('Workspace not found')); + const roles = workspace.members.find(member => member.userId.toString() === accessToken.userId.toString())?.roles || []; + if (!roles.includes('owner') && !roles.includes('admin')) { + throw new Error('Forbidden'); + } + + if (name != null) { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error('Workspace name is required'); + } + if (trimmed.length > 120) { + throw new Error('Workspace name is too long'); + } + workspace.name = trimmed; + } + + await workspace.save(); + + return { workspace }; +}; diff --git a/src/db/invitation.js b/src/db/invitation.js index c8c26af..9451e71 100644 --- a/src/db/invitation.js +++ b/src/db/invitation.js @@ -36,7 +36,7 @@ const invitationSchema = new mongoose.Schema({ } }, { timestamps: true, id: false }); -invitationSchema.post('validate', function () { +invitationSchema.post('validate', function() { if (!this.email && !this.githubUsername) { throw new Error('Either email or githubUsername is required'); } diff --git a/src/db/user.js b/src/db/user.js index 9b3c16e..870a7a0 100644 --- a/src/db/user.js +++ b/src/db/user.js @@ -30,7 +30,7 @@ const userSchema = new mongoose.Schema({ userSchema.post('validate', function() { if (!this.githubUserId && !this.googleUserId) { - throw new Error('Either githubUserId or googleUserId must be set.') + throw new Error('Either githubUserId or googleUserId must be set.'); } }); diff --git a/src/db/workspace.js b/src/db/workspace.js index 7718536..feda508 100644 --- a/src/db/workspace.js +++ b/src/db/workspace.js @@ -41,6 +41,10 @@ const workspaceSchema = new mongoose.Schema({ subscriptionTier: { type: String, enum: ['', 'free', 'pro'] + }, + stripeCustomerEmail: { + type: String, + lowercase: true } }, { timestamps: true, id: false }); diff --git a/src/emailTemplates/newWorkspace.html b/src/emailTemplates/newWorkspace.html new file mode 100644 index 0000000..50a82c6 --- /dev/null +++ b/src/emailTemplates/newWorkspace.html @@ -0,0 +1,29 @@ + + + + + + Mongoose Studio Setup + + + + + + +
+ + + + +
+

Welcome to Mongoose Studio

+

Thank you for your support. Your new workspace is ready and we appreciate you being here.

+

Workspace:

+

Use the link below to sign in and finish setup:

+

Finish Setup

+

If the button does not work, copy and paste this link into your browser:

+

Need help? We're here for you on Discord: Get Help on Discord

+
+
+ + diff --git a/src/integrations/githubOAuth.js b/src/integrations/githubOAuth.js index 5ba1dea..04c2846 100644 --- a/src/integrations/githubOAuth.js +++ b/src/integrations/githubOAuth.js @@ -52,6 +52,22 @@ module.exports = { } return body; }, + async getPrimaryEmail(token) { + const headers = { + authorization: `token ${token}`, + accept: 'application/vnd.github.v3+json' + }; + const response = await fetch('https://api.github.com/user/emails', { headers }); + const body = await response.json(); + if (response.status >= 400) { + throw new Error(`Request failed with status ${response.status}: ${require('util').inspect(body)}`); + } + if (!Array.isArray(body)) { + return null; + } + const primary = body.find(email => email.primary && email.verified) || body.find(email => email.primary) || body[0]; + return primary?.email || null; + }, getAccessToken(code) { const body = { client_id: githubOAuthClientId, diff --git a/src/integrations/mailgun.js b/src/integrations/mailgun.js new file mode 100644 index 0000000..01a314d --- /dev/null +++ b/src/integrations/mailgun.js @@ -0,0 +1,59 @@ +'use strict'; + +// Node.js has built-in form-data, but our version of Axios isn't compatible with it +const FormData = require('form-data'); +const { IntegrationError } = require('../util/error'); +const assert = require('assert'); +const axios = require('axios'); + +const from = process.env.MAILGUN_FROM_EMAIL; +assert.ok(from); + +const domain = process.env.MAILGUN_DOMAIN; +assert.ok(domain); + +const privateKey = process.env.MAILGUN_PRIVATE_KEY; +assert.ok(privateKey); + +exports.sendEmail = async function sendEmail(params) { + const url = `https://api.mailgun.net/v3/${domain}/messages`; + let form = null; + if (params.attachment) { + form = new FormData(); + for (const [key, value] of Object.entries(params)) { + form.append(key, value); + } + } + + try { + const { data } = await axios( + url, + { + method: 'post', + ...(form ? { headers: form.getHeaders(), data: form } : { params }), + auth: { + username: 'api', + password: privateKey + } + } + ); + if (data && data.message) { + return data.message; + } + } catch (error) { + if (error instanceof axios.AxiosError) { + throw new IntegrationError( + `Error sending email: ${error.response?.data?.message ?? error.response?.data} (status code ${error.response?.status})`, + 'mailgun', + error.response?.status, + { + responseData: error.response?.data, + message: error.response?.data?.message, + status: error.response?.status, + params + } + ); + } + throw error; + } +}; diff --git a/src/util/error.js b/src/util/error.js new file mode 100644 index 0000000..eb069ff --- /dev/null +++ b/src/util/error.js @@ -0,0 +1,13 @@ +'use strict'; + +class IntegrationError extends Error { + constructor(message, integration, statusCode, extra) { + super(message); + this.name = 'IntegrationError'; + this.integration = integration; + this.statusCode = statusCode; + this.extra = extra; + } +} + +module.exports = { IntegrationError }; diff --git a/test/stripeWebhook.test.js b/test/stripeWebhook.test.js new file mode 100644 index 0000000..9d48035 --- /dev/null +++ b/test/stripeWebhook.test.js @@ -0,0 +1,98 @@ +'use strict'; + +const assert = require('assert'); +const connect = require('../src/db'); +const { afterEach, beforeEach, describe, it } = require('mocha'); +const mailgun = require('../src/integrations/mailgun'); +const sinon = require('sinon'); +const stripe = require('../src/integrations/stripe'); +const stripeWebhook = require('../src/actions/stripeWebhook'); + +describe('stripeWebhook', function() { + let db; + let Workspace; + + beforeEach(async function() { + db = await connect(); + ({ Workspace } = db.models); + await Workspace.deleteMany({}); + }); + + afterEach(function() { + sinon.restore(); + }); + + it('creates a workspace, rotates api key, and sends setup email for checkout without workspaceId', async function() { + sinon.stub(stripe.client.webhooks, 'constructEvent').returns({}); + sinon.stub(stripe.client.customers, 'retrieve').resolves({ email: 'customer@example.com' }); + const sendEmailStub = sinon.stub(mailgun, 'sendEmail').resolves('Queued'); + + const res = await stripeWebhook( + { + type: 'checkout.session.completed', + data: { + object: { + customer: 'cus_123', + subscription: 'sub_123' + } + } + }, + { body: '{}', headers: { 'stripe-signature': 'sig_123' } } + ); + + assert.ok(res.workspace); + assert.ok(/^[a-z]+-[a-z]+$/.test(res.workspace.name), `expected slug name, got "${res.workspace.name}"`); + assert.equal(res.workspace.stripeCustomerEmail, 'customer@example.com'); + assert.equal(res.workspace.stripeCustomerId, 'cus_123'); + assert.equal(res.workspace.stripeSubscriptionId, 'sub_123'); + assert.equal(res.workspace.subscriptionTier, 'pro'); + assert.ok(/^[a-f0-9]{96}$/.test(res.workspace.apiKey), 'expected hex api key'); + + assert.equal(sendEmailStub.callCount, 1); + const emailParams = sendEmailStub.firstCall.args[0]; + assert.equal(emailParams.to, 'customer@example.com'); + assert.ok( + /^Set up your Mongoose Studio/.test(emailParams.subject), + `unexpected email subject "${emailParams.subject}"` + ); + assert.ok(!emailParams.html.includes(res.workspace.apiKey)); + assert.ok(emailParams.html.includes(`workspaceId=${res.workspace._id}`)); + }); + + it('updates existing workspace and rotates api key', async function() { + sinon.stub(stripe.client.webhooks, 'constructEvent').returns({}); + sinon.stub(stripe.client.customers, 'retrieve').resolves({ email: 'owner@example.com' }); + const sendEmailStub = sinon.stub(mailgun, 'sendEmail').resolves('Queued'); + + const workspace = await Workspace.create({ + name: 'existing-workspace', + apiKey: 'a'.repeat(96), + subscriptionTier: 'free', + members: [] + }); + + const res = await stripeWebhook( + { + type: 'checkout.session.completed', + data: { + object: { + client_reference_id: workspace._id.toString(), + customer: 'cus_456', + subscription: 'sub_456' + } + } + }, + { body: '{}', headers: { 'stripe-signature': 'sig_456' } } + ); + + assert.ok(res.workspace); + assert.equal(res.workspace._id.toString(), workspace._id.toString()); + assert.equal(res.workspace.subscriptionTier, 'pro'); + assert.equal(res.workspace.stripeCustomerId, 'cus_456'); + assert.equal(res.workspace.stripeSubscriptionId, 'sub_456'); + assert.equal(res.workspace.stripeCustomerEmail, 'owner@example.com'); + assert.notEqual(res.workspace.apiKey, 'a'.repeat(96)); + assert.ok(/^[a-f0-9]{96}$/.test(res.workspace.apiKey), 'expected rotated hex api key'); + assert.equal(sendEmailStub.callCount, 1); + }); +}); diff --git a/test/updateWorkspaceMember.test.js b/test/updateWorkspaceMember.test.js index 6cdd19f..d875752 100644 --- a/test/updateWorkspaceMember.test.js +++ b/test/updateWorkspaceMember.test.js @@ -60,7 +60,7 @@ describe('updateWorkspaceMember', function() { authorization: accessToken._id.toString(), workspaceId: workspace._id, userId: memberUser._id, - role: 'admin' + roles: ['admin'] }); assert.ok(result.workspace); diff --git a/test/verifyGithubAccessToken.test.js b/test/verifyGithubAccessToken.test.js index 97d2ff5..552914b 100644 --- a/test/verifyGithubAccessToken.test.js +++ b/test/verifyGithubAccessToken.test.js @@ -17,7 +17,7 @@ describe('verifyGithubAccessToken', function() { user = await User.create({ email: 'test@example.com', githubUsername: 'test', githubUserId: '5678' }); accessToken = await AccessToken.create({ userId: user._id }); - const task = { sideEffect: async (fn, params) => fn(params) }; + const task = { sideEffect: async(fn, params) => fn(params) }; verifyGithubAccessToken = verifyGithubAccessTokenFactory({ task, conn: db }); });