DC-057: close checkout-to-license contract drift (grade B)
Canonical product catalog at src/billing/catalog.js shared by Stripe Checkout client (src/billing/stripe-client.js), webhook bridge (scripts/stripe-license-bridge.js), and pricing page (status/pricing/index.html). One-time payment keyed by productId at $20/$50/$70/$99 — no more monthly/annual subscription drift. Bridge resolves duration via metadata.productId (single contract), requires payment_status === 'paid' before fulfillment (rejects unpaid/no_payment_required/missing with ack 200), handles async_payment_succeeded for ACH/SEPA delayed-payment flow. License persisted to fulfillment-store BEFORE email — SMTP failure path serves the persisted code via the new /api/v1/billing/lookup/:sessionId endpoint (the documented customer recovery path). Layer-1 (event-id) + layer-2 (session-id) idempotency prevent duplicate issuance. Checkout return URLs derived from STRIPE_PUBLIC_ORIGIN or STRIPE_ALLOWED_HOSTS (not raw Host header) — closes host-header-poisoning + session-ID-leak attack class. 1498/1498 Jest tests pass (62 suites), zero new ESLint warnings introduced. Test files: - stripe-license-bridge.test.js (24 tests) - billing-lookup.test.js (8 tests, HTTP-level) - bridge-lookup-http.test.js (5 tests, uses exported createServer) - pricing-page-catalog.test.js (9 tests, per-tier consistency) - checkout-origin.test.js (6 tests, host injection rejection) - stripe-client.test.js (rewrite for productId + mode:payment) Bridge code refactored: handleWebhook decomposed into verifySignature + parseEventBody + checkEventIdempotency + fulfillCheckout + ensureLicensePersisted (under ESLint complexity=20 cap). New createServer()/createRequestHandler() factories guarded by require.main === module. Removed 3 stale test files from the rolled-back DC-055 attempt.
This commit is contained in:
+2
-2
@@ -338,13 +338,13 @@ Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a re
|
||||
- **result:** Hermes sprint 2026-08-02 shipped the public-routes-drift half of DC-055 (commit 86df178, grade A): public-routes-drift.test.js now correctly maps `routes/billing.js` to `/billing` prefix in the walker (was previously bare-mount, so `/api/v1/billing/checkout` was flagged as stale drift) and adds `routes/services.js` to directMounts (was missing — `/api/v1/services` + `/api/v1/services/status` were incorrectly flagged stale). Removed dead `/api/v1/billing/webhook` PUBLIC_ROUTES entry — webhooks are handled out-of-process by `scripts/stripe-license-bridge.js` and the merchant webhook secret never enters the API process. The drift test now has 14/14 pass and the dead entry is documented in code so a re-add would fail loudly. Krystie's prior uncommitted work (`src/billing/stripe-client.js`, `routes/billing.js`, `scripts/stripe-license-bridge.js`, public pricing page, license-keygen LICENSE_SECRET_FILE refactor) is now buildable: full Jest suite is 1486/1486 green, zero new ESLint errors. Remaining for the actual pricing UI commit: verify the standalone `scripts/stripe-license-bridge.js` works against a real Stripe sandbox end-to-end, then add the pricing page to the Caddy file routing.
|
||||
|
||||
### DC-057: Close checkout-to-license contract drift before public billing launch
|
||||
- **status:** in-progress
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Codex audit found the current Stripe checkout and webhook bridge cannot interoperate: `dashcaddy-api/src/billing/stripe-client.js` emits `metadata: {tier, period, product}` while `dashcaddy-api/scripts/stripe-license-bridge.js` requires `metadata.sku`, so every paid Checkout completion returns `unknown-sku` and no license is delivered. The locked product decisions define one-time 30/90/180/365-day licenses at $20/$50/$70/$99, while the current pricing page and billing client present monthly/annual subscriptions. The product spec promises a license key on the success page, while the current page only redirects to Checkout and documents email-only delivery. The bridge comments and implementation also disagree about whether email failures are recorded for retry/idempotency.
|
||||
- **impact:** Current billing can accept payment without issuing a license, which blocks public release and risks paid-customer support incidents.
|
||||
- **prerequisite:** DC-054 and DC-055 working-tree billing artifacts are present but not yet released as a coherent, verified flow.
|
||||
- **acceptance:** Define one canonical paid-product catalog shared by Checkout, webhook fulfillment, tests, and pricing labels; use the locked USD prices and one-time payment/duration semantics; feed the exact Checkout metadata into the bridge in a cross-module contract test; make duplicate/retry events unable to issue two keys; persist a recoverable license before email delivery and provide an explicit, tested SMTP-failure recovery path; reconcile success-page behavior, one-license-per-host wording, and legal/product copy with the actual secure delivery mechanism.
|
||||
- **result:** Rolled back to `todo` on 2026-08-02. The initial implementation attempt added an unintegrated catalog/fulfillment store but did not complete the client/bridge contract, crash-safe generation, production bridge topology/ingress, updater/systemd delivery, or lifetime-path audit. Preserve the evidence above for the next claimant and do not ship the partial working-tree artifacts.
|
||||
- **result:** Codex grade B. 1498/1498 Jest tests pass (62 suites), zero new ESLint warnings introduced. Shipped as one coherent DC-057 commit (no partial worktree artifacts). Single canonical product catalog (`src/billing/catalog.js`) shared by Checkout client, webhook bridge, pricing page, and catalog-consistency test. Stripe Checkout rewritten for **one-time payment** keyed by `productId` (`pro-30d`/`pro-90d`/`pro-180d`/`pro-365d`) at $20/$50/$70/$99, with `metadata.productId` as the single contract feeding the bridge — no SKU drift possible. Webhook bridge now requires `payment_status === 'paid'` before fulfillment (rejects unpaid/no_payment_required/missing with ack 200) and handles the ACH/SEPA delayed-payment flow via `checkout.session.async_payment_succeeded`. License is persisted to the durable fulfillment-store **before** email delivery; on SMTP failure, the lookup endpoint serves the persisted code in `pending_email` state (the documented recovery path) so the customer can save it manually. Layer-1 (event-id-keyed) and layer-2 (session-id-keyed) idempotency prevent duplicate issuance — a second webhook for the same Checkout Session ID reuses the persisted code, never generating a second key. Stripe Checkout return URLs are derived from `STRIPE_PUBLIC_ORIGIN` env var or `STRIPE_ALLOWED_HOSTS` allowlist (not raw `Host` header) — closes the host-header-poisoning + session-ID-leak class of attack. New success page (`status/billing/success.html`) reveals the license key with a copy button and polls the lookup endpoint every 1.5s. New test files: `stripe-license-bridge.test.js` (24 tests — signature, parsing, catalog resolution, idempotency, SMTP recovery, async payment events, lookupSession), `billing-lookup.test.js` (8 tests — HTTP-level route coverage of `/api/v1/billing/lookup/:sessionId` via real Express server), `bridge-lookup-http.test.js` (5 tests — bridge's own `/lookup/:sessionId` HTTP endpoint, uses exported `createServer()` factory so the SAME dispatcher the production server uses is exercised), `pricing-page-catalog.test.js` (9 tests — enforces consistency between catalog and the hardcoded pricing page at the per-tier level, plus success-page existence + lookup-endpoint reference), `checkout-origin.test.js` (6 tests — covers `STRIPE_PUBLIC_ORIGIN`, `STRIPE_ALLOWED_HOSTS`, host-header injection rejection, javascript: scheme rejection, http:// in production rejection). All 3 stale test files from the rolled-back DC-055 attempt removed (`__tests__/stripe-license-bridge.test.js`, `__tests__/routes/billing.test.js`). Bridge code refactored: `handleWebhook` decomposed into `verifySignature` + `parseEventBody` + `checkEventIdempotency` + `fulfillCheckout` + `ensureLicensePersisted` step functions (under ESLint complexity=20 cap). Production server created via exported `createServer()` / `createRequestHandler()` factories guarded by `require.main === module` so test imports don't leak an HTTP server. Pricing page (`status/pricing/index.html`) rewritten as 4 hardcoded tier cards with `data-product-id` attributes; old monthly/annual subscription toggle removed. Success page (`status/billing/success.html`) new — copy-button reveal, 1.5s polling, TTL-aware messages. To deploy: set `STRIPE_PRICE_PRO_30D/90D/180D/365D` env vars + `STRIPE_PUBLIC_ORIGIN=https://status.sami` (or set `STRIPE_ALLOWED_HOSTS=status.sami` for header-based fallback); configure the Stripe webhook endpoint to point at the bridge's `:3010/webhook` URL with the bridge's `STRIPE_WEBHOOK_SECRET`. Deploy the new pricing + success pages to `/var/www/dashcaddy-status/`. Bridge runs as `scripts/stripe-license-bridge.js` on port 3010.
|
||||
|
||||
### DC-056: ToS + Privacy Policy pages — GDPR-aware, no SOC2/HIPAA for v1.0
|
||||
- **status:** done
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* DC-057 billing lookup endpoint tests.
|
||||
*
|
||||
* Tests the GET /api/v1/billing/lookup/:sessionId route handler with a
|
||||
* real fulfillment store on disk. Covers:
|
||||
*
|
||||
* - 404 for unknown sessionId
|
||||
* - processing state (record exists, no code yet)
|
||||
* - pending_email state — license persisted, email failed (SMTP recovery path)
|
||||
* - delivered state
|
||||
* - 404 past the 24h TTL
|
||||
* - Cache-Control: no-store on all responses
|
||||
* - Parameterized PUBLIC_ROUTES entry exists for this path
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-lookup-'));
|
||||
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
||||
|
||||
const billingRoutes = require('../../routes/billing');
|
||||
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
// Mock asyncHandler that calls the inner fn synchronously.
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
const router = billingRoutes({ asyncHandler });
|
||||
app.use('/api/v1/billing', router);
|
||||
return app;
|
||||
}
|
||||
|
||||
function seedRecord(sessionId, overrides = {}) {
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
// Plant a record directly via the mutation API.
|
||||
return store.claim({
|
||||
eventId: overrides.eventId || 'evt_seed',
|
||||
sessionId,
|
||||
productId: overrides.productId || 'pro-30d',
|
||||
durationDays: overrides.durationDays || 30,
|
||||
email: overrides.email || 'alice@example.com',
|
||||
});
|
||||
}
|
||||
|
||||
describe('GET /api/v1/billing/lookup/:sessionId', () => {
|
||||
let app;
|
||||
beforeAll(() => {
|
||||
app = makeApp();
|
||||
});
|
||||
|
||||
function get(sessionId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const port = server.address().port;
|
||||
const http = require('http');
|
||||
http.get(`http://127.0.0.1:${port}/api/v1/billing/lookup/${encodeURIComponent(sessionId)}`, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (c) => { body += c; });
|
||||
res.on('end', () => {
|
||||
server.close();
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: body ? JSON.parse(body) : null });
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('returns 404 for unknown sessionId', async () => {
|
||||
const res = await get('cs_unknown_session');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toMatchObject({ success: false });
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
test('returns 400 for invalid sessionId (too long)', async () => {
|
||||
const longId = 'x'.repeat(300);
|
||||
const res = await get(longId);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
test('returns processing state when record has no code yet', async () => {
|
||||
const sessionId = `cs_proc_${crypto.randomBytes(4).toString('hex')}`;
|
||||
await seedRecord(sessionId);
|
||||
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data).toMatchObject({ status: 'processing', durationDays: 30, productId: 'pro-30d' });
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
test('returns pending_email state with the persisted code (SMTP recovery)', async () => {
|
||||
const sessionId = `cs_pending_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_1', sessionId, productId: 'pro-90d', durationDays: 90, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_1', sessionId, code: 'DC-TEST-CODE-90D', codeId: 'cid_1' });
|
||||
await store.claimDelivery({ sessionId, ownerToken: 'evt_1' });
|
||||
await store.markDeliveryFailed({ sessionId, ownerToken: 'evt_1', error: 'smtp-down' });
|
||||
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({
|
||||
status: 'pending_email',
|
||||
durationDays: 90,
|
||||
productId: 'pro-90d',
|
||||
code: 'DC-TEST-CODE-90D',
|
||||
codeId: 'cid_1',
|
||||
});
|
||||
expect(res.body.data.lastError).toMatch(/smtp-down/);
|
||||
});
|
||||
|
||||
test('returns delivered state with the code + deliveredVia', async () => {
|
||||
const sessionId = `cs_delivered_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_2', sessionId, productId: 'pro-365d', durationDays: 365, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_2', sessionId, code: 'DC-TEST-CODE-365D', codeId: 'cid_2' });
|
||||
await store.claimDelivery({ sessionId, ownerToken: 'evt_2' });
|
||||
await store.markDelivered({ sessionId, ownerToken: 'evt_2', deliveredVia: 'smtp' });
|
||||
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({
|
||||
status: 'delivered',
|
||||
durationDays: 365,
|
||||
productId: 'pro-365d',
|
||||
code: 'DC-TEST-CODE-365D',
|
||||
codeId: 'cid_2',
|
||||
deliveredVia: 'smtp',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns 404 past the 24h TTL', async () => {
|
||||
const sessionId = `cs_old_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_old', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_old', sessionId, code: 'DC-OLD', codeId: 'cid_old' });
|
||||
await store.markDelivered({ sessionId, ownerToken: 'evt_old', deliveredVia: 'smtp' });
|
||||
|
||||
// Manually backdate the record's createdAt to be older than 24h.
|
||||
const fs = require('fs');
|
||||
const file = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE;
|
||||
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const r = state.bySessionId[sessionId];
|
||||
r.createdAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||||
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
||||
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUBLIC_ROUTES + CSRF allowlist for billing/lookup', () => {
|
||||
const fs = require('fs');
|
||||
test('PUBLIC_ROUTES includes /api/v1/billing/lookup/:sessionId', () => {
|
||||
const content = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'utilities', 'middleware.js'), 'utf8');
|
||||
expect(content).toMatch(/path:\s*['"]\/api\/v1\/billing\/lookup\/:sessionId['"]/);
|
||||
});
|
||||
|
||||
test('CSRF excludedPaths includes /api/v1/billing/lookup/:sessionId', () => {
|
||||
const content = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'security', 'csrf-protection.js'), 'utf8');
|
||||
expect(content).toMatch(/['"]\/api\/v1\/billing\/lookup\/:sessionId['"]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* DC-057 bridge HTTP /lookup/:sessionId endpoint tests.
|
||||
*
|
||||
* Tests the bridge's own GET /lookup/:sessionId endpoint (separate from
|
||||
* the API route). The bridge endpoint is for out-of-band operator use —
|
||||
* the production customer lookup goes through routes/billing.js (covered
|
||||
* by billing-lookup.test.js). But the bridge must still serve /lookup/*
|
||||
* correctly for operator workflows and incident recovery.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-bridge-http-'));
|
||||
process.env.STRIPE_BRIDGE_STATE_DIR = TMP;
|
||||
process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json');
|
||||
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_' + crypto.randomBytes(8).toString('hex');
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_FROM;
|
||||
|
||||
jest.mock('../../license-keygen', () => {
|
||||
// Use the built-in Date + Math.random instead of crypto so the jest.mock
|
||||
// factory stays in scope (jest.mock factory bodies cannot reference
|
||||
// outer-scope identifiers like `crypto`).
|
||||
const mockRandom = () => Math.random().toString(16).slice(2, 10).toUpperCase();
|
||||
let mockCounter = 0;
|
||||
return {
|
||||
VALID_DURATIONS: [30, 90, 180, 365],
|
||||
loadSecret: () => 'mock-secret',
|
||||
generateCodes: jest.fn(({ durationDays, count }) => {
|
||||
const codes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
codes.push({
|
||||
code: `DC-TEST-${durationDays}D-${mockRandom()}`,
|
||||
codeId: `cid_${Date.now()}_${i}_${++mockCounter}`,
|
||||
});
|
||||
}
|
||||
return codes;
|
||||
}),
|
||||
};
|
||||
});
|
||||
jest.mock('nodemailer', () => ({
|
||||
createTransport: () => ({ sendMail: jest.fn() }),
|
||||
}));
|
||||
|
||||
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
|
||||
const bridge = require('../../scripts/stripe-license-bridge');
|
||||
|
||||
let server;
|
||||
let port;
|
||||
|
||||
beforeAll((done) => {
|
||||
// Use the bridge's own createServer() factory so the test exercises the
|
||||
// SAME request dispatcher the production server uses (no duplicated
|
||||
// route decoding / status mapping in test code).
|
||||
server = bridge.createServer();
|
||||
server.listen(0, () => {
|
||||
port = server.address().port;
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
function get(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`http://127.0.0.1:${port}${path}`, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (c) => { body += c; });
|
||||
res.on('end', () => {
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: body ? JSON.parse(body) : null });
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('bridge GET /lookup/:sessionId', () => {
|
||||
test('returns 404 for unknown sessionId', async () => {
|
||||
const res = await get('/lookup/cs_unknown_session');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ status: 'not_found' });
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
test('returns 400 for malformed percent-encoded sessionId', async () => {
|
||||
// %ZZ is not valid hex.
|
||||
const res = await get('/lookup/cs_%ZZ_bad');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe('invalid-session-id');
|
||||
});
|
||||
|
||||
test('returns delivered state with code + deliveredVia for planted record', async () => {
|
||||
const sessionId = `cs_test_delivered_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_1', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_1', sessionId, code: 'DC-X', codeId: 'cid_1' });
|
||||
await store.claimDelivery({ sessionId, ownerToken: 'evt_1' });
|
||||
await store.markDelivered({ sessionId, ownerToken: 'evt_1', deliveredVia: 'smtp' });
|
||||
|
||||
const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
status: 'delivered',
|
||||
durationDays: 30,
|
||||
productId: 'pro-30d',
|
||||
code: 'DC-X',
|
||||
codeId: 'cid_1',
|
||||
deliveredVia: 'smtp',
|
||||
});
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
test('returns pending_email state (SMTP recovery)', async () => {
|
||||
const sessionId = `cs_test_pending_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_2', sessionId, productId: 'pro-90d', durationDays: 90, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_2', sessionId, code: 'DC-Y', codeId: 'cid_2' });
|
||||
await store.claimDelivery({ sessionId, ownerToken: 'evt_2' });
|
||||
await store.markDeliveryFailed({ sessionId, ownerToken: 'evt_2', error: 'smtp-down' });
|
||||
|
||||
const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
status: 'pending_email',
|
||||
durationDays: 90,
|
||||
productId: 'pro-90d',
|
||||
code: 'DC-Y',
|
||||
codeId: 'cid_2',
|
||||
});
|
||||
expect(res.body.lastError).toMatch(/smtp-down/);
|
||||
});
|
||||
|
||||
test('returns 404 past the 24h TTL', async () => {
|
||||
const sessionId = `cs_test_old_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_3', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_3', sessionId, code: 'DC-OLD', codeId: 'cid_3' });
|
||||
await store.claimDelivery({ sessionId, ownerToken: 'evt_3' });
|
||||
await store.markDelivered({ sessionId, ownerToken: 'evt_3', deliveredVia: 'smtp' });
|
||||
|
||||
// Backdate createdAt to be older than 24h.
|
||||
const file = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE;
|
||||
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const r = state.bySessionId[sessionId];
|
||||
r.createdAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||||
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
||||
|
||||
const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ status: 'expired' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* DC-057 billing checkout origin resolution tests.
|
||||
*
|
||||
* The checkout endpoint embeds the success_url (and cancel_url) into the
|
||||
* Stripe Checkout Session. These URLs are what Stripe redirects the
|
||||
* customer's browser to after payment. They MUST be derived only from
|
||||
* trusted sources — otherwise a header-injection attacker could redirect
|
||||
* customers to their own origin and capture the session_id, which is
|
||||
* the bearer token for /api/v1/billing/lookup/:sessionId (and that
|
||||
* endpoint serves the customer's license code on success).
|
||||
*
|
||||
* The origin is resolved in this priority order:
|
||||
* 1. STRIPE_PUBLIC_ORIGIN env var (canonical deployment shape)
|
||||
* 2. Request Host header, but ONLY when the host is in
|
||||
* STRIPE_ALLOWED_HOSTS (operator-declared allowlist)
|
||||
* 3. undefined (Stripe falls back to its own defaults)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
|
||||
// Save the fulfillment-store path so the route module captures the same
|
||||
// path the route would in production. (Tests below exercise the
|
||||
// stripe-client, not the fulfillment store, so the lookup endpoint can
|
||||
// share the same file.)
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-origin-'));
|
||||
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
||||
|
||||
const billingRoutes = require('../../routes/billing');
|
||||
const stripeClient = require('../../src/billing/stripe-client');
|
||||
|
||||
const REQUIRED_ENV = {
|
||||
STRIPE_SECRET_KEY: '«redacted:sk_test_…»',
|
||||
STRIPE_PRICE_PRO_30D: 'price_30d_test',
|
||||
STRIPE_PRICE_PRO_90D: 'price_90d_test',
|
||||
STRIPE_PRICE_PRO_180D: 'price_180d_test',
|
||||
STRIPE_PRICE_PRO_365D: 'price_365d_test',
|
||||
};
|
||||
|
||||
function setEnv(overrides = {}) {
|
||||
const all = { ...REQUIRED_ENV, ...overrides };
|
||||
for (const [k, v] of Object.entries(all)) {
|
||||
process.env[k] = v;
|
||||
}
|
||||
}
|
||||
function clearEnv() {
|
||||
for (const k of Object.keys(REQUIRED_ENV)) delete process.env[k];
|
||||
delete process.env.STRIPE_PUBLIC_ORIGIN;
|
||||
delete process.env.STRIPE_ALLOWED_HOSTS;
|
||||
delete process.env.NODE_ENV;
|
||||
}
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(require('express').json());
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
const router = billingRoutes({ asyncHandler });
|
||||
app.use('/api/v1/billing', router);
|
||||
return app;
|
||||
}
|
||||
|
||||
function postCheckout(req, body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = req.listen(0, () => {
|
||||
const port = server.address().port;
|
||||
const data = JSON.stringify(body);
|
||||
const headerLines = Object.entries({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers })
|
||||
.map(([k, v]) => `${k}: ${v}`).join('\r\n');
|
||||
const req2 = http.request({
|
||||
hostname: '127.0.0.1', port, path: '/api/v1/billing/checkout', method: 'POST',
|
||||
headers: Object.fromEntries(Object.entries({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }).map(([k, v]) => [k.toLowerCase(), v])),
|
||||
}, (res) => {
|
||||
let buf = '';
|
||||
res.on('data', (c) => { buf += c; });
|
||||
res.on('end', () => {
|
||||
server.close();
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: buf ? JSON.parse(buf) : null });
|
||||
});
|
||||
});
|
||||
req2.on('error', reject);
|
||||
req2.write(data);
|
||||
req2.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('POST /api/v1/billing/checkout — origin resolution (DC-057 security)', () => {
|
||||
let app;
|
||||
beforeAll(() => {
|
||||
app = makeApp();
|
||||
setEnv();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearEnv();
|
||||
setEnv();
|
||||
stripeClient._setStripeSdk(null);
|
||||
});
|
||||
|
||||
test('uses STRIPE_PUBLIC_ORIGIN env var (canonical deployment shape)', async () => {
|
||||
setEnv({ STRIPE_PUBLIC_ORIGIN: 'https://status.sami' });
|
||||
const mockSession = { id: 'cs_test_orig_1', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_1' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
// The captured Stripe params must include the success_url + cancel_url
|
||||
// built from the operator-declared origin — NOT from the request's Host
|
||||
// header. This is the canonical deployment shape.
|
||||
expect(capturedParams.success_url).toBe('https://status.sami/billing/success?session_id={CHECKOUT_SESSION_ID}');
|
||||
expect(capturedParams.cancel_url).toBe('https://status.sami/pricing');
|
||||
});
|
||||
|
||||
test('rejects Host header injection when STRIPE_ALLOWED_HOSTS is empty', async () => {
|
||||
// Attacker sets X-Forwarded-Host: evil.com. The request reaches our
|
||||
// endpoint. Without STRIPE_PUBLIC_ORIGIN + without STRIPE_ALLOWED_HOSTS,
|
||||
// the origin must be undefined — we MUST NOT trust the attacker header.
|
||||
setEnv({ STRIPE_ALLOWED_HOSTS: '' });
|
||||
const mockSession = { id: 'cs_test_orig_2', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_2' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' }, {
|
||||
'X-Forwarded-Host': 'evil.com',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// origin must be undefined when allowlist is empty — the Stripe SDK
|
||||
// is called with undefined origin and the stripe-client falls back to
|
||||
// relative '/billing/success' which is safe (no host poisoning).
|
||||
expect(capturedParams.success_url).toMatch(/^\/billing\/success/);
|
||||
});
|
||||
|
||||
test('accepts Host header when STRIPE_ALLOWED_HOSTS includes it', async () => {
|
||||
setEnv({ STRIPE_ALLOWED_HOSTS: 'status.sami,dashcaddy.net' });
|
||||
const mockSession = { id: 'cs_test_orig_3', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_3' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' }, {
|
||||
'X-Forwarded-Host': 'status.sami',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedParams.success_url).toContain('status.sami');
|
||||
expect(capturedParams.success_url).toContain('/billing/success');
|
||||
});
|
||||
|
||||
test('rejects Host header when host is NOT in STRIPE_ALLOWED_HOSTS', async () => {
|
||||
setEnv({ STRIPE_ALLOWED_HOSTS: 'status.sami' });
|
||||
const mockSession = { id: 'cs_test_orig_4', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_4' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' }, {
|
||||
'X-Forwarded-Host': 'evil.com',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// origin is undefined → relative /billing/success URL (safe).
|
||||
expect(capturedParams.success_url).toMatch(/^\/billing\/success/);
|
||||
expect(capturedParams.success_url).not.toContain('evil.com');
|
||||
});
|
||||
|
||||
test('rejects javascript: scheme injection via STRIPE_PUBLIC_ORIGIN', async () => {
|
||||
setEnv({ STRIPE_PUBLIC_ORIGIN: 'javascript:alert(1)' });
|
||||
const mockSession = { id: 'cs_test_orig_5', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_5' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' });
|
||||
expect(res.status).toBe(200);
|
||||
// javascript: scheme is rejected; origin falls through to header-based
|
||||
// resolution, which is also gated by STRIPE_ALLOWED_HOSTS (empty here).
|
||||
expect(capturedParams.success_url).not.toMatch(/javascript:/);
|
||||
});
|
||||
|
||||
test('rejects http:// in production when NODE_ENV=production', async () => {
|
||||
setEnv({ STRIPE_PUBLIC_ORIGIN: 'http://status.sami', NODE_ENV: 'production' });
|
||||
const mockSession = { id: 'cs_test_orig_6', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_6' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' });
|
||||
expect(res.status).toBe(200);
|
||||
// http:// rejected in production; origin falls back to undefined.
|
||||
expect(capturedParams.success_url).not.toMatch(/^http:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* DC-057 pricing-page catalog consistency test.
|
||||
*
|
||||
* The pricing page at status/pricing/index.html hard-codes the 4 product
|
||||
* IDs, prices, and labels. This test asserts that those hard-coded values
|
||||
* exactly match the catalog in src/billing/catalog.js — preventing drift
|
||||
* between the two sources.
|
||||
*
|
||||
* If a new tier is added to the catalog, this test will fail until the
|
||||
* pricing page is updated. If the pricing page is updated, the catalog
|
||||
* must change in lockstep (or this test fails the other way).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
|
||||
const PRICING_PAGE_PATH = path.join(__dirname, '..', '..', '..', 'status', 'pricing', 'index.html');
|
||||
|
||||
function extractTiersFromPage(html) {
|
||||
// Extract each `<div class="tier pro" data-product-id="...">` block, then
|
||||
// pull out the dollar amount in the `<div class="price">` element and
|
||||
// the durationDays from the "N-day Pro license" string. The regex is
|
||||
// anchored on the tier-class open + the matching buy-btn close so we
|
||||
// capture the full body of each tier card regardless of how many inner
|
||||
// divs it has.
|
||||
const tierRe = /<div class="tier pro" data-product-id="([^"]+)">([\s\S]*?)<button[^>]*class="buy-btn"[^>]*>\s*Buy/g;
|
||||
const tierBlocks = [...html.matchAll(tierRe)];
|
||||
return tierBlocks.map(([, productId, body]) => {
|
||||
const priceMatch = body.match(/<div class="price">\$(\d+)<\/div>/);
|
||||
const durMatch = body.match(/(\d+)-day Pro license/);
|
||||
return {
|
||||
productId,
|
||||
priceDollars: priceMatch ? parseInt(priceMatch[1], 10) : null,
|
||||
durationDays: durMatch ? parseInt(durMatch[1], 10) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the HTML body for one specific tier (from open div through the
|
||||
* buy-btn). Used by per-tier assertions that must NOT bleed across cards.
|
||||
*/
|
||||
function extractTierBody(html, productId) {
|
||||
const re = new RegExp(
|
||||
`<div class="tier pro" data-product-id="${productId}">([\\s\\S]*?)<button[^>]*class="buy-btn"[^>]*>\\s*Buy`,
|
||||
'i'
|
||||
);
|
||||
const m = html.match(re);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
describe('pricing page <-> catalog consistency (DC-057)', () => {
|
||||
let html;
|
||||
let pageTiers;
|
||||
|
||||
beforeAll(() => {
|
||||
html = fs.readFileSync(PRICING_PAGE_PATH, 'utf8');
|
||||
pageTiers = extractTiersFromPage(html);
|
||||
});
|
||||
|
||||
test('pricing page exists and is readable', () => {
|
||||
expect(html.length).toBeGreaterThan(1000);
|
||||
expect(pageTiers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every catalog product is rendered on the pricing page', () => {
|
||||
const catalogIds = catalog.PRODUCTS.map((p) => p.id).sort();
|
||||
const pageIds = pageTiers.map((t) => t.productId).sort();
|
||||
expect(pageIds).toEqual(catalogIds);
|
||||
});
|
||||
|
||||
test('every pricing-page productId appears in the catalog', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
expect(product).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing-page dollar amounts match catalog amountCents', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
const expectedDollars = product.amountCents / 100;
|
||||
expect(tier.priceDollars).toBe(expectedDollars);
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing-page duration strings match catalog durationDays', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
expect(tier.durationDays).toBe(product.durationDays);
|
||||
}
|
||||
});
|
||||
|
||||
test('catalog and pricing page agree on price label (scoped per tier card)', () => {
|
||||
// Per-tier priceLabel assertion: each tier card must include its
|
||||
// own catalog.priceLabel. A swap or misplaced label fails immediately
|
||||
// because the assertion checks the tier's own HTML body, not the page.
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
const body = extractTierBody(html, tier.productId);
|
||||
expect(body).not.toBeNull();
|
||||
// The priceLabel appears in the price div of THIS tier only,
|
||||
// immediately followed by the closing </div> + the duration block.
|
||||
const labelRegex = new RegExp(`<div class="price">\\s*\\${product.priceLabel}\\s*</div>\\s*<div class="duration"`);
|
||||
expect(body).toMatch(labelRegex);
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing page does not include the monthly/annual subscription toggle (one-time only)', () => {
|
||||
// DC-057 acceptance: locked spec is ONE-TIME 30/90/180/365-day licenses
|
||||
// at $20/$50/$70/$99. The old monthly/annual subscription toggle
|
||||
// would contradict the spec.
|
||||
expect(html).not.toMatch(/period-monthly|period-annual/);
|
||||
expect(html).not.toMatch(/Subscribe to Pro/);
|
||||
});
|
||||
|
||||
test('pricing page references the success-page endpoint', () => {
|
||||
// The success URL is constructed server-side in stripe-client.js
|
||||
// (${origin}/billing/success?session_id=...). The pricing page itself
|
||||
// doesn't need to embed it — but the FOOTER must reference it so the
|
||||
// customer knows where to go after Stripe redirects.
|
||||
expect(html.toLowerCase()).toContain('after payment');
|
||||
expect(html).toContain('/admin/license');
|
||||
expect(html).toContain('/api/v1/billing/checkout');
|
||||
});
|
||||
|
||||
test('success page (status/billing/success.html) exists and references the lookup endpoint', () => {
|
||||
const successPath = path.join(__dirname, '..', '..', '..', 'status', 'billing', 'success.html');
|
||||
const successHtml = fs.readFileSync(successPath, 'utf8');
|
||||
expect(successHtml).toContain('/api/v1/billing/lookup/');
|
||||
expect(successHtml.length).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* DC-055 + DC-057 billing/stripe-client tests.
|
||||
*
|
||||
* Strategy: inject a mock Stripe SDK via _setStripeSdk so no real network
|
||||
* calls ever happen. Cover the key behaviors of the one-time payment flow:
|
||||
*
|
||||
* 1. Configuration validation — missing STRIPE_SECRET_KEY fails loudly with 503.
|
||||
* 2. productId validation — unknown productId returns 400 INVALID_PRODUCT_ID.
|
||||
* 3. Product not configured — Stripe Price ID env var unset returns 503.
|
||||
* 4. Happy path — creates a session with mode:payment + correct price ID + URLs.
|
||||
* 5. Stripe SDK errors — surface as 502 to the customer, not 500.
|
||||
* 6. Metadata contract — emits metadata.productId that the bridge can read back.
|
||||
* 7. payment_intent_data — also carries productId metadata for downstream consumers.
|
||||
* 8. Catalog drives everything — _resolveProduct reads the catalog, not env.
|
||||
*/
|
||||
|
||||
const stripeClient = require('../../src/billing/stripe-client');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
|
||||
const REQUIRED_ENV = {
|
||||
STRIPE_SECRET_KEY: '«redacted:sk_test_…»',
|
||||
STRIPE_PRICE_PRO_30D: 'price_30d_test',
|
||||
STRIPE_PRICE_PRO_90D: 'price_90d_test',
|
||||
STRIPE_PRICE_PRO_180D: 'price_180d_test',
|
||||
STRIPE_PRICE_PRO_365D: 'price_365d_test',
|
||||
};
|
||||
|
||||
function setEnv(overrides = {}) {
|
||||
const all = { ...REQUIRED_ENV, ...overrides };
|
||||
for (const [k, v] of Object.entries(all)) {
|
||||
process.env[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
function clearEnv() {
|
||||
for (const k of Object.keys(REQUIRED_ENV)) delete process.env[k];
|
||||
}
|
||||
|
||||
function makeMockStripe(sessionsCreateImpl) {
|
||||
const sessions = { create: jest.fn().mockImplementation(sessionsCreateImpl) };
|
||||
return jest.fn().mockReturnValue({ checkout: { sessions } });
|
||||
}
|
||||
|
||||
describe('billing/stripe-client', () => {
|
||||
afterEach(() => {
|
||||
clearEnv();
|
||||
stripeClient._setStripeSdk(null);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('throws STRIPE_NOT_CONFIGURED when STRIPE_SECRET_KEY is missing', async () => {
|
||||
setEnv({ STRIPE_SECRET_KEY: '' });
|
||||
await expect(
|
||||
stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'STRIPE_NOT_CONFIGURED',
|
||||
statusCode: 503,
|
||||
missing: expect.arrayContaining(['STRIPE_SECRET_KEY']),
|
||||
});
|
||||
});
|
||||
|
||||
test('throws STRIPE_NOT_CONFIGURED when 30d product Stripe Price ID is missing', async () => {
|
||||
setEnv({ STRIPE_PRICE_PRO_30D: '' });
|
||||
await expect(
|
||||
stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'STRIPE_NOT_CONFIGURED',
|
||||
missing: expect.arrayContaining(['STRIPE_PRICE_PRO_30D']),
|
||||
productId: 'pro-30d',
|
||||
});
|
||||
});
|
||||
|
||||
test('throws INVALID_PRODUCT_ID when productId is missing', async () => {
|
||||
setEnv();
|
||||
await expect(
|
||||
stripeClient.createCheckoutSession({ productId: '', origin: 'https://status.sami' })
|
||||
).rejects.toMatchObject({ code: 'INVALID_PRODUCT_ID', statusCode: 400, field: 'productId' });
|
||||
});
|
||||
|
||||
test('throws INVALID_PRODUCT_ID when productId is unknown', async () => {
|
||||
setEnv();
|
||||
await expect(
|
||||
stripeClient.createCheckoutSession({ productId: 'pro-1000d', origin: 'https://status.sami' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'INVALID_PRODUCT_ID',
|
||||
statusCode: 400,
|
||||
field: 'productId',
|
||||
});
|
||||
});
|
||||
|
||||
test('happy path: pro-30d creates session with mode=payment + correct params', async () => {
|
||||
setEnv();
|
||||
const mockSession = { id: 'cs_test_abc123', url: 'https://checkout.stripe.com/c/pay/cs_test_abc123' };
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
// DC-057: one-time payment, NOT subscription.
|
||||
expect(params.mode).toBe('payment');
|
||||
expect(params.line_items).toEqual([{ price: 'price_30d_test', quantity: 1 }]);
|
||||
expect(params.success_url).toBe('https://status.sami/billing/success?session_id={CHECKOUT_SESSION_ID}');
|
||||
expect(params.cancel_url).toBe('https://status.sami/pricing');
|
||||
// The bridge reads this metadata back to map session → product → duration.
|
||||
expect(params.metadata).toMatchObject({ productId: 'pro-30d', product: 'dashcaddy-pro' });
|
||||
// payment_intent_data.metadata mirrors it for downstream Stripe→bridge consumers.
|
||||
expect(params.payment_intent_data).toBeDefined();
|
||||
expect(params.payment_intent_data.metadata).toMatchObject({ productId: 'pro-30d', product: 'dashcaddy-pro' });
|
||||
// No subscription_data on one-time payment.
|
||||
expect(params.subscription_data).toBeUndefined();
|
||||
return mockSession;
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
const result = await stripeClient.createCheckoutSession({
|
||||
productId: 'pro-30d',
|
||||
origin: 'https://status.sami',
|
||||
});
|
||||
expect(result).toEqual({ id: 'cs_test_abc123', url: mockSession.url });
|
||||
expect(mockStripe).toHaveBeenCalledWith('«redacted:sk_test_…»');
|
||||
});
|
||||
|
||||
test('happy path: pro-365d uses 365d price ID', async () => {
|
||||
setEnv();
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect(params.line_items[0].price).toBe('price_365d_test');
|
||||
expect(params.metadata.productId).toBe('pro-365d');
|
||||
return { id: 'cs_365_xyz', url: 'https://checkout.stripe.com/c/pay/cs_365_xyz' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
const result = await stripeClient.createCheckoutSession({
|
||||
productId: 'pro-365d',
|
||||
origin: 'https://status.sami',
|
||||
});
|
||||
expect(result.id).toBe('cs_365_xyz');
|
||||
});
|
||||
|
||||
test('forwards customerEmail when provided', async () => {
|
||||
setEnv();
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect(params.customer_email).toBe('alice@example.com');
|
||||
return { id: 'cs_emailed', url: 'https://checkout.stripe.com/c/pay/cs_emailed' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
await stripeClient.createCheckoutSession({
|
||||
productId: 'pro-90d',
|
||||
customerEmail: 'alice@example.com',
|
||||
origin: 'https://status.sami',
|
||||
});
|
||||
});
|
||||
|
||||
test('omits customer_email when not provided (no undefined leakage to Stripe)', async () => {
|
||||
setEnv();
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect('customer_email' in params).toBe(false);
|
||||
return { id: 'cs_no_email', url: 'https://checkout.stripe.com/c/pay/cs_no_email' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
await stripeClient.createCheckoutSession({
|
||||
productId: 'pro-30d',
|
||||
origin: 'https://status.sami',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses STRIPE_SUCCESS_URL override when set', async () => {
|
||||
setEnv({ STRIPE_SUCCESS_URL: 'https://custom.example.com/thanks' });
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect(params.success_url).toBe('https://custom.example.com/thanks');
|
||||
return { id: 'cs_custom', url: 'x' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' });
|
||||
});
|
||||
|
||||
test('uses STRIPE_CANCEL_URL override when set', async () => {
|
||||
setEnv({ STRIPE_CANCEL_URL: 'https://custom.example.com/back' });
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect(params.cancel_url).toBe('https://custom.example.com/back');
|
||||
return { id: 'cs_cancel', url: 'x' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' });
|
||||
});
|
||||
|
||||
test('works with relative origin (no host header)', async () => {
|
||||
setEnv();
|
||||
const mockStripe = makeMockStripe(async () => ({ id: 'x', url: 'x' }));
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
const result = await stripeClient.createCheckoutSession({ productId: 'pro-30d' });
|
||||
expect(result.id).toBe('x');
|
||||
});
|
||||
|
||||
test('each catalog product drives a different price ID', async () => {
|
||||
setEnv();
|
||||
for (const product of catalog.PRODUCTS) {
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect(params.line_items[0].price).toBe(REQUIRED_ENV[product.priceEnv]);
|
||||
expect(params.metadata.productId).toBe(product.id);
|
||||
return { id: `cs_${product.id}`, url: 'x' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
await stripeClient.createCheckoutSession({ productId: product.id, origin: 'https://status.sami' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('billing/stripe-client — _resolveProduct unit', () => {
|
||||
test('resolves known productId with configured price', () => {
|
||||
setEnv();
|
||||
const result = stripeClient._resolveProduct('pro-30d');
|
||||
expect(result.product.id).toBe('pro-30d');
|
||||
expect(result.priceId).toBe('price_30d_test');
|
||||
});
|
||||
|
||||
test('returns INVALID_PRODUCT_ID error for unknown productId', () => {
|
||||
setEnv();
|
||||
expect(() => stripeClient._resolveProduct('pro-1000d')).toThrow();
|
||||
try { stripeClient._resolveProduct('pro-1000d'); } catch (e) {
|
||||
expect(e.code).toBe('INVALID_PRODUCT_ID');
|
||||
expect(e.statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns STRIPE_NOT_CONFIGURED error when product price is unset', () => {
|
||||
setEnv({ STRIPE_PRICE_PRO_180D: '' });
|
||||
try { stripeClient._resolveProduct('pro-180d'); } catch (e) {
|
||||
expect(e.code).toBe('STRIPE_NOT_CONFIGURED');
|
||||
expect(e.statusCode).toBe(503);
|
||||
expect(e.missing).toContain('STRIPE_PRICE_PRO_180D');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* DC-054 + DC-057 stripe-license-bridge tests.
|
||||
*
|
||||
* Strategy: no live network, no live Stripe SDK. We use `jest.mock` to
|
||||
* substitute license-keygen + nodemailer before the bridge loads, drive
|
||||
* handleWebhook() with crafted raw bodies + signatures.
|
||||
*
|
||||
* Coverage:
|
||||
* - Signature validation (pass / missing / wrong / out-of-tolerance)
|
||||
* - JSON parse failure
|
||||
* - Duplicate event-id → 200 idempotent
|
||||
* - Two different events for the SAME session → single license (layer-2 idempotency)
|
||||
* - License persisted BEFORE email (crash-safety)
|
||||
* - Email failure → markDeliveryFailed → returns 500 → customer can retrieve via lookup
|
||||
* - Retry from pending_email delivers the SAME code
|
||||
* - Concurrent lease (busy) returns 409
|
||||
* - Catalog resolution: missing productId → 400; unknown productId → 400;
|
||||
* product-not-configured → 400
|
||||
* - Lookup endpoint: not_found, processing, pending_email, delivered, expired TTL
|
||||
* - Layer-1 + Layer-2 idempotency under Stripe retry
|
||||
*/
|
||||
|
||||
// jest.mock must be hoisted before any require.
|
||||
jest.mock('../../license-keygen', () => {
|
||||
const crypto = require('crypto');
|
||||
let calls = 0;
|
||||
return {
|
||||
VALID_DURATIONS: [30, 90, 180, 365],
|
||||
loadSecret: () => 'mock-license-secret-' + crypto.randomBytes(8).toString('hex'),
|
||||
generateCodes: jest.fn(({ durationDays, count }) => {
|
||||
calls++;
|
||||
const codes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
codes.push({
|
||||
code: `DC-TEST-${durationDays}D-${crypto.randomBytes(4).toString('hex').toUpperCase()}`,
|
||||
codeId: `codeid_${Date.now()}_${i}_${calls}`,
|
||||
});
|
||||
}
|
||||
return codes;
|
||||
}),
|
||||
__resetGenerateCalls() { calls = 0; },
|
||||
__getGenerateCalls() { return calls; },
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('nodemailer', () => ({
|
||||
createTransport: jest.fn(() => ({
|
||||
sendMail: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Set up isolated tmp dirs BEFORE requiring the bridge (it captures paths at require time).
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-bridge-'));
|
||||
process.env.STRIPE_BRIDGE_STATE_DIR = TMP;
|
||||
process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json');
|
||||
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
||||
// Use a unique webhook secret so tests don't pollute each other.
|
||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_' + crypto.randomBytes(8).toString('hex');
|
||||
// Configure all Stripe Prices so catalog.getConfiguredProducts() returns them.
|
||||
process.env.STRIPE_PRICE_PRO_30D = 'price_30d_test';
|
||||
process.env.STRIPE_PRICE_PRO_90D = 'price_90d_test';
|
||||
process.env.STRIPE_PRICE_PRO_180D = 'price_180d_test';
|
||||
process.env.STRIPE_PRICE_PRO_365D = 'price_365d_test';
|
||||
// Disable SMTP so the bridge falls back to dev-console unless a test
|
||||
// explicitly injects nodemailer.
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_FROM;
|
||||
|
||||
const licenseKeygenMock = require('../../license-keygen');
|
||||
const nodemailerMock = require('nodemailer');
|
||||
const bridge = require('../../scripts/stripe-license-bridge');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_FROM;
|
||||
licenseKeygenMock.__resetGenerateCalls();
|
||||
// Reset nodemailer.sendMail mock implementations between tests.
|
||||
nodemailerMock.createTransport.mockClear();
|
||||
});
|
||||
|
||||
// Helper: build a signed Stripe webhook payload.
|
||||
function buildSignedPayload(body, opts = {}) {
|
||||
const secret = opts.secret || process.env.STRIPE_WEBHOOK_SECRET;
|
||||
const ts = opts.timestamp || Math.floor(Date.now() / 1000);
|
||||
const rawBody = Buffer.from(JSON.stringify(body));
|
||||
const sig = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`, 'utf8').digest('hex');
|
||||
const header = `t=${ts},v1=${sig}`;
|
||||
return { rawBody, signatureHeader: header };
|
||||
}
|
||||
|
||||
function buildSessionEvent({ productId = 'pro-30d', sessionId, customerEmail = 'alice@example.com',
|
||||
eventId, lineItems, paymentStatus = 'paid' }) {
|
||||
const product = catalog.getProduct(productId);
|
||||
// For tests of "unknown productId" the catalog.getProduct returns null —
|
||||
// we still build a valid event so the bridge can return its own 400.
|
||||
const priceId = product ? catalog.getConfiguredPrice(product) : 'price_unconfigured';
|
||||
return {
|
||||
id: eventId || `evt_${crypto.randomBytes(6).toString('hex')}`,
|
||||
type: 'checkout.session.completed',
|
||||
data: {
|
||||
object: {
|
||||
id: sessionId || `cs_test_${crypto.randomBytes(6).toString('hex')}`,
|
||||
customer_email: customerEmail,
|
||||
customer_details: { email: customerEmail },
|
||||
payment_status: paymentStatus,
|
||||
amount_total: product ? product.amountCents : 0,
|
||||
currency: 'usd',
|
||||
metadata: { productId, product: 'dashcaddy-pro' },
|
||||
line_items: { data: lineItems || [{ price: { id: priceId } }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function injectSmtp(impl) {
|
||||
nodemailerMock.createTransport.mockImplementation(() => ({
|
||||
sendMail: jest.fn().mockImplementation(impl),
|
||||
}));
|
||||
}
|
||||
|
||||
describe('stripe-license-bridge signature verification', () => {
|
||||
test('rejects missing signature header', async () => {
|
||||
const { rawBody } = buildSignedPayload({ id: 'evt_1', type: 'x' });
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader: '' });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('signature-missing-signature');
|
||||
});
|
||||
|
||||
test('rejects wrong signature', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const rawBody = Buffer.from(JSON.stringify(event));
|
||||
const sig = crypto.createHmac('sha256', 'wrong').update(`${ts}.${rawBody}`, 'utf8').digest('hex');
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader: `t=${ts},v1=${sig}` });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toMatch(/^signature-/);
|
||||
});
|
||||
|
||||
test('rejects out-of-tolerance timestamp', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const oldTs = Math.floor(Date.now() / 1000) - 3600; // 1h ago, > 300s tolerance
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event, { timestamp: oldTs });
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('signature-timestamp-out-of-tolerance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge event parsing', () => {
|
||||
test('rejects invalid JSON', async () => {
|
||||
const rawBody = Buffer.from('not json');
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const sig = crypto.createHmac('sha256', process.env.STRIPE_WEBHOOK_SECRET).update(`${ts}.${rawBody}`, 'utf8').digest('hex');
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader: `t=${ts},v1=${sig}` });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('invalid-json');
|
||||
});
|
||||
|
||||
test('rejects event without id', async () => {
|
||||
const event = { type: 'checkout.session.completed', data: { object: {} } };
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('invalid-event');
|
||||
});
|
||||
|
||||
test('acks unknown event types with 200 (so Stripe stops retrying)', async () => {
|
||||
const event = { id: 'evt_unknown', type: 'customer.created', data: { object: {} } };
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.reason).toBe('ignored-event-type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge catalog resolution', () => {
|
||||
test('rejects session without productId metadata', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
delete event.data.object.metadata.productId;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('missing-productId');
|
||||
});
|
||||
|
||||
test('rejects unknown productId', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-1000d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('unknown-productId');
|
||||
});
|
||||
|
||||
test('rejects when product Stripe Price is unconfigured', async () => {
|
||||
const productId = 'pro-30d';
|
||||
const saved = process.env.STRIPE_PRICE_PRO_30D;
|
||||
delete process.env.STRIPE_PRICE_PRO_30D;
|
||||
try {
|
||||
const event = buildSessionEvent({ productId });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('product-not-configured');
|
||||
} finally {
|
||||
process.env.STRIPE_PRICE_PRO_30D = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects when customer email is missing', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', customerEmail: '' });
|
||||
delete event.data.object.customer_email;
|
||||
delete event.data.object.customer_details.email;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('missing-customer-email');
|
||||
});
|
||||
|
||||
test('accepts sessions without expanded line_items (Stripe webhook default)', async () => {
|
||||
// DC-057 acceptance: Stripe does NOT expand line_items in webhooks by
|
||||
// default — the bridge must accept the canonical metadata.productId
|
||||
// even when line_items is absent. (Price verification, when added,
|
||||
// should be an optional belt-and-suspenders via a separate API call,
|
||||
// not a hard requirement.)
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
delete event.data.object.line_items;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.productId).toBe('pro-30d');
|
||||
expect(result.body.durationDays).toBe(30);
|
||||
});
|
||||
|
||||
test('rejects unpaid sessions (no license until payment clears)', async () => {
|
||||
// DC-057: a checkout.session.completed event with payment_status='unpaid'
|
||||
// arrives when the customer closes the browser mid-checkout or for
|
||||
// delayed-payment methods (ACH/SEPA) before they clear. The bridge
|
||||
// MUST ack 200 (so Stripe stops retrying) but MUST NOT generate a
|
||||
// license. The async_payment_succeeded event will fire later.
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: 'unpaid' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('payment-not-unpaid');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects no_payment_required sessions (DashCaddy does not sell free products)', async () => {
|
||||
// 'no_payment_required' is a Stripe-internal edge case for free
|
||||
// sessions. DashCaddy has no $0 product, so reject explicitly.
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: 'no_payment_required' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('payment-not-no_payment_required');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects sessions with missing payment_status', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: '' });
|
||||
delete event.data.object.payment_status;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('payment-not-confirmed');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
|
||||
test('fulfills async_payment_succeeded events for delayed-payment methods', async () => {
|
||||
// ACH/SEPA: Stripe first sends checkout.session.completed (unpaid),
|
||||
// then async_payment_succeeded (paid) when the bank clears. The
|
||||
// bridge generates the license on the second event.
|
||||
const sessionId = `cs_test_ach_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const event = {
|
||||
id: `evt_ach_${crypto.randomBytes(6).toString('hex')}`,
|
||||
type: 'checkout.session.async_payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: sessionId,
|
||||
customer_email: 'alice@example.com',
|
||||
customer_details: { email: 'alice@example.com' },
|
||||
payment_status: 'paid',
|
||||
amount_total: 5000,
|
||||
currency: 'usd',
|
||||
metadata: { productId: 'pro-90d', product: 'dashcaddy-pro' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.productId).toBe('pro-90d');
|
||||
expect(result.body.durationDays).toBe(90);
|
||||
});
|
||||
|
||||
test('acks async_payment_failed events without generating a license', async () => {
|
||||
const event = {
|
||||
id: `evt_ach_fail_${crypto.randomBytes(6).toString('hex')}`,
|
||||
type: 'checkout.session.async_payment_failed',
|
||||
data: {
|
||||
object: {
|
||||
id: `cs_test_fail_${crypto.randomBytes(6).toString('hex')}`,
|
||||
payment_status: 'unpaid',
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('async-payment-failed');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge happy path', () => {
|
||||
test('generates + persists + delivers license (dev-console SMTP fallback)', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-90d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.productId).toBe('pro-90d');
|
||||
expect(result.body.durationDays).toBe(90);
|
||||
expect(result.body.codeId).toBeTruthy();
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
|
||||
// Fulfillment record exists.
|
||||
const record = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE })
|
||||
.readBySession(event.data.object.id);
|
||||
expect(record.status).toBe('delivered');
|
||||
expect(record.code).toBeTruthy();
|
||||
expect(record.codeId).toBe(result.body.codeId);
|
||||
expect(record.deliveredVia).toBe('dev-console');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge idempotency', () => {
|
||||
test('duplicate eventId (Stripe retry) returns 200 without regenerating', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
|
||||
const first = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.delivered).toBe(true);
|
||||
|
||||
const second = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.deduplicated).toBe(true);
|
||||
// generateCodes called exactly once across both deliveries.
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
});
|
||||
|
||||
test('two events for the same session reuse the same license (layer-2 idempotency)', async () => {
|
||||
const sessionId = `cs_test_shared_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
|
||||
const payloadA = buildSignedPayload(eventA);
|
||||
const payloadB = buildSignedPayload(eventB);
|
||||
|
||||
const rA = await bridge.handleWebhook({ rawBody: payloadA.rawBody, signatureHeader: payloadA.signatureHeader });
|
||||
const rB = await bridge.handleWebhook({ rawBody: payloadB.rawBody, signatureHeader: payloadB.signatureHeader });
|
||||
|
||||
expect(rA.status).toBe(200);
|
||||
expect(rA.body.delivered).toBe(true);
|
||||
// Second event hits layer-1 idempotency by eventId — different eventId,
|
||||
// so falls through to layer-2 by sessionId; sees existing delivered record.
|
||||
expect(rB.status).toBe(200);
|
||||
expect(rB.body.delivered).toBe(true);
|
||||
expect(rB.body.codeId).toBe(rA.body.codeId); // SAME license code
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1); // only one code generated
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge SMTP failure recovery (DC-057 acceptance)', () => {
|
||||
beforeEach(() => {
|
||||
// Inject SMTP BEFORE each test so SMTP_HOST is set when deliverCode runs.
|
||||
injectSmtp(async () => { throw new Error('smtp-down'); });
|
||||
process.env.SMTP_HOST = 'smtp.example.com';
|
||||
process.env.SMTP_FROM = 'noreply@example.com';
|
||||
});
|
||||
|
||||
test('SMTP failure persists license, returns 500, but customer can retrieve via lookup', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-180d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.body.reason).toBe('email-failed');
|
||||
|
||||
// License IS persisted (the documented SMTP-failure recovery path).
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const record = store.readBySession(event.data.object.id);
|
||||
expect(record.code).toBeTruthy();
|
||||
expect(record.status).toBe('pending_email');
|
||||
expect(record.lastError).toMatch(/smtp-down/);
|
||||
|
||||
// The lookup endpoint serves the persisted code ANYWAY.
|
||||
const lookup = bridge.lookupSession(event.data.object.id);
|
||||
expect(lookup.status).toBe('pending_email');
|
||||
expect(lookup.code).toBe(record.code);
|
||||
expect(lookup.durationDays).toBe(180);
|
||||
expect(lookup.productId).toBe('pro-180d');
|
||||
});
|
||||
|
||||
test('Stripe retry after SMTP failure keeps retrying (customer recovers via lookup)', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-365d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
|
||||
const first = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(first.status).toBe(500);
|
||||
|
||||
// Stripe retries with the SAME eventId. SMTP is still down → bridge
|
||||
// keeps retrying (returns 500) until either SMTP recovers or Stripe
|
||||
// gives up. The customer recovery path is via the lookup endpoint —
|
||||
// the license IS persisted in the fulfillment store regardless.
|
||||
const retry = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(retry.status).toBe(500);
|
||||
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const record = store.readBySession(event.data.object.id);
|
||||
expect(record.code).toBeTruthy();
|
||||
expect(record.status).toBe('pending_email');
|
||||
|
||||
// Lookup serves the persisted code.
|
||||
const lookup = bridge.lookupSession(event.data.object.id);
|
||||
expect(lookup.code).toBe(record.code);
|
||||
|
||||
// Only one license generated across the retries (layer-2 idempotency).
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
});
|
||||
|
||||
test('SMTP recovers on a subsequent attempt (different eventId, same session) — still reuses the persisted code', async () => {
|
||||
let smtpCalls = 0;
|
||||
injectSmtp(async () => {
|
||||
smtpCalls++;
|
||||
if (smtpCalls === 1) throw new Error('smtp-temp-down');
|
||||
return { messageId: 'msg-ok' };
|
||||
});
|
||||
|
||||
const sessionId = `cs_test_recover_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
|
||||
const payloadA = buildSignedPayload(eventA);
|
||||
const payloadB = buildSignedPayload(eventB);
|
||||
|
||||
const rA = await bridge.handleWebhook({ rawBody: payloadA.rawBody, signatureHeader: payloadA.signatureHeader });
|
||||
expect(rA.status).toBe(500); // first attempt: SMTP down
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
|
||||
// Read the persisted code from the store (rA.body doesn't include it on
|
||||
// failure — by design, we don't leak license material in error responses).
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const persistedCode = store.readBySession(sessionId).code;
|
||||
expect(persistedCode).toBeTruthy();
|
||||
|
||||
const rB = await bridge.handleWebhook({ rawBody: payloadB.rawBody, signatureHeader: payloadB.signatureHeader });
|
||||
expect(rB.status).toBe(200); // second event, same session: reuses persisted code, delivery succeeds
|
||||
expect(rB.body.delivered).toBe(true);
|
||||
// Same code reused, NOT a fresh generation.
|
||||
expect(rB.body.codeId).toBeTruthy();
|
||||
|
||||
// The store's codeId matches rB.body.codeId (proves reuse, not regeneration).
|
||||
expect(rB.body.codeId).toBe(store.readBySession(sessionId).codeId);
|
||||
|
||||
// No new license generated.
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
expect(smtpCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge lookupSession', () => {
|
||||
test('returns not_found for unknown sessionId', () => {
|
||||
expect(bridge.lookupSession('cs_unknown')).toEqual({ status: 'not_found' });
|
||||
});
|
||||
|
||||
test('returns expired for record past TTL', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
|
||||
// Far-future "now" past the 24h TTL.
|
||||
const future = Date.now() + 25 * 60 * 60 * 1000;
|
||||
const lookup = bridge.lookupSession(event.data.object.id, { nowMs: future });
|
||||
expect(lookup.status).toBe('expired');
|
||||
});
|
||||
|
||||
test('returns processing state for fresh claim without code', async () => {
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const sessionId = `cs_test_processing_${crypto.randomBytes(4).toString('hex')}`;
|
||||
await store.claim({ eventId: 'evt_pend', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
|
||||
const lookup = bridge.lookupSession(sessionId);
|
||||
expect(lookup.status).toBe('processing');
|
||||
expect(lookup.durationDays).toBe(30);
|
||||
expect(lookup.productId).toBe('pro-30d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge constants', () => {
|
||||
test('LOOKUP_TTL_MS defaults to 24h', () => {
|
||||
expect(bridge.LOOKUP_TTL_MS).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('DELIVERY_LEASE_MS is exported', () => {
|
||||
expect(bridge.DELIVERY_LEASE_MS).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -401,59 +401,68 @@ describe('license-keygen: CLI regression', () => {
|
||||
|
||||
function _setupSecret() {
|
||||
fs.writeFileSync(path.join(tmp, '.license-secret'), TEST_SECRET);
|
||||
return path.join(tmp, '.license-secret');
|
||||
}
|
||||
|
||||
test('omitted --start-id uses the auto-counter path (CLI integration)', () => {
|
||||
_setupSecret();
|
||||
const secretFile = _setupSecret();
|
||||
const counterFile = path.join(tmp, '.license-counter');
|
||||
|
||||
// First call: no --start-id, expects counter to be created at 1.
|
||||
const out1 = _runCli(['--duration', '30', '--count', '1', '--json'], {
|
||||
LICENSE_COUNTER_FILE: counterFile,
|
||||
LICENSE_SECRET_FILE: secretFile,
|
||||
});
|
||||
const codes1 = JSON.parse(out1.split('Generated')[0]);
|
||||
expect(codes1).toHaveLength(1);
|
||||
expect(codes1[0].codeId).toBe(1);
|
||||
|
||||
expect(codes1.length).toBe(1);
|
||||
expect(codes1[0].durationDays).toBe(30);
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('1');
|
||||
|
||||
// Second call: counter should auto-increment to 2.
|
||||
const out2 = _runCli(['--duration', '30', '--count', '1', '--json'], {
|
||||
LICENSE_COUNTER_FILE: counterFile,
|
||||
LICENSE_SECRET_FILE: secretFile,
|
||||
});
|
||||
const codes2 = JSON.parse(out2.split('Generated')[0]);
|
||||
expect(codes2[0].codeId).toBe(2);
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('2');
|
||||
|
||||
expect(codes2[0].codeId).toBeGreaterThan(codes1[0].codeId);
|
||||
});
|
||||
|
||||
test('--start-id override skips counter file update (CLI integration)', () => {
|
||||
_setupSecret();
|
||||
const secretFile = _setupSecret();
|
||||
const counterFile = path.join(tmp, '.license-counter');
|
||||
fs.writeFileSync(counterFile, '99');
|
||||
|
||||
const out = _runCli(['--duration', '30', '--start-id', '500', '--count', '2', '--json'], {
|
||||
LICENSE_COUNTER_FILE: counterFile,
|
||||
LICENSE_SECRET_FILE: secretFile,
|
||||
});
|
||||
const codes = JSON.parse(out.split('Generated')[0]);
|
||||
expect(codes.map(c => c.codeId)).toEqual([500, 501]);
|
||||
// Counter file untouched.
|
||||
|
||||
expect(codes.length).toBe(2);
|
||||
expect(codes[0].codeId).toBe(500);
|
||||
expect(codes[1].codeId).toBe(501);
|
||||
// Counter file remains untouched at '99' (override skips auto-update).
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('99');
|
||||
});
|
||||
|
||||
test('--lifetime and --duration are mutually exclusive (CLI integration)', () => {
|
||||
_setupSecret();
|
||||
const secretFile = _setupSecret();
|
||||
expect(() =>
|
||||
_runCli(['--duration', '30', '--lifetime', '--count', '1'], {
|
||||
LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
|
||||
LICENSE_SECRET_FILE: secretFile,
|
||||
}),
|
||||
).toThrow(/mutually exclusive/);
|
||||
});
|
||||
|
||||
test('--tier pro without --duration or --lifetime still requires one of them', () => {
|
||||
_setupSecret();
|
||||
const secretFile = _setupSecret();
|
||||
expect(() =>
|
||||
_runCli(['--tier', 'pro', '--count', '1'], {
|
||||
LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
|
||||
LICENSE_SECRET_FILE: secretFile,
|
||||
}),
|
||||
).toThrow(/--duration is required/);
|
||||
});
|
||||
|
||||
@@ -16,8 +16,16 @@ const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Master secret file — lives only on admin machine, NEVER shipped
|
||||
const SECRET_FILE = path.join(__dirname, '.license-secret');
|
||||
// Master secret file — lives only on admin machine, NEVER shipped.
|
||||
// Default is `path.join(__dirname, '.license-secret')`. The path is
|
||||
// overridable via the `LICENSE_SECRET_FILE` env var so the CLI can be
|
||||
// driven from CI / isolated test environments without polluting the
|
||||
// source directory (mirrors the `LICENSE_COUNTER_FILE` override pattern).
|
||||
// The Stripe bridge uses the same env var to point at its own secret file
|
||||
// on the bridge host.
|
||||
function _defaultSecretFile() {
|
||||
return process.env.LICENSE_SECRET_FILE || path.join(__dirname, '.license-secret');
|
||||
}
|
||||
|
||||
// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE
|
||||
// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(40bit)
|
||||
@@ -61,12 +69,13 @@ function base32Decode(str) {
|
||||
}
|
||||
|
||||
function getSecret() {
|
||||
if (!fs.existsSync(SECRET_FILE)) {
|
||||
console.error('No master secret found at', SECRET_FILE);
|
||||
const file = _defaultSecretFile();
|
||||
if (!fs.existsSync(file)) {
|
||||
console.error('No master secret found at', file);
|
||||
console.error('Run with --init-secret first.');
|
||||
process.exit(1);
|
||||
}
|
||||
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||
return fs.readFileSync(file, 'utf8').trim();
|
||||
}
|
||||
|
||||
// Counter location: the default is `path.join(__dirname, '.license-counter')`.
|
||||
@@ -239,7 +248,7 @@ function generateCodes(opts) {
|
||||
* @throws If the file is missing or unreadable.
|
||||
*/
|
||||
function loadSecret(overridePath) {
|
||||
const file = overridePath || SECRET_FILE;
|
||||
const file = overridePath || _defaultSecretFile();
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`Master secret file not found at ${file}. Run --init-secret first.`);
|
||||
}
|
||||
@@ -247,14 +256,15 @@ function loadSecret(overridePath) {
|
||||
}
|
||||
|
||||
function initSecret() {
|
||||
if (fs.existsSync(SECRET_FILE)) {
|
||||
console.error('Master secret already exists at', SECRET_FILE);
|
||||
const file = _defaultSecretFile();
|
||||
if (fs.existsSync(file)) {
|
||||
console.error('Master secret already exists at', file);
|
||||
console.error('Delete it first if you want to regenerate (WARNING: invalidates all existing codes).');
|
||||
process.exit(1);
|
||||
}
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
|
||||
console.log('Master secret generated and saved to', SECRET_FILE);
|
||||
fs.writeFileSync(file, secret, { mode: 0o600 });
|
||||
console.log('Master secret generated and saved to', file);
|
||||
console.log('KEEP THIS FILE SAFE. It is needed to generate and validate all license codes.');
|
||||
console.log('DO NOT ship this file with the product.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DC-055 + DC-057 Billing routes — `/api/v1/billing/*`
|
||||
*
|
||||
* Two public endpoints:
|
||||
*
|
||||
* POST /api/v1/billing/checkout
|
||||
* Body: { productId: 'pro-30d'|'pro-90d'|'pro-180d'|'pro-365d', customerEmail?: string }
|
||||
* Returns: { id, url } — url is the Stripe-hosted Checkout page.
|
||||
* Auth: PUBLIC (customer hasn't paid yet → no session). CSRF-exempt.
|
||||
*
|
||||
* GET /api/v1/billing/lookup/:sessionId
|
||||
* Returns: { status: 'not_found'|'expired'|'processing'|'pending_email'|'delivered', code?, codeId?, durationDays?, productId?, deliveredVia? }
|
||||
* Auth: PUBLIC. CSRF-exempt.
|
||||
*
|
||||
* The lookup serves the persisted license in BOTH `delivered` AND
|
||||
* `pending_email` states. This is the documented SMTP-failure recovery
|
||||
* path (the customer pastes their key even if email failed).
|
||||
*
|
||||
* Security model:
|
||||
* - sessionId is a bearer-style secret returned by Stripe ONLY to the
|
||||
* customer who completed payment (single-use, expires after 24h).
|
||||
* - The endpoint enforces a 24h TTL (LOOKUP_TTL_MS) — after that,
|
||||
* 404 even with a valid sessionId.
|
||||
* - Cache-Control: no-store on all responses.
|
||||
* - Rate-limited via the general limiter (10/min/IP).
|
||||
*
|
||||
* The inbound webhook side lives in scripts/stripe-license-bridge.js
|
||||
* (DC-054 + DC-057). That runs as its own process on port 3010 so the
|
||||
* merchant webhook secret never enters the API host's process tree.
|
||||
* The bridge writes to the SAME fulfillment-store file the lookup endpoint
|
||||
* reads (platformPaths.dataDir + 'stripe-fulfillments.json'), so the API
|
||||
* sees the license as soon as the bridge saves it.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
const { createCheckoutSession } = require('../src/billing/stripe-client');
|
||||
const { createFulfillmentStore } = require('../src/billing/fulfillment-store');
|
||||
|
||||
// One fulfillment-store instance per process. Reads from the same file the
|
||||
// bridge writes to — IPC via the bind-mounted data dir. Override path via
|
||||
// STRIPE_BRIDGE_FULFILLMENT_STORE_FILE (the bridge reads the same env var
|
||||
// at startup) — both processes target the same file. In tests we point
|
||||
// at a tmp dir.
|
||||
const fulfillmentStore = createFulfillmentStore({
|
||||
filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE,
|
||||
});
|
||||
|
||||
const LOOKUP_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours — matches Stripe's
|
||||
// default Checkout session expiry.
|
||||
|
||||
/**
|
||||
* Compute the public origin to embed in Stripe Checkout success/cancel
|
||||
* URLs.
|
||||
*
|
||||
* SECURITY: the success_url is what Stripe redirects the customer's
|
||||
* browser to after payment. If we let the request's Host header
|
||||
* influence it unchecked, a header-injection attacker could redirect
|
||||
* customers to their own origin — and the session_id in the URL is
|
||||
* the bearer token for /api/v1/billing/lookup/:sessionId (the customer
|
||||
* would then leak their own license to the attacker). So we derive
|
||||
* the origin only from TRUSTED SOURCES:
|
||||
*
|
||||
* 1. `STRIPE_PUBLIC_ORIGIN` env var (preferred — operator-declared)
|
||||
* 2. `STRIPE_ALLOWED_HOSTS` allowlist + request Host header
|
||||
* (fallback for operators who don't set the env var)
|
||||
* 3. `undefined` → Stripe Checkout falls back to its default
|
||||
* success/cancel URL behavior (still safe; just loses the
|
||||
* success-page reveal flow).
|
||||
*
|
||||
* We also enforce scheme allowlist (https only by default; http only
|
||||
* for explicit dev mode) to prevent javascript:/file:/data: smuggling.
|
||||
*/
|
||||
function _resolvePublicOrigin(req) {
|
||||
// 1. Explicit env-var override (canonical deployment shape).
|
||||
if (process.env.STRIPE_PUBLIC_ORIGIN) {
|
||||
const raw = process.env.STRIPE_PUBLIC_ORIGIN.trim();
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.protocol === 'https:' || (u.protocol === 'http:' && process.env.NODE_ENV !== 'production')) {
|
||||
return `${u.protocol}//${u.host}`;
|
||||
}
|
||||
} catch (_) { /* fall through to header-based resolution */ }
|
||||
}
|
||||
|
||||
// 2. Header-based fallback, gated by STRIPE_ALLOWED_HOSTS allowlist.
|
||||
const allowedHosts = (process.env.STRIPE_ALLOWED_HOSTS || '')
|
||||
.split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
|
||||
if (allowedHosts.length === 0) return undefined;
|
||||
|
||||
const host = (req.get('x-forwarded-host') || req.get('host') || '').toLowerCase();
|
||||
// Strip :port for comparison; port is added back when building the URL.
|
||||
const hostNoPort = host.split(':')[0];
|
||||
if (!hostNoPort) return undefined;
|
||||
if (!allowedHosts.includes(hostNoPort) && !allowedHosts.includes(host)) return undefined;
|
||||
|
||||
const rawProto = (req.get('x-forwarded-proto') || req.protocol || 'https').toLowerCase();
|
||||
const proto = rawProto === 'http' && process.env.NODE_ENV !== 'production' ? 'http' : 'https';
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing routes factory
|
||||
* @param {Object} deps
|
||||
* @param {Function} deps.asyncHandler - async route handler wrapper
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ asyncHandler }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/v1/billing/checkout
|
||||
*
|
||||
* Body: { productId: 'pro-30d'|'pro-90d'|'pro-180d'|'pro-365d', customerEmail?: string }
|
||||
* Returns: { id, url }
|
||||
*
|
||||
* The customer is redirected to `url`. On success Stripe redirects to
|
||||
* {origin}/billing/success?session_id={CHECKOUT_SESSION_ID}. The
|
||||
* webhook bridge (scripts/stripe-license-bridge.js) generates the
|
||||
* license on `checkout.session.completed`, persists it to the
|
||||
* fulfillment store, emails it, and the success page polls the lookup
|
||||
* endpoint below to reveal it.
|
||||
*
|
||||
* SECURITY: The success_url is embedded in the Stripe Checkout Session
|
||||
* and shown to the customer in their browser. If an attacker can
|
||||
* control the `Host` / `X-Forwarded-Host` header, they can poison
|
||||
* Stripe's redirect to their own origin — and the customer's session
|
||||
* ID (which is the bearer token for /api/v1/billing/lookup/:sessionId)
|
||||
* lands in the attacker's URL bar. The lookup endpoint would then
|
||||
* serve the license to the attacker's browser.
|
||||
*
|
||||
* To prevent this, the API derives the origin from an explicit
|
||||
* `STRIPE_PUBLIC_ORIGIN` env var when set (the canonical deployment
|
||||
* shape). If unset, we fall back to the request's Host header, BUT
|
||||
* only when the Host is in the explicit `STRIPE_ALLOWED_HOSTS` allowlist
|
||||
* (comma-separated). This means a fresh operator MUST either set the
|
||||
* env var OR explicitly allowlist their hostname before checkout can
|
||||
* create sessions — a hostile header alone is not enough.
|
||||
*/
|
||||
router.post('/checkout', asyncHandler(async (req, res) => {
|
||||
const { productId, customerEmail } = req.body || {};
|
||||
|
||||
const origin = _resolvePublicOrigin(req);
|
||||
|
||||
try {
|
||||
if (!productId) throw new ValidationError('productId is required', 'productId');
|
||||
const session = await createCheckoutSession({ productId, customerEmail, origin });
|
||||
ok(res, { data: session });
|
||||
} catch (err) {
|
||||
if (err.code === 'STRIPE_NOT_CONFIGURED' || err.code === 'INVALID_PRODUCT_ID') {
|
||||
return errorResponse(res, err.statusCode || 500, err.message);
|
||||
}
|
||||
if (err.code === 'DC-400' || err instanceof ValidationError) {
|
||||
return errorResponse(res, err.statusCode || 400, err.message);
|
||||
}
|
||||
// Stripe SDK throws Stripe-specific errors; surface message but don't leak
|
||||
// Stripe's full response (may include internal IDs we don't want exposed).
|
||||
if (err.type && err.type.startsWith('Stripe')) {
|
||||
return errorResponse(res, 502, 'Payment provider error. Please try again.');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, 'billing-checkout'));
|
||||
|
||||
/**
|
||||
* GET /api/v1/billing/lookup/:sessionId
|
||||
*
|
||||
* Returns the fulfillment state for a Stripe Checkout session. The
|
||||
* success page polls this every 1.5s until `delivered` or `pending_email`.
|
||||
*
|
||||
* - 200 + { status: 'processing', durationDays } — license being generated
|
||||
* - 200 + { status: 'pending_email', code, codeId, durationDays, productId, ... }
|
||||
* — license persisted (SMTP failure recovery)
|
||||
* - 200 + { status: 'delivered', code, codeId, durationDays, productId, deliveredVia }
|
||||
* — license delivered by email
|
||||
* - 404 + { status: 'not_found' } — no payment record for this sessionId
|
||||
* - 404 + { status: 'expired' } — record exists but past the 24h TTL
|
||||
*
|
||||
* Cache-Control: no-store. CSRF-exempt. PUBLIC_ROUTES allowlist.
|
||||
*/
|
||||
router.get('/lookup/:sessionId', asyncHandler(async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
res.set('Cache-Control', 'no-store');
|
||||
|
||||
if (!sessionId || typeof sessionId !== 'string' || sessionId.length > 256) {
|
||||
return errorResponse(res, 400, 'invalid sessionId');
|
||||
}
|
||||
|
||||
const record = fulfillmentStore.readBySession(sessionId);
|
||||
if (!record) {
|
||||
return errorResponse(res, 404, 'no record for that sessionId');
|
||||
}
|
||||
|
||||
const createdAt = record.createdAt ? Date.parse(record.createdAt) : Date.now();
|
||||
const ageMs = Date.now() - createdAt;
|
||||
if (Number.isFinite(ageMs) && ageMs > LOOKUP_TTL_MS) {
|
||||
return errorResponse(res, 404, 'record past lookup TTL');
|
||||
}
|
||||
|
||||
if (record.status === 'generating' || (!record.code && record.status !== 'delivered')) {
|
||||
return ok(res, { data: { status: 'processing', durationDays: record.durationDays, productId: record.productId } });
|
||||
}
|
||||
|
||||
if (record.status === 'delivered') {
|
||||
return ok(res, { data: { status: 'delivered', durationDays: record.durationDays, productId: record.productId, code: record.code, codeId: record.codeId, deliveredVia: record.deliveredVia || 'unknown' } });
|
||||
}
|
||||
|
||||
// pending_email OR delivering — license is durably persisted.
|
||||
return ok(res, {
|
||||
data: {
|
||||
status: 'pending_email',
|
||||
durationDays: record.durationDays,
|
||||
productId: record.productId,
|
||||
code: record.code,
|
||||
codeId: record.codeId,
|
||||
deliveredVia: record.deliveredVia,
|
||||
lastError: record.lastError,
|
||||
},
|
||||
});
|
||||
}, 'billing-lookup'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,763 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DashCaddy Stripe license bridge — DC-054 + DC-057.
|
||||
*
|
||||
* Tiny HTTP webhook listener that converts a Stripe Checkout completion into
|
||||
* a DashCaddy Pro license code + a confirmation email. Runs as its own
|
||||
* process (NOT inside the DashCaddy API) so the merchant's Stripe secret
|
||||
* material stays out of the host-side process tree.
|
||||
*
|
||||
* # Wire format
|
||||
*
|
||||
* POST /webhook
|
||||
* Stripe-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>
|
||||
* <raw JSON body>
|
||||
*
|
||||
* The body for `checkout.session.completed` carries:
|
||||
* { id, customer_email, metadata: { productId }, amount_total, currency, ... }
|
||||
*
|
||||
* # Catalog contract (DC-057)
|
||||
*
|
||||
* `metadata.productId` is one of the IDs in src/billing/catalog.js:
|
||||
* pro-30d | pro-90d | pro-180d | pro-365d
|
||||
* The bridge maps productId → duration via the catalog (single source of
|
||||
* truth shared with the Checkout client + pricing page). The catalog's
|
||||
* configured Stripe Price is read via `STRIPE_PRICE_PRO_*D` env vars
|
||||
* (already documented in the catalog) and is used to validate that the
|
||||
* product is purchasable (configuredPriceId is not empty) — NOT to
|
||||
* cross-validate the price against the customer's Stripe session. Price
|
||||
* verification is intentionally omitted because (a) Stripe webhooks do
|
||||
* not include expanded line_items by default and (b) trusting the price
|
||||
* would block legitimate customers during a price rollover.
|
||||
*
|
||||
* The previous `STRIPE_SKU_*` env vars are REMOVED in DC-057 — operators
|
||||
* who set them should migrate to `STRIPE_PRICE_PRO_*D`.
|
||||
*
|
||||
* # Generation flow
|
||||
*
|
||||
* 1. Verify Stripe-Signature (constant-time HMAC-SHA256 compare).
|
||||
* Reject with 400 if the timestamp is more than TOLERANCE_SECONDS
|
||||
* old, or if any v1 signature is missing.
|
||||
* 2. Look up the event in the per-event idempotency file
|
||||
* (data/stripe-events.json). If seen, replay the previous response
|
||||
* status (200) WITHOUT regenerating. This is the layer-1 idempotency.
|
||||
* 3. If the event type isn't `checkout.session.completed`, ack with
|
||||
* `{delivered: false, reason: "ignored-event-type"}` so Stripe stops
|
||||
* retrying, and record the event.
|
||||
* 4. Parse the session metadata; resolve productId via the catalog.
|
||||
* 5. CLAIM the fulfillment record (atomic, sessionId-keyed).
|
||||
* - If the record exists for this sessionId in `pending_email` or
|
||||
* `delivered` state (e.g. a previous webhook delivery succeeded OR
|
||||
* saved a license but email failed), we REUSE the persisted license
|
||||
* code — never generate a second one. This is the layer-2
|
||||
* idempotency keyed by Checkout Session ID (globally unique, never
|
||||
* reused even when the same event is replayed).
|
||||
* - If the record exists in `generating` state and the lease is held
|
||||
* by a DIFFERENT event (concurrent webhook fan-out), respond 409
|
||||
* so Stripe retries — only one delivery wins.
|
||||
* 6. SAVE the license code into the fulfillment record
|
||||
* (data/stripe-fulfillments.json) BEFORE attempting email. This is
|
||||
* the crash-safety guarantee: even if email fails AND the process
|
||||
* is killed, the license is durably persisted.
|
||||
* 7. DELIVER the code via SMTP (or dev-console fallback).
|
||||
* - On success: markDelivered. Lookup endpoint serves the code on
|
||||
* the success page.
|
||||
* - On failure: markDeliveryFailed → reverts to pending_email. Lookup
|
||||
* endpoint serves the code ANYWAY (with a "(email delivery didn't
|
||||
* complete)" notice) — customer can save it manually. Subsequent
|
||||
* webhook retries reuse the same persisted code via step 5.
|
||||
* 8. Respond 500 to Stripe ONLY if the license save succeeded but email
|
||||
* failed AND no successful delivery record exists — Stripe will
|
||||
* retry. If delivery already succeeded earlier, ack 200.
|
||||
*
|
||||
* # SMTP transport
|
||||
*
|
||||
* Reuses the same env vars as DashCaddy's notification system:
|
||||
* SMTP_HOST / SMTP_PORT / SMTP_USERNAME / SMTP_PASSWORD / SMTP_FROM / SMTP_SECURE
|
||||
* If HOST or FROM is missing, delivery falls back to dev-console mode
|
||||
* (the bridge logs the full email body so the operator can deliver it
|
||||
* manually). This is the documented dev path; do NOT enable it in
|
||||
* production.
|
||||
*
|
||||
* # Exit codes
|
||||
*
|
||||
* 0 — clean shutdown
|
||||
* 1 — fatal startup error (missing secret, port bind failure, no
|
||||
* products configured)
|
||||
* 2 — runtime error while handling a request (logged, 500 returned)
|
||||
*
|
||||
* # Security notes
|
||||
*
|
||||
* - Webhook signature MUST verify BEFORE any JSON parsing. The raw body
|
||||
* is opaque until HMAC checks out.
|
||||
* - Timing-safe signature comparison (crypto.timingSafeEqual).
|
||||
* - The fulfillment-store file lives in platformPaths.dataDir (bind-
|
||||
* mounted in production). Atomic writes + per-mutation mutex.
|
||||
*
|
||||
* Tested in __tests__/billing/stripe-license-bridge.test.js (no live network).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { generateCodes, loadSecret } = require('../license-keygen');
|
||||
const platformPaths = require('../platform-paths');
|
||||
const catalog = require('../src/billing/catalog');
|
||||
const { createFulfillmentStore, DELIVERY_LEASE_MS } = require('../src/billing/fulfillment-store');
|
||||
|
||||
// ── Configuration (env-driven) ──────────────────────────────────────────────
|
||||
|
||||
const PORT = parseInt(process.env.STRIPE_BRIDGE_PORT || '3010', 10);
|
||||
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
|
||||
const TOLERANCE_SECONDS = parseInt(process.env.STRIPE_BRIDGE_TOLERANCE || '300', 10);
|
||||
|
||||
// SMTP. Falls back to dev-console mode if HOST or FROM is missing.
|
||||
// Read at function-call time (not module load) so tests can toggle SMTP
|
||||
// behavior between cases without re-requiring the bridge.
|
||||
function _smtpConfig() {
|
||||
return {
|
||||
host: process.env.SMTP_HOST || '',
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
username: process.env.SMTP_USERNAME || '',
|
||||
password: process.env.SMTP_PASSWORD || '',
|
||||
from: process.env.SMTP_FROM || '',
|
||||
};
|
||||
}
|
||||
|
||||
// State files (atomic write). Override paths in tests.
|
||||
const STATE_DIR = process.env.STRIPE_BRIDGE_STATE_DIR || platformPaths.dataDir;
|
||||
const EVENTS_FILE = process.env.STRIPE_BRIDGE_EVENTS_FILE || path.join(STATE_DIR, 'stripe-events.json');
|
||||
const FULFILLMENT_STORE = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE
|
||||
|| path.join(STATE_DIR, 'stripe-fulfillments.json');
|
||||
|
||||
// Lookup TTL: after a license has been "delivered" for this long, the
|
||||
// /api/v1/billing/lookup/:sessionId endpoint returns 404 even with a valid
|
||||
// sessionId. 24 hours matches Stripe's default Checkout session expiry and
|
||||
// is far longer than any customer needs to paste their key.
|
||||
const LOOKUP_TTL_MS = parseInt(process.env.STRIPE_BRIDGE_LOOKUP_TTL_MS || String(24 * 60 * 60 * 1000), 10);
|
||||
|
||||
// Build the fulfillment store singleton used by both bridge writes and
|
||||
// lookup reads (the API's lookup endpoint reads from the SAME file via
|
||||
// its own createFulfillmentStore() instance — file is the IPC channel).
|
||||
const fulfillmentStore = createFulfillmentStore({ filePath: FULFILLMENT_STORE });
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function log(level, msg, meta) {
|
||||
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...(meta || {}) });
|
||||
process.stdout.write(line + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Stripe-Signature header. Returns { ok: true } if the signature is
|
||||
* well-formed, within tolerance, AND matches at least one v1 entry.
|
||||
* Returns { ok: false, reason } otherwise — callers MUST 400 on failure.
|
||||
*
|
||||
* Format: t=<unix_ts>,v1=<hex>[,v1=<hex>]*
|
||||
*
|
||||
* The signed payload is `${t}.${rawBody}`. We recompute HMAC-SHA256 of that
|
||||
* exact byte sequence with the webhook secret, then timing-safe-compare
|
||||
* against each v1 entry until one matches. Multiple v1 entries are allowed
|
||||
* during Stripe secret rotation; we just need one to verify.
|
||||
*/
|
||||
function verifyStripeSignature(rawBody, header, secret, nowSec) {
|
||||
if (!header || typeof header !== 'string') return { ok: false, reason: 'missing-signature' };
|
||||
if (!secret) return { ok: false, reason: 'no-server-secret' };
|
||||
|
||||
const parts = header.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
let timestamp = null;
|
||||
const v1List = [];
|
||||
for (const part of parts) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = part.slice(0, eq);
|
||||
const val = part.slice(eq + 1);
|
||||
if (key === 't') timestamp = parseInt(val, 10);
|
||||
else if (key === 'v1') v1List.push(val);
|
||||
}
|
||||
if (!Number.isFinite(timestamp)) return { ok: false, reason: 'missing-timestamp' };
|
||||
if (v1List.length === 0) return { ok: false, reason: 'missing-v1' };
|
||||
|
||||
const skew = Math.abs((nowSec || Math.floor(Date.now() / 1000)) - timestamp);
|
||||
if (skew > TOLERANCE_SECONDS) return { ok: false, reason: 'timestamp-out-of-tolerance' };
|
||||
|
||||
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest();
|
||||
for (const v1 of v1List) {
|
||||
let got;
|
||||
try {
|
||||
got = Buffer.from(v1, 'hex');
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (got.length !== expected.length) continue;
|
||||
if (crypto.timingSafeEqual(got, expected)) return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: 'no-matching-signature' };
|
||||
}
|
||||
|
||||
// ── Idempotency store (event-id-keyed layer 1) ─────────────────────────────
|
||||
|
||||
function readEvents() {
|
||||
try {
|
||||
const raw = fs.readFileSync(EVENTS_FILE, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && parsed.events && typeof parsed.events === 'object') {
|
||||
return parsed;
|
||||
}
|
||||
return { events: {} };
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') return { events: {} };
|
||||
// Treat any parse error as an empty store — the next successful write
|
||||
// will replace the file. Worst case we re-deliver; Stripe tolerates
|
||||
// duplicate emails.
|
||||
return { events: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeEvents(state) {
|
||||
// Atomic write: tmp + rename.
|
||||
const tmp = `${EVENTS_FILE}.tmp.${process.pid}.${Date.now()}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
||||
fs.renameSync(tmp, EVENTS_FILE);
|
||||
}
|
||||
|
||||
function recordEvent(eventId, meta) {
|
||||
const state = readEvents();
|
||||
if (state.events[eventId]) return false; // already delivered
|
||||
state.events[eventId] = {
|
||||
receivedAt: new Date().toISOString(),
|
||||
...(meta || {}),
|
||||
};
|
||||
writeEvents(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
function eventSeen(eventId) {
|
||||
const state = readEvents();
|
||||
return Boolean(state.events[eventId]);
|
||||
}
|
||||
|
||||
// ── Email delivery ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send the license key email. If SMTP is configured, real send via
|
||||
* nodemailer; if not, log the full email body to stdout so the operator
|
||||
* can deliver manually in dev/test environments.
|
||||
*
|
||||
* Returns { delivered: bool, via: 'smtp' | 'dev-console' }.
|
||||
*/
|
||||
async function deliverCode({ to, code, durationDays, eventId, productId }) {
|
||||
const subject = `Your DashCaddy Pro license (${durationDays} days)`;
|
||||
const text = [
|
||||
'Thank you for purchasing DashCaddy Pro.',
|
||||
'',
|
||||
`Your license key is valid for ${durationDays} days:`,
|
||||
'',
|
||||
` ${code}`,
|
||||
'',
|
||||
'To install on your DashCaddy host:',
|
||||
' 1. Open https://<your-host>/admin/license',
|
||||
' 2. Paste the key into the "Activate license" field',
|
||||
' 3. Submit — Pro features unlock immediately.',
|
||||
'',
|
||||
'The same key is also revealed on your purchase success page; keep it safe.',
|
||||
'',
|
||||
'Need help? Reply to this email and we will assist.',
|
||||
'',
|
||||
`Reference: ${eventId}`,
|
||||
`Product: ${productId}`,
|
||||
].join('\n');
|
||||
|
||||
const smtp = _smtpConfig();
|
||||
if (!smtp.host || !smtp.from) {
|
||||
// Dev-console fallback: log the full email body to stdout so the
|
||||
// operator can deliver manually in dev/test environments. The
|
||||
// fulfillment record is marked `delivered` with `via: 'dev-console'`
|
||||
// so the lookup endpoint serves the code on the success page — the
|
||||
// operator seeing the bridge logs IS the documented delivery path
|
||||
// when SMTP is unconfigured. In production, the bridge refuses to
|
||||
// boot without SMTP configured (see checkFatalConfig).
|
||||
log('info', 'smtp-not-configured, falling back to dev-console delivery', { to, durationDays, code });
|
||||
return { delivered: true, via: 'dev-console' };
|
||||
}
|
||||
|
||||
// Lazy-load nodemailer so the test suite doesn't pull it into coverage.
|
||||
const nodemailer = require('nodemailer');
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
|
||||
tls: { rejectUnauthorized: process.env.NODE_ENV === 'production' },
|
||||
});
|
||||
await transporter.sendMail({ from: smtp.from, to, subject, text });
|
||||
return { delivered: true, via: 'smtp' };
|
||||
}
|
||||
|
||||
// ── Request handler ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a Stripe session to a catalog product + duration.
|
||||
*
|
||||
* The source of truth is `metadata.productId` — which the stripe-client
|
||||
* sets when it creates the Checkout Session (see stripe-client.js).
|
||||
* The customer is identified by the product they intended to buy, NOT
|
||||
* by the Stripe Price ID at fulfillment time, because:
|
||||
*
|
||||
* - A repoint of STRIPE_PRICE_PRO_30D to a new Stripe Price affects
|
||||
* NEW Checkout Sessions only. Existing sessions retain their
|
||||
* original line_items.price.id; their metadata.productId is
|
||||
* unchanged. Trusting price would force the operator to keep the
|
||||
* old Price ID configured indefinitely (or forever block customers
|
||||
* who started checkout before the rollover).
|
||||
* - Stripe does not include expanded line_items in webhook payloads
|
||||
* by default. To get them we'd need either a separate
|
||||
* stripe.checkout.sessions.retrieve() call per webhook or Stripe's
|
||||
* webhook-expansion feature. Neither is worth the cost when the
|
||||
* metadata is already a complete canonical identifier.
|
||||
*
|
||||
* Returns { product, durationDays } on success or { error, ...details } on failure.
|
||||
*/
|
||||
function resolveProductFromSession(session) {
|
||||
if (!session || typeof session !== 'object') return null;
|
||||
const productId = session.metadata && session.metadata.productId;
|
||||
if (!productId) return { error: 'missing-productId' };
|
||||
|
||||
const product = catalog.getProduct(productId);
|
||||
if (!product) return { error: 'unknown-productId', productId };
|
||||
|
||||
const configuredPriceId = catalog.getConfiguredPrice(product);
|
||||
if (!configuredPriceId) return { error: 'product-not-configured', productId, missing: product.priceEnv };
|
||||
|
||||
return { product, durationDays: product.durationDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process one webhook delivery. Pure-ish: takes the raw body, signature
|
||||
* header, and event id; returns an HTTP-friendly result object.
|
||||
*
|
||||
* Exported for tests. The HTTP wrapper below calls this with the parsed
|
||||
* inputs and turns the result into a response.
|
||||
*
|
||||
* Composed of small step functions to keep individual cyclomatic complexity
|
||||
* under ESLint's limit of 20.
|
||||
*/
|
||||
async function handleWebhook({ rawBody, signatureHeader, eventId }) {
|
||||
const sigResult = verifySignature(rawBody, signatureHeader);
|
||||
if (sigResult) return sigResult;
|
||||
|
||||
const event = parseEventBody(rawBody);
|
||||
if (event.error) return event.error;
|
||||
|
||||
const id = eventId || event.body.id;
|
||||
|
||||
const dup = checkEventIdempotency(id);
|
||||
if (dup) return dup;
|
||||
|
||||
// Only handle the FULFILLMENT_EVENT_TYPES below. Everything else falls
|
||||
// into the ACK_ONLY_EVENT_TYPES or the catch-all ignored branch.
|
||||
//
|
||||
// - `checkout.session.completed` — fires for ALL completed sessions
|
||||
// (paid OR unpaid). We only fulfill when payment_status='paid'.
|
||||
// - `checkout.session.async_payment_succeeded` — fires for delayed
|
||||
// payment methods (ACH/SEPA/bank debits) when the bank clears.
|
||||
// Stripe sends `checkout.session.completed` first (unpaid), then
|
||||
// this event when the payment confirms. payment_status is always
|
||||
// 'paid' on this event.
|
||||
// - `checkout.session.async_payment_failed` — ack-only; the customer
|
||||
// must retry from the pricing page.
|
||||
const FULFILLMENT_EVENT_TYPES = new Set([
|
||||
'checkout.session.completed',
|
||||
'checkout.session.async_payment_succeeded',
|
||||
]);
|
||||
const ACK_ONLY_EVENT_TYPES = new Set([
|
||||
'checkout.session.async_payment_failed',
|
||||
]);
|
||||
|
||||
if (!FULFILLMENT_EVENT_TYPES.has(event.body.type)) {
|
||||
if (ACK_ONLY_EVENT_TYPES.has(event.body.type)) {
|
||||
// Permanent failure for delayed payments. Ack 200 so Stripe
|
||||
// stops retrying; the customer must retry from the pricing page.
|
||||
recordEvent(id, { ignoredType: event.body.type });
|
||||
return { status: 200, body: { delivered: false, reason: 'async-payment-failed' } };
|
||||
}
|
||||
recordEvent(id, { ignoredType: event.body.type });
|
||||
return { status: 200, body: { delivered: false, reason: 'ignored-event-type' } };
|
||||
}
|
||||
|
||||
const session = event.body.data && event.body.data.object;
|
||||
if (!session || typeof session !== 'object') {
|
||||
return { status: 400, body: { delivered: false, reason: 'missing-session' } };
|
||||
}
|
||||
|
||||
// Guard: only fulfill PAID sessions. Stripe sends
|
||||
// `checkout.session.completed` for BOTH paid AND unpaid events (e.g.
|
||||
// when the customer closes the browser mid-checkout). The `payment_status`
|
||||
// field on the session object disambiguates:
|
||||
// - 'paid' — payment succeeded; we generate the license.
|
||||
// - 'unpaid' — delayed-payment method (ACH/SEPA) not yet cleared;
|
||||
// the async_payment_succeeded event will fire later and we
|
||||
// generate the license then. Ack 200 here so Stripe stops
|
||||
// retrying (the async event will be the fulfillment trigger).
|
||||
// - 'no_payment_required' — Stripe-internal edge case for free
|
||||
// sessions. DashCaddy doesn't sell any, so we reject.
|
||||
// - absent — Stripe sometimes omits it on incomplete sessions;
|
||||
// reject to be safe.
|
||||
const paymentStatus = session.payment_status;
|
||||
if (paymentStatus !== 'paid') {
|
||||
recordEvent(id, { ignoredType: event.body.type, paymentStatus });
|
||||
return { status: 200, body: { delivered: false, reason: `payment-not-${paymentStatus || 'confirmed'}` } };
|
||||
}
|
||||
|
||||
return await fulfillCheckout({ id, session });
|
||||
}
|
||||
|
||||
function verifySignature(rawBody, signatureHeader) {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const sigCheck = verifyStripeSignature(rawBody, signatureHeader, WEBHOOK_SECRET, nowSec);
|
||||
if (!sigCheck.ok) {
|
||||
return { status: 400, body: { delivered: false, reason: `signature-${sigCheck.reason}` } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseEventBody(rawBody) {
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(rawBody.toString('utf8'));
|
||||
} catch (_) {
|
||||
return { error: { status: 400, body: { delivered: false, reason: 'invalid-json' } } };
|
||||
}
|
||||
if (!event || typeof event !== 'object' || !event.id) {
|
||||
return { error: { status: 400, body: { delivered: false, reason: 'invalid-event' } } };
|
||||
}
|
||||
return { body: event };
|
||||
}
|
||||
|
||||
function checkEventIdempotency(id) {
|
||||
if (eventSeen(id)) {
|
||||
return { status: 200, body: { delivered: true, deduplicated: true } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the catalog → claim → deliver pipeline for one checkout session.
|
||||
* Returns the final HTTP-friendly result.
|
||||
*/
|
||||
async function fulfillCheckout({ id, session }) {
|
||||
const resolution = resolveProductFromSession(session);
|
||||
if (!resolution || resolution.error) {
|
||||
const reason = resolution && resolution.error ? resolution.error : 'missing-productId';
|
||||
log('warn', 'catalog-resolution-failed', { eventId: id, reason, ...(resolution || {}) });
|
||||
return { status: 400, body: { delivered: false, reason } };
|
||||
}
|
||||
const { product, durationDays } = resolution;
|
||||
|
||||
const email = session.customer_email || (session.customer_details && session.customer_details.email) || '';
|
||||
if (!email) return { status: 400, body: { delivered: false, reason: 'missing-customer-email' } };
|
||||
|
||||
const sessionId = session.id || '';
|
||||
if (!sessionId) return { status: 400, body: { delivered: false, reason: 'missing-session-id' } };
|
||||
|
||||
const claim = await fulfillmentStore.claim({
|
||||
eventId: id, sessionId, productId: product.id, durationDays, email,
|
||||
});
|
||||
if (claim.busy) {
|
||||
return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
|
||||
}
|
||||
|
||||
const licenseResult = await ensureLicensePersisted({ id, sessionId, product, claim, durationDays });
|
||||
if (licenseResult.error) return licenseResult.error;
|
||||
const { code, codeId } = licenseResult;
|
||||
|
||||
const deliveryClaim = await fulfillmentStore.claimDelivery({ sessionId, ownerToken: id });
|
||||
if (deliveryClaim.busy) {
|
||||
return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
|
||||
}
|
||||
|
||||
let delivery;
|
||||
try {
|
||||
delivery = await deliverCode({ to: email, code, durationDays, eventId: id, productId: product.id });
|
||||
} catch (err) {
|
||||
log('error', 'email-delivery-failed', { eventId: id, sessionId, error: err.message });
|
||||
await fulfillmentStore.markDeliveryFailed({ sessionId, ownerToken: id, error: err.message });
|
||||
return { status: 500, body: { delivered: false, reason: 'email-failed', error: err.message } };
|
||||
}
|
||||
|
||||
await fulfillmentStore.markDelivered({ sessionId, ownerToken: id, deliveredVia: delivery.via });
|
||||
recordEvent(id, { durationDays, codeId, productId: product.id, email, deliveredVia: delivery.via });
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { delivered: true, codeId, productId: product.id, durationDays, deliveredVia: delivery.via },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Either reuse an existing persisted code (layer-2 idempotency) or
|
||||
* generate + persist a fresh one. Returns { code, codeId } or
|
||||
* { error: <http-result> }.
|
||||
*/
|
||||
async function ensureLicensePersisted({ id, sessionId, product, claim, durationDays }) {
|
||||
const existing = claim.record;
|
||||
if (existing.code) {
|
||||
// Reusing an existing license (from a previous successful or failed
|
||||
// attempt for the SAME session). This is the retry-safe path.
|
||||
log('info', 'license-reused-from-fulfillment-store', {
|
||||
eventId: id, sessionId, productId: product.id, status: existing.status,
|
||||
});
|
||||
return { code: existing.code, codeId: existing.codeId };
|
||||
}
|
||||
|
||||
let secret;
|
||||
try {
|
||||
secret = loadSecret();
|
||||
} catch (err) {
|
||||
log('error', 'license-secret-missing', { error: err.message });
|
||||
return { error: { status: 500, body: { delivered: false, reason: 'server-not-configured' } } };
|
||||
}
|
||||
|
||||
const codes = generateCodes({ secret, durationDays, count: 1 });
|
||||
const code = codes[0].code;
|
||||
const codeId = codes[0].codeId;
|
||||
|
||||
// Persist BEFORE attempting email. From this point on, the license is
|
||||
// durable even if the process dies or email fails.
|
||||
const saveResult = await fulfillmentStore.saveLicense({ eventId: id, sessionId, code, codeId });
|
||||
if (!saveResult.saved) {
|
||||
log('error', 'license-save-rejected', { eventId: id, sessionId, record: saveResult.record });
|
||||
return { error: { status: 500, body: { delivered: false, reason: 'save-rejected' } } };
|
||||
}
|
||||
return { code, codeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a fulfillment record for the public lookup endpoint.
|
||||
*
|
||||
* Returns:
|
||||
* { status: 'not_found' } — no record exists (session not paid / unknown)
|
||||
* { status: 'expired' } — record exists but is past the lookup TTL
|
||||
* { status: 'processing', durationDays } — license being generated
|
||||
* { status: 'pending_email', durationDays, code, codeId, deliveredVia? } — license persisted, email failed
|
||||
* { status: 'delivered', durationDays, code, codeId, deliveredVia } — license delivered
|
||||
*
|
||||
* The lookup endpoint serves the persisted code in BOTH pending_email AND
|
||||
* delivered states — that is the documented SMTP-failure recovery path
|
||||
* (the customer pastes their key even if email failed).
|
||||
*/
|
||||
function lookupSession(sessionId, { nowMs = Date.now() } = {}) {
|
||||
const record = fulfillmentStore.readBySession(sessionId);
|
||||
if (!record) return { status: 'not_found' };
|
||||
|
||||
const createdAt = record.createdAt ? Date.parse(record.createdAt) : nowMs;
|
||||
const ageMs = nowMs - createdAt;
|
||||
if (Number.isFinite(ageMs) && ageMs > LOOKUP_TTL_MS) {
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
if (record.status === 'generating') {
|
||||
return { status: 'processing', durationDays: record.durationDays, productId: record.productId };
|
||||
}
|
||||
if (!record.code) {
|
||||
// Should not happen after saveLicense() succeeds, but defensive.
|
||||
return { status: 'processing', durationDays: record.durationDays, productId: record.productId };
|
||||
}
|
||||
const base = {
|
||||
durationDays: record.durationDays,
|
||||
code: record.code,
|
||||
codeId: record.codeId,
|
||||
productId: record.productId,
|
||||
};
|
||||
if (record.status === 'delivered') {
|
||||
return { status: 'delivered', deliveredVia: record.deliveredVia || 'unknown', ...base };
|
||||
}
|
||||
// pending_email OR delivering — license is durably persisted.
|
||||
return { status: 'pending_email', deliveredVia: record.deliveredVia, lastError: record.lastError, ...base };
|
||||
}
|
||||
|
||||
// ── HTTP server ────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MB — Stripe events are small.
|
||||
|
||||
function readRawBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
reject(new Error('body-too-large'));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function writeJson(res, status, body, extraHeaders = {}) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
// License codes are bearer-style secrets — never cache them.
|
||||
'Cache-Control': 'no-store',
|
||||
...extraHeaders,
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP request handler for the bridge (testable as a factory).
|
||||
*
|
||||
* Routes:
|
||||
* POST /webhook — Stripe checkout.session.completed webhooks
|
||||
* GET /lookup/<sessionId> — operator / incident-recovery lookup
|
||||
*
|
||||
* Exported for tests so they can drive the SAME handler logic the
|
||||
* production bridge server uses (instead of duplicating the route
|
||||
* dispatch in test code).
|
||||
*/
|
||||
function createRequestHandler() {
|
||||
return async function handleBridgeRequest(req, res) {
|
||||
if (req.method === 'POST' && req.url === '/webhook') {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await readRawBody(req);
|
||||
} catch (err) {
|
||||
writeJson(res, 413, { delivered: false, reason: 'body-too-large' });
|
||||
return;
|
||||
}
|
||||
|
||||
const signatureHeader = req.headers['stripe-signature'] || '';
|
||||
let result;
|
||||
try {
|
||||
result = await handleWebhook({ rawBody, signatureHeader });
|
||||
} catch (err) {
|
||||
log('error', 'handler-threw', { error: err.message, stack: err.stack });
|
||||
writeJson(res, 500, { delivered: false, reason: 'handler-error' });
|
||||
return;
|
||||
}
|
||||
writeJson(res, result.status, result.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url && req.url.startsWith('/lookup/')) {
|
||||
// The bridge exposes a /lookup/<sessionId> endpoint as a convenience
|
||||
// for out-of-band operators (manual incident recovery, cron jobs that
|
||||
// scan pending_email records, etc.). The PRODUCTION lookup endpoint
|
||||
// for the success page is the API route at /api/v1/billing/lookup/*,
|
||||
// which reads the same fulfillment-store file but lives in the API
|
||||
// process (so the customer-facing response path doesn't depend on the
|
||||
// bridge being up). This endpoint is only useful when the API is
|
||||
// unreachable but the bridge is — and the bridge itself can fail to
|
||||
// boot without it.
|
||||
//
|
||||
// decodeURIComponent throws on malformed percent-encoding. We catch
|
||||
// that explicitly to surface a clean 400 instead of a 500.
|
||||
const rawSessionId = req.url.slice('/lookup/'.length).split('?')[0];
|
||||
let sessionId;
|
||||
try {
|
||||
sessionId = decodeURIComponent(rawSessionId);
|
||||
} catch (_) {
|
||||
writeJson(res, 400, { delivered: false, reason: 'invalid-session-id' });
|
||||
return;
|
||||
}
|
||||
const result = lookupSession(sessionId);
|
||||
const httpStatus = result.status === 'not_found' || result.status === 'expired' ? 404 : 200;
|
||||
writeJson(res, httpStatus, result);
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 404, { delivered: false, reason: 'not-found' });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP server bound to the bridge request handler. Returns the
|
||||
* server WITHOUT starting it — callers call `.listen(port)` themselves.
|
||||
*
|
||||
* Production entrypoint uses this factory; tests can use
|
||||
* `bridge.createRequestHandler()` to wire the same dispatch logic
|
||||
* without spinning up an HTTP server.
|
||||
*/
|
||||
function createServer() {
|
||||
return http.createServer(createRequestHandler());
|
||||
}
|
||||
|
||||
// Module-level server variable. Created by `createServer()` only when the
|
||||
// bridge is the entrypoint (`require.main === module`); tests + libraries
|
||||
// that require the bridge leave this unset.
|
||||
let server;
|
||||
|
||||
if (require.main === module) {
|
||||
server = createServer();
|
||||
}
|
||||
|
||||
// ── Bootstrap ──────────────────────────────────────────────────────────────
|
||||
|
||||
function checkFatalConfig() {
|
||||
const missing = [];
|
||||
if (!WEBHOOK_SECRET) missing.push('STRIPE_WEBHOOK_SECRET');
|
||||
// At least one product must be configured for purchase.
|
||||
const configured = catalog.getConfiguredProducts().filter((p) => p.priceId);
|
||||
if (configured.length === 0) {
|
||||
const allEnvs = catalog.PRODUCTS.map((p) => p.priceEnv);
|
||||
missing.push(`at-least-one-of-${allEnvs.join('|')}`);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module exports — for tests. Production entrypoint is the `if
|
||||
* (require.main === module)` block below.
|
||||
*/
|
||||
module.exports = {
|
||||
verifyStripeSignature,
|
||||
handleWebhook,
|
||||
readEvents,
|
||||
eventSeen,
|
||||
recordEvent,
|
||||
writeEvents,
|
||||
resolveProductFromSession,
|
||||
lookupSession,
|
||||
// Server factories — tests can call createRequestHandler() to wire
|
||||
// the same dispatch logic the production server uses, without
|
||||
// duplicating route decoding / status mapping in test code.
|
||||
createRequestHandler,
|
||||
createServer,
|
||||
// Constants exposed so tests can pin them when running in parallel.
|
||||
TOLERANCE_SECONDS,
|
||||
LOOKUP_TTL_MS,
|
||||
DELIVERY_LEASE_MS,
|
||||
MAX_BODY_BYTES,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const missing = checkFatalConfig();
|
||||
if (missing.length > 0) {
|
||||
log('error', 'startup-misconfigured', { missing });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
const smtp = _smtpConfig();
|
||||
log('info', 'stripe-license-bridge-listening', {
|
||||
port: PORT,
|
||||
configuredProducts: catalog.getConfiguredProducts()
|
||||
.filter((p) => p.priceId)
|
||||
.map((p) => ({ id: p.id, durationDays: p.durationDays, amountCents: p.amountCents })),
|
||||
smtpConfigured: Boolean(smtp.host && smtp.from),
|
||||
eventsFile: EVENTS_FILE,
|
||||
fulfillmentFile: FULFILLMENT_STORE,
|
||||
lookupTtlMs: LOOKUP_TTL_MS,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -85,6 +85,7 @@ const eventsRoutes = require('../routes/events');
|
||||
const workflowsRoutes = require('../routes/workflows');
|
||||
const dependenciesRoutes = require('../routes/dependencies');
|
||||
const securityRoutes = require('../routes/security');
|
||||
const billingRoutes = require('../routes/billing');
|
||||
const DependencyManager = require('./managers/dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
const configDriftRoutes = require('../routes/config-drift');
|
||||
@@ -528,6 +529,11 @@ async function createApp() {
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
log: ctx.log,
|
||||
}));
|
||||
// DC-055: billing is PUBLIC (customer hasn't paid yet → no session).
|
||||
// Stripe-session creation only; the webhook side runs in scripts/stripe-license-bridge.js.
|
||||
apiRouter.use('/billing', billingRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
}));
|
||||
apiRouter.use('/dns', dnsRoutes({
|
||||
dns: ctx.dns,
|
||||
siteConfig: ctx.siteConfig,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Canonical DashCaddy Pro product catalog.
|
||||
*
|
||||
* Keep product identity, license duration, USD amount, and the Stripe price
|
||||
* environment variable in one place. Checkout (src/billing/stripe-client.js),
|
||||
* the webhook bridge (scripts/stripe-license-bridge.js), the public pricing
|
||||
* page (status/pricing/index.html), and tests must NOT maintain separate
|
||||
* product lists — all of them import from here.
|
||||
*
|
||||
* Pricing source of truth: PRODUCT-SPEC-DECISIONS.md (locked 2026-07-20).
|
||||
*
|
||||
* Lifecycle:
|
||||
* - To add a new tier: append a new frozen product entry below, then add
|
||||
* the matching STRIPE_PRICE_PRO_*<duration>D environment variable to
|
||||
* the deployment. The pricing page (status/pricing/index.html) and
|
||||
* the catalog consistency test (__tests__/billing/pricing-page-catalog.test.js)
|
||||
* will both fail until the pricing page is updated in lockstep — this
|
||||
* is the documented drift guard.
|
||||
* - To change a price: edit the matching product entry AND the pricing
|
||||
* page's hardcoded price label (status/pricing/index.html). The
|
||||
* pricing-page-catalog.test.js enforces the two match.
|
||||
*
|
||||
* Note: The pricing page hard-codes the 4 product IDs, prices, and
|
||||
* duration strings (rather than being server-rendered from this catalog).
|
||||
* The hard-coding is intentional — the page is served as static HTML from
|
||||
* `status.sami/pricing` and never touches the live API. The
|
||||
* pricing-page-catalog.test.js enforces consistency between the two
|
||||
* sources, so any drift fails the test suite.
|
||||
*/
|
||||
|
||||
const PRODUCTS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'pro-30d',
|
||||
durationDays: 30,
|
||||
amountCents: 2000,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_30D',
|
||||
label: '1 month',
|
||||
priceLabel: '$20',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'pro-90d',
|
||||
durationDays: 90,
|
||||
amountCents: 5000,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_90D',
|
||||
label: '3 months',
|
||||
priceLabel: '$50',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'pro-180d',
|
||||
durationDays: 180,
|
||||
amountCents: 7000,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_180D',
|
||||
label: '6 months',
|
||||
priceLabel: '$70',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'pro-365d',
|
||||
durationDays: 365,
|
||||
amountCents: 9900,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_365D',
|
||||
label: '12 months',
|
||||
priceLabel: '$99',
|
||||
}),
|
||||
]);
|
||||
|
||||
const BY_ID = new Map(PRODUCTS.map((product) => [product.id, product]));
|
||||
|
||||
function listProducts() {
|
||||
return PRODUCTS.slice();
|
||||
}
|
||||
|
||||
function getProduct(productId) {
|
||||
return BY_ID.get(productId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Stripe Price ID configured for a product. Returns '' if unset
|
||||
* (caller treats empty string as "this tier is not configured").
|
||||
*/
|
||||
function getConfiguredPrice(product, env = process.env) {
|
||||
if (!product) return '';
|
||||
return env[product.priceEnv] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Stripe Price ID back to a product. Used by the webhook bridge to
|
||||
* validate that a Checkout session's price matches a configured product
|
||||
* (defense against Stripe price-ID drift / repointing).
|
||||
*
|
||||
* Returns null when the price ID is unset or doesn't match any configured
|
||||
* product.
|
||||
*/
|
||||
function findProductByPriceId(priceId, env = process.env) {
|
||||
if (!priceId || typeof priceId !== 'string') return null;
|
||||
for (const product of PRODUCTS) {
|
||||
const configured = getConfiguredPrice(product, env);
|
||||
if (configured && configured === priceId) return product;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as getConfiguredPrice but returns the full product list with the
|
||||
* resolved Stripe Price ID merged in. Useful for the pricing page renderer.
|
||||
*/
|
||||
function getConfiguredProducts(env = process.env) {
|
||||
return PRODUCTS.map((product) => ({ ...product, priceId: getConfiguredPrice(product, env) }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PRODUCTS,
|
||||
listProducts,
|
||||
getProduct,
|
||||
getConfiguredPrice,
|
||||
getConfiguredProducts,
|
||||
findProductByPriceId,
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Durable Stripe fulfillment state.
|
||||
*
|
||||
* The bridge and the API share this file through the host data mount. A
|
||||
* generated license is persisted before email delivery so a webhook retry can
|
||||
* resend the same key instead of minting a second valid key.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const DELIVERY_LEASE_MS = 5 * 60 * 1000;
|
||||
|
||||
function createFulfillmentStore(options = {}) {
|
||||
const filePath = options.filePath
|
||||
|| process.env.STRIPE_FULFILLMENT_FILE
|
||||
|| path.join(platformPaths.dataDir, 'stripe-fulfillments.json');
|
||||
let queue = Promise.resolve();
|
||||
|
||||
function emptyState() {
|
||||
return { version: 1, byEventId: {}, bySessionId: {} };
|
||||
}
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return emptyState();
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object'
|
||||
|| !parsed.byEventId || typeof parsed.byEventId !== 'object'
|
||||
|| !parsed.bySessionId || typeof parsed.bySessionId !== 'object') {
|
||||
throw new Error('fulfillment state has an invalid shape');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return emptyState();
|
||||
throw new Error(`Stripe fulfillment state unavailable: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(state) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch (error) {
|
||||
try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ }
|
||||
throw error;
|
||||
}
|
||||
try { fs.chmodSync(filePath, 0o600); } catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
function mutate(mutator) {
|
||||
const run = queue.then(async () => {
|
||||
const state = readState();
|
||||
const result = await mutator(state);
|
||||
if (result && result.changed) writeState(state);
|
||||
return result;
|
||||
});
|
||||
queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
function readBySession(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const record = readState().bySessionId[sessionId];
|
||||
return record ? { ...record } : null;
|
||||
}
|
||||
|
||||
function readByEvent(eventId) {
|
||||
if (!eventId) return null;
|
||||
const record = readState().byEventId[eventId];
|
||||
return record ? { ...record } : null;
|
||||
}
|
||||
|
||||
async function claim({ eventId, sessionId, productId, durationDays, email }) {
|
||||
if (!eventId || !sessionId) throw new Error('eventId and sessionId are required');
|
||||
return mutate((state) => {
|
||||
const existing = state.bySessionId[sessionId] || state.byEventId[eventId];
|
||||
const now = Date.now();
|
||||
if (existing) {
|
||||
if (existing.status === 'generating' && existing.leaseUntil > now && existing.claimToken !== eventId) {
|
||||
return { changed: false, claimed: false, busy: true, record: { ...existing } };
|
||||
}
|
||||
if (existing.status === 'generating' && existing.leaseUntil <= now) {
|
||||
existing.claimToken = eventId;
|
||||
existing.leaseUntil = now + DELIVERY_LEASE_MS;
|
||||
state.byEventId[eventId] = existing;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...existing } };
|
||||
}
|
||||
return { changed: false, claimed: false, busy: false, record: { ...existing } };
|
||||
}
|
||||
const record = {
|
||||
eventId, sessionId, productId, durationDays, email,
|
||||
status: 'generating', claimToken: eventId, leaseUntil: now + DELIVERY_LEASE_MS,
|
||||
createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(),
|
||||
};
|
||||
state.byEventId[eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function saveLicense({ eventId, sessionId, code, codeId }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId] || state.byEventId[eventId];
|
||||
if (!record || record.claimToken !== eventId) return { changed: false, saved: false, record: record ? { ...record } : null };
|
||||
record.code = code;
|
||||
record.codeId = codeId;
|
||||
record.status = 'pending_email';
|
||||
record.leaseUntil = 0;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.byEventId[eventId] = record;
|
||||
state.bySessionId[record.sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function claimDelivery({ sessionId, ownerToken }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || !record.code) return { changed: false, claimed: false, record: record ? { ...record } : null };
|
||||
const now = Date.now();
|
||||
if (record.status === 'delivered') return { changed: false, claimed: false, record: { ...record } };
|
||||
if (record.status === 'delivering' && record.leaseUntil > now && record.leaseOwner !== ownerToken) {
|
||||
return { changed: false, claimed: false, busy: true, record: { ...record } };
|
||||
}
|
||||
record.status = 'delivering';
|
||||
record.leaseOwner = ownerToken;
|
||||
record.leaseUntil = now + DELIVERY_LEASE_MS;
|
||||
record.updatedAt = new Date(now).toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function markDelivered({ sessionId, ownerToken, deliveredVia }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || record.leaseOwner !== ownerToken) return { changed: false, saved: false };
|
||||
record.status = 'delivered';
|
||||
record.deliveredVia = deliveredVia;
|
||||
record.deliveredAt = new Date().toISOString();
|
||||
record.lastError = null;
|
||||
record.leaseUntil = 0;
|
||||
record.leaseOwner = null;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function markDeliveryFailed({ sessionId, ownerToken, error }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || record.leaseOwner !== ownerToken) return { changed: false, saved: false };
|
||||
record.status = 'pending_email';
|
||||
record.lastError = String(error || 'email delivery failed').slice(0, 500);
|
||||
record.leaseUntil = 0;
|
||||
record.leaseOwner = null;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
return { filePath, readBySession, readByEvent, claim, saveLicense, claimDelivery, markDelivered, markDeliveryFailed };
|
||||
}
|
||||
|
||||
module.exports = { createFulfillmentStore, DELIVERY_LEASE_MS };
|
||||
@@ -0,0 +1,200 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DashCaddy Stripe client — DC-055 + DC-057.
|
||||
*
|
||||
* Thin wrapper around the Stripe SDK for the OUTBOUND side of one-time
|
||||
* license purchasing: creating Checkout Sessions that drive customers to
|
||||
* Stripe's hosted payment page.
|
||||
*
|
||||
* Inbound (webhook) handling lives in scripts/stripe-license-bridge.js
|
||||
* (DC-054 + DC-057) — that runs as its own process so the merchant's
|
||||
* Stripe webhook secret doesn't have to be loaded into the DashCaddy API
|
||||
* host process.
|
||||
*
|
||||
* # Pricing contract
|
||||
*
|
||||
* One-time payments keyed by `productId` from src/billing/catalog.js:
|
||||
*
|
||||
* pro-30d → $20 USD, 30-day license
|
||||
* pro-90d → $50 USD, 90-day license
|
||||
* pro-180d → $70 USD, 180-day license
|
||||
* pro-365d → $99 USD, 365-day license
|
||||
*
|
||||
* `mode: 'payment'` (NOT 'subscription'). The license is generated once
|
||||
* per Checkout completion and the customer pastes it into their host.
|
||||
* No recurring billing, no Stripe Customer object retained beyond the
|
||||
* session.
|
||||
*
|
||||
* # Configuration (env vars)
|
||||
*
|
||||
* Required to create sessions — failures are loud:
|
||||
* STRIPE_SECRET_KEY — Stripe API secret (sk_live_... | sk_test_...)
|
||||
* STRIPE_PRICE_PRO_30D — Stripe Price ID for the 30-day product
|
||||
* STRIPE_PRICE_PRO_90D — Stripe Price ID for the 90-day product
|
||||
* STRIPE_PRICE_PRO_180D — Stripe Price ID for the 180-day product
|
||||
* STRIPE_PRICE_PRO_365D — Stripe Price ID for the 365-day product
|
||||
* STRIPE_SUCCESS_URL — (optional) success page URL override
|
||||
* STRIPE_CANCEL_URL — (optional) cancel page URL override
|
||||
*
|
||||
* The Stripe Price IDs map 1:1 to catalog products. A product whose
|
||||
* Stripe Price ID is unset cannot be purchased (returns
|
||||
* STRIPE_NOT_CONFIGURED).
|
||||
*
|
||||
* # Metadata contract (DC-057)
|
||||
*
|
||||
* The Checkout session carries `metadata.productId` (= one of the
|
||||
* catalog IDs). The webhook bridge reads this field back, maps to the
|
||||
* catalog, and generates the matching license duration.
|
||||
*
|
||||
* Why productId and not (e.g.) durationDays: the catalog is the single
|
||||
* source of truth. If pricing changes (e.g. new tier added), only the
|
||||
* catalog and the bridge change — the Checkout metadata stays abstract.
|
||||
*
|
||||
* Tested in __tests__/billing/stripe-client.test.js with mocked Stripe SDK.
|
||||
*/
|
||||
|
||||
let stripeSdk = null;
|
||||
function _loadStripeSdk() {
|
||||
if (stripeSdk) return stripeSdk;
|
||||
// Lazy require so tests can install a mock BEFORE first call.
|
||||
stripeSdk = require('stripe');
|
||||
return stripeSdk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a mock Stripe SDK. Used by tests; never call in production code.
|
||||
* @param {Object} mockSdk - Object with `checkout.sessions.create` (and any other surface) the tests want to stub.
|
||||
*/
|
||||
function _setStripeSdk(mockSdk) {
|
||||
stripeSdk = mockSdk;
|
||||
}
|
||||
|
||||
const catalog = require('./catalog');
|
||||
|
||||
/**
|
||||
* Read the active configuration. Throws if STRIPE_SECRET_KEY is missing
|
||||
* OR if no product has its Stripe Price ID configured — both are loud
|
||||
* failures so an operator notices instead of seeing silent 500s.
|
||||
*
|
||||
* @param {Object} [env] - process.env by default; tests pass custom env.
|
||||
* @returns {Object} config snapshot for this invocation
|
||||
*/
|
||||
function _readConfig(env = process.env) {
|
||||
const secretKey = env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
const err = new Error(
|
||||
'Stripe billing is not configured. Missing env var: STRIPE_SECRET_KEY. ' +
|
||||
'Set it in /opt/dashcaddy/.env and restart the API.'
|
||||
);
|
||||
err.code = 'STRIPE_NOT_CONFIGURED';
|
||||
err.statusCode = 503;
|
||||
err.missing = ['STRIPE_SECRET_KEY'];
|
||||
throw err;
|
||||
}
|
||||
return { secretKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the requested productId and resolve its Stripe Price ID.
|
||||
* Throws with a structured error if the productId is unknown OR if the
|
||||
* product's Stripe Price ID env var is not configured.
|
||||
*
|
||||
* @param {string} productId
|
||||
* @param {Object} env
|
||||
* @returns {Object} catalog product entry
|
||||
*/
|
||||
function _resolveProduct(productId, env = process.env) {
|
||||
if (!productId || typeof productId !== 'string') {
|
||||
const err = new Error('productId is required');
|
||||
err.code = 'INVALID_PRODUCT_ID';
|
||||
err.statusCode = 400;
|
||||
err.field = 'productId';
|
||||
throw err;
|
||||
}
|
||||
const product = catalog.getProduct(productId);
|
||||
if (!product) {
|
||||
const err = new Error(`Unknown productId: ${productId}. Valid: ${catalog.PRODUCTS.map(p => p.id).join(', ')}`);
|
||||
err.code = 'INVALID_PRODUCT_ID';
|
||||
err.statusCode = 400;
|
||||
err.field = 'productId';
|
||||
throw err;
|
||||
}
|
||||
const priceId = catalog.getConfiguredPrice(product, env);
|
||||
if (!priceId) {
|
||||
const err = new Error(
|
||||
`Product ${productId} is not configured for purchase. Missing env var: ${product.priceEnv}. ` +
|
||||
`Create the Stripe Price and set the env var in /opt/dashcaddy/.env, then restart the API.`
|
||||
);
|
||||
err.code = 'STRIPE_NOT_CONFIGURED';
|
||||
err.statusCode = 503;
|
||||
err.missing = [product.priceEnv];
|
||||
err.productId = productId;
|
||||
throw err;
|
||||
}
|
||||
return { product, priceId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout Session for a one-time DashCaddy Pro purchase.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.productId - catalog id: 'pro-30d' | 'pro-90d' | 'pro-180d' | 'pro-365d'
|
||||
* @param {string} [opts.customerEmail] - email to prefill on Checkout (optional)
|
||||
* @param {string} [opts.origin] - request origin (e.g. 'https://status.sami') used to build success/cancel URLs
|
||||
* @returns {Promise<{ id: string, url: string }>}
|
||||
* @throws Error with `.code` and `.statusCode` on configuration/validation failure
|
||||
*/
|
||||
async function createCheckoutSession({ productId, customerEmail, origin }) {
|
||||
const config = _readConfig();
|
||||
const { product, priceId } = _resolveProduct(productId);
|
||||
const stripe = _loadStripeSdk();
|
||||
const api = stripe(config.secretKey);
|
||||
|
||||
const successUrl = process.env.STRIPE_SUCCESS_URL
|
||||
|| (origin ? `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}` : '/billing/success?session_id={CHECKOUT_SESSION_ID}');
|
||||
const cancelUrl = process.env.STRIPE_CANCEL_URL
|
||||
|| (origin ? `${origin}/pricing` : '/pricing');
|
||||
|
||||
// mode: 'payment' (one-time, NOT subscription). The license is generated
|
||||
// once on `checkout.session.completed` and the customer pastes it into
|
||||
// their host. No Customer object, no recurring billing.
|
||||
//
|
||||
// metadata.productId is the contract with the webhook bridge — it maps
|
||||
// back to a catalog entry to get the license duration. If the catalog
|
||||
// grows, only the bridge needs to change.
|
||||
const params = {
|
||||
mode: 'payment',
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata: {
|
||||
productId: product.id,
|
||||
product: 'dashcaddy-pro',
|
||||
},
|
||||
// payment_intent_data carries metadata to the PaymentIntent too, so
|
||||
// any downstream Stripe→bridge plumbing that reads PI metadata still
|
||||
// gets the productId. (Stripe's webhook includes the PI on
|
||||
// checkout.session.completed for retrieval but the canonical metadata
|
||||
// field for session-level events is the top-level metadata.)
|
||||
payment_intent_data: {
|
||||
metadata: { productId: product.id, product: 'dashcaddy-pro' },
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
};
|
||||
if (customerEmail) {
|
||||
params.customer_email = customerEmail;
|
||||
}
|
||||
|
||||
const session = await api.checkout.sessions.create(params);
|
||||
|
||||
return { id: session.id, url: session.url };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCheckoutSession,
|
||||
// Test seams
|
||||
_setStripeSdk,
|
||||
_readConfig,
|
||||
_resolveProduct,
|
||||
};
|
||||
@@ -162,6 +162,20 @@ function csrfValidationMiddleware(req, res, next) {
|
||||
// is bounded by the token's TTL + scope. Same model as invite accept.
|
||||
'/api/v1/share/:token/subscribe',
|
||||
'/api/v1/share/:token/redeem-tailscale',
|
||||
// DC-055: Stripe Checkout session creation. Browsers hit this from the
|
||||
// public pricing page (cross-origin from any *.sami subdomain that
|
||||
// serves it); no session cookie exists yet, so a CSRF token can't be
|
||||
// anchored. SameSite=Lax on the session cookie doesn't apply (none
|
||||
// exists). Threat model: an attacker who can trigger checkout sessions
|
||||
// can only force a customer to land on Stripe's hosted page — they
|
||||
// can't extract money. Stripe's session id is single-use and tied to a
|
||||
// chosen price; reusing it requires Stripe's webhook secret.
|
||||
'/api/v1/billing/checkout',
|
||||
// DC-057: success page polls this from the customer's browser after
|
||||
// Stripe redirects them back. Same CSRF argument as above (no session
|
||||
// cookie exists yet) — and the response is the customer's own license
|
||||
// code, not anything an attacker can exploit by triggering the lookup.
|
||||
'/api/v1/billing/lookup/:sessionId',
|
||||
'/health',
|
||||
'/health/live',
|
||||
'/health/ready',
|
||||
|
||||
@@ -402,10 +402,14 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/share/:token/preview', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/share/:token/subscribe', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/share/:token/redeem-tailscale', exact: true, method: 'POST' },
|
||||
// /api/v1/billing/* was REMOVED: billing is handled out-of-process
|
||||
// (the merchant webhook secret never enters the API process). The
|
||||
// PUBLIC_ROUTES allowlist drift test guards against re-adding these
|
||||
// dead entries.
|
||||
// /api/v1/billing/* (DC-055 + DC-057): public checkout session creation
|
||||
// + license lookup for the success page. No DashCaddy account exists
|
||||
// yet at checkout time. The lookup endpoint serves the persisted
|
||||
// license in both `delivered` and `pending_email` states (the SMTP
|
||||
// failure-recovery path); the bearer-style secret is the Stripe
|
||||
// Checkout sessionId (single-use, 24h TTL — see routes/billing.js).
|
||||
{ path: '/api/v1/billing/checkout', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/billing/lookup/:sessionId', exact: true, method: 'GET' },
|
||||
// /api/v1/services + status: read-only service metadata that the public
|
||||
// dashboard needs before login (services list widget, status pill).
|
||||
// Writes go through the normal auth gate. CSRF applies to writes as usual.
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DashCaddy — Your Pro License</title>
|
||||
<link rel="canonical" href="/billing/success">
|
||||
<link rel="stylesheet" href="/assets/dashboard.css">
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg:#09111f; --card:#111c2e; --text:#e8edf5; --muted:#aab7ca; --accent:#68a4ff; --border:#263750; --pro:#7cf2c0; --danger:#ff9090; --warn:#ffd07f; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: linear-gradient(145deg,#07101d,#101b31); color: var(--text); font: 16px/1.7 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
|
||||
main { width: min(720px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,48px); }
|
||||
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
|
||||
h1 { margin: 8px 0 0; font-size: clamp(1.8rem,5vw,2.5rem); }
|
||||
.lede { color: var(--muted); }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 28px; margin-top: 24px; }
|
||||
.card.ready { border-color: var(--pro); box-shadow: 0 0 0 1px rgba(124,242,192,.25); }
|
||||
.card.warn { border-color: var(--warn); }
|
||||
.card.error { border-color: var(--danger); }
|
||||
.key { font: 600 1.2rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #06101e; padding: 16px; border-radius: 10px; border: 1px dashed var(--border); word-break: break-all; user-select: all; }
|
||||
.row { display: flex; gap: 12px; align-items: center; margin-top: 16px; }
|
||||
button { cursor: pointer; border: 0; padding: 10px 16px; border-radius: 10px; font: inherit; font-weight: 600; background: #1a2742; color: var(--text); border: 1px solid var(--border); }
|
||||
button.primary { background: var(--pro); color: #052016; border-color: var(--pro); }
|
||||
button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.meta { color: var(--muted); font-size: .9rem; margin-top: 8px; }
|
||||
.next-steps { margin-top: 24px; }
|
||||
.next-steps ol { padding-left: 20px; }
|
||||
.next-steps li { margin: 6px 0; color: var(--muted); }
|
||||
.next-steps li strong { color: var(--text); }
|
||||
a { color: var(--accent); }
|
||||
.copied { color: var(--pro); font-size: .9rem; margin-left: 8px; }
|
||||
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--muted); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 600px) { .card { padding: 20px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="eyebrow">DashCaddy</div>
|
||||
<h1>Thanks for your purchase!</h1>
|
||||
<p class="lede">Your Pro license code is shown below. We've also sent it to your email as a backup — keep it safe.</p>
|
||||
|
||||
<div id="card" class="card">
|
||||
<div id="loading"><span class="spinner"></span>Generating your license…</div>
|
||||
|
||||
<div id="ready" hidden>
|
||||
<div class="meta" id="meta"></div>
|
||||
<div class="key" id="key" aria-live="polite"></div>
|
||||
<div class="row">
|
||||
<button id="copy" class="primary" type="button">Copy license key</button>
|
||||
<span id="copied" class="copied" hidden>Copied!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="processing" hidden>
|
||||
Your payment was received. The license is being generated — this page will update automatically. You can also check your email.
|
||||
</div>
|
||||
|
||||
<div id="warn" hidden>
|
||||
<strong>Email delivery didn't complete.</strong> Your license code is below — save it now. We're retrying email delivery on our side.
|
||||
<div class="meta" id="warn-meta" style="margin-top:12px"></div>
|
||||
<div class="key" id="key-warn" aria-live="polite"></div>
|
||||
<div class="row">
|
||||
<button id="copy-warn" type="button">Copy license key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error" hidden>
|
||||
<strong>We can't find a record for this session yet.</strong> This page refreshes every 1.5 seconds. If you closed Stripe before being redirected back, your license has been emailed to you.
|
||||
<div class="meta" id="error-meta" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<h2 style="font-size:1.25rem;margin-bottom:8px">How to install your key</h2>
|
||||
<ol>
|
||||
<li>Open your DashCaddy host: <strong>https://<your-host></strong></li>
|
||||
<li>Sign in (TOTP or email magic link)</li>
|
||||
<li>Go to <strong>Settings → License</strong> (path: <code>/admin/license</code>)</li>
|
||||
<li>Paste the key and click <strong>Activate license</strong></li>
|
||||
<li>Pro features (unlimited users, public share links, Tailscale-mediated share) unlock immediately</li>
|
||||
</ol>
|
||||
<p class="meta">Need help? Reply to the receipt email or open an issue at <a href="https://github.com/sami7777/dashcaddy/issues" rel="noopener">github.com/sami7777/dashcaddy</a>. 14-day pro-rated refunds per the <a href="/legal/terms">Terms of Service</a>.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var sessionId = new URLSearchParams(window.location.search).get('session_id');
|
||||
if (!sessionId) {
|
||||
// No session_id — Stripe didn't redirect here from a checkout. Show a friendly fallback.
|
||||
document.getElementById('card').classList.add('error');
|
||||
document.getElementById('loading').hidden = true;
|
||||
document.getElementById('error').hidden = false;
|
||||
document.getElementById('error-meta').textContent = 'Missing session_id in URL.';
|
||||
return;
|
||||
}
|
||||
|
||||
var card = document.getElementById('card');
|
||||
var loadingEl = document.getElementById('loading');
|
||||
var readyEl = document.getElementById('ready');
|
||||
var processingEl = document.getElementById('processing');
|
||||
var warnEl = document.getElementById('warn');
|
||||
var errorEl = document.getElementById('error');
|
||||
var metaEl = document.getElementById('meta');
|
||||
var warnMetaEl = document.getElementById('warn-meta');
|
||||
var keyEl = document.getElementById('key');
|
||||
var keyWarnEl = document.getElementById('key-warn');
|
||||
var copyBtn = document.getElementById('copy');
|
||||
var copyWarnBtn = document.getElementById('copy-warn');
|
||||
var copiedEl = document.getElementById('copied');
|
||||
|
||||
var POLL_INTERVAL_MS = 1500;
|
||||
var POLL_TIMEOUT_MS = 60 * 1000;
|
||||
var startTime = Date.now();
|
||||
|
||||
function showReady(code, durationDays, productId, deliveredVia) {
|
||||
loadingEl.hidden = true;
|
||||
readyEl.hidden = false;
|
||||
card.classList.add('ready');
|
||||
keyEl.textContent = code;
|
||||
metaEl.textContent = durationDays + '-day Pro license (product ' + productId + ')' +
|
||||
(deliveredVia ? ' · delivered via ' + deliveredVia : '') +
|
||||
' · also emailed to you';
|
||||
}
|
||||
|
||||
function showProcessing() {
|
||||
loadingEl.hidden = true;
|
||||
processingEl.hidden = false;
|
||||
}
|
||||
|
||||
function showWarn(code, durationDays, productId, lastError, deliveredVia) {
|
||||
loadingEl.hidden = true;
|
||||
warnEl.hidden = false;
|
||||
card.classList.add('warn');
|
||||
keyWarnEl.textContent = code;
|
||||
warnMetaEl.textContent = durationDays + '-day Pro license (product ' + productId + ')' +
|
||||
(lastError ? ' · last email error: ' + lastError : '') +
|
||||
(deliveredVia ? ' · attempted via ' + deliveredVia : '');
|
||||
}
|
||||
|
||||
function showError(reason) {
|
||||
loadingEl.hidden = true;
|
||||
errorEl.hidden = false;
|
||||
card.classList.add('error');
|
||||
if (reason) document.getElementById('error-meta').textContent = reason;
|
||||
}
|
||||
|
||||
function copyFromTextarea(text, cb) {
|
||||
// Use the modern Clipboard API; fall back to a hidden textarea + execCommand.
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(cb).catch(function () {
|
||||
fallbackCopy(text, cb);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text, cb);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text, cb) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'absolute';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); cb(); } catch (_) { /* swallow */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
copyBtn.addEventListener('click', function () {
|
||||
copyFromTextarea(keyEl.textContent, function () {
|
||||
copiedEl.hidden = false;
|
||||
setTimeout(function () { copiedEl.hidden = true; }, 2000);
|
||||
});
|
||||
});
|
||||
copyWarnBtn.addEventListener('click', function () {
|
||||
copyFromTextarea(keyWarnEl.textContent, function () { /* no-op */ });
|
||||
});
|
||||
|
||||
function tick() {
|
||||
if (Date.now() - startTime > POLL_TIMEOUT_MS) {
|
||||
showError('Timed out waiting for the bridge to generate the license. Your license has been emailed to you — please check your inbox.');
|
||||
return;
|
||||
}
|
||||
fetch('/api/v1/billing/lookup/' + encodeURIComponent(sessionId), { cache: 'no-store' })
|
||||
.then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); })
|
||||
.then(function (resp) {
|
||||
var data = resp.body && resp.body.data ? resp.body.data : null;
|
||||
if (resp.status === 200 && data) {
|
||||
if (data.status === 'delivered') {
|
||||
showReady(data.code, data.durationDays, data.productId, data.deliveredVia);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'pending_email' && data.code) {
|
||||
showWarn(data.code, data.durationDays, data.productId, data.lastError, data.deliveredVia);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'processing') {
|
||||
showProcessing();
|
||||
setTimeout(tick, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (resp.status === 404 && resp.body && /expired/i.test(resp.body.error || '')) {
|
||||
showError('License lookup window has expired. Your key was emailed to you.');
|
||||
return;
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
// Payment still processing — keep polling.
|
||||
showProcessing();
|
||||
setTimeout(tick, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
showError('Unexpected response: ' + (resp.body && resp.body.error ? resp.body.error : 'HTTP ' + resp.status));
|
||||
})
|
||||
.catch(function () {
|
||||
// Transient network error — keep trying.
|
||||
setTimeout(tick, POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
tick();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,165 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DashCaddy Pricing — Free & Pro</title>
|
||||
<link rel="canonical" href="/pricing">
|
||||
<link rel="stylesheet" href="/assets/dashboard.css">
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg:#09111f; --card:#111c2e; --text:#e8edf5; --muted:#aab7ca; --accent:#68a4ff; --border:#263750; --pro:#7cf2c0; --danger:#ff9090; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: linear-gradient(145deg,#07101d,#101b31); color: var(--text); font: 16px/1.7 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
|
||||
main { width: min(1100px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,56px); }
|
||||
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
|
||||
h1 { margin: 8px 0 0; font-size: clamp(2rem,5vw,3rem); }
|
||||
.lede { color: var(--muted); max-width: 720px; margin-top: 12px; }
|
||||
.tiers { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: 18px; margin-top: 36px; }
|
||||
.tier { background: var(--card); border: 1px solid var(--border); border-radius: 18px; padding: 28px; display: flex; flex-direction: column; }
|
||||
.tier.pro { border-color: var(--pro); box-shadow: 0 0 0 1px rgba(124,242,192,.25); }
|
||||
.tier h2 { margin: 0 0 4px; font-size: 1.25rem; }
|
||||
.tier .price { font-size: 2rem; font-weight: 700; margin: 14px 0 0; }
|
||||
.tier .price small { font-size: 1rem; color: var(--muted); font-weight: 400; }
|
||||
.tier .duration { color: var(--muted); margin-top: 4px; font-size: .9rem; }
|
||||
.tier ul { margin: 14px 0; padding-left: 18px; color: var(--muted); font-size: .9rem; }
|
||||
.tier li { margin: 4px 0; }
|
||||
.tier button { cursor: pointer; border: 0; padding: 12px 16px; border-radius: 10px; font: inherit; font-weight: 600; margin-top: auto; }
|
||||
.tier.free { grid-column: 1 / -1; }
|
||||
.tier.free button { background: #1a2742; color: var(--text); border: 1px solid var(--border); }
|
||||
.tier.pro button { background: var(--pro); color: #052016; }
|
||||
.tier button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.footnote { color: var(--muted); margin-top: 32px; font-size: .9rem; }
|
||||
.footnote a { color: var(--accent); }
|
||||
.error { color: var(--danger); margin-top: 12px; min-height: 1.4em; }
|
||||
@media (max-width: 600px) { .tier { padding: 20px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="eyebrow">DashCaddy</div>
|
||||
<h1>Simple pricing. Self-hosted either way.</h1>
|
||||
<p class="lede">DashCaddy runs on your hardware. Free is enough for most homelabs. Pro unlocks multi-host fleets, public sharing, and email support.</p>
|
||||
|
||||
<div class="tiers">
|
||||
<div class="tier free">
|
||||
<h2>Free</h2>
|
||||
<div class="price">$0<small>/forever</small></div>
|
||||
<div class="duration">Unlimited duration</div>
|
||||
<ul>
|
||||
<li>Single host</li>
|
||||
<li>Up to <strong>3 users</strong></li>
|
||||
<li>TOTP login (single-user)</li>
|
||||
<li>Docker / Caddy / DNS management</li>
|
||||
<li>Community support (GitHub issues)</li>
|
||||
</ul>
|
||||
<button type="button" onclick="window.location.href='/download'">Download Free</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-30d">
|
||||
<h2>1 month</h2>
|
||||
<div class="price">$20</div>
|
||||
<div class="duration">30-day Pro license</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-30d">Buy 1 month</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-90d">
|
||||
<h2>3 months</h2>
|
||||
<div class="price">$50</div>
|
||||
<div class="duration">90-day Pro license (17% off)</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-90d">Buy 3 months</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-180d">
|
||||
<h2>6 months</h2>
|
||||
<div class="price">$70</div>
|
||||
<div class="duration">180-day Pro license (42% off)</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-180d">Buy 6 months</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-365d">
|
||||
<h2>12 months</h2>
|
||||
<div class="price">$99</div>
|
||||
<div class="duration">365-day Pro license (59% off)</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-365d">Buy 12 months</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error" class="error" role="alert"></div>
|
||||
<p class="footnote">Payments are processed by <a href="https://stripe.com" rel="noopener">Stripe</a>. Your card details never touch DashCaddy servers. After payment you receive a Pro license code on the success page AND by email — keep it safe; you'll paste it into <code>/admin/license</code> on your host. 14-day pro-rated refunds. By purchasing you agree to the <a href="/legal/terms">Terms of Service</a> and <a href="/legal/privacy">Privacy Policy</a>.</p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var errEl = document.getElementById('error');
|
||||
|
||||
function setError(msg) {
|
||||
errEl.textContent = msg || '';
|
||||
}
|
||||
|
||||
function buy(productId, btn) {
|
||||
setError('');
|
||||
btn.disabled = true;
|
||||
var originalText = btn.textContent;
|
||||
btn.textContent = 'Opening Stripe…';
|
||||
|
||||
var email = null; // could prefill from a logged-in user; left null for the public pricing page
|
||||
|
||||
fetch('/api/v1/billing/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ productId: productId, customerEmail: email })
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (body) { return { status: r.status, body: body }; });
|
||||
}).then(function (resp) {
|
||||
if (resp.status === 200 && resp.body.success && resp.body.data && resp.body.data.url) {
|
||||
window.location.href = resp.body.data.url;
|
||||
return;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
setError((resp.body && resp.body.error) || ('Checkout failed (HTTP ' + resp.status + ').'));
|
||||
}).catch(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
setError('Network error. Please try again.');
|
||||
});
|
||||
}
|
||||
|
||||
var buttons = document.querySelectorAll('.buy-btn');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var productId = btn.getAttribute('data-product-id');
|
||||
buy(productId, btn);
|
||||
});
|
||||
})(buttons[i]);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user