fix: OpenAPI spec accuracy — hide internal endpoints, mark signup/verify deprecated
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m9s

- Remove @openapi annotations from /v1/billing/webhook (Stripe-internal)
- Remove @openapi annotations from /v1/billing/success (browser redirect)
- Mark /v1/signup/verify as deprecated (returns 410)
- Add 3 TDD tests in openapi-spec.test.ts
- Update 2 existing tests in app-routes.test.ts
- 530 tests passing (was 527)
This commit is contained in:
Hoid 2026-03-07 14:06:12 +01:00
parent 1d5d9adf08
commit 6b1b3d584e
15 changed files with 399 additions and 290 deletions

110
dist/routes/billing.js vendored
View file

@ -15,8 +15,29 @@ function getStripe() {
return _stripe;
}
const router = Router();
// Track provisioned session IDs to prevent duplicate key creation
const provisionedSessions = new Set();
// Track provisioned session IDs with TTL to prevent duplicate key creation and memory leaks
// Map<sessionId, timestamp> - entries older than 24h are periodically cleaned up
const provisionedSessions = new Map();
// TTL Configuration
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // Clean up every 1 hour
// Cleanup old provisioned session entries
function cleanupOldSessions() {
const now = Date.now();
const cutoff = now - SESSION_TTL_MS;
let cleanedCount = 0;
for (const [sessionId, timestamp] of provisionedSessions.entries()) {
if (timestamp < cutoff) {
provisionedSessions.delete(sessionId);
cleanedCount++;
}
}
if (cleanedCount > 0) {
logger.info({ cleanedCount, remainingCount: provisionedSessions.size }, "Cleaned up expired provisioned sessions");
}
}
// Start periodic cleanup
setInterval(cleanupOldSessions, CLEANUP_INTERVAL_MS);
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
// Returns true if the given Stripe subscription contains a DocFast product.
// Used to filter webhook events — this Stripe account is shared with other projects.
@ -101,43 +122,15 @@ router.post("/checkout", checkoutLimiter, async (req, res) => {
res.status(500).json({ error: "Failed to create checkout session" });
}
});
/**
* @openapi
* /v1/billing/success:
* get:
* tags: [Billing]
* summary: Checkout success page
* description: |
* Provisions a Pro API key after successful Stripe checkout and displays it in an HTML page.
* Called by Stripe redirect after payment completion.
* parameters:
* - in: query
* name: session_id
* required: true
* schema:
* type: string
* description: Stripe Checkout session ID
* responses:
* 200:
* description: HTML page displaying the new API key
* content:
* text/html:
* schema:
* type: string
* 400:
* description: Missing session_id or no customer found
* 409:
* description: Checkout session already used
* 500:
* description: Failed to retrieve session
*/
// Success page — provision Pro API key after checkout
// Success page — provision Pro API key after checkout (browser redirect, not a public API)
router.get("/success", async (req, res) => {
const sessionId = req.query.session_id;
if (!sessionId) {
res.status(400).json({ error: "Missing session_id" });
return;
}
// Clean up old sessions before checking duplicates
cleanupOldSessions();
// Prevent duplicate provisioning from same session
if (provisionedSessions.has(sessionId)) {
res.status(409).json({ error: "This checkout session has already been used to provision a key. If you lost your key, use the key recovery feature." });
@ -151,10 +144,10 @@ router.get("/success", async (req, res) => {
res.status(400).json({ error: "No customer found" });
return;
}
// Check DB for existing key (survives pod restarts, unlike provisionedSessions Set)
// Check DB for existing key (survives pod restarts, unlike provisionedSessions Map)
const existingKey = await findKeyByCustomerId(customerId);
if (existingKey) {
provisionedSessions.add(session.id);
provisionedSessions.set(session.id, Date.now());
res.send(`<!DOCTYPE html>
<html><head><title>DocFast Pro Key Already Provisioned</title>
<style>
@ -173,7 +166,7 @@ a { color: #4f9; }
return;
}
const keyInfo = await createProKey(email, customerId);
provisionedSessions.add(session.id);
provisionedSessions.set(session.id, Date.now());
// Return a nice HTML page instead of raw JSON
res.send(`<!DOCTYPE html>
<html><head><title>Welcome to DocFast Pro!</title>
@ -202,48 +195,7 @@ a { color: #4f9; }
res.status(500).json({ error: "Failed to retrieve session" });
}
});
/**
* @openapi
* /v1/billing/webhook:
* post:
* tags: [Billing]
* summary: Stripe webhook endpoint
* description: |
* Receives Stripe webhook events for subscription lifecycle management.
* Requires the raw request body and a valid Stripe-Signature header for verification.
* Handles checkout.session.completed, customer.subscription.updated,
* customer.subscription.deleted, and customer.updated events.
* parameters:
* - in: header
* name: Stripe-Signature
* required: true
* schema:
* type: string
* description: Stripe webhook signature for payload verification
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* description: Raw Stripe event payload
* responses:
* 200:
* description: Webhook received
* content:
* application/json:
* schema:
* type: object
* properties:
* received:
* type: boolean
* example: true
* 400:
* description: Missing Stripe-Signature header or invalid signature
* 500:
* description: Webhook secret not configured
*/
// Stripe webhook for subscription lifecycle events
// Stripe webhook for subscription lifecycle events (internal, not in public API docs)
router.post("/webhook", async (req, res) => {
const sig = req.headers["stripe-signature"];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
@ -297,7 +249,7 @@ router.post("/webhook", async (req, res) => {
break;
}
const keyInfo = await createProKey(email, customerId);
provisionedSessions.add(session.id);
provisionedSessions.set(session.id, Date.now());
logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key");
break;
}