feat: wire async app flows for checkout x mentions signed playback and queued generation
This commit is contained in:
131
src/app.js
131
src/app.js
@@ -32,6 +32,11 @@ const {
|
|||||||
SimulateMentionFormSchema,
|
SimulateMentionFormSchema,
|
||||||
parseOrThrow,
|
parseOrThrow,
|
||||||
} = require("./lib/validation");
|
} = 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") {
|
function sanitizeReturnTo(value, fallback = "/app") {
|
||||||
if (!value || typeof value !== "string") {
|
if (!value || typeof value !== "string") {
|
||||||
@@ -54,12 +59,46 @@ function buildApp({
|
|||||||
initialState = null,
|
initialState = null,
|
||||||
onMutation = null,
|
onMutation = null,
|
||||||
logger = console,
|
logger = console,
|
||||||
|
polarAdapter = null,
|
||||||
|
xAdapter = null,
|
||||||
|
ttsAdapter = null,
|
||||||
|
storageAdapter = null,
|
||||||
|
audioGenerationService = null,
|
||||||
}) {
|
}) {
|
||||||
const engine = new XArtAudioEngine({
|
const engine = new XArtAudioEngine({
|
||||||
creditConfig: config.credit,
|
creditConfig: config.credit,
|
||||||
initialState: initialState && initialState.engine ? initialState.engine : null,
|
initialState: initialState && initialState.engine ? initialState.engine : null,
|
||||||
});
|
});
|
||||||
const rateLimits = config.rateLimits || {};
|
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({
|
const webhookLimiter = new FixedWindowRateLimiter({
|
||||||
limit: rateLimits.webhookPerMinute || 120,
|
limit: rateLimits.webhookPerMinute || 120,
|
||||||
windowMs: 60_000,
|
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) {
|
function ensureAuth(userId, returnTo) {
|
||||||
if (userId) {
|
if (userId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -161,6 +225,7 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
persistMutation();
|
persistMutation();
|
||||||
|
scheduleAudioGeneration(result.job);
|
||||||
|
|
||||||
return json(200, {
|
return json(200, {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -176,6 +241,12 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePolarWebhook(headers, rawBody) {
|
function handlePolarWebhook(headers, rawBody) {
|
||||||
|
try {
|
||||||
|
let payload;
|
||||||
|
|
||||||
|
if (hasStandardWebhookHeaders(headers)) {
|
||||||
|
payload = polar.extractTopUp(polar.parseWebhookEvent(rawBody, headers));
|
||||||
|
} else {
|
||||||
const signature = headers["x-signature"];
|
const signature = headers["x-signature"];
|
||||||
const isValid = verifySignature({
|
const isValid = verifySignature({
|
||||||
payload: rawBody,
|
payload: rawBody,
|
||||||
@@ -187,13 +258,15 @@ function buildApp({
|
|||||||
return json(401, { error: "invalid_signature" });
|
return json(401, { error: "invalid_signature" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = parseOrThrow(
|
payload = polar.extractTopUp(
|
||||||
|
parseOrThrow(
|
||||||
PolarWebhookPayloadSchema,
|
PolarWebhookPayloadSchema,
|
||||||
parseJSON(rawBody),
|
parseJSON(rawBody),
|
||||||
"invalid_polar_webhook_payload",
|
"invalid_polar_webhook_payload",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
|
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
|
||||||
persistMutation();
|
persistMutation();
|
||||||
return json(200, { status: "credited" });
|
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 safeHeaders = headers || {};
|
||||||
const safeQuery = query || {};
|
const safeQuery = query || {};
|
||||||
const userId = getAuthenticatedUserId(safeHeaders);
|
const userId = getAuthenticatedUserId(safeHeaders);
|
||||||
@@ -360,6 +433,7 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
persistMutation();
|
persistMutation();
|
||||||
|
scheduleAudioGeneration(result.job);
|
||||||
return redirect(withQuery(`/audio/${result.job.assetId}`, {
|
return redirect(withQuery(`/audio/${result.job.assetId}`, {
|
||||||
flash: "Audiobook generated",
|
flash: "Audiobook generated",
|
||||||
}));
|
}));
|
||||||
@@ -395,7 +469,15 @@ function buildApp({
|
|||||||
const accessDecision = audio
|
const accessDecision = audio
|
||||||
? engine.checkAudioAccess(assetId, userId)
|
? engine.checkAudioAccess(assetId, userId)
|
||||||
: { allowed: false, reason: "not_found" };
|
: { 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") {
|
if (method === "POST" && path === "/api/webhooks/x") {
|
||||||
@@ -406,6 +488,20 @@ function buildApp({
|
|||||||
return handleXWebhook(safeHeaders, rawBody);
|
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") {
|
if (method === "POST" && path === "/api/webhooks/polar") {
|
||||||
const rateLimited = enforceJsonRateLimit(webhookLimiter, `webhook:${clientAddress}`);
|
const rateLimited = enforceJsonRateLimit(webhookLimiter, `webhook:${clientAddress}`);
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
@@ -414,6 +510,33 @@ function buildApp({
|
|||||||
return handlePolarWebhook(safeHeaders, rawBody);
|
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 (method === "GET" && path === "/api/me/wallet") {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return json(401, { error: "auth_required" });
|
return json(401, { error: "auth_required" });
|
||||||
|
|||||||
@@ -151,6 +151,21 @@ class XArtAudioEngine {
|
|||||||
return this.assets.get(String(assetId)) || null;
|
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) {
|
checkAudioAccess(assetId, userId) {
|
||||||
const audio = this.getAsset(assetId);
|
const audio = this.getAsset(assetId);
|
||||||
if (!audio) {
|
if (!audio) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function createHttpServer({ app }) {
|
|||||||
return http.createServer(async (req, res) => {
|
return http.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rawBody = await readBody(req);
|
const rawBody = await readBody(req);
|
||||||
const response = app.handleRequest(mapToAppRequest({ req, rawBody }));
|
const response = await app.handleRequest(mapToAppRequest({ req, rawBody }));
|
||||||
|
|
||||||
res.statusCode = response.status;
|
res.statusCode = response.status;
|
||||||
for (const [key, value] of Object.entries(response.headers || {})) {
|
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) {
|
if (!audio) {
|
||||||
return shell({
|
return shell({
|
||||||
title: "Audio not found",
|
title: "Audio not found",
|
||||||
@@ -218,7 +218,9 @@ function renderAudioPage({ audio, accessDecision, userId }) {
|
|||||||
<h1 class="text-xl font-bold">${escapeHtml(audio.articleTitle)}</h1>
|
<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>
|
<div class="text-xs text-slate-400">Duration ~ ${audio.durationSec}s • Asset ${escapeHtml(audio.id)}</div>
|
||||||
${action}
|
${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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`,
|
`,
|
||||||
|
|||||||
277
test/app.test.js
277
test/app.test.js
@@ -9,6 +9,22 @@ function createApp(options = {}) {
|
|||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
xWebhookSecret: "x-secret",
|
xWebhookSecret: "x-secret",
|
||||||
polarWebhookSecret: "polar-secret",
|
polarWebhookSecret: "polar-secret",
|
||||||
|
xBearerToken: "",
|
||||||
|
xBotUserId: "",
|
||||||
|
polarAccessToken: "",
|
||||||
|
polarServer: "production",
|
||||||
|
polarProductIds: [],
|
||||||
|
appBaseUrl: "http://localhost:3000",
|
||||||
|
ttsApiKey: "",
|
||||||
|
ttsBaseUrl: "",
|
||||||
|
ttsModel: "gpt-4o-mini-tts",
|
||||||
|
ttsVoice: "alloy",
|
||||||
|
s3Bucket: "",
|
||||||
|
s3Region: "",
|
||||||
|
s3Endpoint: "",
|
||||||
|
s3AccessKeyId: "",
|
||||||
|
s3SecretAccessKey: "",
|
||||||
|
s3SignedUrlTtlSec: 3600,
|
||||||
rateLimits: {
|
rateLimits: {
|
||||||
webhookPerMinute: 120,
|
webhookPerMinute: 120,
|
||||||
authPerMinute: 30,
|
authPerMinute: 30,
|
||||||
@@ -46,7 +62,7 @@ function createApp(options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function call(app, { method, path, headers = {}, body = "", query = {} }) {
|
async function call(app, { method, path, headers = {}, body = "", query = {} }) {
|
||||||
return app.handleRequest({
|
return app.handleRequest({
|
||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
@@ -56,36 +72,36 @@ function call(app, { method, path, headers = {}, body = "", query = {} }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function postJSONWebhook(app, path, payload, secret) {
|
async function postJSONWebhook(app, path, payload, secret, extraHeaders) {
|
||||||
const rawBody = JSON.stringify(payload);
|
const rawBody = JSON.stringify(payload);
|
||||||
const sig = hmacSHA256Hex(rawBody, secret);
|
const sig = hmacSHA256Hex(rawBody, secret);
|
||||||
|
|
||||||
return call(app, {
|
return call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path,
|
path,
|
||||||
headers: { "x-signature": `sha256=${sig}` },
|
headers: { "x-signature": `sha256=${sig}`, ...(extraHeaders || {}) },
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("GET / renders landing page", () => {
|
test("GET / renders landing page", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const response = call(app, { method: "GET", path: "/" });
|
const response = await call(app, { method: "GET", path: "/" });
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.match(response.body, /From X Article to audiobook in one mention/);
|
assert.match(response.body, /From X Article to audiobook in one mention/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unauthenticated /app redirects to /login with returnTo", () => {
|
test("unauthenticated /app redirects to /login with returnTo", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const response = call(app, { method: "GET", path: "/app" });
|
const response = await call(app, { method: "GET", path: "/app" });
|
||||||
assert.equal(response.status, 303);
|
assert.equal(response.status, 303);
|
||||||
assert.match(response.headers.location, /^\/login\?/);
|
assert.match(response.headers.location, /^\/login\?/);
|
||||||
assert.match(response.headers.location, /returnTo=%2Fapp/);
|
assert.match(response.headers.location, /returnTo=%2Fapp/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POST /auth/dev-login sets cookie and redirects", () => {
|
test("POST /auth/dev-login sets cookie and redirects", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const response = call(app, {
|
const response = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/auth/dev-login",
|
path: "/auth/dev-login",
|
||||||
body: "userId=matiss&returnTo=%2Fapp",
|
body: "userId=matiss&returnTo=%2Fapp",
|
||||||
@@ -96,11 +112,11 @@ test("POST /auth/dev-login sets cookie and redirects", () => {
|
|||||||
assert.match(response.headers["set-cookie"], /^xartaudio_user=matiss/);
|
assert.match(response.headers["set-cookie"], /^xartaudio_user=matiss/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("authenticated dashboard topup + simulate mention flow", () => {
|
test("authenticated dashboard topup + simulate mention flow", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const cookieHeader = "xartaudio_user=alice";
|
const cookieHeader = "xartaudio_user=alice";
|
||||||
|
|
||||||
const topup = call(app, {
|
const topup = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/app/actions/topup",
|
path: "/app/actions/topup",
|
||||||
headers: { cookie: cookieHeader },
|
headers: { cookie: cookieHeader },
|
||||||
@@ -109,7 +125,7 @@ test("authenticated dashboard topup + simulate mention flow", () => {
|
|||||||
assert.equal(topup.status, 303);
|
assert.equal(topup.status, 303);
|
||||||
assert.match(topup.headers.location, /Added%208%20credits/);
|
assert.match(topup.headers.location, /Added%208%20credits/);
|
||||||
|
|
||||||
const simulate = call(app, {
|
const simulate = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/app/actions/simulate-mention",
|
path: "/app/actions/simulate-mention",
|
||||||
headers: { cookie: cookieHeader },
|
headers: { cookie: cookieHeader },
|
||||||
@@ -118,7 +134,7 @@ test("authenticated dashboard topup + simulate mention flow", () => {
|
|||||||
assert.equal(simulate.status, 303);
|
assert.equal(simulate.status, 303);
|
||||||
assert.match(simulate.headers.location, /^\/audio\//);
|
assert.match(simulate.headers.location, /^\/audio\//);
|
||||||
|
|
||||||
const dashboard = call(app, {
|
const dashboard = await call(app, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/app",
|
path: "/app",
|
||||||
headers: { cookie: cookieHeader },
|
headers: { cookie: cookieHeader },
|
||||||
@@ -128,23 +144,23 @@ test("authenticated dashboard topup + simulate mention flow", () => {
|
|||||||
assert.match(dashboard.body, /Hello/);
|
assert.match(dashboard.body, /Hello/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("audio flow requires auth for unlock and supports permanent unlock", () => {
|
test("audio flow requires auth for unlock and supports permanent unlock", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
call(app, {
|
await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/app/actions/topup",
|
path: "/app/actions/topup",
|
||||||
headers: { cookie: "xartaudio_user=owner" },
|
headers: { cookie: "xartaudio_user=owner" },
|
||||||
body: "amount=5",
|
body: "amount=5",
|
||||||
});
|
});
|
||||||
call(app, {
|
await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/app/actions/topup",
|
path: "/app/actions/topup",
|
||||||
headers: { cookie: "xartaudio_user=viewer" },
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
body: "amount=5",
|
body: "amount=5",
|
||||||
});
|
});
|
||||||
|
|
||||||
const generated = call(app, {
|
const generated = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/app/actions/simulate-mention",
|
path: "/app/actions/simulate-mention",
|
||||||
headers: { cookie: "xartaudio_user=owner" },
|
headers: { cookie: "xartaudio_user=owner" },
|
||||||
@@ -154,28 +170,28 @@ test("audio flow requires auth for unlock and supports permanent unlock", () =>
|
|||||||
const audioPath = generated.headers.location.split("?")[0];
|
const audioPath = generated.headers.location.split("?")[0];
|
||||||
const assetId = audioPath.replace("/audio/", "");
|
const assetId = audioPath.replace("/audio/", "");
|
||||||
|
|
||||||
const beforeUnlock = call(app, {
|
const beforeUnlock = await call(app, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: audioPath,
|
path: audioPath,
|
||||||
headers: { cookie: "xartaudio_user=viewer" },
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
});
|
});
|
||||||
assert.match(beforeUnlock.body, /Unlock required: 1 credits/);
|
assert.match(beforeUnlock.body, /Unlock required: 1 credits/);
|
||||||
|
|
||||||
const unlock = call(app, {
|
const unlock = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/audio/${assetId}/unlock`,
|
path: `/audio/${assetId}/unlock`,
|
||||||
headers: { cookie: "xartaudio_user=viewer" },
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
});
|
});
|
||||||
assert.equal(unlock.status, 303);
|
assert.equal(unlock.status, 303);
|
||||||
|
|
||||||
const afterUnlock = call(app, {
|
const afterUnlock = await call(app, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: audioPath,
|
path: audioPath,
|
||||||
headers: { cookie: "xartaudio_user=viewer" },
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
});
|
});
|
||||||
assert.match(afterUnlock.body, /Access granted/);
|
assert.match(afterUnlock.body, /Access granted/);
|
||||||
|
|
||||||
const wallet = call(app, {
|
const wallet = await call(app, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/api/me/wallet",
|
path: "/api/me/wallet",
|
||||||
headers: { cookie: "xartaudio_user=viewer" },
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
@@ -184,9 +200,146 @@ test("audio flow requires auth for unlock and supports permanent unlock", () =>
|
|||||||
assert.equal(walletData.balance, 4);
|
assert.equal(walletData.balance, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("X webhook invalid signature is rejected", () => {
|
test("audio page uses signed storage URL when storage adapter is configured", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
storageAdapter: {
|
||||||
|
isConfigured() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async getSignedDownloadUrl(key) {
|
||||||
|
return `https://signed.local/${key}`;
|
||||||
|
},
|
||||||
|
async uploadAudio() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/app/actions/topup",
|
||||||
|
headers: { cookie: "xartaudio_user=owner" },
|
||||||
|
body: "amount=2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const generated = await call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/app/actions/simulate-mention",
|
||||||
|
headers: { cookie: "xartaudio_user=owner" },
|
||||||
|
body: "title=Signed&body=Audio+Body",
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioPath = generated.headers.location.split("?")[0];
|
||||||
|
const page = await call(app, {
|
||||||
|
method: "GET",
|
||||||
|
path: audioPath,
|
||||||
|
headers: { cookie: "xartaudio_user=owner" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(page.body, /https:\/\/signed\.local\/audio\/1\.mp3/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/api/x/mentions returns upstream mentions when configured", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
xAdapter: {
|
||||||
|
isConfigured() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async listMentions({ sinceId }) {
|
||||||
|
return [{ id: "m1", sinceId: sinceId || null }];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await call(app, {
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/x/mentions",
|
||||||
|
query: { sinceId: "100" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const body = JSON.parse(response.body);
|
||||||
|
assert.equal(body.mentions.length, 1);
|
||||||
|
assert.equal(body.mentions[0].id, "m1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulate mention schedules background audio generation when service is configured", async () => {
|
||||||
|
const queued = [];
|
||||||
|
const app = createApp({
|
||||||
|
audioGenerationService: {
|
||||||
|
isConfigured() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async enqueueJob(payload) {
|
||||||
|
queued.push(payload);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/app/actions/topup",
|
||||||
|
headers: { cookie: "xartaudio_user=alice" },
|
||||||
|
body: "amount=3",
|
||||||
|
});
|
||||||
|
|
||||||
|
const simulate = await call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/app/actions/simulate-mention",
|
||||||
|
headers: { cookie: "xartaudio_user=alice" },
|
||||||
|
body: "title=T&body=hello+world",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(simulate.status, 303);
|
||||||
|
assert.equal(queued.length, 1);
|
||||||
|
assert.equal(typeof queued[0].assetId, "string");
|
||||||
|
assert.equal(queued[0].text, "hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/api/payments/create-checkout returns 503 when Polar is not configured", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const response = call(app, {
|
|
||||||
|
const response = await call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/payments/create-checkout",
|
||||||
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 503);
|
||||||
|
assert.equal(JSON.parse(response.body).error, "polar_checkout_not_configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/api/payments/create-checkout returns checkout URL when adapter is configured", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
polarAdapter: {
|
||||||
|
isConfigured() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async createCheckoutSession() {
|
||||||
|
return { id: "chk_1", url: "https://polar.sh/checkout/chk_1" };
|
||||||
|
},
|
||||||
|
parseWebhookEvent() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
extractTopUp(payload) {
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/payments/create-checkout",
|
||||||
|
headers: { cookie: "xartaudio_user=buyer" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const body = JSON.parse(response.body);
|
||||||
|
assert.equal(body.checkoutId, "chk_1");
|
||||||
|
assert.equal(body.checkoutUrl, "https://polar.sh/checkout/chk_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("X webhook invalid signature is rejected", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const response = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/webhooks/x",
|
path: "/api/webhooks/x",
|
||||||
headers: { "x-signature": "sha256=deadbeef" },
|
headers: { "x-signature": "sha256=deadbeef" },
|
||||||
@@ -196,11 +349,11 @@ test("X webhook invalid signature is rejected", () => {
|
|||||||
assert.equal(response.status, 401);
|
assert.equal(response.status, 401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("X webhook valid flow processes article", () => {
|
test("X webhook valid flow processes article", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt1" }, "polar-secret");
|
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt1" }, "polar-secret");
|
||||||
const response = postJSONWebhook(
|
const response = await postJSONWebhook(
|
||||||
app,
|
app,
|
||||||
"/api/webhooks/x",
|
"/api/webhooks/x",
|
||||||
{
|
{
|
||||||
@@ -220,7 +373,55 @@ test("X webhook valid flow processes article", () => {
|
|||||||
assert.equal(body.creditsCharged, 1);
|
assert.equal(body.creditsCharged, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("emits persistence snapshots on mutating actions", () => {
|
test("Polar webhook uses adapter parsing for standard webhook headers", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
polarAdapter: {
|
||||||
|
isConfigured() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
async createCheckoutSession() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
parseWebhookEvent() {
|
||||||
|
return {
|
||||||
|
type: "order.paid",
|
||||||
|
data: {
|
||||||
|
id: "ord_1",
|
||||||
|
metadata: { xartaudio_user_id: "u9", xartaudio_credits: "7" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractTopUp(event) {
|
||||||
|
return {
|
||||||
|
userId: event.data.metadata.xartaudio_user_id,
|
||||||
|
credits: Number.parseInt(event.data.metadata.xartaudio_credits, 10),
|
||||||
|
eventId: event.data.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/webhooks/polar",
|
||||||
|
headers: {
|
||||||
|
"webhook-id": "wh_1",
|
||||||
|
"webhook-timestamp": "1",
|
||||||
|
"webhook-signature": "sig",
|
||||||
|
},
|
||||||
|
body: "{\"type\":\"order.paid\"}",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const wallet = await call(app, {
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/me/wallet",
|
||||||
|
headers: { cookie: "xartaudio_user=u9" },
|
||||||
|
});
|
||||||
|
assert.equal(JSON.parse(wallet.body).balance, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits persistence snapshots on mutating actions", async () => {
|
||||||
const snapshots = [];
|
const snapshots = [];
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
onMutation(state) {
|
onMutation(state) {
|
||||||
@@ -228,14 +429,14 @@ test("emits persistence snapshots on mutating actions", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
call(app, {
|
await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/app/actions/topup",
|
path: "/app/actions/topup",
|
||||||
headers: { cookie: "xartaudio_user=alice" },
|
headers: { cookie: "xartaudio_user=alice" },
|
||||||
body: "amount=5",
|
body: "amount=5",
|
||||||
});
|
});
|
||||||
|
|
||||||
call(app, {
|
await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/app/actions/simulate-mention",
|
path: "/app/actions/simulate-mention",
|
||||||
headers: { cookie: "xartaudio_user=alice" },
|
headers: { cookie: "xartaudio_user=alice" },
|
||||||
@@ -249,7 +450,7 @@ test("emits persistence snapshots on mutating actions", () => {
|
|||||||
assert.equal(typeof latest.engine, "object");
|
assert.equal(typeof latest.engine, "object");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can boot app from previously persisted state snapshot", () => {
|
test("can boot app from previously persisted state snapshot", async () => {
|
||||||
const snapshots = [];
|
const snapshots = [];
|
||||||
const app1 = createApp({
|
const app1 = createApp({
|
||||||
onMutation(state) {
|
onMutation(state) {
|
||||||
@@ -257,7 +458,7 @@ test("can boot app from previously persisted state snapshot", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
call(app1, {
|
await call(app1, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/app/actions/topup",
|
path: "/app/actions/topup",
|
||||||
headers: { cookie: "xartaudio_user=restart-user" },
|
headers: { cookie: "xartaudio_user=restart-user" },
|
||||||
@@ -269,7 +470,7 @@ test("can boot app from previously persisted state snapshot", () => {
|
|||||||
initialState: persistedState,
|
initialState: persistedState,
|
||||||
});
|
});
|
||||||
|
|
||||||
const wallet = call(app2, {
|
const wallet = await call(app2, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/api/me/wallet",
|
path: "/api/me/wallet",
|
||||||
headers: { cookie: "xartaudio_user=restart-user" },
|
headers: { cookie: "xartaudio_user=restart-user" },
|
||||||
@@ -279,7 +480,7 @@ test("can boot app from previously persisted state snapshot", () => {
|
|||||||
assert.equal(JSON.parse(wallet.body).balance, 6);
|
assert.equal(JSON.parse(wallet.body).balance, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rate limits repeated webhook calls", () => {
|
test("rate limits repeated webhook calls", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
config: {
|
config: {
|
||||||
rateLimits: {
|
rateLimits: {
|
||||||
@@ -288,13 +489,13 @@ test("rate limits repeated webhook calls", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = call(app, {
|
const first = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/webhooks/x",
|
path: "/api/webhooks/x",
|
||||||
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
|
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
const second = call(app, {
|
const second = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/webhooks/x",
|
path: "/api/webhooks/x",
|
||||||
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
|
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
|
||||||
@@ -305,7 +506,7 @@ test("rate limits repeated webhook calls", () => {
|
|||||||
assert.equal(second.status, 429);
|
assert.equal(second.status, 429);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rate limits repeated login attempts from same IP", () => {
|
test("rate limits repeated login attempts from same IP", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
config: {
|
config: {
|
||||||
rateLimits: {
|
rateLimits: {
|
||||||
@@ -314,13 +515,13 @@ test("rate limits repeated login attempts from same IP", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = call(app, {
|
const first = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/auth/dev-login",
|
path: "/auth/dev-login",
|
||||||
headers: { "x-forwarded-for": "5.5.5.5" },
|
headers: { "x-forwarded-for": "5.5.5.5" },
|
||||||
body: "userId=alice&returnTo=%2Fapp",
|
body: "userId=alice&returnTo=%2Fapp",
|
||||||
});
|
});
|
||||||
const second = call(app, {
|
const second = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/auth/dev-login",
|
path: "/auth/dev-login",
|
||||||
headers: { "x-forwarded-for": "5.5.5.5" },
|
headers: { "x-forwarded-for": "5.5.5.5" },
|
||||||
|
|||||||
@@ -180,3 +180,25 @@ test("round-trips state snapshot across engine restart", () => {
|
|||||||
assert.equal(engine2.getAsset(created.job.assetId).articleTitle, "Snap");
|
assert.equal(engine2.getAsset(created.job.assetId).articleTitle, "Snap");
|
||||||
assert.equal(engine2.checkAudioAccess(created.job.assetId, "u1").allowed, true);
|
assert.equal(engine2.checkAudioAccess(created.job.assetId, "u1").allowed, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("updateAsset patches stored asset metadata", () => {
|
||||||
|
const engine = createEngine();
|
||||||
|
engine.topUpCredits("u1", 5, "topup-update-asset");
|
||||||
|
const created = engine.processMention({
|
||||||
|
mentionPostId: "m-update-asset",
|
||||||
|
callerUserId: "u1",
|
||||||
|
parentPost: {
|
||||||
|
id: "p-update-asset",
|
||||||
|
authorId: "author",
|
||||||
|
article: { id: "a-update", title: "T", body: "hello" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = engine.updateAsset(created.job.assetId, {
|
||||||
|
storageKey: "audio/real-file.mp3",
|
||||||
|
sizeBytes: 12345,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(updated.storageKey, "audio/real-file.mp3");
|
||||||
|
assert.equal(updated.sizeBytes, 12345);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user