feat: add webhook-first app router with wallet, job, and unlock endpoints
This commit is contained in:
193
src/app.js
Normal file
193
src/app.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
28
src/config.js
Normal file
28
src/config.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
146
test/app.test.js
Normal file
146
test/app.test.js
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user