fix: webhook idempotency — unique index on stripe_customer_id + UPSERT + DB dedup on success page
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m44s

- Add partial unique index on api_keys(stripe_customer_id) WHERE NOT NULL
- Use INSERT ... ON CONFLICT in createProKey for cross-pod dedup
- Add findKeyByCustomerId() to query DB directly
- Success page checks DB before creating key (survives pod restarts)
- Refresh in-memory cache after UPSERT
This commit is contained in:
DocFast Bot 2026-02-20 16:03:17 +00:00
parent e074562f73
commit e9440a4e6a
3 changed files with 71 additions and 12 deletions

View file

@ -1,7 +1,7 @@
import { Router, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import Stripe from "stripe";
import { createProKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js";
import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js";
import logger from "../services/logger.js";
function escapeHtml(s: string): string {
@ -138,6 +138,28 @@ router.get("/success", async (req: Request, res: Response) => {
return;
}
// Check DB for existing key (survives pod restarts, unlike provisionedSessions Set)
const existingKey = await findKeyByCustomerId(customerId);
if (existingKey) {
provisionedSessions.add(session.id);
res.send(`<!DOCTYPE html>
<html><head><title>DocFast Pro Key Already Provisioned</title>
<style>
body { font-family: system-ui; background: #0a0a0a; color: #e8e8e8; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
.card { background: #141414; border: 1px solid #222; border-radius: 16px; padding: 48px; max-width: 500px; text-align: center; }
h1 { color: #4f9; margin-bottom: 8px; }
p { color: #888; line-height: 1.6; }
a { color: #4f9; }
</style></head><body>
<div class="card">
<h1> Key Already Provisioned</h1>
<p>A Pro API key has already been created for this purchase.</p>
<p>If you lost your key, use the <a href="/docs#key-recovery">key recovery feature</a>.</p>
<p><a href="/docs">View API docs </a></p>
</div></body></html>`);
return;
}
const keyInfo = await createProKey(email, customerId);
provisionedSessions.add(session.id);

View file

@ -162,6 +162,8 @@ export async function initDatabase(): Promise<void> {
);
CREATE INDEX IF NOT EXISTS idx_api_keys_email ON api_keys(email);
CREATE INDEX IF NOT EXISTS idx_api_keys_stripe ON api_keys(stripe_customer_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_stripe_unique
ON api_keys(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS verifications (
id SERIAL PRIMARY KEY,

View file

@ -86,6 +86,7 @@ export async function createFreeKey(email?: string): Promise<ApiKey> {
}
export async function createProKey(email: string, stripeCustomerId: string): Promise<ApiKey> {
// Check in-memory cache first (fast path)
const existing = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId);
if (existing) {
existing.tier = "pro";
@ -93,19 +94,36 @@ export async function createProKey(email: string, stripeCustomerId: string): Pro
return existing;
}
// UPSERT: handles duplicate webhooks across pods via DB unique index
const newKey = generateKey("df_pro");
const now = new Date().toISOString();
const result = await queryWithRetry(
`INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (stripe_customer_id) WHERE stripe_customer_id IS NOT NULL
DO UPDATE SET tier = 'pro'
RETURNING key, tier, email, created_at, stripe_customer_id`,
[newKey, "pro", email, now, stripeCustomerId]
);
const row = result.rows[0];
const entry: ApiKey = {
key: generateKey("df_pro"),
tier: "pro",
email,
createdAt: new Date().toISOString(),
stripeCustomerId,
key: row.key,
tier: row.tier as "free" | "pro",
email: row.email,
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
stripeCustomerId: row.stripe_customer_id || undefined,
};
await queryWithRetry(
"INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id) VALUES ($1, $2, $3, $4, $5)",
[entry.key, entry.tier, entry.email, entry.createdAt, entry.stripeCustomerId]
);
// Refresh in-memory cache
const cacheIdx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
if (cacheIdx >= 0) {
keysCache[cacheIdx] = entry;
} else {
keysCache.push(entry);
}
return entry;
}
@ -119,6 +137,23 @@ export async function downgradeByCustomer(stripeCustomerId: string): Promise<boo
return false;
}
export async function findKeyByCustomerId(stripeCustomerId: string): Promise<ApiKey | null> {
// Check DB directly — survives pod restarts unlike in-memory cache
const result = await queryWithRetry(
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1 LIMIT 1",
[stripeCustomerId]
);
if (result.rows.length === 0) return null;
const r = result.rows[0];
return {
key: r.key,
tier: r.tier as "free" | "pro",
email: r.email,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
stripeCustomerId: r.stripe_customer_id || undefined,
};
}
export function getAllKeys(): ApiKey[] {
return [...keysCache];
}