'use strict'; /** * Durable Stripe fulfillment state. * * The bridge and the API share this file through the host data mount. A * generated license is persisted before email delivery so a webhook retry can * resend the same key instead of minting a second valid key. */ const fs = require('fs'); const path = require('path'); const platformPaths = require('../../platform-paths'); const { atomicWriteJSON } = require('../utils/atomic-write'); const DELIVERY_LEASE_MS = 5 * 60 * 1000; function createFulfillmentStore(options = {}) { const filePath = options.filePath || process.env.STRIPE_FULFILLMENT_FILE || path.join(platformPaths.dataDir, 'stripe-fulfillments.json'); let queue = Promise.resolve(); function emptyState() { return { version: 1, byEventId: {}, bySessionId: {} }; } function readState() { try { if (!fs.existsSync(filePath)) return emptyState(); const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); if (!parsed || typeof parsed !== 'object' || !parsed.byEventId || typeof parsed.byEventId !== 'object' || !parsed.bySessionId || typeof parsed.bySessionId !== 'object') { throw new Error('fulfillment state has an invalid shape'); } return parsed; } catch (error) { if (error && error.code === 'ENOENT') return emptyState(); throw new Error(`Stripe fulfillment state unavailable: ${error.message}`); } } function writeState(state) { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); // Canonical atomic-write (DC-099): fsync-before-rename + exclusive-create // tmp + dir fsync. A crash mid-write can no longer leave a torn // stripe-fulfillments.json — which would have forced the bridge to // re-mint a duplicate license key on the next webhook retry. atomicWriteJSON(filePath, state); } function mutate(mutator) { const run = queue.then(async () => { const state = readState(); const result = await mutator(state); if (result && result.changed) writeState(state); return result; }); queue = run.catch(() => {}); return run; } function readBySession(sessionId) { if (!sessionId) return null; const record = readState().bySessionId[sessionId]; return record ? { ...record } : null; } function readByEvent(eventId) { if (!eventId) return null; const record = readState().byEventId[eventId]; return record ? { ...record } : null; } async function claim({ eventId, sessionId, productId, durationDays, email }) { if (!eventId || !sessionId) throw new Error('eventId and sessionId are required'); return mutate((state) => { const existing = state.bySessionId[sessionId] || state.byEventId[eventId]; const now = Date.now(); if (existing) { if (existing.status === 'generating' && existing.leaseUntil > now && existing.claimToken !== eventId) { return { changed: false, claimed: false, busy: true, record: { ...existing } }; } if (existing.status === 'generating' && existing.leaseUntil <= now) { existing.claimToken = eventId; existing.leaseUntil = now + DELIVERY_LEASE_MS; state.byEventId[eventId] = existing; return { changed: true, claimed: true, busy: false, record: { ...existing } }; } return { changed: false, claimed: false, busy: false, record: { ...existing } }; } const record = { eventId, sessionId, productId, durationDays, email, status: 'generating', claimToken: eventId, leaseUntil: now + DELIVERY_LEASE_MS, createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(), }; state.byEventId[eventId] = record; state.bySessionId[sessionId] = record; return { changed: true, claimed: true, busy: false, record: { ...record } }; }); } async function saveLicense({ eventId, sessionId, code, codeId }) { return mutate((state) => { const record = state.bySessionId[sessionId] || state.byEventId[eventId]; if (!record || record.claimToken !== eventId) return { changed: false, saved: false, record: record ? { ...record } : null }; record.code = code; record.codeId = codeId; record.status = 'pending_email'; record.leaseUntil = 0; record.updatedAt = new Date().toISOString(); state.byEventId[record.eventId] = record; state.byEventId[eventId] = record; state.bySessionId[record.sessionId] = record; return { changed: true, saved: true, record: { ...record } }; }); } async function claimDelivery({ sessionId, ownerToken }) { return mutate((state) => { const record = state.bySessionId[sessionId]; if (!record || !record.code) return { changed: false, claimed: false, record: record ? { ...record } : null }; const now = Date.now(); if (record.status === 'delivered') return { changed: false, claimed: false, record: { ...record } }; if (record.status === 'delivering' && record.leaseUntil > now && record.leaseOwner !== ownerToken) { return { changed: false, claimed: false, busy: true, record: { ...record } }; } record.status = 'delivering'; record.leaseOwner = ownerToken; record.leaseUntil = now + DELIVERY_LEASE_MS; record.updatedAt = new Date(now).toISOString(); state.byEventId[record.eventId] = record; state.bySessionId[sessionId] = record; return { changed: true, claimed: true, busy: false, record: { ...record } }; }); } async function markDelivered({ sessionId, ownerToken, deliveredVia }) { return mutate((state) => { const record = state.bySessionId[sessionId]; if (!record || record.leaseOwner !== ownerToken) return { changed: false, saved: false }; record.status = 'delivered'; record.deliveredVia = deliveredVia; record.deliveredAt = new Date().toISOString(); record.lastError = null; record.leaseUntil = 0; record.leaseOwner = null; record.updatedAt = new Date().toISOString(); state.byEventId[record.eventId] = record; state.bySessionId[sessionId] = record; return { changed: true, saved: true, record: { ...record } }; }); } async function markDeliveryFailed({ sessionId, ownerToken, error }) { return mutate((state) => { const record = state.bySessionId[sessionId]; if (!record || record.leaseOwner !== ownerToken) return { changed: false, saved: false }; record.status = 'pending_email'; record.lastError = String(error || 'email delivery failed').slice(0, 500); record.leaseUntil = 0; record.leaseOwner = null; record.updatedAt = new Date().toISOString(); state.byEventId[record.eventId] = record; state.bySessionId[sessionId] = record; return { changed: true, saved: true, record: { ...record } }; }); } return { filePath, readBySession, readByEvent, claim, saveLicense, claimDelivery, markDelivered, markDeliveryFailed }; } module.exports = { createFulfillmentStore, DELIVERY_LEASE_MS };