feat: wire async app flows for checkout x mentions signed playback and queued generation

This commit is contained in:
Codex
2026-02-18 13:35:10 +00:00
parent d68ccc70bf
commit 74ab63f488
6 changed files with 423 additions and 60 deletions

View File

@@ -32,6 +32,11 @@ const {
SimulateMentionFormSchema,
parseOrThrow,
} = require("./lib/validation");
const { createPolarAdapter, hasStandardWebhookHeaders } = require("./integrations/polar");
const { createTTSAdapter } = require("./integrations/tts-client");
const { createStorageAdapter } = require("./integrations/storage-client");
const { createXAdapter } = require("./integrations/x-client");
const { createAudioGenerationService } = require("./services/audio-generation");
function sanitizeReturnTo(value, fallback = "/app") {
if (!value || typeof value !== "string") {
@@ -54,12 +59,46 @@ function buildApp({
initialState = null,
onMutation = null,
logger = console,
polarAdapter = null,
xAdapter = null,
ttsAdapter = null,
storageAdapter = null,
audioGenerationService = null,
}) {
const engine = new XArtAudioEngine({
creditConfig: config.credit,
initialState: initialState && initialState.engine ? initialState.engine : null,
});
const rateLimits = config.rateLimits || {};
const polar = polarAdapter || createPolarAdapter({
accessToken: config.polarAccessToken,
server: config.polarServer,
productIds: config.polarProductIds,
webhookSecret: config.polarWebhookSecret,
});
const x = xAdapter || createXAdapter({
bearerToken: config.xBearerToken,
botUserId: config.xBotUserId,
});
const tts = ttsAdapter || createTTSAdapter({
apiKey: config.ttsApiKey,
baseURL: config.ttsBaseUrl || undefined,
model: config.ttsModel,
voice: config.ttsVoice,
});
const storage = storageAdapter || createStorageAdapter({
bucket: config.s3Bucket,
region: config.s3Region,
endpoint: config.s3Endpoint || undefined,
accessKeyId: config.s3AccessKeyId,
secretAccessKey: config.s3SecretAccessKey,
signedUrlTtlSec: config.s3SignedUrlTtlSec,
});
const generationService = audioGenerationService || createAudioGenerationService({
tts,
storage,
logger,
});
const webhookLimiter = new FixedWindowRateLimiter({
limit: rateLimits.webhookPerMinute || 120,
windowMs: 60_000,
@@ -117,6 +156,31 @@ function buildApp({
}));
}
function scheduleAudioGeneration(job) {
if (!generationService || !generationService.isConfigured()) {
return;
}
generationService.enqueueJob({
assetId: job.assetId,
text: job.article.content,
onCompleted: (audioMeta) => {
try {
engine.updateAsset(job.assetId, audioMeta);
persistMutation();
logger.info({ assetId: job.assetId }, "audio generation completed");
} catch (error) {
logger.error({ err: error, assetId: job.assetId }, "failed to apply generated audio metadata");
}
},
onFailed: (error) => {
logger.error({ err: error, assetId: job.assetId }, "audio generation job failed");
},
}).catch((error) => {
logger.error({ err: error, assetId: job.assetId }, "audio generation scheduling failed");
});
}
function ensureAuth(userId, returnTo) {
if (userId) {
return null;
@@ -161,6 +225,7 @@ function buildApp({
}
persistMutation();
scheduleAudioGeneration(result.job);
return json(200, {
status: "completed",
@@ -176,24 +241,32 @@ function buildApp({
}
function handlePolarWebhook(headers, rawBody) {
const signature = headers["x-signature"];
const isValid = verifySignature({
payload: rawBody,
secret: config.polarWebhookSecret,
signature,
});
if (!isValid) {
return json(401, { error: "invalid_signature" });
}
const payload = parseOrThrow(
PolarWebhookPayloadSchema,
parseJSON(rawBody),
"invalid_polar_webhook_payload",
);
try {
let payload;
if (hasStandardWebhookHeaders(headers)) {
payload = polar.extractTopUp(polar.parseWebhookEvent(rawBody, headers));
} else {
const signature = headers["x-signature"];
const isValid = verifySignature({
payload: rawBody,
secret: config.polarWebhookSecret,
signature,
});
if (!isValid) {
return json(401, { error: "invalid_signature" });
}
payload = polar.extractTopUp(
parseOrThrow(
PolarWebhookPayloadSchema,
parseJSON(rawBody),
"invalid_polar_webhook_payload",
),
);
}
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
persistMutation();
return json(200, { status: "credited" });
@@ -203,7 +276,7 @@ function buildApp({
}
}
function handleRequest({ method, path, headers, rawBody, query }) {
async function handleRequest({ method, path, headers, rawBody, query }) {
const safeHeaders = headers || {};
const safeQuery = query || {};
const userId = getAuthenticatedUserId(safeHeaders);
@@ -360,6 +433,7 @@ function buildApp({
}
persistMutation();
scheduleAudioGeneration(result.job);
return redirect(withQuery(`/audio/${result.job.assetId}`, {
flash: "Audiobook generated",
}));
@@ -395,7 +469,15 @@ function buildApp({
const accessDecision = audio
? engine.checkAudioAccess(assetId, userId)
: { allowed: false, reason: "not_found" };
return html(200, renderAudioPage({ audio, accessDecision, userId }));
let playbackUrl = null;
if (audio && accessDecision.allowed && storage.isConfigured()) {
try {
playbackUrl = await storage.getSignedDownloadUrl(audio.storageKey);
} catch (error) {
logger.warn({ err: error, assetId }, "failed to create signed playback url");
}
}
return html(200, renderAudioPage({ audio, accessDecision, userId, playbackUrl }));
}
if (method === "POST" && path === "/api/webhooks/x") {
@@ -406,6 +488,20 @@ function buildApp({
return handleXWebhook(safeHeaders, rawBody);
}
if (method === "GET" && path === "/api/x/mentions") {
if (!x.isConfigured()) {
return json(503, { error: "x_api_not_configured" });
}
try {
const mentions = await x.listMentions({ sinceId: safeQuery.sinceId || undefined });
return json(200, { mentions });
} catch (error) {
logger.warn({ err: error }, "failed to fetch mentions from x api");
return json(400, { error: error.message });
}
}
if (method === "POST" && path === "/api/webhooks/polar") {
const rateLimited = enforceJsonRateLimit(webhookLimiter, `webhook:${clientAddress}`);
if (rateLimited) {
@@ -414,6 +510,33 @@ function buildApp({
return handlePolarWebhook(safeHeaders, rawBody);
}
if (method === "POST" && path === "/api/payments/create-checkout") {
if (!userId) {
return json(401, { error: "auth_required" });
}
if (!polar.isConfigured()) {
return json(503, { error: "polar_checkout_not_configured" });
}
try {
const checkout = await polar.createCheckoutSession({
userId,
successUrl: `${config.appBaseUrl}/app?flash=Payment%20confirmed`,
returnUrl: `${config.appBaseUrl}/app`,
metadata: {
xartaudio_user_id: userId,
xartaudio_credits: "50",
},
});
return json(200, { checkoutUrl: checkout.url, checkoutId: checkout.id });
} catch (error) {
logger.warn({ err: error }, "failed to create polar checkout session");
return json(400, { error: error.message });
}
}
if (method === "GET" && path === "/api/me/wallet") {
if (!userId) {
return json(401, { error: "auth_required" });

View File

@@ -151,6 +151,21 @@ class XArtAudioEngine {
return this.assets.get(String(assetId)) || null;
}
updateAsset(assetId, patch) {
const key = String(assetId);
const current = this.assets.get(key);
if (!current) {
throw new Error("asset_not_found");
}
const next = {
...current,
...(patch || {}),
};
this.assets.set(key, next);
return next;
}
checkAudioAccess(assetId, userId) {
const audio = this.getAsset(assetId);
if (!audio) {

View File

@@ -46,7 +46,7 @@ function createHttpServer({ app }) {
return http.createServer(async (req, res) => {
try {
const rawBody = await readBody(req);
const response = app.handleRequest(mapToAppRequest({ req, rawBody }));
const response = await app.handleRequest(mapToAppRequest({ req, rawBody }));
res.statusCode = response.status;
for (const [key, value] of Object.entries(response.headers || {})) {

View File

@@ -189,7 +189,7 @@ function renderAppPage({ userId, summary, jobs, flash = null }) {
});
}
function renderAudioPage({ audio, accessDecision, userId }) {
function renderAudioPage({ audio, accessDecision, userId, playbackUrl = null }) {
if (!audio) {
return shell({
title: "Audio not found",
@@ -218,7 +218,9 @@ function renderAudioPage({ audio, accessDecision, userId }) {
<h1 class="text-xl font-bold">${escapeHtml(audio.articleTitle)}</h1>
<div class="text-xs text-slate-400">Duration ~ ${audio.durationSec}s • Asset ${escapeHtml(audio.id)}</div>
${action}
${accessDecision.allowed ? `<div class="mockup-code mt-3"><pre><code>stream://${escapeHtml(audio.storageKey)}</code></pre></div>` : ""}
${accessDecision.allowed
? `<div class="mockup-code mt-3"><pre><code>${escapeHtml(playbackUrl || `stream://${audio.storageKey}`)}</code></pre></div>`
: ""}
</div>
</section>
`,