From 7f4efb0b452a82dc38b804258e4617d072ae4714 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 24 Feb 2026 12:27:37 -0500 Subject: [PATCH 1/6] basic login and layout --- .env.test | 6 + netlify/functions/getMyAccount.js | 5 + netlify/functions/github.js | 19 ++- netlify/functions/google.js | 9 ++ package.json | 3 +- public/login.html | 151 +++++++++++++++++++++ public/my-account.html | 189 +++++++++++++++++++++++++++ src/actions/getMyAccount.js | 41 ++++++ src/actions/stripeWebhook.js | 72 ++++++++-- src/db/invitation.js | 2 +- src/db/user.js | 2 +- src/db/workspace.js | 4 + src/emailTemplates/newWorkspace.html | 27 ++++ src/integrations/githubOAuth.js | 16 +++ src/integrations/mailgun.js | 61 +++++++++ src/util/error.js | 13 ++ test/stripeWebhook.test.js | 98 ++++++++++++++ test/updateWorkspaceMember.test.js | 2 +- test/verifyGithubAccessToken.test.js | 2 +- 19 files changed, 701 insertions(+), 21 deletions(-) create mode 100644 netlify/functions/getMyAccount.js create mode 100644 public/login.html create mode 100644 public/my-account.html create mode 100644 src/actions/getMyAccount.js create mode 100644 src/emailTemplates/newWorkspace.html create mode 100644 src/integrations/mailgun.js create mode 100644 src/util/error.js create mode 100644 test/stripeWebhook.test.js 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..afa08bc 100644 --- a/netlify/functions/google.js +++ b/netlify/functions/google.js @@ -73,6 +73,15 @@ 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'] }); + 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/package.json b/package.json index f7607ff..5db430e 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "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" diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..d69c197 --- /dev/null +++ b/public/login.html @@ -0,0 +1,151 @@ + + + + + + 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..b252eea --- /dev/null +++ b/public/my-account.html @@ -0,0 +1,189 @@ + + + + + + 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/stripeWebhook.js b/src/actions/stripeWebhook.js index 616f65d..37c47dc 100644 --- a/src/actions/stripeWebhook.js +++ b/src/actions/stripeWebhook.js @@ -2,8 +2,14 @@ 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 StripeWebhookParams = new Archetype({ @@ -25,6 +31,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 +49,69 @@ 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 = process.env.MONGOOSE_STUDIO_PUBLIC_URL || + new URL(process.env.GITHUB_REDIRECT_URI || process.env.GOOGLE_OAUTH_CALLBACK_URL).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()).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(workspace.name || randomWorkspaceName); + $('#setup-link').attr('href', setupUrl.toString()).text(setupUrl.toString()); + await mailgun.sendEmail({ + to: customerEmail, + from: process.env.MAILGUN_FROM_EMAIL, + subject: 'Set up your Mongoose Studio account', + html: $.html() + }); + } 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..4b1a716 --- /dev/null +++ b/src/emailTemplates/newWorkspace.html @@ -0,0 +1,27 @@ + + + + + + Mongoose Studio Setup + + + + + + +
+ + + + +
+

Welcome to Mongoose Studio

+

Your new workspace is ready.

+

Workspace:

+

Use the link below to sign in and finish setup:

+

Set up my account

+
+
+ + 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..57d96b6 --- /dev/null +++ b/src/integrations/mailgun.js @@ -0,0 +1,61 @@ +'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 Vue = require('vue'); +const assert = require('assert'); +const axios = require('axios'); +const { renderToString } = require('vue/server-renderer'); + +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 }); }); From 7b561a12fc9229fc03f092f740d85e3c5e7e9445 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 24 Feb 2026 14:59:19 -0500 Subject: [PATCH 2/6] update workspace from account --- netlify/functions/updateWorkspace.js | 5 + public/login.html | 12 +- public/my-account.html | 166 ++++++++++++++++++--------- src/actions/stripeWebhook.js | 6 +- src/actions/updateWorkspace.js | 52 +++++++++ src/emailTemplates/newWorkspace.html | 8 +- 6 files changed, 189 insertions(+), 60 deletions(-) create mode 100644 netlify/functions/updateWorkspace.js create mode 100644 src/actions/updateWorkspace.js 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/public/login.html b/public/login.html index d69c197..58d82ad 100644 --- a/public/login.html +++ b/public/login.html @@ -12,9 +12,15 @@

Set up your account

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

-
- - +
+ +

diff --git a/public/my-account.html b/public/my-account.html index b252eea..e842c92 100644 --- a/public/my-account.html +++ b/public/my-account.html @@ -27,6 +27,37 @@

My Account

