Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion node/payments-with-lemon-squeezy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,15 @@ Location: https://ap...re.lemonsqueezy.com/checkout/custom/7576abf3-...e2fb
Location: https://mywebapp.com/cancel
```

### `GET /success`

Lemon Squeezy redirects the user here after checkout. Lemon Squeezy doesn't pass an order ID in the redirect, so the checkout's redirect URL carries a `state` parameter signed with `LEMON_SQUEEZY_WEBHOOK_SECRET`. It holds the user ID, the email entered at checkout and the checkout time. This endpoint looks up purchases made with that email since the checkout started. If no email was entered, it leaves provisioning to the webhook. It provisions the user the same way the webhook does, then redirects to the `successUrl` given to the checkout endpoint.

The webhook and this endpoint both provision the user, so either one works if the other fails. Running twice is safe: orders use the provider ID as the row ID, so the database rejects a second write.

### `POST /webhook`

This endpoint is a webhook that handles Lemon Squeezy event `order_created`. It validates the incoming request using the `X-Signature` header from the Lemon Squeezy webhook. If the validation fails, a `401` response is sent.
This endpoint is a webhook that handles Lemon Squeezy event `order_created`. It validates the incoming request using the `X-Signature` header from the Lemon Squeezy webhook. If the validation fails, a `401` response is sent. The webhook payload isn't trusted: the function fetches the order from Lemon Squeezy and stores it only if its status is `paid`.

**Parameters**

Expand Down
35 changes: 23 additions & 12 deletions node/payments-with-lemon-squeezy/src/appwrite.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Client, Databases, ID, Permission, Role } from 'node-appwrite';
import { Client, Databases, Permission, Role } from 'node-appwrite';

class AppwriteService {
constructor(apiKey) {
Expand All @@ -9,8 +9,6 @@ class AppwriteService {
.setKey(apiKey);

this.databases = new Databases(client);

this.setup();
}

async setup() {
Expand Down Expand Up @@ -78,17 +76,30 @@ class AppwriteService {
}
}

/**
* Stores a paid order. The webhook and the /success redirect can both run
* for the same order, possibly at the same time. Using the order ID as the
* document ID makes the database reject the second write with a 409, so the
* order is only ever stored once.
* @returns {Promise<boolean>} false if the order was already stored
*/
async createOrder(databaseId, collectionId, userId, orderId) {
await this.databases.createDocument(
databaseId,
collectionId,
ID.unique(),
{
userId,
try {
await this.databases.createDocument(
databaseId,
collectionId,
orderId,
},
[Permission.read(Role.user(userId))]
);
{
userId,
orderId,
},
[Permission.read(Role.user(userId))]
);
return true;
} catch (err) {
if (err.code !== 409) throw err;
return false;
}
}
}

Expand Down
89 changes: 88 additions & 1 deletion node/payments-with-lemon-squeezy/src/lemonsqueezy.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
lemonSqueezySetup,
createCheckout,
getOrder,
listOrders,
} from '@lemonsqueezy/lemonsqueezy.js';
import crypto from 'crypto';

Expand All @@ -11,12 +13,21 @@ class LemonSqueezyService {
});
}

async createCheckout(context, userId, userEmail, userName) {
async createCheckout(context, userId, userEmail, userName, verifyUrl, successUrl) {
try {
const storeId = process.env.LEMON_SQUEEZY_STORE_ID;
const variantId = process.env.LEMON_SQUEEZY_VARIANT_ID;
const newCheckout = {
productOptions: {
// Lemon Squeezy returns the user to this function first, which
// verifies the purchase and then redirects to successUrl.
redirectUrl: `${verifyUrl}?state=${this.signState({
userId,
// Only an email the user entered identifies their purchase.
email: userEmail,
successUrl,
createdAt: new Date().toISOString(),
})}`,
name: 'Test Product',
description:
'A product created to test Lemon Squeezy payments in Appwrite Functions.',
Expand Down Expand Up @@ -44,6 +55,82 @@ class LemonSqueezyService {
}
}

/**
* Fetches an order from Lemon Squeezy, so the webhook payload doesn't have
* to be trusted
* @param {string} orderId
*/
async getOrder(orderId) {
const { data, error } = await getOrder(orderId);
if (error) throw error;
return data.data;
}

/**
* Finds the paid orders for the configured variant that were placed with
* the given email after the given time. Lemon Squeezy doesn't send an order
* ID to the redirect URL, so this is how /success finds the purchase.
* @param {string} email
* @param {string} since ISO 8601 timestamp
*/
async findPaidOrders(email, since) {
const { data, error } = await listOrders({
filter: {
storeId: process.env.LEMON_SQUEEZY_STORE_ID,
userEmail: email,
},
});
if (error) throw error;

return data.data.filter(
(order) =>
order.attributes.status === 'paid' &&
String(order.attributes.first_order_item.variant_id) ===
String(process.env.LEMON_SQUEEZY_VARIANT_ID) &&
new Date(order.attributes.created_at) >= new Date(since)
Comment on lines +85 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prevent replayed state from claiming future orders

This only requires an order to be created after since; a signed state token can be replayed later and match future orders with the same email and variant. Fulfillment assigns every match to the userId embedded in that old token, so a later buyer's order can be recorded for the stale token's user.

Prompt To Fix With AI
This is a comment left during a code review.
Path: node/payments-with-lemon-squeezy/src/lemonsqueezy.js
Line: 85-90

Comment:
**Prevent replayed state from claiming future orders**

This only requires an order to be created after `since`; a signed state token can be replayed later and match future orders with the same email and variant. Fulfillment assigns every match to the `userId` embedded in that old token, so a later buyer's order can be recorded for the stale token's user.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟠 Major · security · Reply if this doesn't apply.

);
}

/**
* Signs the redirect state, so /success can trust the user ID in it
* @param {Record<string, string | undefined>} state
* @returns {string}
*/
signState(state) {
const payload = Buffer.from(JSON.stringify(state)).toString('base64url');
const signature = crypto
.createHmac('sha256', process.env.LEMON_SQUEEZY_WEBHOOK_SECRET)
.update(payload)
.digest('base64url');
return `${payload}.${signature}`;
}

/**
* @param {string} token
* @returns {Record<string, string | undefined> | null} The state, or null if
* the signature is invalid
*/
verifyState(token) {
const [payload, signature] = String(token ?? '').split('.');
if (!payload || !signature) return null;

const expected = Buffer.from(
crypto
.createHmac('sha256', process.env.LEMON_SQUEEZY_WEBHOOK_SECRET)
.update(payload)
.digest('base64url')
);
const actual = Buffer.from(signature);
if (
expected.length !== actual.length ||
!crypto.timingSafeEqual(expected, actual)
) {
return null;
}

return JSON.parse(Buffer.from(payload, 'base64url').toString());
}

validateWebhook(context) {
try {
const secret = process.env.LEMON_SQUEEZY_WEBHOOK_SECRET;
Expand Down
96 changes: 78 additions & 18 deletions node/payments-with-lemon-squeezy/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,63 @@ export default async (context) => {
const databaseId = process.env.APPWRITE_DATABASE_ID ?? 'orders';
const collectionId = process.env.APPWRITE_COLLECTION_ID ?? 'orders';

const appwrite = new AppwriteService(context.req.headers['x-appwrite-key']);
const lemonsqueezy = new LemonSqueezyService();

/**
* Stores the order if it's paid. Both the webhook and the /success redirect
* call this, so the order is stored even if one of them never arrives.
* @param {*} order Order fetched from the Lemon Squeezy API
* @param {string} userId
*/
async function fulfillOrder(order, userId) {
if (order.attributes.status !== 'paid') {
log(`Order ${order.id} is ${order.attributes.status}, not fulfilling.`);
return;
}

await appwrite.setup();

const created = await appwrite.createOrder(
databaseId,
collectionId,
userId,
String(order.id)
);
log(
created
? `Created order document for user ${userId} with Lemon Squeezy order ID ${order.id}`
: `Order ${order.id} was already stored.`
);
}

if (req.method === 'GET' && req.path === '/success') {
const state = lemonsqueezy.verifyState(req.query.state);
if (!state) {
error('Invalid redirect state.');
return res.redirect('/', 303);
}

try {
// Without an email the user entered, the order can't be matched to them
// here, so it's left to the webhook.
if (state.email) {
const orders = await lemonsqueezy.findPaidOrders(
state.email,
state.createdAt
);
for (const order of orders) {
await fulfillOrder(order, state.userId);
}
}
} catch (err) {
// The webhook still fulfills the order, so send the user on regardless.
error(err);
}

return res.redirect(state.successUrl || '/', 303);
}

if (req.method === 'GET') {
const html = interpolate(getStaticFile('index.html'), {
APPWRITE_FUNCTION_API_ENDPOINT: process.env.APPWRITE_FUNCTION_API_ENDPOINT,
Expand All @@ -27,13 +84,15 @@ export default async (context) => {
return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' });
}

const appwrite = new AppwriteService(context.req.headers['x-appwrite-key']);
const lemonsqueezy = new LemonSqueezyService();

switch (req.path) {
case '/checkout':
const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/';
const successUrl = req.body?.successUrl ?? fallbackUrl;
const failureUrl = req.body?.failureUrl ?? fallbackUrl;
// Lemon Squeezy returns the user to this function first, which verifies
// the order and then redirects to successUrl.
const verifyUrl =
req.body?.verifyUrl ?? new URL('/success', successUrl).toString();

const userId = req.headers['x-appwrite-user-id'];
let userEmail = req.body?.email;
Expand All @@ -48,7 +107,9 @@ export default async (context) => {
context,
userId,
userEmail,
userName
userName,
verifyUrl,
successUrl
);
if (!checkout) {
error('Failed to create Lemon Squeezy checkout.');
Expand All @@ -67,22 +128,21 @@ export default async (context) => {
return res.json({ success: false }, 401);
}

const order = req.body;

log(order);
const eventType = req.headers['x-event-name'];
if (eventType !== 'order_created') {
log(`Ignored Lemon Squeezy event ${eventType}.`);
return res.json({ success: true });
}

const orderUserId = order.meta.custom_data.user_id;
const orderId = order.data.id;
const orderUserId = req.body.meta.custom_data?.user_id;
if (!orderUserId) {
error(`Order ${req.body.data.id} has no user_id in its custom data.`);
return res.json({ success: true });
}

await appwrite.createOrder(
databaseId,
collectionId,
orderUserId,
orderId
);
log(
`Created order document for user ${orderUserId} with Lemon Squeezy order ID ${orderId}`
);
// Look the order up instead of trusting the event payload.
const order = await lemonsqueezy.getOrder(req.body.data.id);
await fulfillOrder(order, orderUserId);

return res.json({ success: true });

Expand Down
1 change: 1 addition & 0 deletions node/payments-with-lemon-squeezy/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ <h6 class="alert-title">
'{{APPWRITE_FUNCTION_ID}}',
JSON.stringify({
failureUrl: window.location.href,
successUrl: window.location.href,
email,
name,
}),
Expand Down
9 changes: 8 additions & 1 deletion node/payments-with-stripe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This endpoint initiates a Stripe checkout session. The user ID is fetched from t
| x-appwrite-user-id | User ID from Appwrite. | Header | String | 642...7cd |
| Content-Type | The content type of the request body | Header | `application/json` | N/A |
| successUrl | The URL to redirect to after a successful payment. | Body | String | https://example.com/success |
| verifyUrl | Where the provider returns the user to verify the purchase. Defaults to `/success` on the `successUrl` origin. | Body | String | https://<function-domain>/success |
| failureUrl | The URL to redirect to after a cancelled payment attempt. | Body | String | https://example.com/failure |

**Response**
Expand All @@ -31,9 +32,15 @@ Location: https://checkout.stripe.com/pay/cs_test_...#fidkdWxOYHwnP
Location: https://mywebapp.com/cancel
```

### `GET /success`

Stripe redirects the user here after checkout. Stripe adds `session_id` to the redirect URL, and this endpoint fetches the checkout session from Stripe with it. It provisions the user the same way the webhook does, then redirects to the `successUrl` given to the checkout endpoint.

The webhook and this endpoint both provision the user, so either one works if the other fails. Running twice is safe: orders use the provider ID as the row ID, so the database rejects a second write.

### `POST /webhook`

This endpoint is a webhook that handles Stripe event `checkout.session.completed`. It validates the incoming request using the Stripe's validateWebhook method. If the validation fails, a `401` response is sent.
This endpoint is a webhook that handles Stripe events `checkout.session.completed` and `checkout.session.async_payment_succeeded`. It validates the incoming request using the Stripe's validateWebhook method. If the validation fails, a `401` response is sent. The webhook payload isn't trusted: the function fetches the checkout session from Stripe and stores the order only if its `payment_status` is `paid`.

**Parameters**

Expand Down
Loading
Loading