├── .assetsignore ├── .dev.vars.example ├── .gitignore ├── ADR.md ├── CHANGELOG.md ├── README.md ├── SPEC.md ├── context.json ├── encrypt-decrypt-js.js ├── globals.d.ts ├── middleware.ts ├── openapi.json ├── package.json ├── security.drawio.png ├── template.html ├── template.ts └── wrangler.jsonc /.assetsignore: -------------------------------------------------------------------------------- 1 | .wrangler 2 | node_modules 3 | .git 4 | .dev.vars 5 | package-lock.json -------------------------------------------------------------------------------- /.dev.vars.example: -------------------------------------------------------------------------------- 1 | # get these credentials from stripe. every domain requires its own webhook signing secret, other secrets are reuable cross-domain 2 | STRIPE_WEBHOOK_SIGNING_SECRET= 3 | STRIPE_SECRET= 4 | STRIPE_PUBLISHABLE_KEY= 5 | STRIPE_PAYMENT_LINK= 6 | DB_SECRET= 7 | # Set to true in localhost to not use secure cookies 8 | SKIP_LOGIN=true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .wrangler 2 | node_modules 3 | .git 4 | .dev.vars 5 | package-lock.json -------------------------------------------------------------------------------- /ADR.md: -------------------------------------------------------------------------------- 1 | # Encrypthon secret rotation 2 | 3 | problem 1 (RESOLVED): we might loose the original DB_SECRET or might be required to rotate it incase it gets compromised 4 | 5 | It's used to generate `client_reference_id` from provided `access_token` and to re-retrieve the access_token based on client_reference_id. Since the DB_SECRET must be able to be rotated, the client_reference_id is always regenerated based on access token. The time it lives in stripe is very short, as long as the stripe session lasts, so it may end up in a few failed payments when rotating the secret. 6 | 7 | This is solved now by keeping the access_token as the only source of truth for the `client_reference_id` and updating it when it is not the same anymore. 8 | 9 | # Access tokens in db 10 | 11 | problem 2: we don't want to use `access_tokens` as names or store them into the db directly (users table primary key is now `access_token`). instead, we should have another secret (or the same, twice) and store the encrypted value as `access_token_hash` and use that as the primary key, instead. 12 | 13 | ![](security.drawio.png) 14 | 15 | # Login by payment 16 | 17 | Goal is to identify buyers after a payment link payment (without them being logged in). My Learnings: 18 | 19 | - payment links don't provide customer ids that identify the customer. even if enabled to gather customer_id (`customer_creation:always`) they are re-created for every new payment, so this isn't useful. 20 | - we can verify the user using `payment_method_details.card.fingerprint` (https://docs.stripe.com/api/charges/object#charge_object-payment_method_details-card-fingerprint) for `card` payments and using `customer_details.email` for `link` payments. More info: https://docs.stripe.com/api/charges/object#charge_object-payment_method_details. 21 | - Apple Pay, Google Pay, and direct credit cards all probably provide `card` information. Potentially way more ( see: https://docs.stripe.com/api/charges/object#charge_object-payment_method_details-card-wallet-type) 22 | 23 | TODO: 24 | 25 | - ✅ create new version with `verified_user_access_token` column on users 26 | - ✅ if payment is done from access token that has no user yet, create user with 0, and set column `verified_user_access_token`. balance should to to user with that `access_token` instead. 27 | - ✅ in `handleUserSession` when found user has `verified_user_access_token`, change `access_token` to there. 28 | 29 | 🎉 Now I can login from anywhere by logging in with stripe!!! 30 | 31 | # Making stripeflare oauth 2.1 compatible 32 | 33 | Question: Can I make stripeflare oauth 2.1 compatible, effectively allowing this as auth-layer for MCPs? 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # April 17, 2025 2 | 3 | Made an initial POC that uses the stripe-webhook in a Cloudflare worker (now at https://github.com/janwilmake/stripe-webhook-template) 4 | 5 | # May 13 6 | 7 | Revamped this into a middleware that keeps user balance in a "dorm" (with user-based tiny dbs + aggregate) and tied to a browser cookie. 8 | 9 | # May 15 10 | 11 | Changed logic to only create user after payment. Will still create empty DOs (to check) and it will run migrations there and submit that it did that, so still need to find a way to clean this up nicely, possibly at the `remote-sql-cursor` level? 12 | 13 | https://x.com/janwilmake/status/1922903746658341049 14 | 15 | Also, found a way use stripeflare to login by payment. A unauthenticated user can login into their account by making a small payment. See ADR 16 | 17 | # May 22nd 18 | 19 | - Added payment_link to environment variables to make it easier to manage 20 | - Added deploy to cloudflare button to make it easier to try and template from 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stripeflare - Virtual Wallet backed by Stripe Payments and Cloudflare Durable Objects 2 | 3 | [![janwilmake/stripeflare context](https://badge.forgithub.com/janwilmake/stripeflare)](https://uithub.com/janwilmake/stripeflare) [![](https://badge.xymake.com/janwilmake/status/1924404433317675347)](https://xymake.com/janwilmake/status/1924404433317675347) [![](https://b.lmpify.com/guide)](https://lmpify.com?q=https%3A%2F%2Fuuithub.com%2Fjanwilmake%2Fstripeflare%2Ftree%2Fmain%3FpathPatterns%3Dtemplate.ts%26pathPatterns%3Dtemplate.html%0A%0APlease%20create%20a%20new%20cloudflare%20typescript%20worker%20that%20uses%20stripeflare%20for%20monetisation%20with%20the%20following%20state%20and%20functionality%3A%20...) 4 | 5 | Middleware to add Stripe Payments to a Cloudflare Worker and have users keep track of a balance in your own database, without requiring third-party authentication (Just Stripe Payment)! It is a massive improvement upon [Cloudflare Sponsorware](https://github.com/janwilmake/cloudflare-sponsorware) which I made before as it dramatically reduces complexity while improving upon UX and DX. 6 | 7 | Let me know your thoughts in [this thread](https://x.com/janwilmake/status/1924404433317675347) and check [the demo](https://x.com/janwilmake/status/1924766605143142683) 8 | 9 | # Getting started 10 | 11 | 1. Use LMPIFY or any other LLM with [![janwilmake/stripeflare context](https://badge.forgithub.com/janwilmake/stripeflare)](https://uithub.com/janwilmake/stripeflare) to get started building your idea. [![](https://b.lmpify.com/guide)](https://lmpify.com?q=https%3A%2F%2Fuuithub.com%2Fjanwilmake%2Fstripeflare%2Ftree%2Fmain%3FpathPatterns%3Dtemplate.ts%26pathPatterns%3Dtemplate.html%0A%0APlease%20create%20a%20new%20cloudflare%20typescript%20worker%20that%20uses%20stripeflare%20for%20monetisation%20with%20the%20following%20state%20and%20functionality%3A%20...) 12 | 2. Submit the result to a github repo and Deploy to Cloudflare 13 | 3. [Collect the environment variables and set them in dev and production](#collecting-needed-environment-variables) 14 | 15 | [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/janwilmake/stripeflare) 16 | 17 | | Summary | Prompt it | 18 | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 19 | | Template to use stripeflare for a new app | [![](https://b.lmpify.com/guide)](https://lmpify.com?q=https%3A%2F%2Fuuithub.com%2Fjanwilmake%2Fstripeflare%2Ftree%2Fmain%3FpathPatterns%3Dtemplate.ts%26pathPatterns%3Dtemplate.html%0A%0APlease%20create%20a%20new%20cloudflare%20typescript%20worker%20that%20uses%20stripeflare%20for%20monetisation%20with%20the%20following%20state%20and%20functionality%3A%20...) | 20 | | Entire implementation of the package | [![](https://b.lmpify.com/source)](https://lmpify.com?q=https%3A%2F%2Fuuithub.com%2Fjanwilmake%2Fstripeflare%2Ftree%2Fmain%3FpathPatterns%3Dmiddleware.ts%26pathPatterns%3Dpackage.json%26pathPatterns%3Dencrypt-decrypt-js.js%0A%0ACan%20you%20tell%20me%20more%20about%20the%20security%20considerations%20of%20using%20this%20package%3F) | 21 | | Create a customized guide for a particular usecase | [![](https://b.lmpify.com/create_guide)](https://lmpify.com?q=https%3A%2F%2Fuuithub.com%2Fjanwilmake%2Fstripeflare%2Ftree%2Fmain%3FpathPatterns%3DREADME.md%26pathPatterns%3Dtemplate.ts%0A%0APlease%20create%20a%20new%20template%20for%20stripeflare%20similar%20to%20the%20provided%20template%2C%20for%20the%20following%20usecase%3A%20...) | 22 | | General information | [![](https://b.lmpify.com/general)](https://lmpify.com?q=https%3A%2F%2Fuuithub.com%2Fjanwilmake%2Fstripeflare%2Ftree%2Fmain%3FpathPatterns%3DREADME.md%26pathPatterns%3DLICENSE.md%26pathPatterns%3Dmiddleware.ts%0A%0AWhat%20are%20the%20limitations%3F) | 23 | 24 | # About 25 | 26 | ## Features 27 | 28 | - **Performant**: Creates a DB for each user while also mirroring it into an aggregate db (powered by [Durable Objects](https://developers.cloudflare.com/durable-objects/) and [DORM](https://getdorm.com)), resulting in lightning-fast worker requests with user-balance. 29 | - **Flexible**: Leverages [`?client_reference_id`](https://docs.stripe.com/api/checkout/sessions/object#checkout_session_object-client_reference_id) ensure connection to the user session whilst using any [Stripe Payment Link](https://docs.stripe.com/payment-links). 30 | - **Extensible**: Hooks into your own DO-based database so you can extend it however you like. 31 | - **Login by Payment**: Users can access their previous balance from any device as long as they use the same Stripe Payment method (only supports payment methods `card` and `link`, see [ADR](ADR.md)) 32 | 33 | ## When can you use this? 34 | 35 | 1. you want to use Cloudflare Workers for your app, with [DORM](https://github.com/janwilmake/dorm) as your database with segmentation on the user-level with one aggregate-db 36 | 2. You are VERY concerned with the performance of charging users. A user should be able to be charged within ±20ms. 37 | 3. You can use the source code as a starting point, giving you a virtual wallet system for your DORM-database. You don't need to use it as a dependency if you need additional logic and enhanced security. 38 | 39 | ## When not to use this 40 | 41 | 1. When you want more production-ready things, don't use this. May still get breaking changes. This is still a research-project with limitations. See [ADR](ADR.md) for details. 42 | 2. When you care a lot about multiple layers of security, don't use this. Currently, access_tokens are stored in the database as-is without encryption, which could expose them if other layers of security are compromised. 43 | 44 | ## Collecting Needed Environment Variables 45 | 46 | 1. Create a Stripe account, navigate to https://dashboard.stripe.com/apikeys and collect `STRIPE_SECRET` and `STRIPE_PUBLISHABLE_KEY` 47 | 2. Create a webhook at https://dashboard.stripe.com/webhooks/create. Endpoint URL: https://yourdomain.com/stripe-webhook and sollect `STRIPE_WEBHOOK_SIGNING_SECRET` 48 | 3. Create a payment link at https://dashboard.stripe.com/payment-links and set this as `STRIPE_PAYMENT_LINK`, use this in your frontend, but ensure to append `?client_reference_id=${client_reference_id}` taken from the `StripeUser` 49 | 50 | ## Good to know 51 | 52 | - https://github.com/janwilmake/dorm is a dependency. When you want to interact with the same database to charge a user, ensure to use the returned `userClient` from the middleware (if available) or create a client yourself. The DB name access_token, the mirrorName should be "aggregate" 53 | - we have some well-thoguht-out logic in the stripe webhook, allowing login-by-payment; read more here: https://www.lmpify.com/httpsrawgithubus-20o3gj0 54 | - learn how to explore the data with outerbase here: https://www.lmpify.com/how-does-stripeflare-iqt4li0 55 | -------------------------------------------------------------------------------- /SPEC.md: -------------------------------------------------------------------------------- 1 | Context: 2 | 3 | - https://raw.githubusercontent.com/janwilmake/dorm/refs/heads/main/template.ts 4 | - https://raw.githubusercontent.com/janwilmake/stripe-webhook-template/refs/heads/main/main.ts 5 | 6 | Please, make me a backend using dorm and the principles from the stripe example where: 7 | 8 | - create a user table with `{ access_token (primary key), balance (index), email (index), client_reference_id (index) }` in `dorm`. Simply use migrations, not the json schema. keep this migrations objects in the global scope 9 | - use dorm middleware for giving access to the `aggregate` db. use `env.DB_SECRET` as secret (required) 10 | - we check for cookies if they exists and find belonging user based on `access_token` cookie. if not, randomly generate a new `access_token` and `client_reference_id` and create user for it with email `null` and balance `0`. 11 | - return html file for the current route (support only `/` for now leading to index, but put them in a route object. import html files via import filename from "./filename.html";). 12 | - it checks the stripe `checkout.session.completed` webhook event, and if there's a `client_reference_id` there, look it up in `aggregate` to find the `access_token`. then create a client for that `access_token` with mirrorName `aggregate`. add to `balance` using `amount_total` and set `email`. 13 | - do not check for the payment link; checking on the client_reference_id is enough. 14 | 15 | Needed state: 16 | 17 | - secure http-only cookie `access_token` 18 | - window.data get set to `{balance,email,client_reference_id}` 19 | 20 | How to use cookies: 21 | 22 | ```ts 23 | // can be enabled in localhost 24 | const skipLogin = env.SKIP_LOGIN === "true"; 25 | const securePart = skipLogin ? "" : " Secure;"; 26 | const domainPart = skipLogin ? "" : ` Domain=${url.hostname};`; 27 | const cookieSuffix = `;${domainPart} HttpOnly; Path=/;${securePart} Max-Age=34560000; SameSite=Lax`; 28 | 29 | // Set secure HTTP-only cookie for access_token 30 | headers.append( 31 | "Set-Cookie", 32 | `access_token=${user.access_token}${cookieSuffix}`, 33 | ); 34 | ``` 35 | -------------------------------------------------------------------------------- /context.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://contextjson.com/schema", 3 | "attribution": "https://github.com/janwilmake/contextjson", 4 | "context": { 5 | "template": { 6 | "summary": "Create a stripeflare app in one shot", 7 | "pathPatterns": [ 8 | "README", 9 | "template.ts", 10 | "template.html", 11 | ".dev.vars.example", 12 | "wrangler.jsonc", 13 | "openapi.json" 14 | ], 15 | "prompt": "Please create a new cloudflare typescript worker that uses stripeflare for monetisation with the following state and functionality: ..." 16 | }, 17 | "source": { 18 | "summary": "Entire implementation of the package", 19 | "pathPatterns": [ 20 | "middleware.ts", 21 | "package.json", 22 | "encrypt-decrypt-js.js" 23 | ], 24 | "prompt": "Can you tell me more about the security considerations of using this package?" 25 | }, 26 | "create-guide": { 27 | "summary": "Create a customized guide for a particular usecase", 28 | "pathPatterns": [ 29 | "README.md", 30 | "template.ts", 31 | "template.html", 32 | ".dev.vars.example", 33 | "wrangler.jsonc" 34 | ], 35 | "prompt": "Please create a new template for stripeflare similar to the provided template, for the following usecase: ..." 36 | }, 37 | "general": { 38 | "summary": "General information", 39 | "pathPatterns": ["README.md", "LICENSE.md", "middleware.ts"], 40 | "prompt": "What are the limitations?" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /encrypt-decrypt-js.js: -------------------------------------------------------------------------------- 1 | const ALGORITHM = "AES-GCM"; 2 | const KEY_LENGTH = 256; 3 | const IV_LENGTH = 12; // 96 bits for AES-GCM 4 | 5 | // Generate a cryptographic key from the secret 6 | async function deriveKey(secret) { 7 | const encoder = new TextEncoder(); 8 | const keyMaterial = await crypto.subtle.importKey( 9 | "raw", 10 | encoder.encode(secret), 11 | "PBKDF2", 12 | false, 13 | ["deriveKey"], 14 | ); 15 | 16 | return crypto.subtle.deriveKey( 17 | { 18 | name: "PBKDF2", 19 | salt: encoder.encode("deterministic-salt"), // Using fixed salt for determinism 20 | iterations: 100000, 21 | hash: "SHA-256", 22 | }, 23 | keyMaterial, 24 | { name: ALGORITHM, length: KEY_LENGTH }, 25 | false, 26 | ["encrypt", "decrypt"], 27 | ); 28 | } 29 | 30 | // Generate deterministic IV from access token 31 | async function generateIV(accessToken) { 32 | const encoder = new TextEncoder(); 33 | const data = encoder.encode(accessToken); 34 | const hash = await crypto.subtle.digest("SHA-256", data); 35 | // Use first 12 bytes of hash as IV 36 | return new Uint8Array(hash.slice(0, IV_LENGTH)); 37 | } 38 | 39 | // Helper: Convert ArrayBuffer to base64url 40 | function arrayBufferToBase64Url(buffer) { 41 | const bytes = new Uint8Array(buffer); 42 | let binary = ""; 43 | bytes.forEach((byte) => (binary += String.fromCharCode(byte))); 44 | 45 | // Convert to base64url (URL-safe base64) 46 | return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 47 | } 48 | 49 | // Helper: Convert base64url to ArrayBuffer 50 | function base64UrlToArrayBuffer(base64url) { 51 | // Convert base64url back to base64 52 | const base64 = base64url 53 | .replace(/-/g, "+") 54 | .replace(/_/g, "/") 55 | .padEnd(base64url.length + ((4 - (base64url.length % 4)) % 4), "="); 56 | 57 | const binary = atob(base64); 58 | const bytes = new Uint8Array(binary.length); 59 | for (let i = 0; i < binary.length; i++) { 60 | bytes[i] = binary.charCodeAt(i); 61 | } 62 | return bytes; 63 | } 64 | 65 | /** 66 | * Encrypt access token to generate client_reference_id 67 | * 68 | * @param {string} token 69 | * @param {string} secret Minimum 16 characters 70 | * @returns {Promise} cipherText 71 | */ 72 | export async function encryptToken(token, secret) { 73 | const key = await deriveKey(secret); 74 | const iv = await generateIV(token); 75 | const encoder = new TextEncoder(); 76 | 77 | const encrypted = await crypto.subtle.encrypt( 78 | { name: ALGORITHM, iv: iv }, 79 | key, 80 | encoder.encode(token), 81 | ); 82 | 83 | // Combine IV and encrypted data 84 | const combined = new Uint8Array(iv.length + encrypted.byteLength); 85 | combined.set(iv, 0); 86 | combined.set(new Uint8Array(encrypted), iv.length); 87 | 88 | // Convert to base64url for safe URL usage 89 | return arrayBufferToBase64Url(combined); 90 | } 91 | 92 | /** 93 | * 94 | * @param {string} cipherText 95 | * @param {string} secret same secret as used for `encryptToken` 96 | * @returns {Promise} token 97 | */ 98 | export async function decryptToken(cipherText, secret) { 99 | const key = await deriveKey(secret); 100 | const combined = base64UrlToArrayBuffer(cipherText); 101 | 102 | // Extract IV and encrypted data 103 | const iv = combined.slice(0, IV_LENGTH); 104 | const encrypted = combined.slice(IV_LENGTH); 105 | 106 | const decrypted = await crypto.subtle.decrypt( 107 | { name: ALGORITHM, iv: iv }, 108 | key, 109 | encrypted, 110 | ); 111 | 112 | const decoder = new TextDecoder(); 113 | return decoder.decode(decrypted); 114 | } 115 | 116 | // Usage example 117 | async function example() { 118 | const accessToken = "your-access-token-here"; 119 | const secret = "your-secret-key-minimum-16-chars"; // Should be at least 16 characters 120 | 121 | try { 122 | // Encrypt access token to get client_reference_id 123 | const clientReferenceId = await encryptToken(accessToken, secret); 124 | console.log("Client Reference ID:", clientReferenceId); 125 | 126 | // Decrypt client_reference_id back to access token 127 | const decryptedToken = await decryptToken(clientReferenceId, secret); 128 | console.log("Decrypted Access Token:", decryptedToken); 129 | 130 | // Verify they match 131 | console.log("Tokens match:", accessToken === decryptedToken); 132 | } catch (error) { 133 | console.error("Error:", error); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { Stripe } from "stripe"; 2 | import { createClient, DORM, DORMClient } from "dormroom"; 3 | import { decryptToken, encryptToken } from "./encrypt-decrypt-js"; 4 | 5 | // Export DORM for it to be accessible 6 | export { DORM, createClient }; 7 | 8 | export interface Env { 9 | DORM_NAMESPACE: DurableObjectNamespace; 10 | DB_SECRET: string; 11 | STRIPE_WEBHOOK_SIGNING_SECRET: string; 12 | STRIPE_SECRET: string; 13 | STRIPE_PUBLISHABLE_KEY: string; 14 | STRIPE_PAYMENT_LINK: string; 15 | SKIP_LOGIN?: string; 16 | } 17 | 18 | export type StripeUser = { 19 | name: string | null; 20 | access_token: string; 21 | verified_user_access_token: string | null; 22 | balance: number; 23 | email: string | null; 24 | client_reference_id: string; 25 | card_fingerprint: string | null; 26 | verified_email: string | null; 27 | }; 28 | 29 | type Migrations = { [version: number]: string[] }; 30 | 31 | export interface MiddlewareResult { 32 | response?: Response; 33 | session?: { 34 | user: T; 35 | headers: { [key: string]: string }; 36 | userClient?: DORMClient; 37 | charge: ( 38 | amountCent: number, 39 | allowNegativeBalance: boolean, 40 | ) => Promise<{ 41 | charged: boolean; 42 | message: string; 43 | }>; 44 | }; 45 | } 46 | 47 | const parseCookies = (cookieHeader: string): Record => { 48 | const cookies: Record = {}; 49 | cookieHeader.split(";").forEach((cookie) => { 50 | const [name, value] = cookie.split("=").map((c) => c.trim()); 51 | if (name && value) { 52 | cookies[name] = value; 53 | } 54 | }); 55 | return cookies; 56 | }; 57 | 58 | const streamToBuffer = async ( 59 | readableStream: ReadableStream, 60 | ): Promise => { 61 | const chunks: Uint8Array[] = []; 62 | const reader = readableStream.getReader(); 63 | 64 | try { 65 | while (true) { 66 | const { done, value } = await reader.read(); 67 | if (done) break; 68 | chunks.push(value); 69 | } 70 | } finally { 71 | reader.releaseLock(); 72 | } 73 | 74 | const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); 75 | const result = new Uint8Array(totalLength); 76 | 77 | let position = 0; 78 | for (const chunk of chunks) { 79 | result.set(chunk, position); 80 | position += chunk.length; 81 | } 82 | 83 | return result; 84 | }; 85 | 86 | export async function stripeBalanceMiddleware( 87 | request: Request, 88 | env: Env, 89 | ctx: ExecutionContext, 90 | /** 91 | * Your database migrations. Required user-table columns (preferably all indexed): `CREATE TABLE users ( access_token TEXT PRIMARY KEY, balance INTEGER DEFAULT 0, email TEXT, name TEXT, client_reference_id )`. 92 | */ 93 | migrations: Migrations, 94 | version: string, 95 | ): Promise> { 96 | const url = new URL(request.url); 97 | const path = url.pathname; 98 | 99 | if (path === "/rotate-token") { 100 | const rotateResponse = await handleTokenRotation( 101 | request, 102 | env, 103 | ctx, 104 | migrations, 105 | version, 106 | ); 107 | return { response: rotateResponse }; 108 | } 109 | 110 | // Handle Stripe webhook 111 | if (path === "/stripe-webhook") { 112 | const webhookResponse = await handleStripeWebhook( 113 | request, 114 | env, 115 | ctx, 116 | migrations, 117 | version, 118 | ); 119 | return { response: webhookResponse }; 120 | } 121 | 122 | // Handle database API access 123 | if (path.startsWith("/db/")) { 124 | const name = path.split("/")[2]; 125 | 126 | const client = createClient({ 127 | doNamespace: env.DORM_NAMESPACE, 128 | version, 129 | migrations, 130 | ctx, 131 | name, 132 | mirrorName: name === "aggregate" ? undefined : "aggregate", 133 | }); 134 | 135 | const middlewareResponse = await client.middleware(request, { 136 | prefix: "/db/" + name, 137 | secret: env.DB_SECRET, 138 | }); 139 | 140 | if (middlewareResponse) { 141 | return { response: middlewareResponse }; 142 | } 143 | } 144 | 145 | // Handle user session 146 | const { user, userClient, headers } = await handleUserSession( 147 | request, 148 | env, 149 | ctx, 150 | url, 151 | migrations, 152 | version, 153 | ); 154 | 155 | if (path === "/me") { 156 | // NB: Can't put out access_token generally because it's a security leak to expose that to apps that run untrusted code. 157 | const { 158 | access_token, 159 | verified_user_access_token, 160 | client_reference_id, 161 | ...publicUser 162 | } = user || {}; 163 | const paymentLink = client_reference_id 164 | ? env.STRIPE_PAYMENT_LINK + 165 | "?client_reference_id=" + 166 | encodeURIComponent(client_reference_id) 167 | : undefined; 168 | 169 | return { 170 | response: new Response( 171 | JSON.stringify( 172 | { ...publicUser, client_reference_id, paymentLink }, 173 | undefined, 174 | 2, 175 | ), 176 | { 177 | headers, 178 | }, 179 | ), 180 | }; 181 | } 182 | 183 | const charge = async (amountCent: number, allowNegativeBalance: boolean) => { 184 | if (!userClient || !user.access_token) { 185 | return { 186 | charged: false, 187 | message: "User is not signed up yet and cannot be charged", 188 | }; 189 | } 190 | 191 | const update = allowNegativeBalance 192 | ? userClient.exec( 193 | "UPDATE users SET balance = balance - ? WHERE access_token = ?", 194 | amountCent, 195 | user.access_token, 196 | ) 197 | : userClient.exec( 198 | "UPDATE users SET balance = balance - ? WHERE access_token = ? and balance >= ?", 199 | amountCent, 200 | user.access_token, 201 | amountCent, 202 | ); 203 | 204 | await update.toArray(); 205 | const { rowsWritten } = update; 206 | if (rowsWritten === 0) { 207 | return { charged: false, message: "User balance too low" }; 208 | } 209 | 210 | return { charged: true, message: "Successfully charged" }; 211 | }; 212 | 213 | return { session: { user, headers, userClient, charge } }; 214 | } 215 | 216 | async function handleStripeWebhook( 217 | request: Request, 218 | env: Env, 219 | ctx: ExecutionContext, 220 | migrations: Migrations, 221 | version: string, 222 | ): Promise { 223 | if (!request.body) { 224 | return new Response(JSON.stringify({ error: "No body" }), { 225 | status: 400, 226 | headers: { "Content-Type": "application/json" }, 227 | }); 228 | } 229 | 230 | const rawBody = await streamToBuffer(request.body); 231 | const rawBodyString = new TextDecoder().decode(rawBody); 232 | 233 | const stripe = new Stripe(env.STRIPE_SECRET, { 234 | apiVersion: "2025-03-31.basil", 235 | }); 236 | 237 | const stripeSignature = request.headers.get("stripe-signature"); 238 | if (!stripeSignature) { 239 | return new Response(JSON.stringify({ error: "No signature" }), { 240 | status: 400, 241 | headers: { "Content-Type": "application/json" }, 242 | }); 243 | } 244 | 245 | let event: Stripe.Event; 246 | try { 247 | event = await stripe.webhooks.constructEventAsync( 248 | rawBodyString, 249 | stripeSignature, 250 | env.STRIPE_WEBHOOK_SIGNING_SECRET, 251 | ); 252 | } catch (err) { 253 | console.log("WEBHOOK ERR", err.message); 254 | return new Response(`Webhook error: ${String(err)}`, { status: 400 }); 255 | } 256 | 257 | if (event.type === "checkout.session.completed") { 258 | console.log("CHECKOUT COMPLETED"); 259 | const session = event.data.object; 260 | 261 | if (session.payment_status !== "paid" || !session.amount_total) { 262 | return new Response("Payment not completed", { status: 400 }); 263 | } 264 | 265 | const { 266 | client_reference_id, 267 | customer_details, 268 | amount_total, 269 | customer, 270 | customer_creation, 271 | customer_email, 272 | } = session; 273 | 274 | if (!client_reference_id || !customer_details?.email) { 275 | return new Response("Missing required data", { status: 400 }); 276 | } 277 | 278 | let access_token: string | undefined = undefined; 279 | try { 280 | access_token = await decryptToken(client_reference_id, env.DB_SECRET); 281 | } catch (e) { 282 | return new Response("Could not decrypt client_reference_id", { 283 | status: 400, 284 | }); 285 | } 286 | 287 | const aggregateClient = createClient({ 288 | doNamespace: env.DORM_NAMESPACE, 289 | version, 290 | migrations, 291 | ctx, 292 | name: "aggregate", 293 | }); 294 | 295 | // check if we already have a user with this details 296 | const userFromAccessToken = await aggregateClient 297 | .exec( 298 | "SELECT * FROM users WHERE access_token = ?", 299 | access_token, 300 | ) 301 | .one() 302 | .catch(() => null); 303 | 304 | if (userFromAccessToken) { 305 | // existing user found at this access_token, just add balance 306 | const userClient = createClient({ 307 | doNamespace: env.DORM_NAMESPACE, 308 | version, 309 | migrations, 310 | ctx, 311 | name: access_token, 312 | mirrorName: "aggregate", 313 | }); 314 | 315 | await userClient 316 | .exec( 317 | "UPDATE users SET balance = balance + ?, email = ?, name = ? WHERE access_token = ?", 318 | amount_total, 319 | customer_details.email, 320 | customer_details.name || null, 321 | access_token, 322 | ) 323 | .toArray(); 324 | 325 | return new Response("Payment processed successfully", { status: 200 }); 326 | } 327 | 328 | // no exisitng user. Check which user we need to insert it into: 329 | const paymentIntent = await stripe.paymentIntents.retrieve( 330 | session.payment_intent as string, 331 | ); 332 | 333 | // const charge = await stripe.charges.retrieve('') 334 | const { payment_method_details } = await stripe.charges.retrieve( 335 | paymentIntent.latest_charge as string, 336 | ); 337 | 338 | const card_fingerprint = payment_method_details?.card?.fingerprint; 339 | 340 | const verified_email = 341 | payment_method_details?.type === "link" 342 | ? customer_details.email 343 | : undefined; 344 | 345 | const userFromEmail = verified_email 346 | ? ( 347 | await aggregateClient 348 | .exec( 349 | "SELECT access_token FROM users WHERE verified_email = ?", 350 | verified_email, 351 | ) 352 | .toArray() 353 | )[0] 354 | : undefined; 355 | 356 | const userFromFingerprint = card_fingerprint 357 | ? ( 358 | await aggregateClient 359 | .exec( 360 | "SELECT access_token FROM users WHERE card_fingerprint = ?", 361 | card_fingerprint, 362 | ) 363 | .toArray() 364 | )[0] 365 | : undefined; 366 | 367 | const verified_user_access_token = 368 | userFromEmail?.access_token || userFromFingerprint?.access_token; 369 | 370 | if (!verified_user_access_token) { 371 | // user did not exist and there was no alternate access token found. Let's create the user under the provided access token! 372 | const userClient = createClient({ 373 | doNamespace: env.DORM_NAMESPACE, 374 | version, 375 | migrations, 376 | ctx, 377 | name: access_token, 378 | mirrorName: "aggregate", 379 | }); 380 | 381 | await userClient 382 | .exec( 383 | "INSERT INTO users (access_token, balance, email, verified_email, card_fingerprint, name, client_reference_id) VALUES (?, ?, ?, ?, ?, ?, ?)", 384 | access_token, 385 | amount_total, 386 | customer_details.email, 387 | verified_email || null, 388 | card_fingerprint || null, 389 | customer_details.name || null, 390 | client_reference_id, 391 | ) 392 | .toArray(); 393 | 394 | return new Response("Payment processed successfully", { status: 200 }); 395 | } 396 | 397 | const isAlternateAccessToken = verified_user_access_token !== access_token; 398 | 399 | if (!isAlternateAccessToken) { 400 | return new Response( 401 | "Found the user even though 'userFromAccessToken' was not found. Data might be corrupt", 402 | { status: 500 }, 403 | ); 404 | } 405 | 406 | // There is an alternate access token found, and the current access_token did not have a user tied to it yet. 407 | // We should set `verified_user_access_token` on this user, and add the balance to the alternate user. 408 | // The access_token of will be switched to the verified_user_access_token at a later point 409 | 410 | const userClient = createClient({ 411 | doNamespace: env.DORM_NAMESPACE, 412 | version, 413 | migrations, 414 | ctx, 415 | name: access_token, 416 | mirrorName: "aggregate", 417 | }); 418 | 419 | await userClient 420 | .exec( 421 | "INSERT INTO users (access_token, verified_user_access_token) VALUES (?, ?)", 422 | access_token, 423 | verified_user_access_token, 424 | ) 425 | .toArray(); 426 | 427 | const verifiedUserClient = createClient({ 428 | doNamespace: env.DORM_NAMESPACE, 429 | version, 430 | migrations, 431 | ctx, 432 | name: verified_user_access_token, 433 | mirrorName: "aggregate", 434 | }); 435 | 436 | // Add the balance to the verified user 437 | await verifiedUserClient 438 | .exec( 439 | "UPDATE users SET balance = balance + ?, email = ?, name = ? WHERE access_token = ?", 440 | amount_total, 441 | customer_details.email, 442 | customer_details.name || null, 443 | verified_user_access_token, 444 | ) 445 | .toArray(); 446 | 447 | return new Response("Payment processed successfully", { status: 200 }); 448 | } 449 | 450 | return new Response("Event not handled", { status: 200 }); 451 | } 452 | 453 | /** 454 | * Adds simple token rotation. NB: this deletes the old user and creates a new one with same balance, setting the cookie. 455 | * 456 | * Limitation: if the user has other state or user columns on their durable object, this won't be enough! 457 | */ 458 | async function handleTokenRotation( 459 | request: Request, 460 | env: Env, 461 | ctx: ExecutionContext, 462 | migrations: Migrations, 463 | version: string, 464 | ): Promise { 465 | if (request.method !== "POST") { 466 | return new Response("Method not allowed", { status: 405 }); 467 | } 468 | 469 | // Get current session 470 | const { user, userClient } = await handleUserSession( 471 | request, 472 | env, 473 | ctx, 474 | new URL(request.url), 475 | migrations, 476 | version, 477 | ); 478 | 479 | if (!userClient || !user.access_token) { 480 | return new Response(JSON.stringify({ error: "Not authenticated" }), { 481 | status: 401, 482 | headers: { "Content-Type": "application/json" }, 483 | }); 484 | } 485 | 486 | // Generate new access token 487 | const newAccessToken = crypto.randomUUID(); 488 | const newClientReferenceId = await encryptToken( 489 | newAccessToken, 490 | env.DB_SECRET, 491 | ); 492 | 493 | // Create new user client 494 | const newUserClient = createClient({ 495 | doNamespace: env.DORM_NAMESPACE, 496 | version, 497 | migrations, 498 | ctx, 499 | name: newAccessToken, 500 | mirrorName: "aggregate", 501 | }); 502 | 503 | try { 504 | // Copy user data to new access token 505 | await newUserClient 506 | .exec( 507 | `INSERT INTO users ( 508 | access_token, balance, email, verified_email, 509 | card_fingerprint, name, client_reference_id, verified_user_access_token 510 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 511 | newAccessToken, 512 | user.balance, 513 | user.email, 514 | user.verified_email, 515 | user.card_fingerprint, 516 | user.name, 517 | newClientReferenceId, 518 | null, // Clear verified_user_access_token for the new token 519 | ) 520 | .toArray(); 521 | 522 | // Delete old user data 523 | await userClient 524 | .exec("DELETE FROM users WHERE access_token = ?", user.access_token) 525 | .toArray(); 526 | 527 | // Set new cookie 528 | const url = new URL(request.url); 529 | const skipLogin = env.SKIP_LOGIN === "true"; 530 | const securePart = skipLogin ? "" : " Secure;"; 531 | const domainPart = skipLogin ? "" : ` Domain=${url.hostname};`; 532 | const cookieSuffix = `;${domainPart} HttpOnly; Path=/;${securePart} Max-Age=34560000; SameSite=Lax`; 533 | 534 | return new Response( 535 | JSON.stringify({ 536 | success: true, 537 | message: "Token rotated successfully", 538 | // Don't return the new token in response for security 539 | }), 540 | { 541 | status: 200, 542 | headers: { 543 | "Content-Type": "application/json", 544 | "Set-Cookie": `access_token=${newAccessToken}${cookieSuffix}`, 545 | }, 546 | }, 547 | ); 548 | } catch (error) { 549 | // If something goes wrong, clean up the new token 550 | try { 551 | await newUserClient 552 | .exec("DELETE FROM users WHERE access_token = ?", newAccessToken) 553 | .toArray(); 554 | } catch (cleanupError) { 555 | console.error("Failed to cleanup after rotation error:", cleanupError); 556 | } 557 | 558 | return new Response(JSON.stringify({ error: "Failed to rotate token" }), { 559 | status: 500, 560 | headers: { "Content-Type": "application/json" }, 561 | }); 562 | } 563 | } 564 | 565 | async function handleUserSession( 566 | request: Request, 567 | env: Env, 568 | ctx: ExecutionContext, 569 | url: URL, 570 | migrations: Migrations, 571 | version: string, 572 | ): Promise<{ 573 | user: T; 574 | userClient: DORMClient | undefined; 575 | /** The set-cookie header(s) */ 576 | headers: { [key: string]: string }; 577 | }> { 578 | const cookieHeader = request.headers.get("Cookie"); 579 | const authorizationHeader = request.headers.get("Authorization"); 580 | const bearerToken = authorizationHeader?.toLowerCase()?.startsWith("bearer ") 581 | ? authorizationHeader.slice("Bearer ".length) 582 | : undefined; 583 | 584 | const cookies = cookieHeader ? parseCookies(cookieHeader) : {}; 585 | 586 | let accessToken = bearerToken || cookies.access_token; 587 | let user: T | null = null; 588 | let userClient: DORMClient | undefined = undefined; 589 | 590 | // Try to get existing user 591 | if (accessToken) { 592 | // NB: this takes some ms for cold starts because a global lookup is done and new db is created for the accessToken, and happens for every user. Therefore there will be tons of tiny DOs without data, which we should clean up later. 593 | userClient = createClient({ 594 | doNamespace: env.DORM_NAMESPACE, 595 | version, 596 | migrations, 597 | ctx, 598 | name: accessToken, 599 | mirrorName: "aggregate", 600 | }); 601 | 602 | try { 603 | user = await userClient 604 | .exec("SELECT * FROM users WHERE access_token = ?", accessToken) 605 | .one(); 606 | 607 | if (user.verified_user_access_token) { 608 | // udpate access_token 609 | accessToken = user.verified_user_access_token; 610 | // we should switch to this one!!! 611 | userClient = createClient({ 612 | doNamespace: env.DORM_NAMESPACE, 613 | version, 614 | migrations, 615 | ctx, 616 | name: accessToken, 617 | mirrorName: "aggregate", 618 | }); 619 | 620 | user = await userClient 621 | .exec("SELECT * FROM users WHERE access_token = ?", accessToken) 622 | .one(); 623 | } 624 | 625 | let client_reference_id = await encryptToken(accessToken, env.DB_SECRET); 626 | 627 | if (user.client_reference_id !== client_reference_id) { 628 | // ensure to overwrite client_reference_id incase we have a new DB_SECRET 629 | user.client_reference_id = client_reference_id; 630 | 631 | await userClient 632 | .exec( 633 | "UPDATE users SET client_reference_id = ? WHERE access_token = ?", 634 | client_reference_id, 635 | accessToken, 636 | ) 637 | .toArray(); 638 | } 639 | } catch { 640 | userClient = undefined; 641 | 642 | // User not found, will create new one 643 | } 644 | } 645 | 646 | if (!user) { 647 | // Provide user with clientReferenceId without creating it 648 | const uuidGeneralRegex = 649 | /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 650 | 651 | if (!accessToken || !accessToken.match(uuidGeneralRegex)) { 652 | accessToken = crypto.randomUUID(); 653 | } 654 | const client_reference_id = await encryptToken(accessToken, env.DB_SECRET); 655 | 656 | user = { 657 | access_token: accessToken, 658 | balance: 0, 659 | email: null, 660 | client_reference_id, 661 | } as T; 662 | } 663 | 664 | // Set cookie 665 | const skipLogin = env.SKIP_LOGIN === "true"; 666 | const securePart = skipLogin ? "" : " Secure;"; 667 | const domainPart = skipLogin ? "" : ` Domain=${url.hostname};`; 668 | const cookieSuffix = `;${domainPart} HttpOnly; Path=/;${securePart} Max-Age=34560000; SameSite=Lax`; 669 | const headers = { 670 | "Set-Cookie": `access_token=${user.access_token}${cookieSuffix}`, 671 | }; 672 | 673 | return { user, userClient, headers }; 674 | } 675 | -------------------------------------------------------------------------------- /openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "Stripe Balance Middleware API", 5 | "description": "A middleware API for managing user sessions, Stripe payments, and database operations with balance tracking functionality.", 6 | "version": "1.0.0", 7 | "contact": { 8 | "name": "API Support" 9 | } 10 | }, 11 | "components": { 12 | "schemas": { 13 | "StripeUser": { 14 | "type": "object", 15 | "properties": { 16 | "name": { 17 | "type": "string", 18 | "nullable": true, 19 | "description": "User's display name" 20 | }, 21 | "access_token": { 22 | "type": "string", 23 | "description": "User's access token (sensitive - not returned in /me endpoint)" 24 | }, 25 | "verified_user_access_token": { 26 | "type": "string", 27 | "nullable": true, 28 | "description": "Verified user access token for account linking" 29 | }, 30 | "balance": { 31 | "type": "integer", 32 | "description": "User's balance in cents" 33 | }, 34 | "email": { 35 | "type": "string", 36 | "nullable": true, 37 | "format": "email", 38 | "description": "User's email address" 39 | }, 40 | "client_reference_id": { 41 | "type": "string", 42 | "description": "Encrypted client reference ID for Stripe" 43 | }, 44 | "card_fingerprint": { 45 | "type": "string", 46 | "nullable": true, 47 | "description": "Stripe card fingerprint for user identification" 48 | }, 49 | "verified_email": { 50 | "type": "string", 51 | "nullable": true, 52 | "format": "email", 53 | "description": "Verified email address from payment method" 54 | } 55 | }, 56 | "required": ["access_token", "balance", "client_reference_id"] 57 | }, 58 | "PublicUser": { 59 | "type": "object", 60 | "properties": { 61 | "name": { 62 | "type": "string", 63 | "nullable": true 64 | }, 65 | "balance": { 66 | "type": "integer" 67 | }, 68 | "email": { 69 | "type": "string", 70 | "nullable": true, 71 | "format": "email" 72 | }, 73 | "client_reference_id": { 74 | "type": "string" 75 | }, 76 | "card_fingerprint": { 77 | "type": "string", 78 | "nullable": true 79 | }, 80 | "verified_email": { 81 | "type": "string", 82 | "nullable": true, 83 | "format": "email" 84 | } 85 | }, 86 | "required": ["balance", "client_reference_id"] 87 | }, 88 | "TokenRotationResponse": { 89 | "type": "object", 90 | "properties": { 91 | "success": { 92 | "type": "boolean" 93 | }, 94 | "message": { 95 | "type": "string" 96 | } 97 | }, 98 | "required": ["success", "message"] 99 | }, 100 | "ErrorResponse": { 101 | "type": "object", 102 | "properties": { 103 | "error": { 104 | "type": "string" 105 | } 106 | }, 107 | "required": ["error"] 108 | }, 109 | "StripeWebhookEvent": { 110 | "type": "object", 111 | "description": "Stripe webhook event payload", 112 | "properties": { 113 | "id": { 114 | "type": "string" 115 | }, 116 | "object": { 117 | "type": "string", 118 | "enum": ["event"] 119 | }, 120 | "type": { 121 | "type": "string", 122 | "description": "Event type (e.g., checkout.session.completed)" 123 | }, 124 | "data": { 125 | "type": "object", 126 | "properties": { 127 | "object": { 128 | "type": "object", 129 | "description": "The event data object" 130 | } 131 | } 132 | } 133 | } 134 | } 135 | }, 136 | "securitySchemes": { 137 | "BearerAuth": { 138 | "type": "http", 139 | "scheme": "bearer", 140 | "description": "Use your access token as a Bearer token" 141 | }, 142 | "CookieAuth": { 143 | "type": "apiKey", 144 | "in": "cookie", 145 | "name": "access_token", 146 | "description": "Authentication via access_token cookie" 147 | }, 148 | "StripeSignature": { 149 | "type": "apiKey", 150 | "in": "header", 151 | "name": "stripe-signature", 152 | "description": "Stripe webhook signature for verification" 153 | } 154 | } 155 | }, 156 | "security": [ 157 | { 158 | "BearerAuth": [] 159 | }, 160 | { 161 | "CookieAuth": [] 162 | } 163 | ], 164 | "paths": { 165 | "/me": { 166 | "get": { 167 | "summary": "Get current user information", 168 | "description": "Returns the current user's public information (excluding sensitive tokens)", 169 | "tags": ["User"], 170 | "security": [ 171 | { 172 | "BearerAuth": [] 173 | }, 174 | { 175 | "CookieAuth": [] 176 | } 177 | ], 178 | "responses": { 179 | "200": { 180 | "description": "User information retrieved successfully", 181 | "content": { 182 | "application/json": { 183 | "schema": { 184 | "$ref": "#/components/schemas/PublicUser" 185 | } 186 | } 187 | }, 188 | "headers": { 189 | "Set-Cookie": { 190 | "description": "Updates or sets the access_token cookie", 191 | "schema": { 192 | "type": "string" 193 | } 194 | } 195 | } 196 | } 197 | } 198 | } 199 | }, 200 | "/rotate-token": { 201 | "post": { 202 | "summary": "Rotate user access token", 203 | "description": "Generates a new access token for the user, transfers all data to the new token, and deletes the old one. This is useful for security purposes.", 204 | "tags": ["Security"], 205 | "security": [ 206 | { 207 | "BearerAuth": [] 208 | }, 209 | { 210 | "CookieAuth": [] 211 | } 212 | ], 213 | "responses": { 214 | "200": { 215 | "description": "Token rotated successfully", 216 | "content": { 217 | "application/json": { 218 | "schema": { 219 | "$ref": "#/components/schemas/TokenRotationResponse" 220 | } 221 | } 222 | }, 223 | "headers": { 224 | "Set-Cookie": { 225 | "description": "Sets the new access_token cookie", 226 | "schema": { 227 | "type": "string" 228 | } 229 | } 230 | } 231 | }, 232 | "401": { 233 | "description": "Not authenticated", 234 | "content": { 235 | "application/json": { 236 | "schema": { 237 | "$ref": "#/components/schemas/ErrorResponse" 238 | } 239 | } 240 | } 241 | }, 242 | "405": { 243 | "description": "Method not allowed" 244 | }, 245 | "500": { 246 | "description": "Failed to rotate token", 247 | "content": { 248 | "application/json": { 249 | "schema": { 250 | "$ref": "#/components/schemas/ErrorResponse" 251 | } 252 | } 253 | } 254 | } 255 | } 256 | } 257 | }, 258 | "/stripe-webhook": { 259 | "post": { 260 | "summary": "Handle Stripe webhook events", 261 | "description": "Processes Stripe webhook events, particularly checkout.session.completed events for payment processing and balance updates", 262 | "tags": ["Stripe"], 263 | "security": [ 264 | { 265 | "StripeSignature": [] 266 | } 267 | ], 268 | "requestBody": { 269 | "required": true, 270 | "content": { 271 | "application/json": { 272 | "schema": { 273 | "$ref": "#/components/schemas/StripeWebhookEvent" 274 | } 275 | } 276 | } 277 | }, 278 | "responses": { 279 | "200": { 280 | "description": "Webhook processed successfully", 281 | "content": { 282 | "text/plain": { 283 | "schema": { 284 | "type": "string", 285 | "example": "Payment processed successfully" 286 | } 287 | } 288 | } 289 | }, 290 | "400": { 291 | "description": "Bad request - missing body, signature, or invalid event", 292 | "content": { 293 | "application/json": { 294 | "schema": { 295 | "$ref": "#/components/schemas/ErrorResponse" 296 | } 297 | } 298 | } 299 | }, 300 | "500": { 301 | "description": "Internal server error during processing" 302 | } 303 | } 304 | } 305 | }, 306 | "/db/{name}/{path}": { 307 | "get": { 308 | "summary": "Database API access (GET)", 309 | "description": "Provides direct access to any database", 310 | "tags": ["Database"], 311 | "parameters": [ 312 | { 313 | "name": "name", 314 | "in": "path", 315 | "required": true, 316 | "schema": { 317 | "type": "string" 318 | }, 319 | "description": "Database name (aggregate for admin overview - readonly)" 320 | }, 321 | 322 | { 323 | "name": "path", 324 | "in": "path", 325 | "required": true, 326 | "schema": { 327 | "type": "string" 328 | }, 329 | "description": "Database operation path" 330 | }, 331 | { 332 | "name": "secret", 333 | "in": "query", 334 | "required": true, 335 | "schema": { 336 | "type": "string" 337 | }, 338 | "description": "Database secret for authentication" 339 | } 340 | ], 341 | "responses": { 342 | "200": { 343 | "description": "Database operation successful", 344 | "content": { 345 | "application/json": { 346 | "schema": { 347 | "type": "object", 348 | "description": "Database query results" 349 | } 350 | } 351 | } 352 | }, 353 | "401": { 354 | "description": "Unauthorized - invalid secret" 355 | }, 356 | "400": { 357 | "description": "Bad request - invalid query" 358 | } 359 | } 360 | }, 361 | "post": { 362 | "summary": "Database API access (POST)", 363 | "description": "Provides direct access to the aggregate database for write operations", 364 | "tags": ["Database"], 365 | "parameters": [ 366 | { 367 | "name": "path", 368 | "in": "path", 369 | "required": true, 370 | "schema": { 371 | "type": "string" 372 | }, 373 | "description": "Database operation path" 374 | } 375 | ], 376 | "requestBody": { 377 | "required": true, 378 | "content": { 379 | "application/json": { 380 | "schema": { 381 | "type": "object", 382 | "properties": { 383 | "secret": { 384 | "type": "string", 385 | "description": "Database secret for authentication" 386 | } 387 | }, 388 | "additionalProperties": true 389 | } 390 | } 391 | } 392 | }, 393 | "responses": { 394 | "200": { 395 | "description": "Database operation successful", 396 | "content": { 397 | "application/json": { 398 | "schema": { 399 | "type": "object", 400 | "description": "Database operation results" 401 | } 402 | } 403 | } 404 | }, 405 | "401": { 406 | "description": "Unauthorized - invalid secret" 407 | }, 408 | "400": { 409 | "description": "Bad request - invalid query" 410 | } 411 | } 412 | } 413 | } 414 | }, 415 | "tags": [ 416 | { 417 | "name": "User", 418 | "description": "User management and information endpoints" 419 | }, 420 | { 421 | "name": "Security", 422 | "description": "Security-related operations like token rotation" 423 | }, 424 | { 425 | "name": "Stripe", 426 | "description": "Stripe payment processing and webhook handling" 427 | }, 428 | { 429 | "name": "Database", 430 | "description": "Direct database access endpoints" 431 | } 432 | ] 433 | } 434 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripeflare", 3 | "version": "0.0.22", 4 | "main": "middleware.ts", 5 | "files": [ 6 | "middleware.ts", 7 | "encrypt-decrypt-js.js" 8 | ], 9 | "dependencies": { 10 | "dormroom": "^1.0.0-next.20", 11 | "stripe": "18.0.0", 12 | "stripeflare": "^0.0.16" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /security.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janwilmake/stripeflare/c539d556fe834d4a34dfb5599e21205396d8ed7f/security.drawio.png -------------------------------------------------------------------------------- /template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Stripeflare Demo 8 | 9 | 10 | 195 | 196 | 197 | 198 |
199 |
200 |
201 | 202 |