+ + + + diff --git a/src/actions/stripeWebhook.js b/src/actions/stripeWebhook.js index 37c47dc..fdc54e7 100644 --- a/src/actions/stripeWebhook.js +++ b/src/actions/stripeWebhook.js @@ -78,7 +78,8 @@ module.exports = async function stripeWebhook(params, event) { setupUrl.searchParams.set('workspaceId', newWorkspace._id.toString()); const $ = cheerio.load(newWorkspaceTemplate); $('#workspace-name').text(newWorkspace.name); - $('#setup-link').attr('href', setupUrl.toString()).text(setupUrl.toString()); + $('#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, @@ -104,7 +105,8 @@ module.exports = async function stripeWebhook(params, event) { setupUrl.searchParams.set('workspaceId', workspace._id.toString()); const $ = cheerio.load(newWorkspaceTemplate); $('#workspace-name').text(workspace.name || randomWorkspaceName); - $('#setup-link').attr('href', setupUrl.toString()).text(setupUrl.toString()); + $('#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, 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/emailTemplates/newWorkspace.html b/src/emailTemplates/newWorkspace.html index 4b1a716..50a82c6 100644 --- a/src/emailTemplates/newWorkspace.html +++ b/src/emailTemplates/newWorkspace.html @@ -12,11 +12,13 @@
-

Welcome to Mongoose Studio

-

Your new workspace is ready.

+

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:

-

Set up my account

+

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

From ee20d8caae42edb48b3329c29f43a549992532d0 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 24 Feb 2026 16:21:32 -0500 Subject: [PATCH 3/6] Update src/actions/stripeWebhook.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/actions/stripeWebhook.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/stripeWebhook.js b/src/actions/stripeWebhook.js index fdc54e7..0cf1624 100644 --- a/src/actions/stripeWebhook.js +++ b/src/actions/stripeWebhook.js @@ -110,7 +110,7 @@ module.exports = async function stripeWebhook(params, event) { await mailgun.sendEmail({ to: customerEmail, from: process.env.MAILGUN_FROM_EMAIL, - subject: 'Set up your Mongoose Studio account', + subject: 'Set up your Mongoose Studio Workspace', html: $.html() }); } From d3a6707840c6a941ca33cac1ba08e1a005d777d3 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 24 Feb 2026 16:21:55 -0500 Subject: [PATCH 4/6] Update src/integrations/mailgun.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/integrations/mailgun.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/integrations/mailgun.js b/src/integrations/mailgun.js index 57d96b6..f76f77f 100644 --- a/src/integrations/mailgun.js +++ b/src/integrations/mailgun.js @@ -45,13 +45,13 @@ exports.sendEmail = async function sendEmail(params) { } 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})`, + `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, + responseData: error.response?.data, + message: error.response?.data?.message, + status: error.response?.status, params } ); From d16dfc993a18b93b5d6bfc6a9b5446c766a56ae3 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 24 Feb 2026 16:22:22 -0500 Subject: [PATCH 5/6] Update src/integrations/mailgun.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/integrations/mailgun.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/integrations/mailgun.js b/src/integrations/mailgun.js index f76f77f..01a314d 100644 --- a/src/integrations/mailgun.js +++ b/src/integrations/mailgun.js @@ -3,10 +3,8 @@ // 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 Vue = require('vue'); const assert = require('assert'); const axios = require('axios'); -const { renderToString } = require('vue/server-renderer'); const from = process.env.MAILGUN_FROM_EMAIL; assert.ok(from); From f08c0af3f126ee7aa5b89b394da3aa1d85f089f2 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 24 Feb 2026 16:52:03 -0500 Subject: [PATCH 6/6] address code review comments --- netlify/functions/google.js | 1 - package.json | 3 ++- src/actions/inviteToWorkspace.js | 3 ++- src/actions/stripeWebhook.js | 6 +++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/netlify/functions/google.js b/netlify/functions/google.js index afa08bc..bef5426 100644 --- a/netlify/functions/google.js +++ b/netlify/functions/google.js @@ -79,7 +79,6 @@ module.exports = extrovert.toNetlifyFunction(async function googleLogin(params) 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') { diff --git a/package.json b/package.json index 5db430e..02b9218 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "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/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 0cf1624..0147528 100644 --- a/src/actions/stripeWebhook.js +++ b/src/actions/stripeWebhook.js @@ -11,6 +11,7 @@ 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: { @@ -57,8 +58,7 @@ module.exports = async function stripeWebhook(params, event) { const customer = await stripe.client.customers.retrieve(stripeCustomerId); const customerEmail = customer?.email?.toLowerCase?.() ?? null; const newApiKey = crypto.randomBytes(48).toString('hex'); - const publicAppBaseUrl = process.env.MONGOOSE_STUDIO_PUBLIC_URL || - new URL(process.env.GITHUB_REDIRECT_URI || process.env.GOOGLE_OAUTH_CALLBACK_URL).origin; + 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 @@ -104,7 +104,7 @@ module.exports = async function stripeWebhook(params, event) { const setupUrl = new URL('/login.html', publicAppBaseUrl); setupUrl.searchParams.set('workspaceId', workspace._id.toString()); const $ = cheerio.load(newWorkspaceTemplate); - $('#workspace-name').text(workspace.name || randomWorkspaceName); + $('#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({