diff --git a/.env.example b/.env.example
index 0f9756c..533b13d 100644
--- a/.env.example
+++ b/.env.example
@@ -4,4 +4,14 @@ DASHCADDY_WEBSITE_URL=https://dashcaddy.net
STRIPE_SECRET_KEY=sk_live_your_secret_key_here
STRIPE_PUBLISHABLE_KEY=pk_live_your_publishable_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
+DASHCADDY_LICENSE_SECRET=64_hex_characters_shared_with_dashcaddy
DATA_DIR=./data
+ADMIN_TOKEN=generate_a_long_random_admin_token
+
+SMTP_HOST=mail.sami-ahmed.net
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USERNAME=licenses@dashcaddy.net
+SMTP_PASSWORD=your_smtp_password
+SMTP_FROM=licenses@dashcaddy.net
+SMTP_TLS_REJECT_UNAUTHORIZED=true
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index a44e109..32eb5ff 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -34,9 +34,9 @@ Paid subscriptions unlock:
- `swarm`
### Stripe plans
-- 1 month — $25
+- 1 month — $20
- 3 months — $50
-- 6 months — $65
+- 6 months — $70
- 12 months — $99
### Subscription policy
@@ -78,7 +78,8 @@ Source of truth for:
## Proposed API surface
### Public endpoints for website
-- `POST /api/checkout/session`
+- `POST /api/checkout/one-time`
+- `POST /api/checkout/subscription`
- `GET /api/public/plans`
### DashCaddy app endpoints
@@ -200,7 +201,7 @@ On cancellation:
The `dashcaddy.net` marketing site should be updated to:
- add Premium pricing section buttons
-- each button POSTs to `/api/checkout/session` with a plan code
+- each button POSTs to `/api/checkout/{one-time,subscription}` with a plan code
- redirect to returned Stripe Checkout URL
- success page explains license delivery/activation flow
- cancellation page returns user to pricing
@@ -223,7 +224,7 @@ Initial env expected:
1. Scaffold Node service for `dashcaddy-license-server`
2. Add Stripe SDK, Express, and SQLite/Postgres adapter layer
3. Build `/api/public/plans`
-4. Build `/api/checkout/session`
+4. Build `/api/checkout/one-time` and `/api/checkout/subscription`
5. Build `/api/stripe/webhook`
6. Port/reuse DashCaddy-compatible license generation/validation helpers
7. Build `/api/license/validate`
diff --git a/PRODUCT_PLAN.md b/PRODUCT_PLAN.md
index 2881141..9c73437 100644
--- a/PRODUCT_PLAN.md
+++ b/PRODUCT_PLAN.md
@@ -3,9 +3,9 @@
## Locked Decisions
### Subscription plans
-- 1 month — $25
+- 1 month — $20
- 3 months — $50
-- 6 months — $65
+- 6 months — $70
- 12 months — $99
### Tier model
diff --git a/README.md b/README.md
index 571c2e1..89eb77e 100644
--- a/README.md
+++ b/README.md
@@ -1,54 +1,32 @@
# DashCaddy License Server
-Stripe-driven license automation for DashCaddy.
+Production billing and license fulfillment for purchases made on [dashcaddy.net](https://dashcaddy.net).
-## Purpose
+Requires **Node.js 22.5 or newer** for the built-in `node:sqlite` transactional store.
-This service is the billing and license orchestration layer for DashCaddy.
-It receives Stripe webhooks, maps purchases/subscriptions to license entitlements,
-and exposes license validation/deactivation endpoints for DashCaddy instances.
+## What it does
-## Planned responsibilities
+- Creates Stripe Checkout sessions for one-time purchases and auto-renewing subscriptions.
+- Verifies signed Stripe webhooks with durable event idempotency.
+- Creates one stable DashCaddy license key per customer and extends that key on later purchases or renewals.
+- Tracks one-time and subscription paid-through components separately, so subscription failure cannot erase valid one-time access.
+- Emails the exact key accepted by the validation API and tracks each renewal email per Stripe invoice.
+- Keeps subscriptions active through their paid period; failed renewals receive a seven-day grace period.
+- Supports one-machine activation and deactivation.
+- Migrates the previous JSON store into a transactional SQLite database on first startup.
-- Verify Stripe webhook signatures
-- Track customers, subscriptions, invoices, and purchases
-- Generate or extend DashCaddy licenses
-- Expose `/api/license/validate` for DashCaddy activation
-- Expose `/api/license/deactivate` for DashCaddy deactivation
-- Support renewals, expirations, cancellations, and grace periods
+## Plans
-## Architecture
+| Plan | One-time / renewal amount | Subscription interval |
+|---|---:|---:|
+| `premium_30d` | $20 | 1 month |
+| `premium_90d` | $50 | 3 months |
+| `premium_180d` | $70 | 6 months |
+| `premium_365d` | $99 | 1 year |
-- **Stripe** is billing truth
-- **License server database** is entitlement truth
-- **DashCaddy app** remains the consumer of license validation
-- Existing DashCaddy license logic should be reused, not reinvented
+Stripe remains the billing source of truth. SQLite is the entitlement and fulfillment source of truth.
-## Next steps
-
-1. Extract/reuse the current DashCaddy license key generation and verification logic
-2. Define DB schema for customers, licenses, activations, and Stripe mapping
-3. Implement webhook ingestion and event processing
-4. Implement validate/deactivate endpoints
-5. Add admin tooling for manual recovery and support workflows
-
-
-## Current implementation status
-
-Implemented now:
-- Stripe Checkout session creation
-- Stripe webhook ingestion scaffold with subscription/license sync
-- File-backed persistence for customers, subscriptions, and licenses
-- License validation endpoint
-- License deactivation endpoint
-- One-machine-at-a-time activation enforcement
-
-Still required before production:
-- durable database
-- email delivery for license keys
-- deployment on Contabo
-- Stripe webhook registration
-- end-to-end live checkout verification
+Server-managed keys use DashCaddy's HMAC-compatible code format for initial activation, but renewed expiry is authoritative online because a stable signed code cannot encode changing renewal dates. The DashCaddy client refreshes server-managed entitlements from the license server and does not create a new activation through offline fallback when that server is configured.
## Environment
@@ -59,16 +37,62 @@ DASHCADDY_WEBSITE_URL=https://dashcaddy.net
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
+DASHCADDY_LICENSE_SECRET=64_hex_characters_shared_with_dashcaddy
DATA_DIR=./data
+ADMIN_TOKEN=generate-a-long-random-token
+
+SMTP_HOST=mail.sami-ahmed.net
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USERNAME=licenses@dashcaddy.net
+SMTP_PASSWORD=...
+SMTP_FROM=licenses@dashcaddy.net
+# Keep certificate verification enabled in production.
+SMTP_TLS_REJECT_UNAUTHORIZED=true
```
-## HTTP endpoints
+## Public HTTP endpoints
- `GET /health`
- `GET /api/public/config`
- `GET /api/public/plans`
-- `POST /api/checkout/session`
+- `POST /api/checkout/one-time`
+- `POST /api/checkout/subscription`
+- `GET /api/checkout/session/:sessionId`
- `POST /api/stripe/webhook`
- `POST /api/license/validate`
- `POST /api/license/deactivate`
-- `GET /api/admin/debug/store`
+
+`GET /api/admin/debug/store` requires `Authorization: Bearer $ADMIN_TOKEN` and is hidden with a 404 when no admin token is configured.
+
+## Checkout request
+
+```json
+{
+ "planCode": "premium_30d",
+ "customerEmail": "buyer@example.com"
+}
+```
+
+A successful checkout request returns a Stripe-hosted `url` and a `sessionId`. The website redirects to Stripe. After payment, Stripe calls the webhook, the license is created or extended, SMTP delivery is recorded, and the website polls `/api/checkout/session/:sessionId` to display the same key sent by email.
+
+## Safety properties
+
+- Checkout accepts only valid plan codes and email addresses.
+- Browser CORS is restricted to `dashcaddy.net` and `www.dashcaddy.net`.
+- Checkout creation is rate limited.
+- Webhook event IDs and payment intent IDs are idempotent.
+- Store changes use SQLite transactions and WAL durability.
+- Repeated checkout sessions are stored independently.
+- Admin customer/license data is not public.
+- SMTP certificate verification is enabled by default.
+
+An email marked `delivered` means the configured SMTP provider accepted it; final inbox placement remains the receiving mail system's responsibility. The success page also displays the same valid key, so fulfillment does not depend on inbox delivery.
+
+## Test
+
+```bash
+npm test
+```
+
+The tests cover CORS, invalid checkout input, admin isolation, exact-key lookup and validation, payment idempotency, rate limiting, durable webhook claims, repeat checkout lookup, cancellation, and payment-grace expiry.
diff --git a/assets/dashcaddy-logo.jpg b/assets/dashcaddy-logo.jpg
new file mode 100644
index 0000000..461741c
Binary files /dev/null and b/assets/dashcaddy-logo.jpg differ
diff --git a/assets/samiahmed7777-logo.png b/assets/samiahmed7777-logo.png
new file mode 100644
index 0000000..11c97b1
Binary files /dev/null and b/assets/samiahmed7777-logo.png differ
diff --git a/package-lock.json b/package-lock.json
index 1af4208..a7b2dda 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"express": "^4.21.2",
+ "nodemailer": "^9.0.5",
"stripe": "^22.0.1"
}
},
@@ -526,6 +527,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/nodemailer": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
+ "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
diff --git a/package.json b/package.json
index 9f28052..3f21867 100644
--- a/package.json
+++ b/package.json
@@ -5,10 +5,12 @@
"type": "module",
"scripts": {
"dev": "node --watch src/server.js",
- "start": "node src/server.js"
+ "start": "node src/server.js",
+ "test": "node --test"
},
"dependencies": {
"express": "^4.21.2",
+ "nodemailer": "^9.0.5",
"stripe": "^22.0.1"
}
}
diff --git a/src/config.js b/src/config.js
index a7fa429..160bc26 100644
--- a/src/config.js
+++ b/src/config.js
@@ -4,5 +4,7 @@ export const config = {
websiteUrl: process.env.DASHCADDY_WEBSITE_URL || 'https://dashcaddy.net',
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
- stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || ''
+ stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
+ licenseSecret: process.env.DASHCADDY_LICENSE_SECRET || '',
+ adminToken: process.env.ADMIN_TOKEN || ''
};
diff --git a/src/email.js b/src/email.js
index dcd5e39..2f75905 100644
--- a/src/email.js
+++ b/src/email.js
@@ -1,38 +1,27 @@
-import crypto from 'crypto';
import nodemailer from 'nodemailer';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
-const LICENSE_SECRET = process.env.DASHCADDY_LICENSE_SECRET || '';
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
-const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Crockford-ish (no I, O, 0, 1)
-/**
- * Generate a DashCaddy license code in the format `DC-XXXX-XXXX-XXXX-XXXX`.
- * Deterministic for the same (licenseKey, durationDays) tuple — if a customer
- * pays again, regenerate the code with the same key + new duration, and the
- * code stays the same. That's what "the same key gets more time" means.
- */
-export function generateLicenseCode(licenseKey, durationDays) {
- if (!LICENSE_SECRET) {
- throw new Error('DASHCADDY_LICENSE_SECRET not set');
- }
- const seed = crypto
- .createHmac('sha256', LICENSE_SECRET)
- .update(`${licenseKey}|${durationDays}`)
- .digest();
- const segments = [];
- for (let i = 0; i < seed.length && segments.length < 4; i += 2) {
- const b1 = seed[i];
- const b2 = seed[i + 1] || 0;
- segments.push(
- ALPHABET[b1 % 32] +
- ALPHABET[Math.floor(b1 / 32) % 32] +
- ALPHABET[b2 % 32] +
- ALPHABET[Math.floor(b2 / 32) % 32]
- );
- }
- return 'DC-' + segments.join('-');
+// Cache logos as base64 data URIs so they're inlined in the HTML email
+let dashcaddyLogoDataUri = null;
+let samiahmedLogoDataUri = null;
+
+function loadLogo(filename) {
+ const assetsDir = path.join(__dirname, '..', 'assets');
+ const filepath = path.join(assetsDir, filename);
+ if (!fs.existsSync(filepath)) return null;
+ const buf = fs.readFileSync(filepath);
+ const ext = path.extname(filename).slice(1).toLowerCase();
+ const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : `image/${ext}`;
+ return `data:${mime};base64,${buf.toString('base64')}`;
}
+
let transporter = null;
function getTransporter() {
if (transporter) return transporter;
@@ -44,14 +33,31 @@ function getTransporter() {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD
} : undefined,
- tls: { rejectUnauthorized: false }
+ connectionTimeout: 10000,
+ greetingTimeout: 10000,
+ socketTimeout: 20000,
+ tls: { rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false' }
});
return transporter;
}
function formatDate(iso) {
if (!iso) return 'N/A';
- return new Date(iso).toUTCString();
+ return new Date(iso).toLocaleString('en-US', {
+ timeZone: 'America/Los_Angeles',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ timeZoneName: 'short'
+ });
+}
+
+function planLabel(planCode, durationDays) {
+ if (durationDays) return `${durationDays} days`;
+ return planCode;
}
/**
@@ -59,6 +65,18 @@ function formatDate(iso) {
* (initial purchase or renewal extension).
*/
export async function sendLicenseEmail({ to, code, durationDays, planCode, expiresAt, extended = false }) {
+ // Lazy-load logos as inline data URIs
+ if (!dashcaddyLogoDataUri) dashcaddyLogoDataUri = loadLogo('dashcaddy-logo.jpg');
+ if (!samiahmedLogoDataUri) samiahmedLogoDataUri = loadLogo('samiahmed7777-logo.png');
+
+ const dashcaddyLogo = dashcaddyLogoDataUri
+ ? `
`
+ : `
DashCaddy
`;
+
+ const samiahmedLogo = samiahmedLogoDataUri
+ ? `
`
+ : `a product by samiahmed7777`;
+
const subject = extended
? `Your DashCaddy Premium license has been extended (${durationDays} days added)`
: `Your DashCaddy Premium license key`;
@@ -66,28 +84,108 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir
extended ? 'Your DashCaddy Premium license has been extended.' : 'Thank you for purchasing DashCaddy Premium.',
'',
`License key: ${code}`,
- `Plan: ${planCode}`,
+ `Plan: ${planLabel(planCode, durationDays)}`,
`License valid until: ${formatDate(expiresAt)}`,
'',
'To activate: paste this license key into your DashCaddy dashboard at Admin → License.',
'',
- 'Need help? Reply to this email or visit https://dashcaddy.net/about'
+ 'Need help? Reply to this email or visit https://dashcaddy.net/about',
+ '',
+ '— DashCaddy'
].join('\n');
+
const html = `
-
-
${extended ? 'License extended' : 'Welcome to DashCaddy Premium'}
-
${extended
- ? 'Your DashCaddy Premium license has been extended. Same key, more time.'
- : 'Thank you for purchasing DashCaddy Premium. Your license key is below.'}
-
- ${code}
-
-
Plan: ${planCode}
-
License valid until: ${formatDate(expiresAt)}
-
To activate: paste this license key into your DashCaddy dashboard at Admin → License.
-
-
Need help? Reply to this email or visit dashcaddy.net/about
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ ${dashcaddyLogo}
+ |
+
+
+
+
+
+
+ ${extended ? 'License extended' : 'Welcome to DashCaddy Premium'}
+
+
+ ${extended
+ ? 'Your DashCaddy Premium license has been extended. Same key, more time.'
+ : 'Thank you for purchasing DashCaddy Premium. Your license key is below.'}
+
+
+
+
+ ${code}
+
+
+ Your License Key
+
+
+
+
+
+ | Plan |
+ ${planLabel(planCode, durationDays)} |
+
+
+ | ${extended ? 'New expiration' : 'License valid until'} |
+ ${formatDate(expiresAt)} |
+
+
+
+
+ To activate, paste this license key into your DashCaddy dashboard at Admin → License.
+
+
+
+ |
+
+
+
+
+
+
+
+ Need help? Reply to this email or visit dashcaddy.net/about
+
+ |
+
+
+
+
+ |
+
+ A Product By
+
+ ${samiahmedLogo}
+ |
+
+
+
+ |
+
+
+
+
`;
try {
@@ -104,4 +202,4 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir
console.error('Email delivery failed:', err.message);
return { delivered: false, error: err.message };
}
-}
+}
\ No newline at end of file
diff --git a/src/licenseCode.js b/src/licenseCode.js
new file mode 100644
index 0000000..6b2a4a7
--- /dev/null
+++ b/src/licenseCode.js
@@ -0,0 +1,73 @@
+import crypto from 'crypto';
+
+const VERSION = 1;
+const VALID_DURATIONS = new Set([30, 90, 180, 365]);
+const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+
+function secret() {
+ const value = process.env.DASHCADDY_LICENSE_SECRET || '';
+ if (!/^[A-Fa-f0-9]{32,}$/.test(value)) {
+ throw new Error('DASHCADDY_LICENSE_SECRET must be a hex secret of at least 128 bits');
+ }
+ return value;
+}
+
+function base32Encode(buffer) {
+ let bits = '';
+ for (const byte of buffer) bits += byte.toString(2).padStart(8, '0');
+ while (bits.length % 5 !== 0) bits += '0';
+ let result = '';
+ for (let i = 0; i < bits.length; i += 5) result += BASE32[parseInt(bits.slice(i, i + 5), 2)];
+ return result;
+}
+
+function base32Decode(value) {
+ let bits = '';
+ for (const char of value.toUpperCase()) {
+ const index = BASE32.indexOf(char);
+ if (index < 0) throw new Error(`Invalid base32 character: ${char}`);
+ bits += index.toString(2).padStart(5, '0');
+ }
+ const bytes = [];
+ for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));
+ return Buffer.from(bytes);
+}
+
+export function generateCompatibleLicenseCode(durationDays, codeId, createdTs = Math.floor(Date.now() / 1000)) {
+ if (!VALID_DURATIONS.has(durationDays)) throw new Error(`Invalid duration: ${durationDays}`);
+ if (!Number.isInteger(codeId) || codeId < 1 || codeId > 0xFFFFFFFF) throw new Error('Invalid codeId');
+ const payload = Buffer.alloc(10);
+ payload.writeUInt16BE(((VERSION & 0x0F) << 12) | (durationDays & 0x0FFF), 0);
+ payload.writeUInt32BE(codeId, 2);
+ payload.writeUInt32BE(createdTs, 6);
+ const signature = crypto.createHmac('sha256', secret()).update(payload).digest().subarray(0, 5);
+ const encoded = base32Encode(Buffer.concat([payload, signature])).padEnd(25, '0').slice(0, 25);
+ return `DC-${encoded.match(/.{5}/g).join('-')}`;
+}
+
+export function verifyCompatibleLicenseCode(code) {
+ try {
+ const cleaned = String(code).replace(/^DC-/, '').replace(/-/g, '');
+ if (cleaned.length !== 25) return { valid: false, reason: 'Invalid code length' };
+ const decoded = base32Decode(cleaned).subarray(0, 15);
+ if (decoded.length < 15) return { valid: false, reason: 'Invalid code payload' };
+ const payload = decoded.subarray(0, 10);
+ const signature = decoded.subarray(10, 15);
+ const expected = crypto.createHmac('sha256', secret()).update(payload).digest().subarray(0, 5);
+ if (!crypto.timingSafeEqual(signature, expected)) return { valid: false, reason: 'Invalid signature' };
+ const packed = payload.readUInt16BE(0);
+ const version = (packed >> 12) & 0x0F;
+ const durationDays = packed & 0x0FFF;
+ const codeId = payload.readUInt32BE(2);
+ const createdTs = payload.readUInt32BE(6);
+ return {
+ valid: version === VERSION && VALID_DURATIONS.has(durationDays),
+ version,
+ durationDays,
+ codeId,
+ createdTs,
+ };
+ } catch (error) {
+ return { valid: false, reason: error.message };
+ }
+}
diff --git a/src/licenseLogic.js b/src/licenseLogic.js
index af4c1c9..0bcbc9f 100644
--- a/src/licenseLogic.js
+++ b/src/licenseLogic.js
@@ -1,12 +1,13 @@
import crypto from 'crypto';
-import { PREMIUM_FEATURES } from './plans.js';
+import { PREMIUM_FEATURES, getPlan } from './plans.js';
import {
createOrUpdateLicenseBySubscription,
findLicenseByKey,
- findActiveLicenseByCustomerEmail,
findLicenseByCustomerId,
- extendLicenseByDuration,
- updateLicense
+ updateLicense,
+ grantOneTimeLicenseAtomic,
+ claimLicenseMachine,
+ isStripeTransitionStale
} from './store.js';
export function fingerprintMachine(payload = {}) {
@@ -26,45 +27,34 @@ export function fingerprintMachine(payload = {}) {
* the plan duration instead of creating a new one. This is the "renewal
* adds time to the same key" model.
*/
-export function syncLicenseFromSubscription({ subscriptionId, customerId, customerEmail, planCode, status, currentPeriodEnd, durationDays }) {
+export function syncLicenseFromSubscription({ subscriptionId, customerId, customerEmail, planCode, status, currentPeriodEnd, durationDays, eventCreated, eventId }) {
const premiumFeatures = Object.fromEntries(PREMIUM_FEATURES.map((f) => [f, true]));
-
- // Try to find an existing license by customerId FIRST, then email
- const existing = findLicenseByCustomerId(customerId) || findActiveLicenseByCustomerEmail(customerEmail);
-
- if (existing) {
- // Extend existing license. We use currentPeriodEnd from Stripe for
- // subscriptions (Stripe is the authority on renewal dates), but if
- // durationDays is provided we use that to extend from current expiry.
- const patch = {
- subscriptionId,
- customerId,
- customerEmail,
- planCode,
- status,
- active: ['active', 'trialing', 'past_due'].includes(status),
- premiumFeatures,
- machineFingerprint: existing.machineFingerprint || null,
- deactivatedAt: null
- };
- if (currentPeriodEnd) {
- patch.expiresAt = currentPeriodEnd;
- }
- return updateLicense(existing.id, patch);
+ const existing = findLicenseByCustomerId(customerId);
+ const incomingEvent = Number(eventCreated || 0);
+ const previousEvent = Number(existing?.lastStripeEventCreated || 0);
+ if (existing && isStripeTransitionStale(
+ { ...existing, status: existing.subscriptionStatus || existing.status },
+ incomingEvent,
+ eventId,
+ status
+ )) {
+ return { ...existing, staleEventIgnored: true };
}
- // No existing license — create one with the current period end as the initial expiry
return createOrUpdateLicenseBySubscription(subscriptionId, {
subscriptionId,
customerId,
customerEmail,
planCode,
+ durationDays: durationDays || getPlan(planCode)?.durationDays,
status,
expiresAt: currentPeriodEnd,
- active: ['active', 'trialing', 'past_due'].includes(status),
+ graceUntil: status === 'past_due' ? existing?.subscriptionGraceUntil || existing?.graceUntil || null : null,
premiumFeatures,
- machineFingerprint: null,
- deactivatedAt: null
+ machineFingerprint: existing?.machineFingerprint || null,
+ deactivatedAt: null,
+ lastStripeEventCreated: incomingEvent ? Math.max(incomingEvent, previousEvent) : previousEvent || null,
+ lastStripeEventId: eventId || existing?.lastStripeEventId || null
});
}
@@ -75,36 +65,13 @@ export function syncLicenseFromSubscription({ subscriptionId, customerId, custom
*/
export function grantOneTimeLicense({ paymentIntentId, customerId, customerEmail, planCode, durationDays }) {
const premiumFeatures = Object.fromEntries(PREMIUM_FEATURES.map((f) => [f, true]));
-
- // Look for existing license by customerId, then email
- const existing = findLicenseByCustomerId(customerId) || findActiveLicenseByCustomerEmail(customerEmail);
-
- if (existing) {
- // Extend existing license by the plan duration
- const extended = extendLicenseByDuration(existing.id, durationDays);
- return {
- ...extended,
- extended: true,
- addedDays: durationDays
- };
- }
-
- // No existing license — create one with expiry = now + durationDays
- const expiresAt = new Date();
- expiresAt.setUTCDate(expiresAt.getUTCDate() + durationDays);
-
- return createOrUpdateLicenseBySubscription(`one-time-${paymentIntentId}`, {
- subscriptionId: null,
+ return grantOneTimeLicenseAtomic({
+ paymentIntentId,
customerId,
customerEmail,
planCode,
- status: 'paid',
- expiresAt: expiresAt.toISOString(),
- active: true,
- premiumFeatures,
- machineFingerprint: null,
- deactivatedAt: null,
- paymentIntentId
+ durationDays,
+ premiumFeatures
});
}
@@ -114,28 +81,35 @@ export function validateLicense({ code, machine }) {
return { success: false, message: 'License not found' };
}
+ const now = Date.now();
+ let graceActive = false;
+ if (license.status === 'past_due') {
+ if (!license.graceUntil) return { success: false, message: 'Payment is past due' };
+ if (new Date(license.graceUntil).getTime() <= now) {
+ updateLicense(license.id, { active: false, status: 'grace_expired' });
+ return { success: false, message: 'Payment grace period has expired' };
+ }
+ graceActive = true;
+ }
if (!license.active) {
return { success: false, message: 'License is not active' };
}
-
- const now = Date.now();
- if (license.expiresAt && new Date(license.expiresAt).getTime() < now) {
+ if (!graceActive && license.expiresAt && new Date(license.expiresAt).getTime() < now) {
return { success: false, message: 'License has expired' };
}
const fingerprint = fingerprintMachine(machine || {});
- if (license.machineFingerprint && license.machineFingerprint !== fingerprint) {
+ const machineClaim = claimLicenseMachine(code, fingerprint);
+ if (!machineClaim.claimed) {
return { success: false, message: 'License is already active on another machine' };
}
-
- const updated = license.machineFingerprint
- ? license
- : updateLicense(license.id, { machineFingerprint: fingerprint, activatedAt: new Date().toISOString() });
+ const updated = machineClaim.license;
return {
success: true,
license: {
+ id: updated.id,
code: updated.key,
tier: 'premium',
expiresAt: updated.expiresAt,
@@ -143,7 +117,7 @@ export function validateLicense({ code, machine }) {
subscriptionStatus: updated.status,
customerEmail: updated.customerEmail
},
- message: updated === license ? 'License validated' : 'License activated on this machine'
+ message: machineClaim.existing ? 'License validated' : 'License activated on this machine'
};
}
diff --git a/src/plans.js b/src/plans.js
index 4fd837d..45b5a0d 100644
--- a/src/plans.js
+++ b/src/plans.js
@@ -37,7 +37,7 @@ export const PLAN_DEFS = {
}
};
-export const PREMIUM_FEATURES = ['sso', 'recipes', 'swarm', 'fleet'];
+export const PREMIUM_FEATURES = ['sso', 'recipes', 'swarm'];
export function listPlans() {
return Object.values(PLAN_DEFS);
diff --git a/src/server.js b/src/server.js
index d07d305..4c03abe 100644
--- a/src/server.js
+++ b/src/server.js
@@ -1,18 +1,150 @@
import express from 'express';
+import crypto from 'crypto';
+import { pathToFileURL } from 'url';
import { config } from './config.js';
import { getPlan, listPlans, PREMIUM_FEATURES } from './plans.js';
import { getStripe } from './stripe.js';
-import { getStoreSnapshot, upsertCustomer, upsertSubscription, findLicenseByCustomerId } from './store.js';
+import {
+ getStoreSnapshot,
+ upsertCustomer,
+ upsertSubscription,
+ findLicenseByCustomerId,
+ findCheckoutResult,
+ claimWebhookEvent,
+ finishWebhookEvent,
+ claimBusinessObject,
+ finishBusinessObject,
+ cancelLicenseBySubscription,
+ markLicensePaymentFailed,
+ updateLicense
+} from './store.js';
import {
syncLicenseFromSubscription,
grantOneTimeLicense,
validateLicense,
deactivateLicense
} from './licenseLogic.js';
+import { sendLicenseEmail } from './email.js';
const app = express();
-app.use('/api/stripe/webhook', express.raw({ type: 'application/json' }));
-app.use(express.json());
+app.disable('x-powered-by');
+app.set('trust proxy', 'loopback');
+const allowedOrigins = new Set([
+ config.websiteUrl.replace(/\/$/, ''),
+ 'https://dashcaddy.net',
+ 'https://www.dashcaddy.net'
+]);
+
+app.use((req, res, next) => {
+ const origin = req.headers.origin;
+ if (origin && allowedOrigins.has(origin)) {
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Vary', 'Origin');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Stripe-Signature');
+ res.setHeader('Access-Control-Max-Age', '600');
+ }
+ if (req.method === 'OPTIONS') {
+ return origin && allowedOrigins.has(origin) ? res.sendStatus(204) : res.sendStatus(403);
+ }
+ return next();
+});
+
+app.use('/api/stripe/webhook', express.raw({ type: 'application/json', limit: '1mb' }));
+app.use(express.json({ limit: '16kb' }));
+
+const checkoutAttempts = new Map();
+const CHECKOUT_WINDOW_MS = 15 * 60 * 1000;
+const CHECKOUT_LIMIT = 20;
+function checkoutRateLimit(req, res, next) {
+ const now = Date.now();
+ const key = req.ip || req.socket.remoteAddress || 'unknown';
+ const isNewKey = !checkoutAttempts.has(key);
+ const recent = (checkoutAttempts.get(key) || []).filter((time) => now - time < CHECKOUT_WINDOW_MS);
+ if (recent.length >= CHECKOUT_LIMIT) {
+ res.setHeader('Retry-After', String(Math.ceil((CHECKOUT_WINDOW_MS - (now - recent[0])) / 1000)));
+ return res.status(429).json({ ok: false, error: 'Too many checkout attempts. Try again later.' });
+ }
+ recent.push(now);
+ checkoutAttempts.set(key, recent);
+ if (isNewKey) {
+ const timer = setTimeout(() => checkoutAttempts.delete(key), CHECKOUT_WINDOW_MS + 1000);
+ timer.unref?.();
+ }
+ return next();
+}
+
+function normalizeCustomerEmail(value) {
+ const email = String(value || '').trim().toLowerCase();
+ if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
+ return email;
+}
+
+function stripeObjectId(value) {
+ if (!value) return null;
+ return typeof value === 'string' ? value : value.id || null;
+}
+
+function getInvoiceSubscriptionId(invoice) {
+ return stripeObjectId(invoice?.parent?.subscription_details?.subscription)
+ || stripeObjectId(invoice?.subscription);
+}
+
+function getSubscriptionPeriodEnd(subscription) {
+ const itemEnds = subscription?.items?.data
+ ?.map((item) => Number(item.current_period_end || 0))
+ .filter(Boolean) || [];
+ const timestamp = itemEnds.length ? Math.max(...itemEnds) : Number(subscription?.current_period_end || 0);
+ return timestamp ? new Date(timestamp * 1000).toISOString() : null;
+}
+
+function requireAdmin(req, res, next) {
+ if (!config.adminToken) return res.status(404).json({ ok: false, error: 'Not found' });
+ const supplied = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
+ const expected = Buffer.from(config.adminToken);
+ const actual = Buffer.from(supplied);
+ if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
+ return res.status(401).json({ ok: false, error: 'Unauthorized' });
+ }
+ return next();
+}
+
+async function deliverAndTrackLicenseEmail({ license, customerEmail, code, durationDays, planCode, extended, deliveryId }, sender = sendLicenseEmail) {
+ if (deliveryId && license.lastEmailDeliveryId === deliveryId) {
+ return { delivered: true, via: license.emailDeliveryVia || 'recorded', skipped: true };
+ }
+ if (!customerEmail) {
+ updateLicense(license.id, { emailDeliveryStatus: 'failed', emailDeliveryError: 'missing customer email' });
+ throw new Error('License fulfillment is missing customer email');
+ }
+ updateLicense(license.id, { emailDeliveryStatus: 'pending', emailDeliveryError: null });
+ try {
+ const result = await sender({
+ to: customerEmail,
+ code,
+ durationDays,
+ planCode,
+ expiresAt: license.expiresAt,
+ extended
+ });
+ if (!result.delivered) throw new Error('SMTP did not confirm delivery');
+ updateLicense(license.id, {
+ emailDeliveryStatus: 'delivered',
+ emailDeliveryVia: result.via || null,
+ emailDeliveredAt: new Date().toISOString(),
+ lastEmailDeliveryId: deliveryId || license.lastEmailDeliveryId || null,
+ emailDeliveryError: null
+ });
+ return result;
+ } catch (error) {
+ updateLicense(license.id, {
+ emailDeliveryStatus: 'failed',
+ emailDeliveryError: String(error.message || error).slice(0, 500)
+ });
+ console.error('License email failed', error.message);
+ throw error;
+ }
+}
app.get('/health', (_req, res) => {
res.json({ ok: true, service: 'dashcaddy-license-server' });
@@ -30,7 +162,34 @@ app.get('/api/public/plans', (_req, res) => {
res.json({ ok: true, tier: 'premium', features: PREMIUM_FEATURES, plans: listPlans() });
});
-app.get('/api/admin/debug/store', (_req, res) => {
+app.get('/api/checkout/session/:sessionId', (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+ const sessionId = String(req.params.sessionId || '');
+ if (!/^cs_[A-Za-z0-9_]+$/.test(sessionId)) {
+ return res.status(404).json({ status: 'not_found' });
+ }
+ const result = findCheckoutResult(sessionId);
+ if (!result) return res.status(404).json({ status: 'not_found' });
+ if (!result.license) return res.json({ status: 'processing' });
+
+ const plan = getPlan(result.license.planCode);
+ const expired = result.license.expiresAt && new Date(result.license.expiresAt).getTime() <= Date.now();
+ const deliveryStatus = result.license.emailDeliveryStatus;
+ const status = expired
+ ? 'expired'
+ : deliveryStatus === 'pending' || !deliveryStatus
+ ? 'processing'
+ : deliveryStatus === 'failed' ? 'pending_email' : 'delivered';
+ return res.json({
+ status,
+ code: result.license.key,
+ productId: result.license.planCode?.replace(/^premium_/, 'pro-') || null,
+ durationDays: plan?.durationDays || null,
+ deliveredVia: result.license.emailDeliveryVia || null
+ });
+});
+
+app.get('/api/admin/debug/store', requireAdmin, (_req, res) => {
res.json({ ok: true, store: getStoreSnapshot() });
});
@@ -39,11 +198,13 @@ app.get('/api/admin/debug/store', (_req, res) => {
* plan's interval. The license is created on the first webhook event and
* extended on every renewal.
*/
-app.post('/api/checkout/subscription', async (req, res) => {
+app.post('/api/checkout/subscription', checkoutRateLimit, async (req, res) => {
try {
const { planCode, customerEmail } = req.body || {};
const plan = getPlan(planCode);
if (!plan) return res.status(400).json({ ok: false, error: 'Invalid planCode' });
+ const normalizedEmail = normalizeCustomerEmail(customerEmail);
+ if (!normalizedEmail) return res.status(400).json({ ok: false, error: 'Valid customerEmail is required' });
const stripe = getStripe();
const session = await stripe.checkout.sessions.create({
@@ -62,22 +223,20 @@ app.post('/api/checkout/subscription', async (req, res) => {
cancel_url: `${config.websiteUrl}/pricing`,
allow_promotion_codes: true,
billing_address_collection: 'required',
- customer_email: customerEmail || undefined,
+ customer_email: normalizedEmail,
metadata: {
source: 'dashcaddy.net',
planCode: plan.code,
tier: plan.tier,
mode: 'subscription',
- // Bake the email into session metadata so the webhook can find
- // the existing license without a Stripe API call.
- customerEmail: customerEmail || ''
+ customerEmail: normalizedEmail
},
subscription_data: {
metadata: {
source: 'dashcaddy.net',
planCode: plan.code,
tier: plan.tier,
- customerEmail: customerEmail || ''
+ customerEmail: normalizedEmail
}
}
});
@@ -85,7 +244,7 @@ app.post('/api/checkout/subscription', async (req, res) => {
return res.json({ ok: true, url: session.url, sessionId: session.id });
} catch (error) {
console.error('Subscription checkout error:', error);
- return res.status(500).json({ ok: false, error: error.message || 'Checkout failed' });
+ return res.status(500).json({ ok: false, error: 'Checkout is temporarily unavailable' });
}
});
@@ -93,15 +252,18 @@ app.post('/api/checkout/subscription', async (req, res) => {
* One-time purchase checkout. Customer pays once for a license of the
* plan duration. If they buy again, time is added to their existing license.
*/
-app.post('/api/checkout/one-time', async (req, res) => {
+app.post('/api/checkout/one-time', checkoutRateLimit, async (req, res) => {
try {
const { planCode, customerEmail } = req.body || {};
const plan = getPlan(planCode);
if (!plan) return res.status(400).json({ ok: false, error: 'Invalid planCode' });
+ const normalizedEmail = normalizeCustomerEmail(customerEmail);
+ if (!normalizedEmail) return res.status(400).json({ ok: false, error: 'Valid customerEmail is required' });
const stripe = getStripe();
const session = await stripe.checkout.sessions.create({
mode: 'payment',
+ customer_creation: 'always',
payment_method_types: ['card'],
line_items: [{
price_data: {
@@ -115,14 +277,14 @@ app.post('/api/checkout/one-time', async (req, res) => {
cancel_url: `${config.websiteUrl}/pricing`,
allow_promotion_codes: true,
billing_address_collection: 'required',
- customer_email: customerEmail || undefined,
+ customer_email: normalizedEmail,
metadata: {
source: 'dashcaddy.net',
planCode: plan.code,
tier: plan.tier,
mode: 'one-time',
durationDays: String(plan.durationDays),
- customerEmail: customerEmail || ''
+ customerEmail: normalizedEmail
},
payment_intent_data: {
metadata: {
@@ -131,7 +293,7 @@ app.post('/api/checkout/one-time', async (req, res) => {
tier: plan.tier,
mode: 'one-time',
durationDays: String(plan.durationDays),
- customerEmail: customerEmail || ''
+ customerEmail: normalizedEmail
}
}
});
@@ -139,38 +301,22 @@ app.post('/api/checkout/one-time', async (req, res) => {
return res.json({ ok: true, url: session.url, sessionId: session.id });
} catch (error) {
console.error('One-time checkout error:', error);
- return res.status(500).json({ ok: false, error: error.message || 'Checkout failed' });
+ return res.status(500).json({ ok: false, error: 'Checkout is temporarily unavailable' });
}
});
-/**
- * Look up an existing license for a customer. Tries customerId first,
- * then falls back to email. Returns null if no active license exists.
- */
-function findCustomersLicense(customerId, customerEmail) {
- if (customerId) {
- const byCustomer = findLicenseByCustomerId(customerId);
- if (byCustomer) return byCustomer;
- }
- if (customerEmail) {
- const { findActiveLicenseByCustomerEmail } = require('./store.js');
- const byEmail = findActiveLicenseByCustomerEmail(customerEmail);
- if (byEmail) return byEmail;
- }
- return null;
-}
-
/**
* Stripe webhook endpoint. Handles subscription lifecycle events AND
* one-time payment completion events. The same handler routes based on
* event type and updates the customer's license accordingly.
*/
app.post('/api/stripe/webhook', async (req, res) => {
+ let claimedEventId = null;
+ let claimedBusiness = null;
try {
if (!config.stripeWebhookSecret) {
return res.status(500).json({ ok: false, error: 'Missing STRIPE_WEBHOOK_SECRET' });
}
-
const signature = req.headers['stripe-signature'];
if (!signature) {
return res.status(400).json({ ok: false, error: 'Missing stripe-signature header' });
@@ -178,6 +324,12 @@ app.post('/api/stripe/webhook', async (req, res) => {
const stripe = getStripe();
const event = stripe.webhooks.constructEvent(req.body, signature, config.stripeWebhookSecret);
+ const eventClaim = claimWebhookEvent(event.id);
+ if (!eventClaim.claimed) {
+ if (eventClaim.status === 'completed') return res.json({ ok: true, duplicate: true });
+ return res.status(409).json({ ok: false, retry: true, error: 'Webhook event is still processing' });
+ }
+ claimedEventId = event.id;
switch (event.type) {
case 'checkout.session.completed': {
@@ -185,28 +337,56 @@ app.post('/api/stripe/webhook', async (req, res) => {
const planCode = session.metadata?.planCode;
const mode = session.metadata?.mode || 'subscription';
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id;
- // Email resolution: session metadata (baked at creation) > session.customer_email > session.customer_details.email
const customerEmail = session.metadata?.customerEmail || session.customer_email || session.customer_details?.email || null;
if (customerId) {
upsertCustomer({ id: String(customerId), email: customerEmail, checkoutSessionId: session.id });
}
- // For one-time payments, generate the license on checkout.session.completed
- if (mode === 'one-time' && planCode) {
+ // For one-time payments, generate/extend the license and email it.
+ if (mode === 'one-time' && (!planCode || session.payment_status !== 'paid')) {
+ throw new Error('One-time checkout completed without a paid status or plan code');
+ }
+ if (mode === 'one-time' && planCode && session.payment_status === 'paid') {
const plan = getPlan(planCode);
+ if (!plan) throw new Error('Paid checkout references an unknown plan');
if (plan) {
+ const paymentIntentId = stripeObjectId(session.payment_intent);
+ if (!paymentIntentId || !customerId || !customerEmail) {
+ throw new Error('Paid checkout is missing payment intent, customer, or email');
+ }
const license = grantOneTimeLicense({
- paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
+ paymentIntentId,
customerId,
customerEmail,
planCode,
durationDays: plan.durationDays
});
+
+ // The stored key is already human-readable and is the exact value
+ // accepted by /api/license/validate. Never email a derived code
+ // that the validation store cannot resolve.
+ const code = license.key;
+
+ if (!license.idempotent || license.emailDeliveryStatus !== 'delivered') {
+ const delivery = await deliverAndTrackLicenseEmail({
+ license,
+ customerEmail,
+ code,
+ durationDays: plan.durationDays,
+ planCode,
+ extended: license.extended || false,
+ deliveryId: paymentIntentId
+ });
+ console.log('License email', {
+ delivered: delivery.delivered,
+ via: delivery.via,
+ extended: license.extended
+ });
+ }
+
console.log('One-time license granted', {
sessionId: session.id,
- licenseKey: license.key,
- customerEmail,
extended: license.extended || false,
addedDays: license.addedDays || plan.durationDays
});
@@ -215,11 +395,74 @@ app.post('/api/stripe/webhook', async (req, res) => {
break;
}
+ case 'invoice.paid': {
+ // Subscription renewal. Extend the existing license once per Stripe invoice.
+ const invoice = event.data.object;
+ const invoiceClaim = claimBusinessObject('invoice.paid', invoice.id);
+ if (!invoiceClaim.claimed) {
+ if (invoiceClaim.status === 'completed') {
+ console.log('Duplicate invoice.paid ignored', { invoiceId: invoice.id });
+ break;
+ }
+ throw new Error('Invoice is already being processed; retry later');
+ }
+ claimedBusiness = { kind: 'invoice.paid', id: invoice.id };
+ const subscriptionId = getInvoiceSubscriptionId(invoice);
+ const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
+ if (!subscriptionId || !customerId) throw new Error('Paid invoice is missing subscription or customer');
+ if (subscriptionId && customerId) {
+ const sub = await stripe.subscriptions.retrieve(subscriptionId);
+ const planCode = sub.metadata?.planCode || 'premium_30d';
+ const customerEmail = sub.metadata?.customerEmail || invoice.customer_email || null;
+ const currentPeriodEnd = getSubscriptionPeriodEnd(sub);
+
+ upsertSubscription({
+ id: subscriptionId,
+ customerId,
+ status: sub.status,
+ planCode,
+ currentPeriodEnd,
+ cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end),
+ lastStripeEventCreated: event.created,
+ lastStripeEventId: event.id
+ });
+
+ const license = syncLicenseFromSubscription({
+ subscriptionId,
+ customerId,
+ customerEmail,
+ planCode,
+ status: sub.status,
+ currentPeriodEnd,
+ eventCreated: event.created,
+ eventId: event.id
+ });
+
+ const code = license.key;
+
+ await deliverAndTrackLicenseEmail({
+ license,
+ customerEmail,
+ code,
+ durationDays: getPlan(planCode)?.durationDays || 30,
+ planCode,
+ extended: true,
+ deliveryId: invoice.id
+ });
+
+ console.log('Subscription renewal extended license', {
+ subscriptionId,
+ expiresAt: license.expiresAt
+ });
+ }
+ break;
+ }
+
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object;
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
- // Email resolution: subscription metadata (baked at creation) > fetch from Stripe if missing
+ if (!subscription.id || !customerId) throw new Error('Subscription event is missing subscription or customer');
let customerEmail = subscription.metadata?.customerEmail || null;
if (!customerEmail && customerId) {
try {
@@ -230,9 +473,7 @@ app.post('/api/stripe/webhook', async (req, res) => {
}
}
const planCode = subscription.metadata?.planCode || 'premium_30d';
- const currentPeriodEnd = subscription.current_period_end
- ? new Date(subscription.current_period_end * 1000).toISOString()
- : null;
+ const currentPeriodEnd = getSubscriptionPeriodEnd(subscription);
upsertSubscription({
id: subscription.id,
@@ -240,7 +481,9 @@ app.post('/api/stripe/webhook', async (req, res) => {
status: subscription.status,
planCode,
currentPeriodEnd,
- cancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end)
+ cancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end),
+ lastStripeEventCreated: event.created,
+ lastStripeEventId: event.id
});
const license = syncLicenseFromSubscription({
@@ -249,35 +492,65 @@ app.post('/api/stripe/webhook', async (req, res) => {
customerEmail,
planCode,
status: subscription.status,
- currentPeriodEnd
+ currentPeriodEnd,
+ eventCreated: event.created,
+ eventId: event.id
});
console.log('Subscription license synced', {
subscriptionId: subscription.id,
- licenseKey: license.key,
status: license.status,
- expiresAt: license.expiresAt,
- customerEmail
+ expiresAt: license.expiresAt
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
- // Mark the subscription cancelled. License remains active until
- // currentPeriodEnd (Stripe handles non-renewal automatically).
upsertSubscription({
id: subscription.id,
status: 'canceled',
- cancelAtPeriodEnd: true
+ currentPeriodEnd: getSubscriptionPeriodEnd(subscription),
+ cancelAtPeriodEnd: true,
+ lastStripeEventCreated: event.created,
+ lastStripeEventId: event.id
+ });
+ const license = cancelLicenseBySubscription(subscription.id, event.created, event.id);
+ console.log('Subscription cancelled', {
+ subscriptionId: subscription.id,
+ licenseActiveUntilExpiry: license?.active ?? null,
+ expiresAt: license?.expiresAt || null
});
- console.log('Subscription cancelled', { subscriptionId: subscription.id });
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
- console.warn('Payment failed', { invoiceId: invoice.id, customerId: invoice.customer });
+ const failureClaim = claimBusinessObject('invoice.payment_failed', invoice.id);
+ if (!failureClaim.claimed) {
+ if (failureClaim.status === 'completed') {
+ console.log('Duplicate invoice.payment_failed ignored', { invoiceId: invoice.id });
+ break;
+ }
+ throw new Error('Failed invoice is already being processed; retry later');
+ }
+ claimedBusiness = { kind: 'invoice.payment_failed', id: invoice.id };
+ const subscriptionId = getInvoiceSubscriptionId(invoice);
+ if (!subscriptionId) throw new Error('Failed invoice is missing subscription');
+ const failureAnchor = Number(invoice.due_date || invoice.period_end || invoice.created || event.created);
+ const graceUntil = new Date((failureAnchor + 7 * 24 * 60 * 60) * 1000).toISOString();
+ if (subscriptionId) {
+ upsertSubscription({
+ id: subscriptionId,
+ status: 'past_due',
+ graceUntil,
+ customerId: typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id || null,
+ lastStripeEventCreated: event.created,
+ lastStripeEventId: event.id
+ });
+ markLicensePaymentFailed(subscriptionId, graceUntil, event.created, event.id);
+ }
+ console.warn('Payment failed', { invoiceId: invoice.id, subscriptionId, graceUntil });
break;
}
@@ -285,27 +558,76 @@ app.post('/api/stripe/webhook', async (req, res) => {
console.log('Unhandled Stripe event', event.type);
}
+ if (claimedBusiness) finishBusinessObject(claimedBusiness.kind, claimedBusiness.id);
+ finishWebhookEvent(claimedEventId);
return res.json({ ok: true });
} catch (error) {
+ if (claimedBusiness) {
+ try { finishBusinessObject(claimedBusiness.kind, claimedBusiness.id, error.message || 'Processing failed'); } catch (_) {}
+ }
+ if (claimedEventId) {
+ try { finishWebhookEvent(claimedEventId, error.message || 'Webhook failed'); } catch (_) {}
+ }
console.error('Webhook processing error:', error);
- return res.status(400).json({ ok: false, error: error.message || 'Webhook failed' });
+ return res.status(400).json({ ok: false, error: 'Webhook processing failed' });
}
});
app.post('/api/license/validate', async (req, res) => {
- const { code, machine } = req.body || {};
+ const { code, machine, machineId } = req.body || {};
if (!code) return res.status(400).json({ ok: false, error: 'License code is required' });
- const result = validateLicense({ code, machine });
- return res.status(result.success ? 200 : 400).json(result);
+ const hasMachineObject = machine && typeof machine === 'object'
+ && ['hostname', 'platform', 'arch', 'cpu', 'mac'].some((field) => String(machine[field] || '').trim());
+ if (!machineId && !hasMachineObject) {
+ return res.status(400).json({ success: false, error: 'Machine identity is required' });
+ }
+ const machinePayload = machine || (machineId ? { hostname: String(machineId) } : {});
+ const result = validateLicense({ code, machine: machinePayload });
+ if (!result.success) {
+ return res.status(400).json({ success: false, error: result.message, message: result.message });
+ }
+ const featureList = Object.entries(result.license.features || {})
+ .filter(([, enabled]) => Boolean(enabled))
+ .map(([feature]) => feature);
+ const durationDays = result.license.expiresAt
+ ? Math.max(0, Math.ceil((new Date(result.license.expiresAt).getTime() - Date.now()) / 86400000))
+ : null;
+ return res.json({
+ ...result,
+ codeId: result.license.id || null,
+ durationDays,
+ expiresAt: result.license.expiresAt,
+ features: featureList,
+ token: null
+ });
});
app.post('/api/license/deactivate', async (req, res) => {
- const { code, machine } = req.body || {};
+ const { code, machine, machineId } = req.body || {};
if (!code) return res.status(400).json({ ok: false, error: 'License code is required' });
- const result = deactivateLicense({ code, machine });
+ const hasMachineObject = machine && typeof machine === 'object'
+ && ['hostname', 'platform', 'arch', 'cpu', 'mac'].some((field) => String(machine[field] || '').trim());
+ if (!machineId && !hasMachineObject) {
+ return res.status(400).json({ success: false, error: 'Machine identity is required' });
+ }
+ const machinePayload = machine || (machineId ? { hostname: String(machineId) } : {});
+ const result = deactivateLicense({ code, machine: machinePayload });
return res.status(result.success ? 200 : 400).json(result);
});
-app.listen(config.port, () => {
- console.log(`dashcaddy-license-server listening on :${config.port}`);
-});
+function validateStartupConfig() {
+ const missing = [];
+ if (!config.stripeSecretKey) missing.push('STRIPE_SECRET_KEY');
+ if (!config.stripeWebhookSecret) missing.push('STRIPE_WEBHOOK_SECRET');
+ if (!/^[A-Fa-f0-9]{32,}$/.test(config.licenseSecret)) missing.push('DASHCADDY_LICENSE_SECRET (hex, at least 128 bits)');
+ if (missing.length) throw new Error(`Missing or invalid production configuration: ${missing.join(', ')}`);
+}
+
+export { app, getInvoiceSubscriptionId, getSubscriptionPeriodEnd, validateStartupConfig, deliverAndTrackLicenseEmail };
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ validateStartupConfig();
+ app.listen(config.port, () => {
+ console.log(`dashcaddy-license-server listening on :${config.port}`);
+ });
+}
diff --git a/src/store.js b/src/store.js
index de03972..a857bf9 100644
--- a/src/store.js
+++ b/src/store.js
@@ -1,123 +1,454 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
+import { DatabaseSync } from 'node:sqlite';
+import { generateCompatibleLicenseCode, verifyCompatibleLicenseCode } from './licenseCode.js';
const DATA_DIR = process.env.DATA_DIR || path.resolve(process.cwd(), 'data');
-const DATA_FILE = path.join(DATA_DIR, 'db.json');
+const DB_FILE = path.join(DATA_DIR, 'db.sqlite');
+const LEGACY_JSON_FILE = path.join(DATA_DIR, 'db.json');
-function ensureStore() {
- fs.mkdirSync(DATA_DIR, { recursive: true });
- if (!fs.existsSync(DATA_FILE)) {
- fs.writeFileSync(DATA_FILE, JSON.stringify({ customers: {}, subscriptions: {}, licenses: {} }, null, 2));
+fs.mkdirSync(DATA_DIR, { recursive: true });
+const db = new DatabaseSync(DB_FILE);
+db.exec(`
+ PRAGMA journal_mode = WAL;
+ PRAGMA synchronous = FULL;
+ PRAGMA busy_timeout = 5000;
+ CREATE TABLE IF NOT EXISTS customers (id TEXT PRIMARY KEY, data TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS subscriptions (id TEXT PRIMARY KEY, data TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS licenses (id TEXT PRIMARY KEY, data TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS checkout_sessions (
+ id TEXT PRIMARY KEY,
+ customer_id TEXT,
+ email TEXT,
+ created_at TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS webhook_events (
+ id TEXT PRIMARY KEY,
+ status TEXT NOT NULL,
+ error TEXT,
+ updated_at TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS processed_objects (
+ kind TEXT NOT NULL,
+ object_id TEXT NOT NULL,
+ status TEXT NOT NULL,
+ error TEXT,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (kind, object_id)
+ );
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
+`);
+
+const TABLES = new Set(['customers', 'subscriptions', 'licenses']);
+const PLAN_DURATIONS = { premium_30d: 30, premium_90d: 90, premium_180d: 180, premium_365d: 365 };
+const STRIPE_STATUS_RANK = { grace_expired: 0, past_due: 1, trialing: 2, active: 3, paid: 3, canceled: 4 };
+
+export function isStripeTransitionStale(current, eventCreated = 0, eventId = '', incomingStatus = '') {
+ const incomingTime = Number(eventCreated || 0);
+ const previousTime = Number(current?.lastStripeEventCreated || 0);
+ if (incomingTime && previousTime && incomingTime < previousTime) return true;
+ if (!incomingTime || !previousTime || incomingTime > previousTime) return false;
+ const incomingRank = STRIPE_STATUS_RANK[incomingStatus] ?? 0;
+ const previousRank = STRIPE_STATUS_RANK[current?.status] ?? 0;
+ if (incomingRank !== previousRank) return incomingRank < previousRank;
+ const previousId = String(current?.lastStripeEventId || '');
+ return Boolean(previousId && eventId && String(eventId) <= previousId);
+}
+
+function transaction(fn) {
+ db.exec('BEGIN IMMEDIATE');
+ try {
+ const result = fn();
+ db.exec('COMMIT');
+ return result;
+ } catch (error) {
+ try { db.exec('ROLLBACK'); } catch (_) {}
+ throw error;
}
}
-function readStore() {
- ensureStore();
- return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
+function decode(row) {
+ return row ? JSON.parse(row.data) : null;
}
-function writeStore(db) {
- ensureStore();
- fs.writeFileSync(DATA_FILE, JSON.stringify(db, null, 2));
+function loadOne(table, id) {
+ if (!TABLES.has(table)) throw new Error('Invalid table');
+ return decode(db.prepare(`SELECT data FROM ${table} WHERE id = ?`).get(id));
}
-export function createLicenseKey() {
- const raw = crypto.randomBytes(16).toString('hex').toUpperCase();
- return `DC-${raw.slice(0,5)}-${raw.slice(5,10)}-${raw.slice(10,15)}-${raw.slice(15,20)}-${raw.slice(20,25)}`;
+function loadAll(table) {
+ if (!TABLES.has(table)) throw new Error('Invalid table');
+ return db.prepare(`SELECT data FROM ${table}`).all().map(decode);
+}
+
+function saveOne(table, id, value) {
+ if (!TABLES.has(table)) throw new Error('Invalid table');
+ db.prepare(`INSERT INTO ${table} (id, data) VALUES (?, ?)
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data`).run(id, JSON.stringify(value));
+ return value;
+}
+
+function migrateLegacyJson() {
+ const count = Number(db.prepare('SELECT COUNT(*) AS count FROM licenses').get().count);
+ if (count > 0 || !fs.existsSync(LEGACY_JSON_FILE)) return;
+ let legacy;
+ try { legacy = JSON.parse(fs.readFileSync(LEGACY_JSON_FILE, 'utf8')); } catch (_) { return; }
+ transaction(() => {
+ let maxCodeId = 0;
+ for (const [id, value] of Object.entries(legacy.customers || {})) saveOne('customers', id, value);
+ for (const [id, value] of Object.entries(legacy.subscriptions || {})) saveOne('subscriptions', id, value);
+ for (const [id, value] of Object.entries(legacy.licenses || {})) {
+ saveOne('licenses', id, value);
+ const parsed = verifyCompatibleLicenseCode(value.key);
+ if (parsed.valid) maxCodeId = Math.max(maxCodeId, parsed.codeId);
+ }
+ db.prepare(`INSERT OR REPLACE INTO meta (key, value) VALUES ('license_code_counter', ?)`)
+ .run(String(maxCodeId));
+ for (const [id, value] of Object.entries(legacy.webhookEvents || {})) {
+ db.prepare(`INSERT OR REPLACE INTO webhook_events (id, status, error, updated_at) VALUES (?, ?, ?, ?)`)
+ .run(id, value.status || 'completed', value.error || null, value.updatedAt || new Date().toISOString());
+ }
+ for (const customer of Object.values(legacy.customers || {})) {
+ if (customer.checkoutSessionId) {
+ db.prepare(`INSERT OR REPLACE INTO checkout_sessions (id, customer_id, email, created_at) VALUES (?, ?, ?, ?)`)
+ .run(customer.checkoutSessionId, customer.id || null, customer.email || null, customer.updatedAt || new Date().toISOString());
+ }
+ }
+ });
+ fs.copyFileSync(LEGACY_JSON_FILE, `${LEGACY_JSON_FILE}.migrated-backup`);
+}
+
+migrateLegacyJson();
+
+export function createLicenseKey(durationDays) {
+ db.prepare(`INSERT OR IGNORE INTO meta (key, value) VALUES ('license_code_counter', '0')`).run();
+ const row = db.prepare(`UPDATE meta SET value = CAST(value AS INTEGER) + 1
+ WHERE key = 'license_code_counter' RETURNING value`).get();
+ const codeId = Number(row.value);
+ return { key: generateCompatibleLicenseCode(durationDays, codeId), codeId };
+}
+
+function recomputeEntitlement(license) {
+ const now = Date.now();
+ const oneTimeExpiry = license.oneTimeExpiresAt ? new Date(license.oneTimeExpiresAt).getTime() : 0;
+ const subscriptionExpiry = license.subscriptionExpiresAt ? new Date(license.subscriptionExpiresAt).getTime() : 0;
+ const graceExpiry = license.subscriptionGraceUntil ? new Date(license.subscriptionGraceUntil).getTime() : 0;
+ const oneTimeActive = oneTimeExpiry > now;
+ const subscriptionStatus = license.subscriptionStatus || license.status;
+ const subscriptionActive = ['active', 'trialing'].includes(subscriptionStatus) && subscriptionExpiry > now;
+ const subscriptionGraceActive = subscriptionStatus === 'past_due' && graceExpiry > now;
+ const canceledPaidThrough = subscriptionStatus === 'canceled' && subscriptionExpiry > now;
+ const active = oneTimeActive || subscriptionActive || subscriptionGraceActive || canceledPaidThrough;
+ const effectiveExpiry = Math.max(
+ oneTimeActive ? oneTimeExpiry : 0,
+ subscriptionActive || canceledPaidThrough ? subscriptionExpiry : 0,
+ subscriptionGraceActive ? graceExpiry : 0
+ );
+ return {
+ ...license,
+ active,
+ status: oneTimeActive ? 'active' : (subscriptionStatus || (active ? 'active' : 'expired')),
+ expiresAt: effectiveExpiry ? new Date(effectiveExpiry).toISOString() : license.expiresAt
+ };
}
export function upsertCustomer(customer) {
- const db = readStore();
- db.customers[customer.id] = { ...(db.customers[customer.id] || {}), ...customer, updatedAt: new Date().toISOString() };
- writeStore(db);
- return db.customers[customer.id];
+ return transaction(() => {
+ const next = { ...(loadOne('customers', customer.id) || {}), ...customer, updatedAt: new Date().toISOString() };
+ saveOne('customers', customer.id, next);
+ if (customer.checkoutSessionId) {
+ db.prepare(`INSERT OR REPLACE INTO checkout_sessions (id, customer_id, email, created_at) VALUES (?, ?, ?, ?)`)
+ .run(customer.checkoutSessionId, customer.id || null, customer.email || null, new Date().toISOString());
+ }
+ return next;
+ });
}
export function upsertSubscription(subscription) {
- const db = readStore();
- db.subscriptions[subscription.id] = { ...(db.subscriptions[subscription.id] || {}), ...subscription, updatedAt: new Date().toISOString() };
- writeStore(db);
- return db.subscriptions[subscription.id];
+ return transaction(() => {
+ const existing = loadOne('subscriptions', subscription.id) || {};
+ const incomingEvent = Number(subscription.lastStripeEventCreated || 0);
+ const previousEvent = Number(existing.lastStripeEventCreated || 0);
+ if (isStripeTransitionStale(existing, incomingEvent, subscription.lastStripeEventId, subscription.status)) {
+ return { ...existing, staleEventIgnored: true };
+ }
+ const next = {
+ ...existing,
+ ...subscription,
+ lastStripeEventCreated: incomingEvent ? Math.max(incomingEvent, previousEvent) : previousEvent || null,
+ lastStripeEventId: subscription.lastStripeEventId || existing.lastStripeEventId || null,
+ updatedAt: new Date().toISOString()
+ };
+ return saveOne('subscriptions', subscription.id, next);
+ });
}
export function createOrUpdateLicenseBySubscription(subscriptionId, patch) {
- const db = readStore();
- const existing = Object.values(db.licenses).find((lic) => lic.subscriptionId === subscriptionId);
- const id = existing?.id || crypto.randomUUID();
- const next = {
- id,
- key: existing?.key || createLicenseKey(),
- createdAt: existing?.createdAt || new Date().toISOString(),
- ...existing,
- ...patch,
- updatedAt: new Date().toISOString()
- };
- db.licenses[id] = next;
- writeStore(db);
- return next;
+ return transaction(() => {
+ const licenses = loadAll('licenses');
+ const existing = licenses.find((lic) => lic.subscriptionId === subscriptionId)
+ || licenses.find((lic) => patch.customerId && lic.customerId === patch.customerId);
+ const id = existing?.id || crypto.randomUUID();
+ const durationDays = patch.durationDays || PLAN_DURATIONS[patch.planCode];
+ const allocated = existing ? null : createLicenseKey(durationDays);
+ const inferredOneTimeExpiry = existing?.oneTimeExpiresAt
+ || (existing?.paymentIntentId || existing?.processedPaymentIntentIds?.length ? existing.expiresAt : null);
+ const raw = {
+ id,
+ key: existing?.key || allocated.key,
+ codeId: existing?.codeId || allocated.codeId,
+ createdAt: existing?.createdAt || new Date().toISOString(),
+ ...existing,
+ ...patch,
+ subscriptionId,
+ subscriptionStatus: patch.status || existing?.subscriptionStatus,
+ subscriptionExpiresAt: patch.expiresAt || existing?.subscriptionExpiresAt,
+ subscriptionGraceUntil: patch.graceUntil ?? existing?.subscriptionGraceUntil ?? null,
+ oneTimeExpiresAt: inferredOneTimeExpiry,
+ durationDays: durationDays || existing?.durationDays,
+ updatedAt: new Date().toISOString()
+ };
+ const next = recomputeEntitlement(raw);
+ return saveOne('licenses', id, next);
+ });
+}
+
+export function grantOneTimeLicenseAtomic({ paymentIntentId, customerId, customerEmail, planCode, durationDays, premiumFeatures }) {
+ return transaction(() => {
+ const licenses = loadAll('licenses');
+ const existing = licenses.find((lic) => lic.customerId === customerId && customerId);
+ if (existing) {
+ const processed = Array.isArray(existing.processedPaymentIntentIds) ? existing.processedPaymentIntentIds : [];
+ if (paymentIntentId && processed.includes(paymentIntentId)) {
+ return { ...existing, idempotent: true, extended: false, addedDays: 0 };
+ }
+ const paidThrough = existing.oneTimeExpiresAt || existing.expiresAt;
+ const base = paidThrough && new Date(paidThrough).getTime() > Date.now()
+ ? new Date(paidThrough) : new Date();
+ base.setUTCDate(base.getUTCDate() + durationDays);
+ const raw = {
+ ...existing,
+ customerId: customerId || existing.customerId,
+ customerEmail: customerEmail || existing.customerEmail,
+ planCode,
+ oneTimeExpiresAt: base.toISOString(),
+ premiumFeatures,
+ paymentIntentId: paymentIntentId || existing.paymentIntentId,
+ processedPaymentIntentIds: paymentIntentId ? [...processed, paymentIntentId] : processed,
+ updatedAt: new Date().toISOString()
+ };
+ const next = recomputeEntitlement(raw);
+ saveOne('licenses', existing.id, next);
+ return { ...next, extended: true, addedDays: durationDays };
+ }
+
+ const expiresAt = new Date();
+ expiresAt.setUTCDate(expiresAt.getUTCDate() + durationDays);
+ const id = crypto.randomUUID();
+ const allocated = createLicenseKey(durationDays);
+ const next = {
+ id,
+ key: allocated.key,
+ codeId: allocated.codeId,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ subscriptionId: null,
+ customerId,
+ customerEmail,
+ planCode,
+ status: 'active',
+ expiresAt: expiresAt.toISOString(),
+ oneTimeExpiresAt: expiresAt.toISOString(),
+ active: true,
+ premiumFeatures,
+ machineFingerprint: null,
+ deactivatedAt: null,
+ paymentIntentId,
+ processedPaymentIntentIds: paymentIntentId ? [paymentIntentId] : []
+ };
+ return saveOne('licenses', id, next);
+ });
}
export function findLicenseByKey(key) {
- const db = readStore();
- return Object.values(db.licenses).find((lic) => lic.key === key) || null;
+ return loadAll('licenses').find((lic) => lic.key === key) || null;
}
-/**
- * Find an active license by customer email. Used to EXTEND an existing
- * license when a customer buys again (so renewal adds time to the same key,
- * not a new one).
- */
export function findActiveLicenseByCustomerEmail(email) {
if (!email) return null;
- const db = readStore();
const normalized = email.toLowerCase();
- return Object.values(db.licenses).find((lic) =>
- lic.customerEmail && lic.customerEmail.toLowerCase() === normalized && lic.active
- ) || null;
+ return loadAll('licenses').find((lic) => lic.customerEmail?.toLowerCase() === normalized && lic.active) || null;
}
-/**
- * Find a license by Stripe customer ID. Used by webhook handlers to extend
- * the customer's existing license on subscription renewal.
- */
export function findLicenseByCustomerId(customerId) {
if (!customerId) return null;
- const db = readStore();
- return Object.values(db.licenses).find((lic) => lic.customerId === customerId) || null;
+ return loadAll('licenses').find((lic) => lic.customerId === customerId) || null;
+}
+
+export function findCheckoutResult(sessionId) {
+ if (!sessionId) return null;
+ const session = db.prepare('SELECT customer_id, email FROM checkout_sessions WHERE id = ?').get(sessionId);
+ if (!session) return null;
+ const license = session.customer_id
+ ? loadAll('licenses').find((item) => item.customerId === session.customer_id) || null
+ : null;
+ return { session, license };
+}
+
+export function claimWebhookEvent(eventId) {
+ if (!eventId) return { claimed: false, status: 'invalid' };
+ return transaction(() => {
+ const existing = db.prepare('SELECT status, updated_at FROM webhook_events WHERE id = ?').get(eventId);
+ const processingFresh = existing?.status === 'processing'
+ && Date.now() - new Date(existing.updated_at).getTime() < 5 * 60 * 1000;
+ if (existing?.status === 'completed') return { claimed: false, status: 'completed' };
+ if (processingFresh) return { claimed: false, status: 'processing' };
+ db.prepare(`INSERT INTO webhook_events (id, status, error, updated_at) VALUES (?, 'processing', NULL, ?)
+ ON CONFLICT(id) DO UPDATE SET status='processing', error=NULL, updated_at=excluded.updated_at`)
+ .run(eventId, new Date().toISOString());
+ return { claimed: true, status: 'processing' };
+ });
+}
+
+export function finishWebhookEvent(eventId, error = null) {
+ if (!eventId) return;
+ transaction(() => {
+ db.prepare(`INSERT INTO webhook_events (id, status, error, updated_at) VALUES (?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET status=excluded.status, error=excluded.error, updated_at=excluded.updated_at`)
+ .run(eventId, error ? 'failed' : 'completed', error ? String(error).slice(0, 500) : null, new Date().toISOString());
+ });
+}
+
+export function claimBusinessObject(kind, objectId) {
+ if (!kind || !objectId) return { claimed: false, status: 'invalid' };
+ return transaction(() => {
+ const existing = db.prepare('SELECT status, updated_at FROM processed_objects WHERE kind = ? AND object_id = ?')
+ .get(kind, objectId);
+ const processingFresh = existing?.status === 'processing'
+ && Date.now() - new Date(existing.updated_at).getTime() < 5 * 60 * 1000;
+ if (existing?.status === 'completed') return { claimed: false, status: 'completed' };
+ if (processingFresh) return { claimed: false, status: 'processing' };
+ db.prepare(`INSERT INTO processed_objects (kind, object_id, status, error, updated_at)
+ VALUES (?, ?, 'processing', NULL, ?)
+ ON CONFLICT(kind, object_id) DO UPDATE SET status='processing', error=NULL, updated_at=excluded.updated_at`)
+ .run(kind, objectId, new Date().toISOString());
+ return { claimed: true, status: 'processing' };
+ });
+}
+
+export function finishBusinessObject(kind, objectId, error = null) {
+ if (!kind || !objectId) return;
+ transaction(() => {
+ db.prepare(`INSERT INTO processed_objects (kind, object_id, status, error, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(kind, object_id) DO UPDATE SET status=excluded.status, error=excluded.error, updated_at=excluded.updated_at`)
+ .run(kind, objectId, error ? 'failed' : 'completed', error ? String(error).slice(0, 500) : null, new Date().toISOString());
+ });
+}
+
+export function cancelLicenseBySubscription(subscriptionId, eventCreated = 0, eventId = '') {
+ if (!subscriptionId) return null;
+ return transaction(() => {
+ const license = loadAll('licenses').find((item) => item.subscriptionId === subscriptionId);
+ if (!license) return null;
+ const incomingEvent = Number(eventCreated || 0);
+ const previousEvent = Number(license.lastStripeEventCreated || 0);
+ if (isStripeTransitionStale({ ...license, status: license.subscriptionStatus || license.status }, incomingEvent, eventId, 'canceled')) {
+ return { ...license, staleEventIgnored: true };
+ }
+ const raw = {
+ ...license,
+ subscriptionStatus: 'canceled',
+ subscriptionExpiresAt: license.subscriptionExpiresAt || license.expiresAt,
+ lastStripeEventCreated: incomingEvent ? Math.max(incomingEvent, previousEvent) : previousEvent || null,
+ lastStripeEventId: eventId || license.lastStripeEventId || null,
+ updatedAt: new Date().toISOString()
+ };
+ return saveOne('licenses', license.id, recomputeEntitlement(raw));
+ });
+}
+
+export function markLicensePaymentFailed(subscriptionId, graceUntil, eventCreated = 0, eventId = '') {
+ if (!subscriptionId) return null;
+ return transaction(() => {
+ const license = loadAll('licenses').find((item) => item.subscriptionId === subscriptionId);
+ if (!license) return null;
+ const incomingEvent = Number(eventCreated || 0);
+ const previousEvent = Number(license.lastStripeEventCreated || 0);
+ if (isStripeTransitionStale({ ...license, status: license.subscriptionStatus || license.status }, incomingEvent, eventId, 'past_due')) {
+ return { ...license, staleEventIgnored: true };
+ }
+ const currentGrace = license.subscriptionGraceUntil || license.graceUntil;
+ const effectiveGraceUntil = currentGrace
+ && new Date(currentGrace).getTime() > new Date(graceUntil).getTime()
+ ? currentGrace : graceUntil;
+ const raw = {
+ ...license,
+ subscriptionStatus: 'past_due',
+ subscriptionGraceUntil: effectiveGraceUntil,
+ graceUntil: effectiveGraceUntil,
+ lastStripeEventCreated: incomingEvent ? Math.max(incomingEvent, previousEvent) : previousEvent || null,
+ lastStripeEventId: eventId || license.lastStripeEventId || null,
+ updatedAt: new Date().toISOString()
+ };
+ return saveOne('licenses', license.id, recomputeEntitlement(raw));
+ });
}
-/**
- * Extend an existing license by a number of days. If the license is expired,
- * the extension starts from now. Returns the updated license.
- */
export function extendLicenseByDuration(licenseId, days) {
- const db = readStore();
- const lic = db.licenses[licenseId];
- if (!lic) return null;
- const base = lic.expiresAt ? new Date(lic.expiresAt) : new Date();
- const now = Date.now();
- const start = base.getTime() > now ? base : new Date(now);
- start.setUTCDate(start.getUTCDate() + days);
- db.licenses[licenseId] = {
- ...lic,
- expiresAt: start.toISOString(),
- active: true,
- activatedAt: lic.activatedAt || new Date().toISOString(),
- updatedAt: new Date().toISOString()
- };
- writeStore(db);
- return db.licenses[licenseId];
+ return transaction(() => {
+ const lic = loadOne('licenses', licenseId);
+ if (!lic) return null;
+ const base = lic.expiresAt && new Date(lic.expiresAt).getTime() > Date.now() ? new Date(lic.expiresAt) : new Date();
+ base.setUTCDate(base.getUTCDate() + days);
+ return saveOne('licenses', licenseId, {
+ ...lic,
+ expiresAt: base.toISOString(),
+ active: true,
+ activatedAt: lic.activatedAt || new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ });
+ });
}
export function updateLicense(id, patch) {
- const db = readStore();
- if (!db.licenses[id]) return null;
- db.licenses[id] = { ...db.licenses[id], ...patch, updatedAt: new Date().toISOString() };
- writeStore(db);
- return db.licenses[id];
+ return transaction(() => {
+ const existing = loadOne('licenses', id);
+ if (!existing) return null;
+ return saveOne('licenses', id, { ...existing, ...patch, updatedAt: new Date().toISOString() });
+ });
+}
+
+export function claimLicenseMachine(key, fingerprint) {
+ return transaction(() => {
+ const license = loadAll('licenses').find((item) => item.key === key);
+ if (!license) return { claimed: false, reason: 'not_found', license: null };
+ if (license.machineFingerprint && license.machineFingerprint !== fingerprint) {
+ return { claimed: false, reason: 'different_machine', license };
+ }
+ if (license.machineFingerprint === fingerprint) {
+ return { claimed: true, existing: true, license };
+ }
+ const next = {
+ ...license,
+ machineFingerprint: fingerprint,
+ activatedAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ };
+ saveOne('licenses', license.id, next);
+ return { claimed: true, existing: false, license: next };
+ });
}
export function getStoreSnapshot() {
- return readStore();
+ const toMap = (items) => Object.fromEntries(items.map((item) => [item.id, item]));
+ const webhookEvents = Object.fromEntries(db.prepare('SELECT id, status, error, updated_at FROM webhook_events').all()
+ .map((item) => [item.id, { status: item.status, error: item.error, updatedAt: item.updated_at }]));
+ return {
+ customers: toMap(loadAll('customers')),
+ subscriptions: toMap(loadAll('subscriptions')),
+ licenses: toMap(loadAll('licenses')),
+ webhookEvents
+ };
}
diff --git a/test/server.test.js b/test/server.test.js
new file mode 100644
index 0000000..32b1885
--- /dev/null
+++ b/test/server.test.js
@@ -0,0 +1,618 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+
+const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-license-test-'));
+process.env.DATA_DIR = dataDir;
+process.env.DASHCADDY_WEBSITE_URL = 'https://dashcaddy.net';
+process.env.DASHCADDY_LICENSE_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
+delete process.env.ADMIN_TOKEN;
+fs.writeFileSync(path.join(dataDir, 'db.json'), JSON.stringify({
+ customers: { cus_legacy: { id: 'cus_legacy', email: 'legacy@example.com', checkoutSessionId: 'cs_legacy_123' } },
+ subscriptions: {},
+ licenses: {
+ lic_legacy: {
+ id: 'lic_legacy',
+ key: 'DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE',
+ customerId: 'cus_legacy',
+ customerEmail: 'legacy@example.com',
+ planCode: 'premium_30d',
+ status: 'paid',
+ active: true,
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
+ },
+ },
+}, null, 2));
+
+const { app, getInvoiceSubscriptionId, getSubscriptionPeriodEnd, deliverAndTrackLicenseEmail } = await import('../src/server.js');
+const store = await import('../src/store.js');
+const licenseLogic = await import('../src/licenseLogic.js');
+const { verifyCompatibleLicenseCode } = await import('../src/licenseCode.js');
+const server = app.listen(0, '127.0.0.1');
+await new Promise((resolve) => server.once('listening', resolve));
+const base = `http://127.0.0.1:${server.address().port}`;
+
+test.after(() => {
+ server.close();
+ fs.rmSync(dataDir, { recursive: true, force: true });
+});
+
+test('legacy JSON entitlements migrate into transactional SQLite', () => {
+ const migrated = store.findCheckoutResult('cs_legacy_123');
+ assert.equal(migrated.license.key, 'DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE');
+ assert.equal(fs.existsSync(path.join(dataDir, 'db.json.migrated-backup')), true);
+});
+
+test('browser preflight permits dashcaddy.net and rejects unrelated origins', async () => {
+ const allowed = await fetch(`${base}/api/checkout/one-time`, {
+ method: 'OPTIONS',
+ headers: {
+ Origin: 'https://dashcaddy.net',
+ 'Access-Control-Request-Method': 'POST',
+ 'Access-Control-Request-Headers': 'content-type',
+ },
+ });
+ assert.equal(allowed.status, 204);
+ assert.equal(allowed.headers.get('access-control-allow-origin'), 'https://dashcaddy.net');
+
+ const denied = await fetch(`${base}/api/checkout/one-time`, {
+ method: 'OPTIONS',
+ headers: { Origin: 'https://evil.example', 'Access-Control-Request-Method': 'POST' },
+ });
+ assert.equal(denied.status, 403);
+ assert.equal(denied.headers.get('access-control-allow-origin'), null);
+});
+
+test('checkout rejects invalid email before contacting Stripe', async () => {
+ const response = await fetch(`${base}/api/checkout/one-time`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Origin: 'https://dashcaddy.net' },
+ body: JSON.stringify({ planCode: 'premium_30d', customerEmail: 'not-an-email' }),
+ });
+ assert.equal(response.status, 400);
+ assert.deepEqual(await response.json(), { ok: false, error: 'Valid customerEmail is required' });
+});
+
+test('admin store is closed by default', async () => {
+ const response = await fetch(`${base}/api/admin/debug/store`);
+ assert.equal(response.status, 404);
+});
+
+test('checkout lookup returns the exact stored key accepted by validation', async () => {
+ store.upsertCustomer({ id: 'cus_test_lookup', email: 'buyer@example.com', checkoutSessionId: 'cs_test_lookup_123' });
+ const license = licenseLogic.grantOneTimeLicense({
+ paymentIntentId: 'pi_test_lookup',
+ customerId: 'cus_test_lookup',
+ customerEmail: 'buyer@example.com',
+ planCode: 'premium_30d',
+ durationDays: 30,
+ });
+ store.updateLicense(license.id, { emailDeliveryStatus: 'delivered', emailDeliveryVia: 'smtp' });
+
+ const lookup = await fetch(`${base}/api/checkout/session/cs_test_lookup_123`, {
+ headers: { Origin: 'https://dashcaddy.net' },
+ });
+ assert.equal(lookup.status, 200);
+ const result = await lookup.json();
+ assert.equal(result.status, 'delivered');
+ assert.equal(result.code, license.key);
+ assert.match(result.code, /^DC-(?:[0-9A-Z]{5}-){4}[0-9A-Z]{5}$/);
+ const offline = verifyCompatibleLicenseCode(result.code);
+ assert.equal(offline.valid, true);
+ assert.equal(offline.durationDays, 30);
+ assert.equal(result.productId, 'pro-30d');
+
+ const validate = await fetch(`${base}/api/license/validate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code: result.code, machineId: 'product-machine-1' }),
+ });
+ assert.equal(validate.status, 200);
+ const validation = await validate.json();
+ assert.equal(validation.success, true);
+ assert.equal(validation.expiresAt, license.expiresAt);
+ assert.ok(Array.isArray(validation.features));
+ assert.ok(validation.features.includes('sso'));
+
+ const wrongMachine = await fetch(`${base}/api/license/validate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code: result.code, machineId: 'product-machine-2' }),
+ });
+ assert.equal(wrongMachine.status, 400);
+ assert.match((await wrongMachine.json()).error, /another machine/);
+});
+
+test('license activation rejects missing machine identity', async () => {
+ const license = store.createOrUpdateLicenseBySubscription('sub_missing_machine', {
+ subscriptionId: 'sub_missing_machine',
+ customerId: 'cus_missing_machine',
+ customerEmail: 'machine@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
+ active: true,
+ });
+ const response = await fetch(`${base}/api/license/validate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code: license.key }),
+ });
+ assert.equal(response.status, 400);
+ assert.match((await response.json()).error, /Machine identity is required/);
+});
+
+test('duplicate Stripe payment intent never extends a one-time license twice', () => {
+ const first = licenseLogic.grantOneTimeLicense({
+ paymentIntentId: 'pi_idempotency_1',
+ customerId: 'cus_idempotency',
+ customerEmail: 'idempotency@example.com',
+ planCode: 'premium_30d',
+ durationDays: 30,
+ });
+ const firstExpiry = first.expiresAt;
+
+ const duplicate = licenseLogic.grantOneTimeLicense({
+ paymentIntentId: 'pi_idempotency_1',
+ customerId: 'cus_idempotency',
+ customerEmail: 'idempotency@example.com',
+ planCode: 'premium_30d',
+ durationDays: 30,
+ });
+ assert.equal(duplicate.idempotent, true);
+ assert.equal(duplicate.expiresAt, firstExpiry);
+
+ const secondPayment = licenseLogic.grantOneTimeLicense({
+ paymentIntentId: 'pi_idempotency_2',
+ customerId: 'cus_idempotency',
+ customerEmail: 'idempotency@example.com',
+ planCode: 'premium_30d',
+ durationDays: 30,
+ });
+ assert.equal(secondPayment.extended, true);
+ assert.ok(new Date(secondPayment.expiresAt).getTime() > new Date(firstExpiry).getTime());
+});
+
+test('concurrent duplicate payment delivery is atomic across processes', async () => {
+ const script = `
+ import { grantOneTimeLicense } from './src/licenseLogic.js';
+ grantOneTimeLicense({
+ paymentIntentId: 'pi_concurrent_same',
+ customerId: 'cus_concurrent_same',
+ customerEmail: 'concurrent@example.com',
+ planCode: 'premium_30d',
+ durationDays: 30
+ });
+ `;
+ const run = () => new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
+ cwd: path.resolve(import.meta.dirname, '..'),
+ env: { ...process.env, DATA_DIR: dataDir },
+ stdio: ['ignore', 'ignore', 'pipe'],
+ });
+ let stderr = '';
+ child.stderr.on('data', chunk => { stderr += chunk; });
+ child.on('exit', code => code === 0 ? resolve() : reject(new Error(stderr || `child exited ${code}`)));
+ });
+ await Promise.all([run(), run()]);
+
+ const license = store.findLicenseByCustomerId('cus_concurrent_same');
+ assert.deepEqual(license.processedPaymentIntentIds, ['pi_concurrent_same']);
+ const days = (new Date(license.expiresAt).getTime() - Date.now()) / 86400000;
+ assert.ok(days > 29 && days < 31);
+});
+
+test('checkout creation is rate limited per client IP', async () => {
+ let last;
+ for (let i = 0; i < 21; i++) {
+ last = await fetch(`${base}/api/checkout/one-time`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Forwarded-For': '203.0.113.55',
+ },
+ body: JSON.stringify({ planCode: 'invalid-rate-test', customerEmail: 'buyer@example.com' }),
+ });
+ }
+ assert.equal(last.status, 429);
+ assert.ok(Number(last.headers.get('retry-after')) > 0);
+});
+
+test('webhook event claim is durable and single-use', () => {
+ assert.equal(store.claimWebhookEvent('evt_test_durable_1').claimed, true);
+ assert.equal(store.claimWebhookEvent('evt_test_durable_1').status, 'processing');
+ store.finishWebhookEvent('evt_test_durable_1');
+ assert.equal(store.claimWebhookEvent('evt_test_durable_1').status, 'completed');
+});
+
+test('pinned Stripe API fixtures resolve subscription and paid-through period', () => {
+ const invoice = {
+ id: 'in_fixture_1',
+ parent: { subscription_details: { subscription: { id: 'sub_fixture_1' } } },
+ };
+ const subscription = {
+ id: 'sub_fixture_1',
+ items: { data: [{ current_period_end: 1789990000 }, { current_period_end: 1790000000 }] },
+ };
+ assert.equal(getInvoiceSubscriptionId(invoice), 'sub_fixture_1');
+ assert.equal(getSubscriptionPeriodEnd(subscription), new Date(1790000000 * 1000).toISOString());
+});
+
+test('invoice-level claim prevents distinct events from renewing one invoice twice', () => {
+ assert.equal(store.claimBusinessObject('invoice.paid', 'in_same_invoice').claimed, true);
+ assert.equal(store.claimBusinessObject('invoice.paid', 'in_same_invoice').status, 'processing');
+ store.finishBusinessObject('invoice.paid', 'in_same_invoice');
+ assert.equal(store.claimBusinessObject('invoice.paid', 'in_same_invoice').status, 'completed');
+});
+
+test('subscription cancellation keeps access only until existing expiry', () => {
+ const license = store.createOrUpdateLicenseBySubscription('sub_cancel_test', {
+ subscriptionId: 'sub_cancel_test',
+ customerId: 'cus_cancel_test',
+ customerEmail: 'cancel@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
+ active: true,
+ });
+ const canceled = store.cancelLicenseBySubscription('sub_cancel_test');
+ assert.equal(canceled.id, license.id);
+ assert.equal(canceled.status, 'canceled');
+ assert.equal(canceled.active, true);
+});
+
+test('subscription cancellation fails closed when paid-through expiry is absent', () => {
+ store.createOrUpdateLicenseBySubscription('sub_cancel_no_expiry', {
+ subscriptionId: 'sub_cancel_no_expiry',
+ customerId: 'cus_cancel_no_expiry',
+ planCode: 'premium_30d',
+ status: 'active',
+ expiresAt: null,
+ active: true,
+ });
+ assert.equal(store.cancelLicenseBySubscription('sub_cancel_no_expiry').active, false);
+});
+
+test('checkout sessions remain independently retrievable for repeat customers', () => {
+ store.upsertCustomer({ id: 'cus_repeat', email: 'repeat@example.com', checkoutSessionId: 'cs_repeat_first' });
+ store.upsertCustomer({ id: 'cus_repeat', email: 'repeat@example.com', checkoutSessionId: 'cs_repeat_second' });
+ store.createOrUpdateLicenseBySubscription('sub_repeat', {
+ subscriptionId: 'sub_repeat',
+ customerId: 'cus_repeat',
+ customerEmail: 'repeat@example.com',
+ planCode: 'premium_90d',
+ status: 'active',
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
+ active: true,
+ });
+ assert.ok(store.findCheckoutResult('cs_repeat_first')?.license);
+ assert.ok(store.findCheckoutResult('cs_repeat_second')?.license);
+});
+
+test('expired payment grace blocks license validation', () => {
+ const license = store.createOrUpdateLicenseBySubscription('sub_grace_expired', {
+ subscriptionId: 'sub_grace_expired',
+ customerId: 'cus_grace_expired',
+ customerEmail: 'grace@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
+ active: true,
+ });
+ store.markLicensePaymentFailed('sub_grace_expired', new Date(Date.now() - 1000).toISOString());
+ const result = licenseLogic.validateLicense({ code: license.key, machine: { hostname: 'grace-host' } });
+ assert.equal(result.success, false);
+ assert.match(result.message, /grace period has expired/);
+});
+
+test('past-due subscription updates preserve an existing grace deadline', () => {
+ const graceUntil = new Date(Date.now() + 3 * 86400000).toISOString();
+ store.createOrUpdateLicenseBySubscription('sub_grace_order', {
+ subscriptionId: 'sub_grace_order',
+ customerId: 'cus_grace_order',
+ customerEmail: 'grace-order@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
+ active: true,
+ });
+ store.markLicensePaymentFailed('sub_grace_order', graceUntil);
+ const updated = licenseLogic.syncLicenseFromSubscription({
+ subscriptionId: 'sub_grace_order',
+ customerId: 'cus_grace_order',
+ customerEmail: 'grace-order@example.com',
+ planCode: 'premium_30d',
+ status: 'past_due',
+ currentPeriodEnd: new Date(Date.now() + 86400000).toISOString(),
+ });
+ assert.equal(updated.graceUntil, graceUntil);
+});
+
+test('newer failure event cannot shorten an existing grace deadline', () => {
+ const laterGrace = new Date(Date.now() + 7 * 86400000).toISOString();
+ const earlierGrace = new Date(Date.now() + 2 * 86400000).toISOString();
+ store.createOrUpdateLicenseBySubscription('sub_grace_monotonic', {
+ subscriptionId: 'sub_grace_monotonic',
+ customerId: 'cus_grace_monotonic',
+ customerEmail: 'grace-monotonic@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
+ active: true,
+ });
+ store.markLicensePaymentFailed('sub_grace_monotonic', laterGrace, 100, 'evt_failure_1');
+ const second = store.markLicensePaymentFailed('sub_grace_monotonic', earlierGrace, 101, 'evt_failure_2');
+ assert.equal(second.graceUntil, laterGrace);
+});
+
+test('stale failure cannot regress a newer paid entitlement', () => {
+ const paidExpiry = new Date(Date.now() + 30 * 86400000).toISOString();
+ const paid = licenseLogic.syncLicenseFromSubscription({
+ subscriptionId: 'sub_monotonic_paid',
+ customerId: 'cus_monotonic_paid',
+ customerEmail: 'monotonic@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ currentPeriodEnd: paidExpiry,
+ eventCreated: 200,
+ });
+ const staleFailure = store.markLicensePaymentFailed(
+ 'sub_monotonic_paid',
+ new Date(Date.now() + 7 * 86400000).toISOString(),
+ 100,
+ );
+ assert.equal(staleFailure.staleEventIgnored, true);
+ assert.equal(store.findLicenseByCustomerId('cus_monotonic_paid').status, 'active');
+ assert.equal(store.findLicenseByCustomerId('cus_monotonic_paid').expiresAt, paid.expiresAt);
+});
+
+test('older subscription update cannot replace newer expiry or status', () => {
+ const newerExpiry = new Date(Date.now() + 90 * 86400000).toISOString();
+ licenseLogic.syncLicenseFromSubscription({
+ subscriptionId: 'sub_monotonic_update',
+ customerId: 'cus_monotonic_update',
+ customerEmail: 'update-order@example.com',
+ planCode: 'premium_90d',
+ status: 'active',
+ currentPeriodEnd: newerExpiry,
+ eventCreated: 500,
+ });
+ const stale = licenseLogic.syncLicenseFromSubscription({
+ subscriptionId: 'sub_monotonic_update',
+ customerId: 'cus_monotonic_update',
+ customerEmail: 'update-order@example.com',
+ planCode: 'premium_30d',
+ status: 'past_due',
+ currentPeriodEnd: new Date(Date.now() + 10 * 86400000).toISOString(),
+ eventCreated: 400,
+ });
+ assert.equal(stale.staleEventIgnored, true);
+ const current = store.findLicenseByCustomerId('cus_monotonic_update');
+ assert.equal(current.status, 'active');
+ assert.equal(current.expiresAt, newerExpiry);
+});
+
+test('idempotent entitlement can resume incomplete email fulfillment', () => {
+ const first = licenseLogic.grantOneTimeLicense({
+ paymentIntentId: 'pi_resume_email',
+ customerId: 'cus_resume_email',
+ customerEmail: 'resume@example.com',
+ planCode: 'premium_30d',
+ durationDays: 30,
+ });
+ store.updateLicense(first.id, { emailDeliveryStatus: 'pending' });
+ const retry = licenseLogic.grantOneTimeLicense({
+ paymentIntentId: 'pi_resume_email',
+ customerId: 'cus_resume_email',
+ customerEmail: 'resume@example.com',
+ planCode: 'premium_30d',
+ durationDays: 30,
+ });
+ assert.equal(retry.idempotent, true);
+ assert.equal(retry.emailDeliveryStatus, 'pending');
+ assert.equal(retry.expiresAt, first.expiresAt);
+});
+
+test('equal-timestamp event tie-breaker cannot regress paid state', () => {
+ const expiry = new Date(Date.now() + 30 * 86400000).toISOString();
+ licenseLogic.syncLicenseFromSubscription({
+ subscriptionId: 'sub_equal_timestamp',
+ customerId: 'cus_equal_timestamp',
+ customerEmail: 'equal@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ currentPeriodEnd: expiry,
+ eventCreated: 900,
+ eventId: 'evt_z_newer_tie',
+ });
+ const staleFailure = store.markLicensePaymentFailed(
+ 'sub_equal_timestamp',
+ new Date(Date.now() + 7 * 86400000).toISOString(),
+ 900,
+ 'evt_a_older_tie',
+ );
+ assert.equal(staleFailure.staleEventIgnored, true);
+ assert.equal(store.findLicenseByCustomerId('cus_equal_timestamp').status, 'active');
+});
+
+test('future payment grace keeps access after the paid period expires', () => {
+ const license = store.createOrUpdateLicenseBySubscription('sub_grace_active', {
+ subscriptionId: 'sub_grace_active',
+ customerId: 'cus_grace_active',
+ customerEmail: 'grace-active@example.com',
+ planCode: 'premium_30d',
+ status: 'past_due',
+ expiresAt: new Date(Date.now() - 1000).toISOString(),
+ graceUntil: new Date(Date.now() + 86400000).toISOString(),
+ active: true,
+ });
+ const result = licenseLogic.validateLicense({ code: license.key, machine: { hostname: 'grace-active-host' } });
+ assert.equal(result.success, true);
+});
+
+test('subscription retry preserves recorded email delivery and stable key', () => {
+ const first = store.createOrUpdateLicenseBySubscription('sub_email_delivered', {
+ subscriptionId: 'sub_email_delivered',
+ customerId: 'cus_email_delivered',
+ customerEmail: 'delivered@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
+ active: true,
+ emailDeliveryStatus: 'delivered',
+ emailDeliveredAt: new Date().toISOString(),
+ lastStripeEventCreated: 100,
+ lastStripeEventId: 'evt_email_first',
+ });
+ const retry = licenseLogic.syncLicenseFromSubscription({
+ subscriptionId: 'sub_email_delivered',
+ customerId: 'cus_email_delivered',
+ customerEmail: 'delivered@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ currentPeriodEnd: new Date(Date.now() + 2 * 86400000).toISOString(),
+ eventCreated: 101,
+ eventId: 'evt_email_retry',
+ });
+ assert.equal(retry.key, first.key);
+ assert.equal(retry.emailDeliveryStatus, 'delivered');
+});
+
+test('subscription failure and cancellation cannot revoke one-time paid-through time', () => {
+ const oneTime = licenseLogic.grantOneTimeLicense({
+ paymentIntentId: 'pi_mixed_mode',
+ customerId: 'cus_mixed_mode',
+ customerEmail: 'mixed@example.com',
+ planCode: 'premium_90d',
+ durationDays: 90,
+ });
+ const subscription = licenseLogic.syncLicenseFromSubscription({
+ subscriptionId: 'sub_mixed_mode',
+ customerId: 'cus_mixed_mode',
+ customerEmail: 'mixed@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ currentPeriodEnd: new Date(Date.now() + 30 * 86400000).toISOString(),
+ eventCreated: 1000,
+ eventId: 'evt_mixed_active',
+ });
+ assert.equal(subscription.key, oneTime.key);
+ assert.equal(subscription.oneTimeExpiresAt, oneTime.oneTimeExpiresAt);
+
+ const failed = store.markLicensePaymentFailed(
+ 'sub_mixed_mode',
+ new Date(Date.now() - 1000).toISOString(),
+ 1001,
+ 'evt_mixed_failed',
+ );
+ assert.equal(failed.subscriptionStatus, 'past_due');
+ assert.equal(failed.status, 'active');
+ assert.equal(licenseLogic.validateLicense({ code: failed.key, machine: { hostname: 'mixed-host' } }).success, true);
+
+ const canceled = store.cancelLicenseBySubscription('sub_mixed_mode', 1002, 'evt_mixed_canceled');
+ assert.equal(canceled.subscriptionStatus, 'canceled');
+ assert.equal(canceled.active, true);
+ assert.equal(canceled.key, oneTime.key);
+});
+
+test('renewal email delivery is tracked per Stripe invoice', async () => {
+ const license = licenseLogic.syncLicenseFromSubscription({
+ subscriptionId: 'sub_invoice_email',
+ customerId: 'cus_invoice_email',
+ customerEmail: 'invoice-email@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ currentPeriodEnd: new Date(Date.now() + 30 * 86400000).toISOString(),
+ eventCreated: 2000,
+ eventId: 'evt_invoice_email',
+ });
+ let sent = 0;
+ const sender = async () => { sent += 1; return { delivered: true, via: 'test' }; };
+ const args = {
+ customerEmail: 'invoice-email@example.com',
+ code: license.key,
+ durationDays: 30,
+ planCode: 'premium_30d',
+ extended: true,
+ };
+
+ await deliverAndTrackLicenseEmail({ ...args, license, deliveryId: 'in_invoice_1' }, sender);
+ let fresh = store.findLicenseByCustomerId('cus_invoice_email');
+ await deliverAndTrackLicenseEmail({ ...args, license: fresh, deliveryId: 'in_invoice_1' }, sender);
+ assert.equal(sent, 1);
+ fresh = store.findLicenseByCustomerId('cus_invoice_email');
+ await deliverAndTrackLicenseEmail({ ...args, license: fresh, deliveryId: 'in_invoice_2' }, sender);
+ assert.equal(sent, 2);
+ assert.equal(store.findLicenseByCustomerId('cus_invoice_email').lastEmailDeliveryId, 'in_invoice_2');
+});
+
+test('matching email never transfers a license across Stripe customer IDs', () => {
+ const first = licenseLogic.grantOneTimeLicense({
+ paymentIntentId: 'pi_owner_a',
+ customerId: 'cus_owner_a',
+ customerEmail: 'shared@example.com',
+ planCode: 'premium_30d',
+ durationDays: 30,
+ });
+ const second = licenseLogic.grantOneTimeLicense({
+ paymentIntentId: 'pi_owner_b',
+ customerId: 'cus_owner_b',
+ customerEmail: 'shared@example.com',
+ planCode: 'premium_30d',
+ durationDays: 30,
+ });
+ assert.notEqual(second.key, first.key);
+ assert.equal(store.findLicenseByCustomerId('cus_owner_a').key, first.key);
+ assert.equal(store.findLicenseByCustomerId('cus_owner_b').key, second.key);
+
+ store.upsertCustomer({ id: 'cus_owner_a', email: 'shared@example.com', checkoutSessionId: 'cs_owner_a' });
+ store.upsertCustomer({ id: 'cus_owner_b', email: 'shared@example.com', checkoutSessionId: 'cs_owner_b' });
+ assert.equal(store.findCheckoutResult('cs_owner_a').license.key, first.key);
+ assert.equal(store.findCheckoutResult('cs_owner_b').license.key, second.key);
+
+ const secondSubscription = licenseLogic.syncLicenseFromSubscription({
+ subscriptionId: 'sub_owner_b',
+ customerId: 'cus_owner_b',
+ customerEmail: 'shared@example.com',
+ planCode: 'premium_30d',
+ status: 'active',
+ currentPeriodEnd: new Date(Date.now() + 30 * 86400000).toISOString(),
+ eventCreated: 3000,
+ eventId: 'evt_owner_b',
+ });
+ assert.equal(secondSubscription.key, second.key);
+ assert.notEqual(secondSubscription.key, first.key);
+});
+
+test('concurrent first activation allows exactly one machine', async () => {
+ const license = licenseLogic.grantOneTimeLicense({
+ paymentIntentId: 'pi_machine_race',
+ customerId: 'cus_machine_race',
+ customerEmail: 'machine-race@example.com',
+ planCode: 'premium_30d',
+ durationDays: 30,
+ });
+ const script = `
+ import { validateLicense } from './src/licenseLogic.js';
+ const result = validateLicense({ code: process.env.TEST_LICENSE_CODE, machine: { hostname: process.env.TEST_MACHINE } });
+ process.stdout.write(JSON.stringify(result));
+ `;
+ const run = (machine) => new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
+ cwd: path.resolve('.'),
+ env: { ...process.env, DATA_DIR: dataDir, TEST_LICENSE_CODE: license.key, TEST_MACHINE: machine },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', chunk => { stdout += chunk; });
+ child.stderr.on('data', chunk => { stderr += chunk; });
+ child.on('exit', (code) => code === 0 ? resolve(JSON.parse(stdout)) : reject(new Error(stderr)));
+ });
+ const results = await Promise.all([run('machine-race-a'), run('machine-race-b')]);
+ assert.equal(results.filter((item) => item.success).length, 1);
+ assert.equal(results.filter((item) => !item.success).length, 1);
+});