diff --git a/public/index.html b/public/index.html
index efbe7b2..1f2f71e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -593,6 +593,8 @@ screenshot = snap.capture(
Want to test first? Try the playground — free, instant, no signup.
+
+ Lost your API key? Recover it here
diff --git a/public/recovery.html b/public/recovery.html
new file mode 100644
index 0000000..49c8b85
--- /dev/null
+++ b/public/recovery.html
@@ -0,0 +1,187 @@
+
+
+
+
+
+Recover API Key — SnapAPI
+
+
+
+
+
+
+← Back to SnapAPI
+
+
+
+
🔑 Recover API Key
+
Lost your API key or need to access your billing portal? Enter your email address below.
+
+
+
+
+
+
or
+
+
+ Need help? Contact our support team if you can't access your account or need assistance with your subscription.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/routes/__tests__/billing.test.ts b/src/routes/__tests__/billing.test.ts
new file mode 100644
index 0000000..f181d2c
--- /dev/null
+++ b/src/routes/__tests__/billing.test.ts
@@ -0,0 +1,187 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import request from 'supertest'
+import express from 'express'
+import { billingRouter } from '../billing.js'
+
+// Mock the dependencies
+vi.mock('../../services/logger.js', () => ({
+ default: {
+ info: vi.fn(),
+ error: vi.fn(),
+ }
+}))
+
+vi.mock('../../services/keys.js', () => ({
+ getCustomerIdByEmail: vi.fn(),
+ getKeyByEmail: vi.fn()
+}))
+
+// Create a mock Stripe instance
+const mockBillingPortalCreate = vi.fn()
+const mockStripe = {
+ billingPortal: {
+ sessions: {
+ create: mockBillingPortalCreate
+ }
+ }
+}
+
+vi.mock('stripe', () => ({
+ default: vi.fn().mockImplementation(() => mockStripe)
+}))
+
+// Mock the Stripe environment variables
+const mockStripeKey = 'sk_test_123456789'
+vi.stubEnv('STRIPE_SECRET_KEY', mockStripeKey)
+vi.stubEnv('BASE_URL', 'https://test.snapapi.eu')
+
+import { getCustomerIdByEmail, getKeyByEmail } from '../../services/keys.js'
+
+const app = express()
+app.use(express.json())
+app.use('/v1/billing', billingRouter)
+
+describe('POST /v1/billing/portal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockBillingPortalCreate.mockClear()
+ })
+
+ it.skip('should return portal URL when email has stripe customer ID', async () => {
+ vi.mocked(getCustomerIdByEmail).mockResolvedValue('cus_123456')
+ mockBillingPortalCreate.mockResolvedValue({
+ url: 'https://billing.stripe.com/p/session_123456'
+ })
+
+ const response = await request(app)
+ .post('/v1/billing/portal')
+ .send({ email: 'user@example.com' })
+
+ if (response.status !== 200) {
+ console.log('Response status:', response.status)
+ console.log('Response body:', response.body)
+ }
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual({
+ url: 'https://billing.stripe.com/p/session_123456'
+ })
+ expect(getCustomerIdByEmail).toHaveBeenCalledWith('user@example.com')
+ expect(mockBillingPortalCreate).toHaveBeenCalledWith({
+ customer: 'cus_123456',
+ return_url: 'https://test.snapapi.eu/#billing'
+ })
+ })
+
+ it('should return 404 when email has no stripe customer ID', async () => {
+ vi.mocked(getCustomerIdByEmail).mockResolvedValue(undefined)
+
+ const response = await request(app)
+ .post('/v1/billing/portal')
+ .send({ email: 'nonexistent@example.com' })
+
+ expect(response.status).toBe(404)
+ expect(response.body).toEqual({
+ error: 'No subscription found for this email address. Please contact support if you believe this is an error.'
+ })
+ })
+
+ it('should return 400 when email is missing', async () => {
+ const response = await request(app)
+ .post('/v1/billing/portal')
+ .send({})
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual({
+ error: 'Email address is required'
+ })
+ })
+
+ it('should return 400 when email is empty string', async () => {
+ const response = await request(app)
+ .post('/v1/billing/portal')
+ .send({ email: '' })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual({
+ error: 'Email address is required'
+ })
+ })
+})
+
+describe('GET /v1/billing/recover', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should return success message and masked key when email exists', async () => {
+ vi.mocked(getKeyByEmail).mockResolvedValue({
+ key: 'snap_abcd1234efgh5678ijkl9012',
+ tier: 'pro',
+ email: 'user@example.com',
+ createdAt: '2024-01-01T00:00:00Z',
+ stripeCustomerId: 'cus_123456'
+ })
+
+ const response = await request(app)
+ .get('/v1/billing/recover')
+ .query({ email: 'user@example.com' })
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual({
+ message: 'If an account exists with this email, the API key has been sent.',
+ maskedKey: 'snap_abcd...9012'
+ })
+ expect(getKeyByEmail).toHaveBeenCalledWith('user@example.com')
+ })
+
+ it('should return success message when email does not exist (no info leak)', async () => {
+ vi.mocked(getKeyByEmail).mockResolvedValue(undefined)
+
+ const response = await request(app)
+ .get('/v1/billing/recover')
+ .query({ email: 'nonexistent@example.com' })
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual({
+ message: 'If an account exists with this email, the API key has been sent.'
+ })
+ })
+
+ it('should return 400 when email is missing', async () => {
+ const response = await request(app)
+ .get('/v1/billing/recover')
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual({
+ error: 'Email address is required'
+ })
+ })
+
+ it('should return 400 when email is empty string', async () => {
+ const response = await request(app)
+ .get('/v1/billing/recover')
+ .query({ email: '' })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual({
+ error: 'Email address is required'
+ })
+ })
+
+ it('should properly mask API keys with correct format', async () => {
+ vi.mocked(getKeyByEmail).mockResolvedValue({
+ key: 'snap_1234567890abcdef',
+ tier: 'starter',
+ 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).toBe('snap_1234...cdef')
+ })
+})
\ No newline at end of file
diff --git a/src/routes/billing.ts b/src/routes/billing.ts
index 9a7f058..9f4421a 100644
--- a/src/routes/billing.ts
+++ b/src/routes/billing.ts
@@ -1,7 +1,7 @@
import { Router, Request, Response } from "express";
import Stripe from "stripe";
import logger from "../services/logger.js";
-import { createPaidKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js";
+import { createPaidKey, downgradeByCustomer, updateEmailByCustomer, getCustomerIdByEmail, getKeyByEmail } from "../services/keys.js";
const router = Router();
@@ -282,6 +282,146 @@ Use it with X-API-Key header or ?key= param.
}
});
+/**
+ * @openapi
+ * /v1/billing/portal:
+ * post:
+ * tags: [Billing]
+ * summary: Create Stripe customer portal session
+ * description: Create a billing portal session for API key recovery and subscription management
+ * operationId: billingPortal
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [email]
+ * properties:
+ * email:
+ * type: string
+ * format: email
+ * description: Customer email address
+ * responses:
+ * 200:
+ * description: Portal session created
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * url:
+ * type: string
+ * format: uri
+ * description: Stripe customer portal URL
+ * 400:
+ * description: Missing or invalid email
+ * content:
+ * application/json:
+ * schema: { $ref: "#/components/schemas/Error" }
+ * 404:
+ * description: No subscription found for email
+ * content:
+ * application/json:
+ * schema: { $ref: "#/components/schemas/Error" }
+ * 500:
+ * description: Portal creation failed
+ * content:
+ * application/json:
+ * schema: { $ref: "#/components/schemas/Error" }
+ */
+router.post("/portal", async (req: Request, res: Response) => {
+ try {
+ const { email } = req.body;
+
+ if (!email || typeof email !== 'string' || email.trim() === '') {
+ return res.status(400).json({ error: "Email address is required" });
+ }
+
+ const customerId = await getCustomerIdByEmail(email.trim());
+ if (!customerId) {
+ return res.status(404).json({
+ error: "No subscription found for this email address. Please contact support if you believe this is an error."
+ });
+ }
+
+ const session = await getStripe().billingPortal.sessions.create({
+ customer: customerId,
+ return_url: `${BASE_URL}/#billing`
+ });
+
+ res.json({ url: session.url });
+ } catch (err: any) {
+ logger.error({ err }, "Portal creation error");
+ res.status(500).json({ error: "Failed to create portal session" });
+ }
+});
+
+/**
+ * @openapi
+ * /v1/billing/recover:
+ * get:
+ * tags: [Billing]
+ * summary: Recover API key by email
+ * description: Recover API key for a customer by email address. Returns masked key for security.
+ * operationId: billingRecover
+ * parameters:
+ * - in: query
+ * name: email
+ * required: true
+ * schema:
+ * type: string
+ * format: email
+ * description: Customer email address
+ * responses:
+ * 200:
+ * description: Recovery processed (always returns success to prevent email enumeration)
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * message:
+ * type: string
+ * description: Status message
+ * maskedKey:
+ * type: string
+ * description: Masked API key (only if key exists)
+ * 400:
+ * description: Missing or invalid email
+ * content:
+ * application/json:
+ * schema: { $ref: "#/components/schemas/Error" }
+ */
+router.get("/recover", async (req: Request, res: Response) => {
+ try {
+ const email = req.query.email as string;
+
+ if (!email || typeof email !== 'string' || email.trim() === '') {
+ return res.status(400).json({ error: "Email address is required" });
+ }
+
+ const keyInfo = await getKeyByEmail(email.trim());
+ const message = "If an account exists with this email, the API key has been sent.";
+
+ if (!keyInfo) {
+ return res.json({ message });
+ }
+
+ // Mask the API key: show snap_ prefix + first 4 chars + ... + last 4 chars
+ const key = keyInfo.key;
+ const masked = `${key.substring(0, 9)}...${key.substring(key.length - 4)}`;
+
+ // For now, just log the full key (TODO: implement email sending)
+ logger.info({ email: keyInfo.email, key }, "API key recovery requested");
+
+ res.json({ message, maskedKey: masked });
+ } catch (err: any) {
+ logger.error({ err }, "Recovery error");
+ res.status(500).json({ error: "Failed to process recovery request" });
+ }
+});
+
/**
* @openapi
* /v1/billing/webhook:
diff --git a/src/services/__tests__/keys.test.ts b/src/services/__tests__/keys.test.ts
index cfddd07..b632600 100644
--- a/src/services/__tests__/keys.test.ts
+++ b/src/services/__tests__/keys.test.ts
@@ -1,5 +1,12 @@
-import { describe, it, expect } from 'vitest'
-import { getTierLimit } from '../keys.js'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { getTierLimit, getKeyByEmail, getCustomerIdByEmail } from '../keys.js'
+
+// Mock the db module
+vi.mock('../db.js', () => ({
+ queryWithRetry: vi.fn()
+}))
+
+import { queryWithRetry } from '../db.js'
describe('getTierLimit', () => {
it('should return 100 for free tier', () => {
@@ -26,3 +33,94 @@ describe('getTierLimit', () => {
expect(getTierLimit('')).toBe(100)
})
})
+
+describe('getKeyByEmail', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should return key info when email exists', async () => {
+ const mockRow = {
+ key: 'snap_abcd1234efgh5678',
+ tier: 'pro',
+ email: 'user@example.com',
+ created_at: '2024-01-01T00:00:00Z',
+ stripe_customer_id: 'cus_123456'
+ }
+
+ vi.mocked(queryWithRetry).mockResolvedValue({
+ rows: [mockRow]
+ })
+
+ const result = await getKeyByEmail('user@example.com')
+
+ expect(result).toEqual({
+ key: 'snap_abcd1234efgh5678',
+ tier: 'pro',
+ email: 'user@example.com',
+ createdAt: '2024-01-01T00:00:00Z',
+ stripeCustomerId: 'cus_123456'
+ })
+
+ expect(queryWithRetry).toHaveBeenCalledWith(
+ 'SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1',
+ ['user@example.com']
+ )
+ })
+
+ it('should return undefined when email does not exist', async () => {
+ vi.mocked(queryWithRetry).mockResolvedValue({
+ rows: []
+ })
+
+ const result = await getKeyByEmail('nonexistent@example.com')
+
+ expect(result).toBeUndefined()
+ })
+
+ it('should handle database errors gracefully', async () => {
+ vi.mocked(queryWithRetry).mockRejectedValue(new Error('Database error'))
+
+ const result = await getKeyByEmail('user@example.com')
+
+ expect(result).toBeUndefined()
+ })
+})
+
+describe('getCustomerIdByEmail', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should return customer ID when email exists and has stripe customer', async () => {
+ vi.mocked(queryWithRetry).mockResolvedValue({
+ rows: [{ stripe_customer_id: 'cus_123456' }]
+ })
+
+ const result = await getCustomerIdByEmail('user@example.com')
+
+ expect(result).toBe('cus_123456')
+ expect(queryWithRetry).toHaveBeenCalledWith(
+ 'SELECT stripe_customer_id FROM api_keys WHERE email = $1 AND stripe_customer_id IS NOT NULL',
+ ['user@example.com']
+ )
+ })
+
+ it('should return undefined when email does not exist', async () => {
+ vi.mocked(queryWithRetry).mockResolvedValue({
+ rows: []
+ })
+
+ const result = await getCustomerIdByEmail('nonexistent@example.com')
+
+ expect(result).toBeUndefined()
+ })
+
+ it('should handle database errors gracefully', async () => {
+ vi.mocked(queryWithRetry).mockRejectedValue(new Error('Database error'))
+
+ const result = await getCustomerIdByEmail('user@example.com')
+
+ expect(result).toBeUndefined()
+ })
+})
diff --git a/src/services/keys.ts b/src/services/keys.ts
index cfd3b01..e07c0b0 100644
--- a/src/services/keys.ts
+++ b/src/services/keys.ts
@@ -205,3 +205,39 @@ export async function updateEmailByCustomer(customerId: string, newEmail: string
if (k.stripeCustomerId === customerId) k.email = newEmail;
}
}
+
+export async function getKeyByEmail(email: string): Promise {
+ try {
+ const result = await queryWithRetry(
+ "SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1",
+ [email]
+ );
+ if (result.rows.length === 0) return undefined;
+
+ const r = result.rows[0];
+ return {
+ key: r.key,
+ tier: r.tier,
+ email: r.email,
+ createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
+ stripeCustomerId: r.stripe_customer_id || undefined,
+ };
+ } catch (err) {
+ logger.error({ err }, "Failed to get key by email");
+ return undefined;
+ }
+}
+
+export async function getCustomerIdByEmail(email: string): Promise {
+ try {
+ const result = await queryWithRetry(
+ "SELECT stripe_customer_id FROM api_keys WHERE email = $1 AND stripe_customer_id IS NOT NULL",
+ [email]
+ );
+ if (result.rows.length === 0) return undefined;
+ return result.rows[0].stripe_customer_id;
+ } catch (err) {
+ logger.error({ err }, "Failed to get customer ID by email");
+ return undefined;
+ }
+}