From fedab9f7bd67580d5357a348a59195945f527a54 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 12:36:05 +0000 Subject: [PATCH] feat: add webhook-first app router with wallet, job, and unlock endpoints --- src/app.js | 193 +++++++++++++++++++++++++++++++++++++++++++++++ src/config.js | 28 +++++++ test/app.test.js | 146 +++++++++++++++++++++++++++++++++++ 3 files changed, 367 insertions(+) create mode 100644 src/app.js create mode 100644 src/config.js create mode 100644 test/app.test.js diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..2eb3c17 --- /dev/null +++ b/src/app.js @@ -0,0 +1,193 @@ +"use strict"; + +const { XArtAudioEngine } = require("./lib/engine"); +const { verifySignature } = require("./lib/signature"); +const { renderHomePage, renderAudioPage } = require("./views/pages"); + +function json(status, data) { + return { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify(data), + }; +} + +function html(status, markup) { + return { + status, + headers: { "content-type": "text/html; charset=utf-8" }, + body: markup, + }; +} + +function parseJSON(rawBody) { + if (!rawBody) { + return {}; + } + + try { + return JSON.parse(rawBody); + } catch { + throw new Error("invalid_json"); + } +} + +function getUserIdFromHeaders(headers) { + return headers["x-user-id"] || null; +} + +function buildApp({ config }) { + const engine = new XArtAudioEngine({ + creditConfig: config.credit, + }); + + function handleXWebhook(headers, rawBody) { + const signature = headers["x-signature"]; + const isValid = verifySignature({ + payload: rawBody, + secret: config.xWebhookSecret, + signature, + }); + + if (!isValid) { + return json(401, { error: "invalid_signature" }); + } + + const payload = parseJSON(rawBody); + + const result = engine.processMention({ + mentionPostId: payload.mentionPostId, + callerUserId: payload.callerUserId, + parentPost: payload.parentPost, + }); + + if (!result.ok && result.status === "not_article") { + return json(200, { + status: "not_article", + message: "This parent post is not an X Article.", + }); + } + + return json(200, { + status: "completed", + deduped: result.deduped, + jobId: result.job.id, + creditsCharged: result.job.creditsCharged, + publicLink: result.reply ? result.reply.publicLink : `/audio/${result.job.assetId}`, + }); + } + + 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 = parseJSON(rawBody); + engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`); + return json(200, { status: "credited" }); + } + + function getJobsForUser(userId) { + if (!userId) { + return []; + } + + return Array.from(engine.jobs.values()) + .filter((job) => job.callerUserId === userId) + .sort((a, b) => b.id.localeCompare(a.id)); + } + + function handleRequest({ method, path, headers, rawBody }) { + const safeHeaders = headers || {}; + const userId = getUserIdFromHeaders(safeHeaders); + + if (method === "GET" && path === "/health") { + return json(200, { ok: true }); + } + + if (method === "GET" && path === "/") { + return html(200, renderHomePage({ + authenticated: Boolean(userId), + userId, + balance: userId ? engine.getWalletBalance(userId) : 0, + jobs: getJobsForUser(userId), + })); + } + + if (method === "POST" && path === "/api/webhooks/x") { + return handleXWebhook(safeHeaders, rawBody); + } + + if (method === "POST" && path === "/api/webhooks/polar") { + return handlePolarWebhook(safeHeaders, rawBody); + } + + if (method === "POST" && path === "/api/dev/topup") { + const body = parseJSON(rawBody); + engine.topUpCredits(body.userId, body.amount, `dev:${Date.now()}:${body.userId}`); + return json(200, { status: "credited" }); + } + + if (method === "GET" && path === "/api/me/wallet") { + if (!userId) { + return json(401, { error: "auth_required" }); + } + return json(200, { balance: engine.getWalletBalance(userId) }); + } + + if (method === "GET" && path.startsWith("/api/jobs/")) { + if (!userId) { + return json(401, { error: "auth_required" }); + } + const jobId = path.slice("/api/jobs/".length); + const job = engine.getJob(jobId); + if (!job) { + return json(404, { error: "not_found" }); + } + if (job.callerUserId !== userId) { + return json(403, { error: "forbidden" }); + } + return json(200, { job }); + } + + if (method === "POST" && path.startsWith("/api/audio/") && path.endsWith("/unlock")) { + if (!userId) { + return json(401, { error: "auth_required" }); + } + const assetId = path.slice("/api/audio/".length, -"/unlock".length); + try { + const result = engine.unlockAudio(assetId, userId); + return json(200, result); + } catch (error) { + return json(400, { error: error.message }); + } + } + + if (method === "GET" && path.startsWith("/audio/")) { + const assetId = path.slice("/audio/".length); + const audio = engine.getAsset(assetId); + const accessDecision = audio + ? engine.checkAudioAccess(assetId, userId) + : { allowed: false, reason: "not_found" }; + return html(200, renderAudioPage({ audio, accessDecision })); + } + + return json(404, { error: "not_found" }); + } + + return { + engine, + handleRequest, + }; +} + +module.exports = { + buildApp, +}; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..ed9a25a --- /dev/null +++ b/src/config.js @@ -0,0 +1,28 @@ +"use strict"; + +function intFromEnv(name, fallback) { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + + const parsed = Number.parseInt(raw, 10); + return Number.isInteger(parsed) ? parsed : fallback; +} + +const config = { + port: intFromEnv("PORT", 3000), + xWebhookSecret: process.env.X_WEBHOOK_SECRET || "dev-x-secret", + polarWebhookSecret: process.env.POLAR_WEBHOOK_SECRET || "dev-polar-secret", + credit: { + baseCredits: intFromEnv("BASE_CREDITS", 1), + includedChars: intFromEnv("INCLUDED_CHARS", 25000), + stepChars: intFromEnv("STEP_CHARS", 10000), + stepCredits: intFromEnv("STEP_CREDITS", 1), + maxCharsPerArticle: intFromEnv("MAX_CHARS_PER_ARTICLE", 120000), + }, +}; + +module.exports = { + config, +}; diff --git a/test/app.test.js b/test/app.test.js new file mode 100644 index 0000000..3b8781c --- /dev/null +++ b/test/app.test.js @@ -0,0 +1,146 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { buildApp } = require("../src/app"); +const { hmacSHA256Hex } = require("../src/lib/signature"); + +function createApp() { + return buildApp({ + config: { + xWebhookSecret: "x-secret", + polarWebhookSecret: "polar-secret", + credit: { + baseCredits: 1, + includedChars: 25000, + stepChars: 10000, + stepCredits: 1, + maxCharsPerArticle: 120000, + }, + }, + }); +} + +function postJSON(app, path, payload, secret) { + const rawBody = JSON.stringify(payload); + const sig = hmacSHA256Hex(rawBody, secret); + return app.handleRequest({ + method: "POST", + path, + headers: { "x-signature": `sha256=${sig}` }, + rawBody, + }); +} + +test("rejects invalid X webhook signature", () => { + const app = createApp(); + const response = app.handleRequest({ + method: "POST", + path: "/api/webhooks/x", + headers: { "x-signature": "sha256=deadbeef" }, + rawBody: JSON.stringify({ mentionPostId: "m1", callerUserId: "u1", parentPost: {} }), + }); + + assert.equal(response.status, 401); +}); + +test("X webhook returns not_article response and no charge", () => { + const app = createApp(); + postJSON(app, "/api/webhooks/polar", { userId: "u1", credits: 5, eventId: "evt1" }, "polar-secret"); + + const response = postJSON( + app, + "/api/webhooks/x", + { mentionPostId: "m1", callerUserId: "u1", parentPost: { id: "p1" } }, + "x-secret", + ); + + const body = JSON.parse(response.body); + assert.equal(response.status, 200); + assert.equal(body.status, "not_article"); + assert.equal(app.engine.getWalletBalance("u1"), 5); +}); + +test("X webhook processes article, charges caller, and exposes audio route", () => { + const app = createApp(); + postJSON(app, "/api/webhooks/polar", { userId: "u1", credits: 5, eventId: "evt2" }, "polar-secret"); + + const response = postJSON( + app, + "/api/webhooks/x", + { + mentionPostId: "m2", + callerUserId: "u1", + parentPost: { + id: "p2", + article: { + id: "a2", + title: "Article", + body: "Hello from article", + }, + }, + }, + "x-secret", + ); + + const body = JSON.parse(response.body); + assert.equal(response.status, 200); + assert.equal(body.status, "completed"); + assert.equal(body.creditsCharged, 1); + + const audioPageUnauthed = app.handleRequest({ + method: "GET", + path: body.publicLink, + headers: {}, + rawBody: "", + }); + + assert.equal(audioPageUnauthed.status, 200); + assert.match(audioPageUnauthed.body, /Sign in required before playback/); +}); + +test("non-owner can unlock with same credits then access", () => { + const app = createApp(); + postJSON(app, "/api/webhooks/polar", { userId: "u1", credits: 5, eventId: "evt3" }, "polar-secret"); + postJSON(app, "/api/webhooks/polar", { userId: "u2", credits: 5, eventId: "evt4" }, "polar-secret"); + + const makeAudio = postJSON( + app, + "/api/webhooks/x", + { + mentionPostId: "m3", + callerUserId: "u1", + parentPost: { + id: "p3", + article: { + id: "a3", + title: "Article", + body: "Hello from article", + }, + }, + }, + "x-secret", + ); + + const { publicLink } = JSON.parse(makeAudio.body); + const assetId = publicLink.replace("/audio/", ""); + + const unlock = app.handleRequest({ + method: "POST", + path: `/api/audio/${assetId}/unlock`, + headers: { "x-user-id": "u2" }, + rawBody: "", + }); + + assert.equal(unlock.status, 200); + assert.equal(app.engine.getWalletBalance("u2"), 4); + + const pageAfterUnlock = app.handleRequest({ + method: "GET", + path: publicLink, + headers: { "x-user-id": "u2" }, + rawBody: "", + }); + + assert.match(pageAfterUnlock.body, /Access granted/); +});