feat: wire async app flows for checkout x mentions signed playback and queued generation
This commit is contained in:
161
src/app.js
161
src/app.js
@@ -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" });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || {})) {
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
Reference in New Issue
Block a user