fix: cancelled tier, remove key logging, add billing rate limits
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m13s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m13s
- Add 'cancelled' tier (0 req/month) for downgraded subscriptions - Remove full API key from recovery endpoint logs (security) - Add IP-based rate limiting (10/15min) to billing endpoints - Bump version to 0.7.0 - 4 new tests (338 total)
This commit is contained in:
parent
f3a363fb17
commit
9575d312fe
5 changed files with 68 additions and 7 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "snapapi",
|
"name": "snapapi",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"description": "URL to Screenshot API — PNG, JPEG, WebP via simple REST API",
|
"description": "URL to Screenshot API — PNG, JPEG, WebP via simple REST API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,34 @@ describe('GET /v1/billing/success', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GET /v1/billing/recover - security', () => {
|
||||||
|
beforeEach(() => { vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('should return masked key, not the full key', async () => {
|
||||||
|
vi.mocked(getKeyByEmail).mockResolvedValue({
|
||||||
|
key: 'snap_abcdef1234567890abcdef1234567890abcdef12345678',
|
||||||
|
tier: 'pro',
|
||||||
|
email: 'user@example.com',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await request(app).get('/v1/billing/recover').query({ email: 'user@example.com' })
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body.maskedKey).toBeDefined()
|
||||||
|
expect(response.body.maskedKey).toContain('...')
|
||||||
|
// Must NOT contain the full key
|
||||||
|
expect(response.body.key).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Billing rate limiting', () => {
|
||||||
|
it('should return rate limit headers on billing endpoints', async () => {
|
||||||
|
vi.mocked(getKeyByEmail).mockResolvedValue(undefined)
|
||||||
|
const response = await request(app).get('/v1/billing/recover').query({ email: 'test@example.com' })
|
||||||
|
expect(response.headers['ratelimit-limit']).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('POST /v1/billing/webhook', () => {
|
describe('POST /v1/billing/webhook', () => {
|
||||||
beforeEach(() => { vi.clearAllMocks() })
|
beforeEach(() => { vi.clearAllMocks() })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
import logger from "../services/logger.js";
|
import logger from "../services/logger.js";
|
||||||
import { createPaidKey, downgradeByCustomer, updateEmailByCustomer, getCustomerIdByEmail, getKeyByEmail } from "../services/keys.js";
|
import { createPaidKey, downgradeByCustomer, updateEmailByCustomer, getCustomerIdByEmail, getKeyByEmail } from "../services/keys.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
const billingLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: process.env.NODE_ENV === "test" ? 1000 : 10,
|
||||||
|
message: { error: "Too many requests. Please try again later." },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skip: (req: Request) => req.path === "/webhook",
|
||||||
|
});
|
||||||
|
router.use(billingLimiter);
|
||||||
|
|
||||||
let _stripe: Stripe | null = null;
|
let _stripe: Stripe | null = null;
|
||||||
function getStripe(): Stripe {
|
function getStripe(): Stripe {
|
||||||
if (!_stripe) {
|
if (!_stripe) {
|
||||||
|
|
@ -413,7 +424,7 @@ router.get("/recover", async (req: Request, res: Response) => {
|
||||||
const masked = `${key.substring(0, 9)}...${key.substring(key.length - 4)}`;
|
const masked = `${key.substring(0, 9)}...${key.substring(key.length - 4)}`;
|
||||||
|
|
||||||
// For now, just log the full key (TODO: implement email sending)
|
// For now, just log the full key (TODO: implement email sending)
|
||||||
logger.info({ email: keyInfo.email, key }, "API key recovery requested");
|
logger.info({ email: keyInfo.email }, "API key recovery requested");
|
||||||
|
|
||||||
res.json({ message, maskedKey: masked });
|
res.json({ message, maskedKey: masked });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { getTierLimit, getKeyByEmail, getCustomerIdByEmail } from '../keys.js'
|
import { getTierLimit, getKeyByEmail, getCustomerIdByEmail, downgradeByCustomer } from '../keys.js'
|
||||||
|
|
||||||
// Mock the db module
|
// Mock the db module
|
||||||
vi.mock('../db.js', () => ({
|
vi.mock('../db.js', () => ({
|
||||||
|
|
@ -25,6 +25,10 @@ describe('getTierLimit', () => {
|
||||||
expect(getTierLimit('business')).toBe(25000)
|
expect(getTierLimit('business')).toBe(25000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should return 0 for cancelled tier', () => {
|
||||||
|
expect(getTierLimit('cancelled')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('should return 100 for unknown tier', () => {
|
it('should return 100 for unknown tier', () => {
|
||||||
expect(getTierLimit('enterprise')).toBe(100)
|
expect(getTierLimit('enterprise')).toBe(100)
|
||||||
})
|
})
|
||||||
|
|
@ -124,3 +128,20 @@ describe('getCustomerIdByEmail', () => {
|
||||||
expect(result).toBeUndefined()
|
expect(result).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('downgradeByCustomer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set tier to cancelled instead of free', async () => {
|
||||||
|
vi.mocked(queryWithRetry).mockResolvedValue({ rows: [] })
|
||||||
|
|
||||||
|
await downgradeByCustomer('cus_test_123')
|
||||||
|
|
||||||
|
expect(queryWithRetry).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("'cancelled'"),
|
||||||
|
['cus_test_123']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { queryWithRetry } from "./db.js";
|
||||||
|
|
||||||
export interface ApiKey {
|
export interface ApiKey {
|
||||||
key: string;
|
key: string;
|
||||||
tier: "free" | "starter" | "pro" | "business";
|
tier: "free" | "cancelled" | "starter" | "pro" | "business";
|
||||||
email: string;
|
email: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
stripeCustomerId?: string;
|
stripeCustomerId?: string;
|
||||||
|
|
@ -130,6 +130,7 @@ export function getAllKeys(): ApiKey[] {
|
||||||
export function getTierLimit(tier: string): number {
|
export function getTierLimit(tier: string): number {
|
||||||
switch (tier) {
|
switch (tier) {
|
||||||
case "free": return 100;
|
case "free": return 100;
|
||||||
|
case "cancelled": return 0;
|
||||||
case "starter": return 1000;
|
case "starter": return 1000;
|
||||||
case "pro": return 5000;
|
case "pro": return 5000;
|
||||||
case "business": return 25000;
|
case "business": return 25000;
|
||||||
|
|
@ -184,16 +185,16 @@ export async function createPaidKey(email: string, tier: "starter" | "pro" | "bu
|
||||||
|
|
||||||
export async function downgradeByCustomer(customerId: string): Promise<void> {
|
export async function downgradeByCustomer(customerId: string): Promise<void> {
|
||||||
await queryWithRetry(
|
await queryWithRetry(
|
||||||
"UPDATE api_keys SET tier = 'free', stripe_customer_id = NULL WHERE stripe_customer_id = $1",
|
"UPDATE api_keys SET tier = 'cancelled', stripe_customer_id = NULL WHERE stripe_customer_id = $1",
|
||||||
[customerId]
|
[customerId]
|
||||||
);
|
);
|
||||||
for (const k of keysCache) {
|
for (const k of keysCache) {
|
||||||
if (k.stripeCustomerId === customerId) {
|
if (k.stripeCustomerId === customerId) {
|
||||||
k.tier = "free";
|
k.tier = "cancelled";
|
||||||
k.stripeCustomerId = undefined;
|
k.stripeCustomerId = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info({ customerId }, "Downgraded customer to free");
|
logger.info({ customerId }, "Downgraded customer to cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEmailByCustomer(customerId: string, newEmail: string): Promise<void> {
|
export async function updateEmailByCustomer(customerId: string, newEmail: string): Promise<void> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue