Skip to content

Commit 45a1d03

Browse files
feat(merch): harden checkout API for production readiness
Add CORS allowlist, checkout rate limits, async Stripe webhook fulfilment with retries, order status by session, shop post-payment polling, API v2.1.0 OpenAPI updates, and expanded merch tests. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 739c9b3 commit 45a1d03

25 files changed

Lines changed: 490 additions & 28 deletions

.env.example

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,10 @@ MERCH_ARTWORK_BASE_URL=
2626
# MERCH_S3_ACCESS_KEY=
2727
# MERCH_S3_SECRET_KEY=
2828

29+
# Merch hardening
30+
# MERCH_CORS_ORIGINS=https://6axiscompass.uk,https://www.6axiscompass.uk
31+
# MERCH_CHECKOUT_RATE_LIMIT=20
32+
# MERCH_FULFIL_RETRIES=3
33+
# MERCH_AUTO_REFUND_ON_FAILURE=false
34+
2935
# Static shop build (npm run build)
30-
# MERCH_API_BASE=https://your-api-host

API.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
| Version | Date | Changes |
44
|---------|------|---------|
5+
| 2.1.0 | 2026-05-22 | **Merch API:** `POST /api/checkout/session`, `POST /api/webhooks/stripe`, `GET /api/merch/prices`, `GET /api/orders/*`, chart `background` / `labelMerch`. |
56
| 2.0.2 | 2026-05-22 | **Spatial inversion (app v3.6.0):** default spatial charts use no rim inversion; `register: structural` inverts `Governance` + `Class` only. |
67
| 2.0.1 | 2026-05-20 | **Spatial radar (app v3.1.0):** `POST /api/chart` default `layout: spatial` (OQ5); `orientation: spatial`; `invertedAxes`; pedagogical OQ2 via `layout: pedagogical`. |
78
| 2.0.0 | 2026-05-20 | **Public read API (Phase 1):** `GET /api/actors`, `GET /api/actors/:slug`, `POST /api/chart`, `GET /api/axes`, `GET /api/openapi.json` unauthenticated when `API_PUBLIC_READ=true` (default). Rate limit on chart. Optional legacy Bearer on read. `GET /api/health` adds `apiVersion`, `publicRead`. |
@@ -28,7 +29,7 @@ Production chart API hosting is maintainer-operated (not served from GitHub Page
2829

2930
| Semver | What it tracks | Current |
3031
|--------|----------------|---------|
31-
| **API** (`apiVersion` in `/api/health`) | REST contract (auth zones, routes, limits) | **2.0.2** |
32+
| **API** (`apiVersion` in `/api/health`) | REST contract (auth zones, routes, limits) | **2.1.0** |
3233
| **App** (`version` in `/api/health`) | Compass package / Pages bundle | See `package.json` (e.g. **2.6.2**) |
3334

3435
---

api/lib/config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const API_VERSION = '2.0.2';
1+
export const API_VERSION = '2.1.0';
22

33
/** Public read routes skip Bearer auth when true (default). Set API_PUBLIC_READ=false for legacy mode. */
44
export function isPublicReadEnabled() {

api/lib/cors.js

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,37 @@
11
const PUBLIC_METHODS = 'GET, POST, OPTIONS';
22
const PUBLIC_HEADERS = 'Content-Type, Authorization';
33

4+
function allowedOrigins() {
5+
const raw = process.env.MERCH_CORS_ORIGINS || '';
6+
if (!raw.trim()) return null;
7+
return raw.split(',').map(s => s.trim()).filter(Boolean);
8+
}
9+
10+
function requestOrigin(req) {
11+
const origin = req.headers.origin;
12+
if (origin) return origin;
13+
const referer = req.headers.referer;
14+
if (!referer) return null;
15+
try {
16+
return new URL(referer).origin;
17+
} catch {
18+
return null;
19+
}
20+
}
21+
422
export function applyPublicCors(req, res) {
5-
res.setHeader('Access-Control-Allow-Origin', '*');
23+
const allowlist = allowedOrigins();
24+
const origin = requestOrigin(req);
25+
26+
if (!allowlist) {
27+
res.setHeader('Access-Control-Allow-Origin', '*');
28+
} else if (origin && allowlist.includes(origin)) {
29+
res.setHeader('Access-Control-Allow-Origin', origin);
30+
res.setHeader('Vary', 'Origin');
31+
} else if (origin) {
32+
res.setHeader('Access-Control-Allow-Origin', 'null');
33+
}
34+
635
res.setHeader('Access-Control-Allow-Methods', PUBLIC_METHODS);
736
res.setHeader('Access-Control-Allow-Headers', PUBLIC_HEADERS);
837
}
@@ -12,3 +41,11 @@ export function handleOptions(req, res) {
1241
res.statusCode = 204;
1342
res.end();
1443
}
44+
45+
export function isCorsAllowed(req) {
46+
const allowlist = allowedOrigins();
47+
if (!allowlist) return true;
48+
const origin = requestOrigin(req);
49+
if (!origin) return true;
50+
return allowlist.includes(origin);
51+
}

api/lib/merch-fulfilment-queue.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { fulfilOrderFromStripeSession } from './merch-orders.js';
2+
import { refundPayment, isStripeConfigured } from './stripe-client.js';
3+
import { getOrderByStripeSession, updateOrder } from './order-store.js';
4+
5+
const pending = new Set();
6+
7+
function sleep(ms) {
8+
return new Promise(resolve => setTimeout(resolve, ms));
9+
}
10+
11+
function maxRetries() {
12+
return parseInt(process.env.MERCH_FULFIL_RETRIES || '3', 10);
13+
}
14+
15+
function autoRefundEnabled() {
16+
return process.env.MERCH_AUTO_REFUND_ON_FAILURE === 'true';
17+
}
18+
19+
async function runFulfilmentWithRetry(session, attempt = 1) {
20+
try {
21+
await fulfilOrderFromStripeSession(session);
22+
} catch (err) {
23+
console.error(`Fulfilment attempt ${attempt} failed for session ${session.id}:`, err.message);
24+
if (attempt < maxRetries()) {
25+
await sleep(2000 * attempt);
26+
return runFulfilmentWithRetry(session, attempt + 1);
27+
}
28+
29+
if (autoRefundEnabled() && isStripeConfigured() && session.payment_intent) {
30+
try {
31+
await refundPayment(session.payment_intent);
32+
const order = getOrderByStripeSession(session.id);
33+
if (order) updateOrder(order.id, { status: 'refunded', error: err.message });
34+
console.error(`Refunded payment ${session.payment_intent} after fulfilment failure`);
35+
} catch (refundErr) {
36+
console.error('Auto-refund failed:', refundErr.message);
37+
}
38+
}
39+
}
40+
}
41+
42+
export function scheduleFulfilment(session) {
43+
if (!session?.id) return;
44+
if (pending.has(session.id)) return;
45+
pending.add(session.id);
46+
setImmediate(() => {
47+
runFulfilmentWithRetry(session).finally(() => pending.delete(session.id));
48+
});
49+
}
50+
51+
export function isFulfilmentPending(sessionId) {
52+
return pending.has(sessionId);
53+
}

api/lib/merch-orders.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export async function createMerchCheckoutSession(body) {
4848

4949
export async function fulfilOrderFromStripeSession(session) {
5050
const existing = getOrderByStripeSession(session.id);
51-
if (existing?.status === 'submitted' || existing?.status === 'fulfilled') {
51+
if (['submitted', 'fulfilled', 'awaiting_printful', 'artwork_ready'].includes(existing?.status)) {
5252
return existing;
5353
}
5454

@@ -129,6 +129,16 @@ export async function fulfilOrderFromStripeSession(session) {
129129
export function getPublicOrderStatus(orderId) {
130130
const order = getOrder(orderId);
131131
if (!order) return null;
132+
return publicStatusFromOrder(order);
133+
}
134+
135+
export function getPublicOrderStatusBySession(sessionId) {
136+
const order = getOrderByStripeSession(sessionId);
137+
if (!order) return null;
138+
return publicStatusFromOrder(order);
139+
}
140+
141+
function publicStatusFromOrder(order) {
132142
return {
133143
id: order.id,
134144
status: order.status,

api/lib/rate-limit.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const WINDOW_MS = 60_000;
2-
const MAX_PER_WINDOW = parseInt(process.env.API_CHART_RATE_LIMIT || '60', 10);
2+
const MAX_CHART = parseInt(process.env.API_CHART_RATE_LIMIT || '60', 10);
3+
const MAX_CHECKOUT = parseInt(process.env.MERCH_CHECKOUT_RATE_LIMIT || '20', 10);
34
const buckets = new Map();
45

56
function clientKey(req) {
@@ -8,16 +9,16 @@ function clientKey(req) {
89
return req.socket?.remoteAddress || 'unknown';
910
}
1011

11-
export function checkChartRateLimit(req, res) {
12-
const key = clientKey(req);
12+
function checkLimit(req, res, scope, max) {
13+
const key = `${scope}:${clientKey(req)}`;
1314
const now = Date.now();
1415
let bucket = buckets.get(key);
1516
if (!bucket || now >= bucket.resetAt) {
1617
bucket = { count: 0, resetAt: now + WINDOW_MS };
1718
buckets.set(key, bucket);
1819
}
1920
bucket.count += 1;
20-
if (bucket.count > MAX_PER_WINDOW) {
21+
if (bucket.count > max) {
2122
const retryAfter = Math.ceil((bucket.resetAt - now) / 1000);
2223
res.statusCode = 429;
2324
res.setHeader('Content-Type', 'application/json');
@@ -27,3 +28,11 @@ export function checkChartRateLimit(req, res) {
2728
}
2829
return true;
2930
}
31+
32+
export function checkChartRateLimit(req, res) {
33+
return checkLimit(req, res, 'chart', MAX_CHART);
34+
}
35+
36+
export function checkCheckoutRateLimit(req, res) {
37+
return checkLimit(req, res, 'checkout', MAX_CHECKOUT);
38+
}

api/openapi-v2.0.0.json

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"openapi": "3.1.0",
33
"info": {
44
"title": "Six-Axis Compass API",
5-
"version": "2.0.0",
6-
"description": "Public read API (unauthenticated by default). Private write under /api/admin/* requires ADMIN_SECRET (Phase 2)."
5+
"version": "2.1.0",
6+
"description": "Public read API (unauthenticated by default). Merch checkout routes added in v2.1.0."
77
},
88
"servers": [{ "url": "/" }],
99
"paths": {
@@ -30,7 +30,20 @@
3030
"/api/chart": {
3131
"post": {
3232
"summary": "Render radar chart",
33-
"requestBody": { "content": { "application/json": { "schema": { "type": "object" } } } },
33+
"requestBody": {
34+
"content": {
35+
"application/json": {
36+
"schema": {
37+
"type": "object",
38+
"properties": {
39+
"background": { "enum": ["white", "transparent", "dark"] },
40+
"labelMerch": { "type": "boolean" },
41+
"labelMode": { "enum": ["trigram", "full"] }
42+
}
43+
}
44+
}
45+
}
46+
},
3447
"responses": {
3548
"200": { "description": "image/svg+xml or image/png" },
3649
"400": { "description": "Validation error" },
@@ -41,6 +54,44 @@
4154
"/api/axes": {
4255
"get": { "summary": "Canonical axes and pole labels", "responses": { "200": { "description": "Axis catalog" } } }
4356
},
57+
"/api/merch/prices": {
58+
"get": {
59+
"summary": "Merch GBP prices from catalog",
60+
"responses": { "200": { "description": "Price map keyed by product id" } }
61+
}
62+
},
63+
"/api/checkout/session": {
64+
"post": {
65+
"summary": "Create Stripe Checkout session for merch order",
66+
"responses": {
67+
"200": { "description": "Checkout URL and order id" },
68+
"400": { "description": "Validation error" },
69+
"403": { "description": "CORS origin not allowed" },
70+
"429": { "description": "Rate limited" },
71+
"503": { "description": "Stripe not configured" }
72+
}
73+
}
74+
},
75+
"/api/orders/{orderId}": {
76+
"get": {
77+
"summary": "Public order status by internal order id",
78+
"parameters": [{ "name": "orderId", "in": "path", "required": true, "schema": { "type": "string" } }],
79+
"responses": { "200": { "description": "Order status" }, "404": { "description": "Not found" } }
80+
}
81+
},
82+
"/api/orders/session/{sessionId}": {
83+
"get": {
84+
"summary": "Public order status by Stripe Checkout session id",
85+
"parameters": [{ "name": "sessionId", "in": "path", "required": true, "schema": { "type": "string" } }],
86+
"responses": { "200": { "description": "Order status" }, "404": { "description": "Not found" } }
87+
}
88+
},
89+
"/api/webhooks/stripe": {
90+
"post": {
91+
"summary": "Stripe webhook (checkout.session.completed)",
92+
"responses": { "200": { "description": "Acknowledged" }, "400": { "description": "Invalid signature" } }
93+
}
94+
},
4495
"/api/openapi.json": {
4596
"get": { "summary": "OpenAPI document", "responses": { "200": { "description": "This document" } } }
4697
}

api/routes/checkout.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { sendJSON } from '../lib/auth.js';
2+
import { isCorsAllowed } from '../lib/cors.js';
23
import { createMerchCheckoutSession, isMerchEnabled } from '../lib/merch-orders.js';
34

45
export async function handleCheckoutSession(req, res, body) {
6+
if (!isCorsAllowed(req)) {
7+
sendJSON(res, 403, { error: 'Origin not allowed' });
8+
return;
9+
}
10+
511
if (!isMerchEnabled()) {
612
sendJSON(res, 503, {
713
error: 'Merch checkout is not configured',

api/routes/orders.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { sendJSON } from '../lib/auth.js';
2-
import { getPublicOrderStatus } from '../lib/merch-orders.js';
2+
import { getPublicOrderStatus, getPublicOrderStatusBySession } from '../lib/merch-orders.js';
33

44
export async function handleOrderStatus(req, res, orderId) {
55
const status = getPublicOrderStatus(orderId);
@@ -9,3 +9,12 @@ export async function handleOrderStatus(req, res, orderId) {
99
}
1010
sendJSON(res, 200, status);
1111
}
12+
13+
export async function handleOrderStatusBySession(req, res, sessionId) {
14+
const status = getPublicOrderStatusBySession(sessionId);
15+
if (!status) {
16+
sendJSON(res, 404, { error: 'Order not found' });
17+
return;
18+
}
19+
sendJSON(res, 200, status);
20+
}

0 commit comments

Comments
 (0)