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` + : `

DashCaddy

`; + + const samiahmedLogo = samiahmedLogoDataUri + ? `A product by samiahmed7777` + : `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. +

+ + + + + +
+ View Activation Guide +
+
+
+

+ 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); +});