Repository navigation
feat: verify Stripe and Lemon Squeezy purchases on both webhook and redirect - #367
adityaoberai wants to merge 1 commit into
Conversation
…edirect Webhooks now fetch the order or subscription from the provider instead of trusting the event payload. Checkout returns to a new GET /success route that runs the same check and provisioning before redirecting to successUrl, so either path can provision the user. Deterministic order document IDs and idempotent label updates make concurrent runs safe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🟡 Tier B · Needs changes before merging
The Stripe and Lemon Squeezy templates now verify provider state through both webhook and checkout-return flows, then provision paid orders or synchronize subscription labels. The changes also add signed redirect state for Lemon Squeezy and make order writes idempotent using provider IDs.
Fix with agent prompt### Issue 1
node/subscriptions-with-lemon-squeezy/src/lemonsqueezy.js:86-89
**Prevent replayed state from matching future subscriptions**
This only requires a subscription to be created after `since`; a signed state token can be replayed later and match any future subscription with the same email and variant. Because the result is synchronized for the `userId` embedded in that old token, another user's purchase can grant subscriber access to the stale token's user.
### Issue 2
node/payments-with-lemon-squeezy/src/lemonsqueezy.js:85-90
**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.
### Issue 3
node/payments-with-stripe/src/main.js:94-95
**Derive the verifier URL from the function host**
When `successUrl` points to the app on a separate origin, this resolves `/success` on the app host rather than on the Appwrite function, so the provider bypasses this verifier and the caller's requested success path is lost. The same default is used by the other three checkout handlers, so callers that omit `verifyUrl` do not get redirect-side verification.
### Issue 4
node/subscriptions-with-stripe/src/main.js:37-38
**Concurrent status syncs can leave a stale subscriber label**
If one invocation fetches `active`, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Lemon Squeezy subscription flow.
### Issue 5
node/subscriptions-with-lemon-squeezy/src/main.js:48-49
**Concurrent status syncs can leave a stale subscriber label**
If one invocation fetches `active`, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Stripe subscription flow.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.📂 Walkthrough · 16
Reviewed |
| return data.data.filter( | ||
| (subscription) => | ||
| new Date(subscription.attributes.created_at) >= new Date(since) | ||
| ); |
There was a problem hiding this comment.
Prevent replayed state from matching future subscriptions
This only requires a subscription to be created after since; a signed state token can be replayed later and match any future subscription with the same email and variant. Because the result is synchronized for the userId embedded in that old token, another user's purchase can grant subscriber access to the stale token's user.
Prompt To Fix With AI
This is a comment left during a code review.
Path: node/subscriptions-with-lemon-squeezy/src/lemonsqueezy.js
Line: 86-89
Comment:
**Prevent replayed state from matching future subscriptions**
This only requires a subscription to be created after `since`; a signed state token can be replayed later and match any future subscription with the same email and variant. Because the result is synchronized for the `userId` embedded in that old token, another user's purchase can grant subscriber access to 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.
| 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) |
There was a problem hiding this 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.
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.
| req.body?.verifyUrl ?? new URL('/success', successUrl).toString(); | ||
|
|
There was a problem hiding this comment.
Derive the verifier URL from the function host
When successUrl points to the app on a separate origin, this resolves /success on the app host rather than on the Appwrite function, so the provider bypasses this verifier and the caller's requested success path is lost. The same default is used by the other three checkout handlers, so callers that omit verifyUrl do not get redirect-side verification.
Prompt To Fix With AI
This is a comment left during a code review.
Path: node/payments-with-stripe/src/main.js
Line: 94-95
Comment:
**Derive the verifier URL from the function host**
When `successUrl` points to the app on a separate origin, this resolves `/success` on the app host rather than on the Appwrite function, so the provider bypasses this verifier and the caller's requested success path is lost. The same default is used by the other three checkout handlers, so callers that omit `verifyUrl` do not get redirect-side verification.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.🟡 Minor · bug · Reply if this doesn't apply.
| if (StatusesWithAccess.includes(subscription.status)) { | ||
| await appwrite.createSubscription(userId); |
There was a problem hiding this comment.
Concurrent status syncs can leave a stale subscriber label
If one invocation fetches active, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Lemon Squeezy subscription flow.
Prompt To Fix With AI
This is a comment left during a code review.
Path: node/subscriptions-with-stripe/src/main.js
Line: 37-38
Comment:
**Concurrent status syncs can leave a stale subscriber label**
If one invocation fetches `active`, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Lemon Squeezy subscription flow.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.🟡 Minor · concurrency · Reply if this doesn't apply.
| if (StatusesWithAccess.includes(status)) { | ||
| await appwrite.createSubscription(userId); |
There was a problem hiding this comment.
Concurrent status syncs can leave a stale subscriber label
If one invocation fetches active, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Stripe subscription flow.
Prompt To Fix With AI
This is a comment left during a code review.
Path: node/subscriptions-with-lemon-squeezy/src/main.js
Line: 48-49
Comment:
**Concurrent status syncs can leave a stale subscriber label**
If one invocation fetches `active`, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Stripe subscription flow.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.🟡 Minor · concurrency · Reply if this doesn't apply.
What changed
Applies the same flow as the Dodo Payments templates (#366) to the four Stripe and Lemon Squeezy templates.
GET /successroute. Checkout now returns here first. The route runs the same check and provisioning, then redirects to the app'ssuccessUrl. If either the webhook or the redirect fails, the other still provisions the user.Provider notes
success_urlincludes{CHECKOUT_SESSION_ID}. Payments use the payment intent ID as the document ID because Stripe session IDs are longer than Appwrite's 36-character limit.stateparameter signed with HMAC, holding the user ID, the checkout email and the start time./successmatches orders or subscriptions made with that email since then. If no email was entered, or the buyer changed it during checkout, only the webhook provisions the user.Before merging
checkout.session.async_payment_succeededfor payments, andcustomer.subscription.updatedfor subscriptions.cancelledorpast_due.🤖 Generated with Claude Code