Stripeflare Demo

203 |
204 | 205 |
206 |
207 | Client ID 208 | 209 |
210 |
211 | Email 212 | 213 |
214 |
215 | Name 216 | 217 |
218 |
219 | 220 |
221 |
222 | Balance 223 | 224 |
225 |
226 | Speed 227 | 228 |
229 | 233 |
234 | 235 | 239 | 240 | 241 | Pay with Stripe 242 | 243 | 244 |

After paying, every request will charge $0.01 without limit to showcase speed

245 | 246 | 249 |
250 |
251 | 252 | 315 | 316 | 317 | -------------------------------------------------------------------------------- /template.ts: -------------------------------------------------------------------------------- 1 | // To use this template, replace "./middleware" by "stripeflare" and add stripeflare to your dependencies (npm i stripeflare) 2 | import { 3 | createClient, 4 | Env, 5 | stripeBalanceMiddleware, 6 | type StripeUser, 7 | } from "./middleware"; 8 | export { DORM } from "./middleware"; 9 | 10 | //@ts-ignore 11 | import template from "./template.html"; 12 | 13 | interface User extends StripeUser { 14 | /** Additional properties */ 15 | // twitter_handle: string | null; 16 | } 17 | 18 | export const migrations = { 19 | // can add any other info here 20 | 1: [ 21 | `CREATE TABLE users ( 22 | access_token TEXT PRIMARY KEY, 23 | balance INTEGER DEFAULT 0, 24 | name TEXT, 25 | email TEXT, 26 | verified_email TEXT, 27 | verified_user_access_token TEXT, 28 | card_fingerprint TEXT, 29 | client_reference_id TEXT 30 | )`, 31 | `CREATE INDEX idx_users_balance ON users(balance)`, 32 | `CREATE INDEX idx_users_name ON users(name)`, 33 | `CREATE INDEX idx_users_email ON users(email)`, 34 | `CREATE INDEX idx_users_verified_email ON users(verified_email)`, 35 | `CREATE INDEX idx_users_card_fingerprint ON users(card_fingerprint)`, 36 | `CREATE INDEX idx_users_client_reference_id ON users(client_reference_id)`, 37 | ], 38 | }; 39 | 40 | export default { 41 | async fetch( 42 | request: Request, 43 | env: Env, 44 | ctx: ExecutionContext, 45 | ): Promise { 46 | const result = await stripeBalanceMiddleware( 47 | request, 48 | env, 49 | ctx, 50 | migrations, 51 | // changing this will link to a fully new db 52 | "0.0.10", 53 | ); 54 | 55 | // If middleware returned a response (webhook or db api), return it directly 56 | if (result.response) { 57 | return result.response; 58 | } 59 | 60 | if (!result.session) { 61 | return new Response("Somethign went wrong", { status: 404 }); 62 | } 63 | 64 | let user: User | null = result.session.user; 65 | 66 | const t = Date.now(); 67 | 68 | const { charged, message } = await result.session.charge(1, false); 69 | 70 | // We can also directly connect with the DB through dorm client 71 | // const client = createClient({ 72 | // doNamespace: env.DORM_NAMESPACE, 73 | // ctx, 74 | // migrations, 75 | // mirrorName: "aggregate", 76 | // name: result.session.user.access_token, 77 | // //NB: ensure to specify the same version! 78 | // version: "0.0.10", 79 | // }); 80 | 81 | // let paidUser = await client 82 | // .exec( 83 | // "SELECT * FROM users WHERE access_token = ?", 84 | // result.session.user.access_token, 85 | // ) 86 | // .one() 87 | // .catch(() => null); 88 | 89 | // if (paidUser) { 90 | // const update = client.exec( 91 | // "UPDATE users SET balance = balance - 1 WHERE access_token=?", 92 | // result.session.user.access_token, 93 | // ); 94 | 95 | // await update.toArray(); 96 | // const { rowsRead, rowsWritten } = update; 97 | 98 | // console.log("User has been charged one cent", { rowsRead, rowsWritten }); 99 | 100 | // const updatedUser = await client 101 | // .exec( 102 | // "SELECT * FROM users WHERE access_token=?", 103 | // result.session.user.access_token, 104 | // ) 105 | // .one() 106 | // .catch(() => null); 107 | // if (!updatedUser) { 108 | // return new Response("Couldn't find updated user user", { status: 500 }); 109 | // } 110 | // user = updatedUser; 111 | // } else { 112 | // console.log( 113 | // "This user could not be found, which means they have not made a payment. Change logic accordingly", 114 | // ); 115 | // } 116 | 117 | // Otherwise, inject user data and return HTML 118 | const headers = new Headers(result.session.headers || {}); 119 | headers.append("Content-Type", "text/html"); 120 | 121 | const { access_token, verified_user_access_token, ...rest } = user; 122 | const payment_link = env.STRIPE_PAYMENT_LINK; 123 | const speed = Date.now() - t; 124 | const modifiedHtml = template.replace( 125 | "", 126 | ``, 133 | ); 134 | return new Response(modifiedHtml, { headers }); 135 | }, 136 | }; 137 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripeflare", 3 | "dev": { "port": 3000 }, 4 | "compatibility_date": "2025-04-17", 5 | "main": "./template.ts", 6 | "durable_objects": { 7 | "bindings": [{ "name": "DORM_NAMESPACE", "class_name": "DORM" }] 8 | }, 9 | "migrations": [{ "tag": "v1", "new_sqlite_classes": ["DORM"] }], 10 | "routes": [ 11 | { "pattern": "stripeflare.com", "custom_domain": true }, 12 | { "pattern": "www.stripeflare.com", "custom_domain": true } 13 | ] 14 | } 15 | --------------------------------------------------------------------------------