writeState() drops its private tmp+writeFileSync+rename copy (no fsync, Date.now() tmp names, best-effort chmod) and delegates to src/utils/atomic-write.js atomicWriteJSON (exclusive-create tmp, fsync, rename, parent-dir fsync, 0600). A torn stripe-fulfillments.json could previously make a webhook retry mint a SECOND valid license key for an order that already has one. Both consumers (routes/billing.js, scripts/stripe-license-bridge.js) JSON.parse only - trailing-newline drop in the canonical serializer is harmless. Unused crypto require removed. +2 DC-103 pins: full lifecycle 0600/complete/zero-leftovers, and dual-instance interleaved writes (bridge+API file-IPC) with no tmp collisions. 121 suites / 2776 tests green. Judge: GLM-5.3 round-1 A (deleg_bd49a97b), URN urn:ump:layk7h326sqymcsh6tiusrs2sdbqdcx6ygvcuwwoxqn4rf7ycoua
175 lines
7.0 KiB
JavaScript
175 lines
7.0 KiB
JavaScript
'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 }; |