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

- 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:
Hoid 2026-03-04 09:06:16 +01:00
parent f3a363fb17
commit 9575d312fe
5 changed files with 68 additions and 7 deletions

View file

@ -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",

View file

@ -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() })

View file

@ -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) {

View file

@ -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']
)
})
})

View file

@ -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> {