Compare commits

...

10 Commits

16 changed files with 1713 additions and 293 deletions

View File

@@ -3,6 +3,8 @@ NODE_ENV=production
PORT=3000
LOG_LEVEL=info
APP_BASE_URL=https://xartaudio.example.com
ENABLE_DEV_ROUTES=false
ALLOW_IN_MEMORY_STATE_FALLBACK=false
# Better Auth
BETTER_AUTH_SECRET=replace-me

819
convex/domain.ts Normal file
View File

@@ -0,0 +1,819 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
const WalletTxType = v.union(v.literal("credit"), v.literal("debit"), v.literal("refund"));
const JobStatus = v.union(
v.literal("received"),
v.literal("validated"),
v.literal("priced"),
v.literal("charged"),
v.literal("synthesizing"),
v.literal("uploaded"),
v.literal("completed"),
v.literal("failed_refunded"),
v.literal("failed_not_refunded"),
);
function nowIso() {
return new Date().toISOString();
}
async function findWalletByUser(ctx: any, userId: any) {
return ctx.db
.query("wallets")
.withIndex("by_user_id", (q: any) => q.eq("user_id", userId))
.first();
}
export const upsertUser = mutation({
args: {
x_user_id: v.optional(v.string()),
username: v.optional(v.string()),
email: v.optional(v.string()),
},
handler: async (ctx, args) => {
let existing = null;
if (args.x_user_id) {
existing = await ctx.db
.query("users")
.withIndex("by_x_user_id", (q) => q.eq("x_user_id", args.x_user_id!))
.first();
}
if (!existing && args.email) {
existing = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", args.email!))
.first();
}
if (!existing && args.username) {
existing = await ctx.db
.query("users")
.withIndex("by_username", (q) => q.eq("username", args.username!))
.first();
}
const updated_at = nowIso();
if (existing) {
await ctx.db.patch(existing._id, {
x_user_id: args.x_user_id ?? existing.x_user_id,
username: args.username ?? existing.username,
email: args.email ?? existing.email,
updated_at,
});
return existing._id;
}
return ctx.db.insert("users", {
x_user_id: args.x_user_id,
username: args.username,
email: args.email,
created_at: updated_at,
updated_at,
});
},
});
export const getWallet = query({
args: { user_id: v.id("users") },
handler: async (ctx, args) => {
const wallet = await ctx.db
.query("wallets")
.withIndex("by_user_id", (q) => q.eq("user_id", args.user_id))
.first();
return wallet || {
user_id: args.user_id,
balance_credits: 0,
updated_at: null,
};
},
});
export const applyWalletTransaction = mutation({
args: {
user_id: v.id("users"),
type: WalletTxType,
amount: v.number(),
reason: v.optional(v.string()),
idempotency_key: v.string(),
},
handler: async (ctx, args) => {
if (!Number.isInteger(args.amount) || args.amount <= 0) {
throw new Error("invalid_amount");
}
const existing = await ctx.db
.query("wallet_transactions")
.withIndex("by_idempotency_key", (q) => q.eq("idempotency_key", args.idempotency_key))
.first();
if (existing) {
return {
deduped: true,
transaction: existing,
};
}
const wallet = await findWalletByUser(ctx, args.user_id);
const currentBalance = wallet ? wallet.balance_credits : 0;
if (args.type === "debit" && currentBalance < args.amount) {
throw new Error("insufficient_credits");
}
const delta = args.type === "debit" ? -args.amount : args.amount;
const balance_after = currentBalance + delta;
const updated_at = nowIso();
if (wallet) {
await ctx.db.patch(wallet._id, {
balance_credits: balance_after,
updated_at,
});
} else {
await ctx.db.insert("wallets", {
user_id: args.user_id,
balance_credits: balance_after,
updated_at,
});
}
const txId = await ctx.db.insert("wallet_transactions", {
user_id: args.user_id,
type: args.type,
amount: args.amount,
reason: args.reason,
idempotency_key: args.idempotency_key,
balance_after,
created_at: updated_at,
});
const tx = await ctx.db.get(txId);
return {
deduped: false,
transaction: tx,
};
},
});
export const recordMentionEvent = mutation({
args: {
mention_post_id: v.string(),
mention_author_id: v.string(),
parent_post_id: v.string(),
status: v.string(),
error_code: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("mention_events")
.withIndex("by_mention_post_id", (q) => q.eq("mention_post_id", args.mention_post_id))
.first();
const updated_at = nowIso();
if (existing) {
await ctx.db.patch(existing._id, {
mention_author_id: args.mention_author_id,
parent_post_id: args.parent_post_id,
status: args.status,
error_code: args.error_code,
updated_at,
});
return { id: existing._id, deduped: true };
}
const id = await ctx.db.insert("mention_events", {
mention_post_id: args.mention_post_id,
mention_author_id: args.mention_author_id,
parent_post_id: args.parent_post_id,
status: args.status,
error_code: args.error_code,
created_at: updated_at,
updated_at,
});
return { id, deduped: false };
},
});
export const upsertArticle = mutation({
args: {
x_article_id: v.optional(v.string()),
parent_post_id: v.string(),
author_id: v.optional(v.string()),
title: v.string(),
raw_content: v.optional(v.string()),
char_count: v.optional(v.number()),
content_hash: v.optional(v.string()),
},
handler: async (ctx, args) => {
const normalizedCount = Number.isInteger(args.char_count)
? args.char_count
: (args.raw_content ? args.raw_content.length : 0);
if (!Number.isInteger(normalizedCount) || normalizedCount <= 0) {
throw new Error("invalid_char_count");
}
let existing = null;
if (args.x_article_id) {
existing = await ctx.db
.query("articles")
.withIndex("by_x_article_id", (q) => q.eq("x_article_id", args.x_article_id!))
.first();
}
if (!existing) {
existing = await ctx.db
.query("articles")
.withIndex("by_parent_post_id", (q) => q.eq("parent_post_id", args.parent_post_id))
.first();
}
const updated_at = nowIso();
if (existing) {
await ctx.db.patch(existing._id, {
x_article_id: args.x_article_id ?? existing.x_article_id,
parent_post_id: args.parent_post_id,
author_id: args.author_id,
title: args.title,
raw_content: args.raw_content,
char_count: normalizedCount,
content_hash: args.content_hash,
updated_at,
});
return existing._id;
}
return ctx.db.insert("articles", {
x_article_id: args.x_article_id,
parent_post_id: args.parent_post_id,
author_id: args.author_id,
title: args.title,
raw_content: args.raw_content,
char_count: normalizedCount,
content_hash: args.content_hash,
created_at: updated_at,
updated_at,
});
},
});
export const createAudioJob = mutation({
args: {
user_id: v.id("users"),
mention_event_id: v.optional(v.id("mention_events")),
article_id: v.id("articles"),
status: v.optional(JobStatus),
credits_charged: v.number(),
tts_provider: v.optional(v.string()),
tts_model: v.optional(v.string()),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
if (!Number.isInteger(args.credits_charged) || args.credits_charged < 0) {
throw new Error("invalid_credits_charged");
}
const timestamp = nowIso();
return ctx.db.insert("audio_jobs", {
user_id: args.user_id,
mention_event_id: args.mention_event_id,
article_id: args.article_id,
status: args.status || "received",
credits_charged: args.credits_charged,
tts_provider: args.tts_provider,
tts_model: args.tts_model,
error: args.error,
created_at: timestamp,
updated_at: timestamp,
});
},
});
export const updateAudioJob = mutation({
args: {
job_id: v.id("audio_jobs"),
status: v.optional(JobStatus),
credits_charged: v.optional(v.number()),
tts_provider: v.optional(v.string()),
tts_model: v.optional(v.string()),
error: v.optional(v.string()),
asset_id: v.optional(v.id("audio_assets")),
},
handler: async (ctx, args) => {
const job = await ctx.db.get(args.job_id);
if (!job) {
throw new Error("job_not_found");
}
await ctx.db.patch(args.job_id, {
status: args.status ?? job.status,
credits_charged: args.credits_charged ?? job.credits_charged,
tts_provider: args.tts_provider ?? job.tts_provider,
tts_model: args.tts_model ?? job.tts_model,
error: args.error,
asset_id: args.asset_id ?? job.asset_id,
updated_at: nowIso(),
});
return ctx.db.get(args.job_id);
},
});
export const createAudioAsset = mutation({
args: {
job_id: v.id("audio_jobs"),
storage_key: v.optional(v.string()),
duration_sec: v.optional(v.number()),
size_bytes: v.optional(v.number()),
codec: v.optional(v.string()),
public_url_ttl: v.optional(v.number()),
},
handler: async (ctx, args) => {
const job = await ctx.db.get(args.job_id);
if (!job) {
throw new Error("job_not_found");
}
const timestamp = nowIso();
const assetId = await ctx.db.insert("audio_assets", {
job_id: args.job_id,
storage_key: args.storage_key,
duration_sec: args.duration_sec,
size_bytes: args.size_bytes,
codec: args.codec,
public_url_ttl: args.public_url_ttl,
created_at: timestamp,
updated_at: timestamp,
});
await ctx.db.patch(args.job_id, {
asset_id: assetId,
updated_at: timestamp,
});
return assetId;
},
});
export const updateAudioAsset = mutation({
args: {
asset_id: v.id("audio_assets"),
storage_key: v.optional(v.string()),
duration_sec: v.optional(v.number()),
size_bytes: v.optional(v.number()),
codec: v.optional(v.string()),
public_url_ttl: v.optional(v.number()),
deleted_at: v.optional(v.string()),
last_played_at: v.optional(v.string()),
},
handler: async (ctx, args) => {
const asset = await ctx.db.get(args.asset_id);
if (!asset) {
throw new Error("asset_not_found");
}
await ctx.db.patch(args.asset_id, {
storage_key: args.storage_key ?? asset.storage_key,
duration_sec: args.duration_sec ?? asset.duration_sec,
size_bytes: args.size_bytes ?? asset.size_bytes,
codec: args.codec ?? asset.codec,
public_url_ttl: args.public_url_ttl ?? asset.public_url_ttl,
deleted_at: args.deleted_at,
last_played_at: args.last_played_at,
updated_at: nowIso(),
});
return ctx.db.get(args.asset_id);
},
});
export const grantAudioAccess = mutation({
args: {
audio_asset_id: v.id("audio_assets"),
user_id: v.id("users"),
granted_via: v.union(v.literal("owner"), v.literal("repurchase"), v.literal("admin")),
credits_paid: v.number(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("audio_access_grants")
.withIndex("by_asset_and_user", (q) => q.eq("audio_asset_id", args.audio_asset_id).eq("user_id", args.user_id))
.first();
if (existing) {
return { id: existing._id, deduped: true };
}
const id = await ctx.db.insert("audio_access_grants", {
audio_asset_id: args.audio_asset_id,
user_id: args.user_id,
granted_via: args.granted_via,
credits_paid: args.credits_paid,
created_at: nowIso(),
});
return { id, deduped: false };
},
});
export const hasAudioAccess = query({
args: {
audio_asset_id: v.id("audio_assets"),
user_id: v.optional(v.id("users")),
},
handler: async (ctx, args) => {
const asset = await ctx.db.get(args.audio_asset_id);
if (!asset) {
return { allowed: false, reason: "not_found" };
}
const job = await ctx.db.get(asset.job_id);
if (!job) {
return { allowed: false, reason: "job_not_found" };
}
if (!args.user_id) {
return { allowed: false, reason: "auth_required", credits_required: job.credits_charged };
}
if (job.user_id === args.user_id) {
return { allowed: true, reason: "owner", credits_required: 0 };
}
const grant = await ctx.db
.query("audio_access_grants")
.withIndex("by_asset_and_user", (q) => q.eq("audio_asset_id", args.audio_asset_id).eq("user_id", args.user_id!))
.first();
if (grant) {
return { allowed: true, reason: "grant", credits_required: 0 };
}
return {
allowed: false,
reason: "payment_required",
credits_required: job.credits_charged,
};
},
});
export const recordPaymentEvent = mutation({
args: {
provider: v.string(),
provider_event_id: v.string(),
status: v.string(),
payload_hash: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("payment_events")
.withIndex("by_provider_and_event", (q) => q.eq("provider", args.provider).eq("provider_event_id", args.provider_event_id))
.first();
const updated_at = nowIso();
if (existing) {
await ctx.db.patch(existing._id, {
status: args.status,
payload_hash: args.payload_hash,
updated_at,
});
return { id: existing._id, deduped: true };
}
const id = await ctx.db.insert("payment_events", {
provider: args.provider,
provider_event_id: args.provider_event_id,
status: args.status,
payload_hash: args.payload_hash,
created_at: updated_at,
updated_at,
});
return { id, deduped: false };
},
});
export const getJob = query({
args: { job_id: v.id("audio_jobs") },
handler: async (ctx, args) => {
const job = await ctx.db.get(args.job_id);
if (!job) {
return null;
}
const article = await ctx.db.get(job.article_id);
const asset = job.asset_id ? await ctx.db.get(job.asset_id) : null;
return {
job,
article,
asset,
};
},
});
export const listJobsForUser = query({
args: { user_id: v.id("users") },
handler: async (ctx, args) => {
const jobs = await ctx.db
.query("audio_jobs")
.withIndex("by_user_id", (q) => q.eq("user_id", args.user_id))
.collect();
jobs.sort((a, b) => (a.created_at > b.created_at ? -1 : 1));
return jobs;
},
});
async function clearTable(ctx: any, tableName: string) {
const rows = await (ctx.db as any).query(tableName).collect();
for (const row of rows) {
await (ctx.db as any).delete(row._id);
}
}
function normalizeStatus(rawStatus: string | undefined) {
const allowed = new Set([
"received",
"validated",
"priced",
"charged",
"synthesizing",
"uploaded",
"completed",
"failed_refunded",
"failed_not_refunded",
]);
if (rawStatus && allowed.has(rawStatus)) {
return rawStatus;
}
return "received";
}
export async function syncFromEngineSnapshot(ctx: any, rawSnapshot: any) {
const engine = rawSnapshot && typeof rawSnapshot === "object" && rawSnapshot.engine
? rawSnapshot.engine
: rawSnapshot;
if (!engine || typeof engine !== "object") {
return { imported: false, reason: "missing_engine_snapshot" };
}
const walletsState = engine.wallets || {};
const balances = walletsState.balances && typeof walletsState.balances === "object"
? walletsState.balances
: {};
const walletTransactions = Array.isArray(walletsState.transactions)
? walletsState.transactions
: [];
const jobsById = engine.jobs && typeof engine.jobs === "object" ? engine.jobs : {};
const assetsById = engine.assets && typeof engine.assets === "object" ? engine.assets : {};
const mentionsByPostId = engine.mentions && typeof engine.mentions === "object" ? engine.mentions : {};
const grantsByAssetId = engine.access && engine.access.grants && typeof engine.access.grants === "object"
? engine.access.grants
: {};
await clearTable(ctx, "audio_access_grants");
await clearTable(ctx, "audio_assets");
await clearTable(ctx, "audio_jobs");
await clearTable(ctx, "articles");
await clearTable(ctx, "mention_events");
await clearTable(ctx, "wallet_transactions");
await clearTable(ctx, "wallets");
await clearTable(ctx, "payment_events");
await clearTable(ctx, "users");
const userKeys = new Set<string>();
for (const key of Object.keys(balances)) {
userKeys.add(String(key));
}
for (const tx of walletTransactions) {
if (tx && tx.userId) {
userKeys.add(String(tx.userId));
}
}
for (const job of Object.values(jobsById)) {
if (job && (job as any).callerUserId) {
userKeys.add(String((job as any).callerUserId));
}
}
for (const asset of Object.values(assetsById)) {
if (asset && (asset as any).ownerUserId) {
userKeys.add(String((asset as any).ownerUserId));
}
}
for (const users of Object.values(grantsByAssetId)) {
if (!Array.isArray(users)) {
continue;
}
for (const user of users) {
if (user) {
userKeys.add(String(user));
}
}
}
const userIdByLegacy = new Map<string, any>();
for (const userKey of Array.from(userKeys.values()).sort()) {
const ts = nowIso();
const userId = await (ctx.db as any).insert("users", {
username: userKey,
email: userKey.includes("@") ? userKey : undefined,
x_user_id: undefined,
created_at: ts,
updated_at: ts,
});
userIdByLegacy.set(userKey, userId);
}
for (const [legacyUser, userId] of userIdByLegacy.entries()) {
await (ctx.db as any).insert("wallets", {
user_id: userId,
balance_credits: Number.isInteger((balances as any)[legacyUser]) ? (balances as any)[legacyUser] : 0,
updated_at: nowIso(),
});
}
walletTransactions.sort((a: any, b: any) => {
const aTime = String(a && a.createdAt ? a.createdAt : "");
const bTime = String(b && b.createdAt ? b.createdAt : "");
return aTime.localeCompare(bTime);
});
for (const tx of walletTransactions) {
if (!tx || !tx.userId || !userIdByLegacy.has(String(tx.userId))) {
continue;
}
await (ctx.db as any).insert("wallet_transactions", {
user_id: userIdByLegacy.get(String(tx.userId)),
type: tx.type,
amount: Number.isInteger(tx.amount) ? tx.amount : 0,
reason: tx.reason || undefined,
idempotency_key: String(tx.idempotencyKey || `legacy-tx:${tx.id || nowIso()}`),
balance_after: Number.isInteger(tx.balanceAfter) ? tx.balanceAfter : 0,
created_at: String(tx.createdAt || nowIso()),
});
}
const mentionIdByPost = new Map<string, any>();
for (const [mentionPostId, legacyJobId] of Object.entries(mentionsByPostId)) {
const job = (jobsById as any)[legacyJobId];
const id = await (ctx.db as any).insert("mention_events", {
mention_post_id: mentionPostId,
mention_author_id: String((job && job.callerUserId) || "unknown"),
parent_post_id: String((job && job.article && job.article.parentPostId) || "unknown"),
status: normalizeStatus(job && job.status),
error_code: job && job.error ? String(job.error) : undefined,
created_at: String((job && job.createdAt) || nowIso()),
updated_at: String((job && job.updatedAt) || nowIso()),
});
mentionIdByPost.set(mentionPostId, id);
}
const articleIdByLegacyKey = new Map<string, any>();
const articleIdByJobId = new Map<string, any>();
for (const [legacyJobId, rawJob] of Object.entries(jobsById)) {
const job: any = rawJob || {};
const article = job.article || {};
const legacyArticleKey = String(article.xArticleId || article.parentPostId || `job:${legacyJobId}`);
if (!articleIdByLegacyKey.has(legacyArticleKey)) {
const id = await (ctx.db as any).insert("articles", {
x_article_id: article.xArticleId || undefined,
parent_post_id: String(article.parentPostId || `legacy-parent:${legacyJobId}`),
author_id: article.authorId || undefined,
title: String(article.title || "Untitled"),
char_count: Number.isInteger(article.charCount)
? article.charCount
: String(article.content || "").length,
content_hash: article.contentHash || undefined,
raw_content: typeof article.content === "string" ? article.content : undefined,
created_at: String(job.createdAt || nowIso()),
updated_at: String(job.updatedAt || nowIso()),
});
articleIdByLegacyKey.set(legacyArticleKey, id);
}
articleIdByJobId.set(legacyJobId, articleIdByLegacyKey.get(legacyArticleKey));
}
const legacyJobByAssetId = new Map<string, string>();
const jobIdByLegacy = new Map<string, any>();
const sortedJobs = Object.entries(jobsById).sort(([, aRaw], [, bRaw]) => {
const a = aRaw as any;
const b = bRaw as any;
const aTime = String(a && a.createdAt ? a.createdAt : "");
const bTime = String(b && b.createdAt ? b.createdAt : "");
return aTime.localeCompare(bTime);
});
for (const [legacyJobId, rawJob] of sortedJobs) {
const job: any = rawJob || {};
if (job.assetId) {
legacyJobByAssetId.set(String(job.assetId), legacyJobId);
}
const caller = String(job.callerUserId || "");
const userId = userIdByLegacy.get(caller);
if (!userId) {
continue;
}
const id = await (ctx.db as any).insert("audio_jobs", {
user_id: userId,
mention_event_id: mentionIdByPost.get(String(job.mentionPostId || "")) || undefined,
article_id: articleIdByJobId.get(legacyJobId),
status: normalizeStatus(job.status),
credits_charged: Number.isInteger(job.creditsCharged) ? job.creditsCharged : 0,
tts_provider: undefined,
tts_model: undefined,
error: job.error || undefined,
created_at: String(job.createdAt || nowIso()),
updated_at: String(job.updatedAt || nowIso()),
});
jobIdByLegacy.set(legacyJobId, id);
}
const assetIdByLegacy = new Map<string, any>();
for (const [legacyAssetId, rawAsset] of Object.entries(assetsById)) {
const asset: any = rawAsset || {};
const legacyJobId = legacyJobByAssetId.get(legacyAssetId);
const jobId = legacyJobId ? jobIdByLegacy.get(legacyJobId) : null;
if (!jobId) {
continue;
}
const id = await (ctx.db as any).insert("audio_assets", {
job_id: jobId,
storage_key: asset.storageKey || undefined,
duration_sec: Number.isInteger(asset.durationSec) ? asset.durationSec : undefined,
size_bytes: Number.isInteger(asset.sizeBytes) ? asset.sizeBytes : undefined,
codec: "mp3",
public_url_ttl: undefined,
deleted_at: asset.deletedAt || undefined,
last_played_at: asset.lastPlayedAt || undefined,
created_at: String(asset.createdAt || nowIso()),
updated_at: String(asset.updatedAt || asset.createdAt || nowIso()),
});
assetIdByLegacy.set(legacyAssetId, id);
await (ctx.db as any).patch(jobId, {
asset_id: id,
updated_at: nowIso(),
});
}
for (const [legacyAssetId, users] of Object.entries(grantsByAssetId)) {
if (!Array.isArray(users)) {
continue;
}
const assetId = assetIdByLegacy.get(legacyAssetId);
if (!assetId) {
continue;
}
const legacyJobId = legacyJobByAssetId.get(legacyAssetId);
const job = legacyJobId ? (jobsById as any)[legacyJobId] : null;
const owner = job && job.callerUserId ? String(job.callerUserId) : null;
const creditsCharged = job && Number.isInteger(job.creditsCharged) ? job.creditsCharged : 0;
for (const legacyUserId of users) {
const userId = userIdByLegacy.get(String(legacyUserId));
if (!userId) {
continue;
}
const isOwner = owner && owner === String(legacyUserId);
await (ctx.db as any).insert("audio_access_grants", {
audio_asset_id: assetId,
user_id: userId,
granted_via: isOwner ? "owner" : "repurchase",
credits_paid: isOwner ? 0 : creditsCharged,
created_at: nowIso(),
});
}
}
return {
imported: true,
users: userIdByLegacy.size,
wallets: userIdByLegacy.size,
wallet_transactions: walletTransactions.length,
mention_events: mentionIdByPost.size,
articles: articleIdByLegacyKey.size,
audio_jobs: jobIdByLegacy.size,
audio_assets: assetIdByLegacy.size,
};
}
export const syncFromEngineSnapshotMutation = mutation({
args: {
snapshot: v.any(),
},
handler: async (ctx, args) => syncFromEngineSnapshot(ctx, args.snapshot),
});

View File

@@ -2,6 +2,127 @@ import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
x_user_id: v.optional(v.string()),
username: v.optional(v.string()),
email: v.optional(v.string()),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_x_user_id", ["x_user_id"])
.index("by_username", ["username"])
.index("by_email", ["email"]),
wallets: defineTable({
user_id: v.id("users"),
balance_credits: v.number(),
updated_at: v.string(),
}).index("by_user_id", ["user_id"]),
wallet_transactions: defineTable({
user_id: v.id("users"),
type: v.union(v.literal("credit"), v.literal("debit"), v.literal("refund")),
amount: v.number(),
reason: v.optional(v.string()),
idempotency_key: v.string(),
balance_after: v.number(),
created_at: v.string(),
})
.index("by_user_id", ["user_id"])
.index("by_idempotency_key", ["idempotency_key"]),
mention_events: defineTable({
mention_post_id: v.string(),
mention_author_id: v.string(),
parent_post_id: v.string(),
status: v.string(),
error_code: v.optional(v.string()),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_mention_post_id", ["mention_post_id"])
.index("by_parent_post_id", ["parent_post_id"])
.index("by_mention_author_id", ["mention_author_id"]),
articles: defineTable({
x_article_id: v.optional(v.string()),
parent_post_id: v.string(),
author_id: v.optional(v.string()),
title: v.string(),
char_count: v.number(),
content_hash: v.optional(v.string()),
raw_content: v.optional(v.string()),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_x_article_id", ["x_article_id"])
.index("by_parent_post_id", ["parent_post_id"])
.index("by_content_hash", ["content_hash"]),
audio_jobs: defineTable({
user_id: v.id("users"),
mention_event_id: v.optional(v.id("mention_events")),
article_id: v.id("articles"),
status: v.union(
v.literal("received"),
v.literal("validated"),
v.literal("priced"),
v.literal("charged"),
v.literal("synthesizing"),
v.literal("uploaded"),
v.literal("completed"),
v.literal("failed_refunded"),
v.literal("failed_not_refunded"),
),
credits_charged: v.number(),
tts_provider: v.optional(v.string()),
tts_model: v.optional(v.string()),
error: v.optional(v.string()),
asset_id: v.optional(v.id("audio_assets")),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_user_id", ["user_id"])
.index("by_mention_event_id", ["mention_event_id"])
.index("by_article_id", ["article_id"])
.index("by_status", ["status"]),
audio_assets: defineTable({
job_id: v.id("audio_jobs"),
storage_key: v.optional(v.string()),
duration_sec: v.optional(v.number()),
size_bytes: v.optional(v.number()),
codec: v.optional(v.string()),
public_url_ttl: v.optional(v.number()),
deleted_at: v.optional(v.string()),
last_played_at: v.optional(v.string()),
created_at: v.string(),
updated_at: v.string(),
}).index("by_job_id", ["job_id"]),
audio_access_grants: defineTable({
audio_asset_id: v.id("audio_assets"),
user_id: v.id("users"),
granted_via: v.union(v.literal("owner"), v.literal("repurchase"), v.literal("admin")),
credits_paid: v.number(),
created_at: v.string(),
})
.index("by_audio_asset_id", ["audio_asset_id"])
.index("by_user_id", ["user_id"])
.index("by_asset_and_user", ["audio_asset_id", "user_id"]),
payment_events: defineTable({
provider: v.string(),
provider_event_id: v.string(),
status: v.string(),
payload_hash: v.optional(v.string()),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_provider_event_id", ["provider_event_id"])
.index("by_provider_and_event", ["provider", "provider_event_id"]),
// Legacy compatibility table while runtime is still transitioning away from snapshot persistence.
state_snapshots: defineTable({
snapshot: v.any(),
updatedAt: v.string(),

View File

@@ -1,5 +1,6 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { syncFromEngineSnapshot } from "./domain";
export const getLatestSnapshot = query({
args: {},
@@ -22,6 +23,7 @@ export const saveSnapshot = mutation({
args: {
snapshot: v.any(),
updatedAt: v.string(),
syncToDomain: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const latest = await ctx.db
@@ -29,17 +31,29 @@ export const saveSnapshot = mutation({
.order("desc")
.first();
const shouldSync = Boolean(args.syncToDomain);
const syncSummary = shouldSync
? await syncFromEngineSnapshot(ctx, args.snapshot)
: null;
if (latest) {
await ctx.db.patch(latest._id, {
snapshot: args.snapshot,
updatedAt: args.updatedAt,
});
return latest._id;
return {
snapshotId: latest._id,
syncSummary,
};
}
return ctx.db.insert("state_snapshots", {
const snapshotId = await ctx.db.insert("state_snapshots", {
snapshot: args.snapshot,
updatedAt: args.updatedAt,
});
return {
snapshotId,
syncSummary,
};
},
});

View File

@@ -73,6 +73,9 @@ function buildApp({
});
const rateLimits = config.rateLimits || {};
const abusePolicy = config.abuse || {};
const devRoutesEnabled = config.enableDevRoutes !== undefined
? Boolean(config.enableDevRoutes)
: true;
const polar = polarAdapter || createPolarAdapter({
accessToken: config.polarAccessToken,
server: config.polarServer,
@@ -126,20 +129,15 @@ function buildApp({
windowMs: 60_000,
});
function persistMutation() {
async function persistMutation() {
if (!onMutation) {
return;
}
try {
onMutation({
await onMutation({
version: 1,
updatedAt: new Date().toISOString(),
engine: engine.exportState(),
});
} catch (error) {
logger.error({ err: error }, "failed to persist mutation");
}
}
function clientAddressFromHeaders(headers) {
@@ -174,7 +172,9 @@ function buildApp({
if (!generationService || !generationService.isConfigured()) {
try {
engine.completeJob(job.id);
persistMutation();
void persistMutation().catch((error) => {
logger.error({ err: error, jobId: job.id }, "failed to persist completion without generation worker");
});
} catch (error) {
logger.error({ err: error, jobId: job.id }, "failed to mark job as completed without generation worker");
}
@@ -183,7 +183,9 @@ function buildApp({
try {
engine.startJob(job.id);
persistMutation();
void persistMutation().catch((error) => {
logger.error({ err: error, jobId: job.id }, "failed to persist job start");
});
} catch (error) {
logger.error({ err: error, jobId: job.id }, "failed to start audio generation job");
return;
@@ -196,8 +198,13 @@ function buildApp({
onCompleted: (audioMeta) => {
try {
engine.completeJob(job.id, audioMeta);
persistMutation();
void persistMutation()
.then(() => {
logger.info({ assetId: job.assetId, jobId: job.id }, "audio generation completed");
})
.catch((error) => {
logger.error({ err: error, assetId: job.assetId, jobId: job.id }, "failed to persist completed job");
});
} catch (error) {
logger.error({ err: error, assetId: job.assetId }, "failed to apply generated audio metadata");
}
@@ -208,7 +215,9 @@ function buildApp({
error: error && error.message ? error.message : "audio_generation_failed",
refund: true,
});
persistMutation();
void persistMutation().catch((persistError) => {
logger.error({ err: persistError, jobId: job.id }, "failed to persist failed job state");
});
} catch (failureError) {
logger.error({ err: failureError, jobId: job.id }, "failed to mark generation failure");
}
@@ -243,6 +252,49 @@ function buildApp({
return null;
}
function enforceBrowserCsrf(headers) {
const secFetchSite = headers["sec-fetch-site"];
if (secFetchSite === "cross-site") {
return json(403, { error: "csrf_blocked" });
}
const expectedOrigin = (() => {
try {
return new URL(config.appBaseUrl).origin;
} catch {
return null;
}
})();
if (!expectedOrigin) {
return null;
}
const originHeader = headers.origin || null;
if (originHeader) {
try {
if (new URL(originHeader).origin !== expectedOrigin) {
return json(403, { error: "invalid_origin" });
}
} catch {
return json(403, { error: "invalid_origin" });
}
}
const refererHeader = headers.referer || null;
if (refererHeader) {
try {
if (new URL(refererHeader).origin !== expectedOrigin) {
return json(403, { error: "invalid_referer" });
}
} catch {
return json(403, { error: "invalid_referer" });
}
}
return null;
}
function getAbuseDecision(callerUserId) {
if (!callerUserId) {
return { allowed: true };
@@ -362,12 +414,29 @@ function buildApp({
}
async function handleXWebhook(headers, rawBody) {
const signature = headers["x-signature"];
const isValid = verifySignature({
const twitterSignature = headers["x-twitter-webhooks-signature"];
const legacySignature = headers["x-signature"];
let isValid = false;
if (twitterSignature) {
isValid = verifySignature({
payload: rawBody,
secret: config.xWebhookSecret,
signature,
signature: twitterSignature,
encoding: "base64",
});
} else if (legacySignature) {
const normalized = legacySignature.startsWith("sha256=")
? legacySignature.slice("sha256=".length)
: legacySignature;
const looksLikeHex = /^[a-f0-9]+$/i.test(normalized);
isValid = verifySignature({
payload: rawBody,
secret: config.xWebhookSecret,
signature: legacySignature,
encoding: looksLikeHex ? "hex" : "base64",
});
}
if (!isValid) {
return json(401, { error: "invalid_signature" });
@@ -392,7 +461,7 @@ function buildApp({
});
}
persistMutation();
await persistMutation();
scheduleAudioGeneration(result.job);
const replyMessage = result.reply
? result.reply.message
@@ -413,7 +482,7 @@ function buildApp({
}
}
function handlePolarWebhook(headers, rawBody) {
async function handlePolarWebhook(headers, rawBody) {
try {
let payload;
@@ -441,7 +510,7 @@ function buildApp({
}
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
persistMutation();
await persistMutation();
return json(200, { status: "credited" });
} catch (error) {
logger.warn({ err: error }, "polar webhook request failed");
@@ -523,6 +592,10 @@ function buildApp({
}
if (method === "POST" && path === "/auth/email/sign-in") {
const csrf = enforceBrowserCsrf(safeHeaders);
if (csrf) {
return csrf;
}
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
if (rateLimited) {
return rateLimited;
@@ -549,6 +622,10 @@ function buildApp({
}
if (method === "POST" && path === "/auth/email/sign-up") {
const csrf = enforceBrowserCsrf(safeHeaders);
if (csrf) {
return csrf;
}
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
if (rateLimited) {
return rateLimited;
@@ -575,6 +652,10 @@ function buildApp({
}
if (method === "POST" && path === "/auth/x") {
const csrf = enforceBrowserCsrf(safeHeaders);
if (csrf) {
return csrf;
}
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
if (rateLimited) {
return rateLimited;
@@ -605,6 +686,10 @@ function buildApp({
}
if (method === "POST" && path === "/auth/logout") {
const csrf = enforceBrowserCsrf(safeHeaders);
if (csrf) {
return csrf;
}
const signOut = await auth.signOut(safeHeaders);
return redirect("/", signOut.setCookie
? { "set-cookie": signOut.setCookie }
@@ -622,10 +707,18 @@ function buildApp({
summary: engine.getUserSummary(userId),
jobs: engine.listJobsForUser(userId),
flash: safeQuery.flash || null,
showDeveloperActions: devRoutesEnabled,
}));
}
if (method === "POST" && path === "/app/actions/topup") {
if (!devRoutesEnabled) {
return json(404, { error: "not_found" });
}
const csrf = enforceBrowserCsrf(safeHeaders);
if (csrf) {
return csrf;
}
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app");
if (rateLimited) {
return rateLimited;
@@ -645,11 +738,18 @@ function buildApp({
}
engine.topUpCredits(userId, amount, `app-topup:${userId}:${randomUUID()}`);
persistMutation();
await persistMutation();
return redirect(withQuery("/app", { flash: `Added ${amount} credits` }));
}
if (method === "POST" && path === "/app/actions/simulate-mention") {
if (!devRoutesEnabled) {
return json(404, { error: "not_found" });
}
const csrf = enforceBrowserCsrf(safeHeaders);
if (csrf) {
return csrf;
}
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app");
if (rateLimited) {
return rateLimited;
@@ -701,7 +801,7 @@ function buildApp({
return redirect(withQuery("/app", { flash: "Parent post is not an article" }));
}
persistMutation();
await persistMutation();
scheduleAudioGeneration(result.job);
return redirect(withQuery(`/audio/${result.job.assetId}`, {
flash: "Audiobook generated",
@@ -712,6 +812,10 @@ function buildApp({
}
if (method === "POST" && path.startsWith("/audio/") && path.endsWith("/unlock")) {
const csrf = enforceBrowserCsrf(safeHeaders);
if (csrf) {
return csrf;
}
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, path.replace("/unlock", ""));
if (rateLimited) {
return rateLimited;
@@ -725,7 +829,7 @@ function buildApp({
try {
engine.unlockAudio(assetId, userId);
persistMutation();
await persistMutation();
return redirect(withQuery(`/audio/${assetId}`, { flash: "Unlocked" }));
} catch (error) {
return redirect(withQuery(`/audio/${assetId}`, { flash: `Unlock failed: ${error.message}` }));
@@ -797,6 +901,11 @@ function buildApp({
}
if (method === "GET" && path === "/api/x/mentions") {
const authResponse = ensureInternalAuth(safeHeaders);
if (authResponse) {
return authResponse;
}
if (!x.isConfigured()) {
return json(503, { error: "x_api_not_configured" });
}
@@ -821,7 +930,7 @@ function buildApp({
rawArticleHours: Number.isFinite(payload.rawArticleHours) ? payload.rawArticleHours : 24,
audioDays: Number.isFinite(payload.audioDays) ? payload.audioDays : 90,
});
persistMutation();
await persistMutation();
return json(200, { status: "ok", summary });
}
@@ -830,7 +939,7 @@ function buildApp({
if (rateLimited) {
return rateLimited;
}
return handlePolarWebhook(safeHeaders, rawBody);
return await handlePolarWebhook(safeHeaders, rawBody);
}
if (method === "POST" && path === "/api/payments/create-checkout") {
@@ -895,7 +1004,7 @@ function buildApp({
const assetId = path.slice("/api/audio/".length, -"/unlock".length);
try {
const result = engine.unlockAudio(assetId, userId);
persistMutation();
await persistMutation();
return json(200, result);
} catch (error) {
return json(400, { error: error.message });
@@ -910,7 +1019,7 @@ function buildApp({
const assetId = path.slice("/api/audio/".length);
try {
const deleted = engine.takedownAudio(assetId, userId);
persistMutation();
await persistMutation();
return json(200, { status: "deleted", assetId: deleted.id });
} catch (error) {
const status = error.message === "forbidden" ? 403 : 400;
@@ -927,7 +1036,7 @@ function buildApp({
const jobId = path.slice("/internal/jobs/".length, -"/start".length);
try {
const job = engine.startJob(jobId);
persistMutation();
await persistMutation();
return json(200, { job });
} catch (error) {
return json(400, { error: error.message });
@@ -945,7 +1054,7 @@ function buildApp({
try {
const job = engine.completeJob(jobId, payload.asset || {});
persistMutation();
await persistMutation();
return json(200, { job });
} catch (error) {
return json(400, { error: error.message });
@@ -967,7 +1076,7 @@ function buildApp({
error: payload.error || "generation_failed",
refund: shouldRefund,
});
persistMutation();
await persistMutation();
return json(200, { job });
} catch (error) {
return json(400, { error: error.message });

View File

@@ -49,6 +49,7 @@ function boolFromEnv(name, fallback) {
}
const parsed = {
nodeEnv: strFromEnv("NODE_ENV", "development"),
port: intFromEnv("PORT", 3000),
logLevel: strFromEnv("LOG_LEVEL", "info"),
appBaseUrl: strFromEnv("APP_BASE_URL", "http://localhost:3000"),
@@ -100,7 +101,17 @@ const parsed = {
},
};
parsed.allowInMemoryStateFallback = boolFromEnv(
"ALLOW_IN_MEMORY_STATE_FALLBACK",
parsed.nodeEnv !== "production",
);
parsed.enableDevRoutes = boolFromEnv(
"ENABLE_DEV_ROUTES",
parsed.nodeEnv !== "production",
);
const ConfigSchema = z.object({
nodeEnv: z.string().min(1),
port: z.number().int().positive(),
logLevel: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]),
appBaseUrl: z.string().min(1),
@@ -150,6 +161,8 @@ const ConfigSchema = z.object({
stepCredits: z.number().int().positive(),
maxCharsPerArticle: z.number().int().positive(),
}),
allowInMemoryStateFallback: z.boolean(),
enableDevRoutes: z.boolean(),
});
const config = ConfigSchema.parse(parsed);

View File

@@ -2,14 +2,28 @@
const crypto = require("node:crypto");
function hmacSHA256Hex(payload, secret) {
function hmacSHA256(payload, secret, encoding = "hex") {
return crypto
.createHmac("sha256", secret)
.update(payload, "utf8")
.digest("hex");
.digest(encoding);
}
function verifySignature({ payload, secret, signature }) {
function hmacSHA256Hex(payload, secret) {
return hmacSHA256(payload, secret, "hex");
}
function hmacSHA256Base64(payload, secret) {
return hmacSHA256(payload, secret, "base64");
}
function verifySignature({
payload,
secret,
signature,
encoding = "hex",
prefix = "sha256=",
}) {
if (!secret) {
throw new Error("webhook_secret_required");
}
@@ -18,14 +32,15 @@ function verifySignature({ payload, secret, signature }) {
return false;
}
const normalized = signature.startsWith("sha256=")
? signature.slice("sha256=".length)
const normalized = prefix && signature.startsWith(prefix)
? signature.slice(prefix.length)
: signature;
const expected = hmacSHA256Hex(payload, secret);
const expected = hmacSHA256(payload, secret, encoding);
const binaryEncoding = encoding === "hex" ? "hex" : "base64";
const expectedBuf = Buffer.from(expected, "hex");
const givenBuf = Buffer.from(normalized, "hex");
const expectedBuf = Buffer.from(expected, binaryEncoding);
const givenBuf = Buffer.from(normalized, binaryEncoding);
if (expectedBuf.length !== givenBuf.length) {
return false;
@@ -35,6 +50,8 @@ function verifySignature({ payload, secret, signature }) {
}
module.exports = {
hmacSHA256,
hmacSHA256Hex,
hmacSHA256Base64,
verifySignature,
};

View File

@@ -64,13 +64,19 @@ function createHttpServer({ app }) {
function createMutationPersister({ stateStore, logger = console }) {
let queue = Promise.resolve();
let lastError = null;
return {
enqueue(state) {
queue = queue
.then(() => stateStore.save(state))
.then(
() => stateStore.save(state),
() => stateStore.save(state),
)
.catch((error) => {
lastError = error;
logger.error({ err: error }, "failed to persist state");
throw error;
});
return queue;
@@ -78,6 +84,9 @@ function createMutationPersister({ stateStore, logger = console }) {
flush() {
return queue;
},
getLastError() {
return lastError;
},
};
}
@@ -92,10 +101,15 @@ async function createRuntime({ runtimeConfig = config, logger = console, stateSt
try {
initialState = await effectiveStateStore.load();
} catch (error) {
logger.warn(
{ err: error },
"failed to initialize configured state store; falling back to in-memory state",
);
const allowFallback = runtimeConfig.allowInMemoryStateFallback !== undefined
? Boolean(runtimeConfig.allowInMemoryStateFallback)
: true;
if (!allowFallback) {
throw new Error("state_store_unavailable_without_fallback", { cause: error });
}
logger.warn({ err: error }, "failed to initialize configured state store; falling back to in-memory state");
effectiveStateStore = new InMemoryStateStore();
initialState = await effectiveStateStore.load();
}

View File

@@ -1,5 +1,10 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: dim --default;
themes: black --default, light;
logs: false;
}
@theme {
--font-sans: "Inter", sans-serif;
}

View File

@@ -9,28 +9,86 @@ function escapeHtml(value) {
.replaceAll("'", "&#39;");
}
function shell({ title, content, compact = false }) {
const container = compact ? "max-w-xl" : "max-w-5xl";
function shell({ title, content, compact = false, user = null }) {
const container = compact ? "max-w-md" : "max-w-5xl";
const authenticated = user?.authenticated;
const userId = user?.userId;
const authButtons = authenticated
? `<div class="flex items-center gap-3">
<span class="text-sm font-medium opacity-70 hidden sm:inline-block">@${escapeHtml(userId)}</span>
<form method="POST" action="/auth/logout">
<button class="btn btn-sm btn-ghost">Log out</button>
</form>
</div>`
: `<div class="flex items-center gap-2">
<a class="btn btn-sm btn-ghost" href="/login">Sign in</a>
<a class="btn btn-sm btn-primary shadow-sm" href="/login">Get Started</a>
</div>`;
const menuItems = `
<li><a href="/#how">How it works</a></li>
<li><a href="/#pricing">Pricing</a></li>
<li><a href="/app">Dashboard</a></li>
`;
return `<!doctype html>
<html lang="en">
<html lang="en" class="scroll-smooth">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<meta name="theme-color" content="#0f172a" />
<meta name="theme-color" content="#000000" />
<link rel="manifest" href="/manifest.webmanifest" />
<link href="/assets/styles.css" rel="stylesheet" type="text/css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<div class="fixed inset-0 -z-10 overflow-hidden">
<div class="absolute -top-24 -left-20 h-72 w-72 rounded-full bg-cyan-500/20 blur-3xl"></div>
<div class="absolute top-40 -right-20 h-80 w-80 rounded-full bg-emerald-500/20 blur-3xl"></div>
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(30,41,59,.65),rgba(2,6,23,1)_45%)]"></div>
<body class="min-h-screen bg-base-100 text-base-content antialiased flex flex-col">
<div class="fixed inset-0 -z-10 bg-base-100 selection:bg-primary selection:text-primary-content"></div>
<nav class="navbar sticky top-0 z-50 bg-base-100/80 backdrop-blur-md border-b border-base-content/5 px-4 md:px-8">
<div class="navbar-start">
<a href="/" class="btn btn-ghost text-xl font-bold tracking-tight px-0 hover:bg-transparent">XArtAudio</a>
</div>
<main class="${container} mx-auto px-4 py-6 md:py-10">
<div class="navbar-center hidden md:flex">
<ul class="menu menu-horizontal px-1 gap-2 font-medium text-sm">
${menuItems}
</ul>
</div>
<div class="navbar-end gap-2">
<div class="hidden md:flex">
${authButtons}
</div>
<div class="dropdown dropdown-end md:hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" /></svg>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-50 p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-content/10">
${menuItems}
<li class="mt-2 border-t border-base-content/10 pt-2"></li>
${authenticated
? `<li><span class="text-xs opacity-50">@${escapeHtml(userId)}</span></li>
<li><form method="POST" action="/auth/logout" class="w-full"><button class="w-full text-left">Log out</button></form></li>`
: `<li><a href="/login">Sign in</a></li>
<li><a href="/login" class="font-bold text-primary">Get Started</a></li>`
}
</ul>
</div>
</div>
</nav>
<main class="flex-grow w-full ${container} mx-auto px-4 py-8 md:py-12">
${content}
</main>
<footer class="footer footer-center p-10 bg-base-200 text-base-content rounded-t-2xl mt-auto max-w-5xl mx-auto opacity-80">
<aside>
<p class="font-bold">XArtAudio <br/>Turning articles into audio since 2024</p>
<p>Copyright © ${new Date().getFullYear()} - All right reserved</p>
</aside>
</footer>
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
@@ -40,113 +98,103 @@ function shell({ title, content, compact = false }) {
</html>`;
}
function nav({ authenticated, userId }) {
const right = authenticated
? `<div class="flex items-center gap-2"><span class="badge badge-outline">@${escapeHtml(userId)}</span><form method="POST" action="/auth/logout"><button class="btn btn-xs btn-ghost">Log out</button></form></div>`
: `<a class="btn btn-sm btn-primary" href="/login">Get started</a>`;
const menuItems = `
<li><a href="/#how">How it works</a></li>
<li><a href="/#pricing">Pricing</a></li>
<li><a href="/app">Dashboard</a></li>
`;
return `<div class="navbar bg-base-100 shadow-sm rounded-box mb-6 md:mb-8">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /> </svg>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
${menuItems}
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl font-bold tracking-tight">XArtAudio</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
${menuItems}
</ul>
</div>
<div class="navbar-end">${right}</div>
</div>`;
}
function renderLandingPage({ authenticated, userId }) {
const primaryCta = authenticated
? `<a href="/app" class="btn btn-primary btn-lg">Open Dashboard</a>`
: `<a href="/login" class="btn btn-primary btn-lg">Start Free</a>`;
? `<a href="/app" class="btn btn-primary btn-lg font-bold shadow-lg hover:shadow-xl transition-all">Open Dashboard</a>`
: `<div class="flex flex-col sm:flex-row gap-4 items-center justify-center">
<a href="/login" class="btn btn-primary btn-lg font-bold shadow-lg hover:shadow-xl transition-all">Start for Free</a>
<a href="#how" class="btn btn-ghost btn-lg group">Learn more <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg></a>
</div>`;
return shell({
title: "XArtAudio | Turn X Articles into Audiobooks",
user: { authenticated, userId },
content: `
${nav({ authenticated, userId })}
<section class="hero min-h-[60vh] bg-base-200 rounded-box mb-12">
<div class="hero-content flex-col lg:flex-row-reverse gap-8 p-8">
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
<div class="card-body">
<h2 class="card-title justify-center mb-4">Live flow</h2>
<ul class="steps steps-vertical w-full">
<li class="step step-primary">User mentions <code>@YourBot</code></li>
<li class="step step-primary">Webhook charges credits</li>
<li class="step step-primary">Audio generated</li>
<li class="step">Unlock &amp; Listen</li>
</ul>
<section class="py-16 md:py-24 text-center max-w-3xl mx-auto">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-base-200/50 border border-base-content/5 text-sm font-medium mb-8 text-base-content/70">
<span class="badge badge-primary badge-xs">New</span> Webhook-first automation
</div>
</div>
<div class="text-center lg:text-left">
<span class="badge badge-accent mb-4">Webhook-first automation</span>
<h1 class="text-5xl font-bold">From X Article to audiobook.</h1>
<p class="py-6 text-lg">Mention the bot under a long-form X Article. We verify it, convert it, and return an access-controlled public link.</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
<h1 class="text-5xl md:text-7xl font-extrabold tracking-tight mb-6 bg-gradient-to-br from-base-content to-base-content/50 bg-clip-text text-transparent">
Listen to X Articles.
</h1>
<p class="text-xl md:text-2xl text-base-content/70 mb-10 leading-relaxed max-w-2xl mx-auto">
Mention our bot under any long-form X post. We'll convert it to audio and send you a link instantly.
</p>
${primaryCta}
<a href="/app" class="btn btn-outline btn-lg">See Product</a>
<p class="mt-6 text-sm text-base-content/40 font-mono">No credit card required • ${escapeHtml(userId ? `Logged in as @${userId}` : "Public access valid")}</p>
</section>
<section id="how" class="py-16 border-t border-base-content/5">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold mb-4">How it works</h2>
<p class="text-base-content/60 max-w-2xl mx-auto">Three simple steps to turn reading into listening.</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
<div class="card bg-base-100 border border-base-content/10 hover:border-base-content/20 transition-colors">
<div class="card-body items-center text-center">
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
</div>
<h3 class="font-bold text-lg mb-2">1. Mention the bot</h3>
<p class="text-base-content/70">Reply to any long article on X with <code class="bg-base-200 px-1 py-0.5 rounded">@XArtAudio</code>.</p>
</div>
</div>
<div class="card bg-base-100 border border-base-content/10 hover:border-base-content/20 transition-colors">
<div class="card-body items-center text-center">
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" /></svg>
</div>
<h3 class="font-bold text-lg mb-2">2. Processing</h3>
<p class="text-base-content/70">Our webhook verifies the article and generates high-quality audio.</p>
</div>
</div>
<div class="card bg-base-100 border border-base-content/10 hover:border-base-content/20 transition-colors">
<div class="card-body items-center text-center">
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<h3 class="font-bold text-lg mb-2">3. Listen</h3>
<p class="text-base-content/70">Receive a link to your audiobook. Listen anywhere, anytime.</p>
</div>
<p class="text-xs text-base-content/60 mt-4">Caller pays credits. ID: ${escapeHtml(userId || 'guest')}</p>
</div>
</div>
</section>
<section id="how" class="grid gap-6 md:grid-cols-3 mb-12">
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">For creators</h2>
<p>Call the bot from X and track generated audiobooks in your dashboard.</p>
<section id="pricing" class="py-16 border-t border-base-content/5">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold mb-4">Simple Pricing</h2>
<p class="text-base-content/60">Pay only for what you convert. Credits never expire.</p>
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">For listeners</h2>
<p>Open a public link, authenticate, then unlock once for permanent access.</p>
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">For teams</h2>
<p>Credit ledger, idempotent webhooks, and predictable per-article pricing.</p>
</div>
</div>
</section>
<section id="pricing" class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title text-2xl mb-2">Pricing logic</h2>
<p class="text-base-content/70">1 credit up to 25,000 chars, then +1 credit for each additional 10,000 chars.</p>
<div class="stats stats-vertical lg:stats-horizontal shadow mt-4 bg-base-200">
<div class="stat">
<div class="stat-title">Included</div>
<div class="stat-value">25k chars</div>
<div class="stat-desc">Base cost</div>
<div class="card bg-base-200 border border-base-content/5 max-w-4xl mx-auto overflow-hidden">
<div class="grid md:grid-cols-2">
<div class="p-8 md:p-12">
<h3 class="text-2xl font-bold mb-4">Pay-as-you-go</h3>
<div class="text-5xl font-extrabold mb-6">$1 <span class="text-xl font-normal text-base-content/60">/ 25k chars</span></div>
<ul class="space-y-3 mb-8 text-left">
<li class="flex items-center gap-2"><svg class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> High-quality Neural Voices</li>
<li class="flex items-center gap-2"><svg class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> Permanent Storage</li>
<li class="flex items-center gap-2"><svg class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> Shareable Links</li>
</ul>
<a href="/login" class="btn btn-primary w-full shadow-md hover:shadow-lg">Get Started</a>
</div>
<div class="bg-base-300/50 p-8 md:p-12 flex flex-col justify-center">
<h4 class="font-bold mb-4">Credit Logic</h4>
<div class="space-y-4 text-sm">
<div class="flex justify-between border-b border-base-content/10 pb-2">
<span>Base (up to 25k chars)</span>
<span class="font-mono font-bold">1 credit</span>
</div>
<div class="flex justify-between border-b border-base-content/10 pb-2">
<span>Additional 10k chars</span>
<span class="font-mono font-bold">+1 credit</span>
</div>
<div class="flex justify-between text-base-content/60">
<span>Maximum article size</span>
<span class="font-mono">120k chars</span>
</div>
<div class="stat">
<div class="stat-title">Step</div>
<div class="stat-value">10k chars</div>
<div class="stat-desc">Additional cost</div>
</div>
<div class="stat">
<div class="stat-title">Max size</div>
<div class="stat-value">120k chars</div>
<div class="stat-desc">Hard limit</div>
</div>
</div>
</div>
@@ -156,127 +204,168 @@ function renderLandingPage({ authenticated, userId }) {
}
function renderLoginPage({ returnTo = "/app", error = null }) {
const errorBlock = error ? `<div class="alert alert-error mb-3">${escapeHtml(error)}</div>` : "";
const errorBlock = error ? `<div role="alert" class="alert alert-error mb-6 text-sm py-2 px-4 rounded-lg flex items-center gap-2"><svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><span>${escapeHtml(error)}</span></div>` : "";
return shell({
title: "Sign in | XArtAudio",
compact: true,
user: null,
content: `
${nav({ authenticated: false, userId: null })}
<section class="card bg-slate-900/80 border border-slate-700 shadow-xl">
<div class="card-body">
<h1 class="text-2xl font-bold">Sign in</h1>
<p class="text-sm text-slate-300">Use your email account or continue with X.</p>
<div class="flex flex-col items-center justify-center min-h-[60vh]">
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold mb-2">Welcome back</h1>
<p class="text-base-content/60">Sign in to your account to continue</p>
</div>
<div class="card bg-base-100 border border-base-content/10 shadow-sm">
<div class="card-body p-6">
${errorBlock}
<form method="POST" action="/auth/x" class="mb-4">
<form method="POST" action="/auth/x" class="mb-6">
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
<button class="btn btn-accent w-full">Continue with X</button>
<button class="btn btn-outline w-full">Continue with X</button>
</form>
<div class="divider text-xs uppercase text-slate-400">Email</div>
<div class="divider text-xs text-base-content/40 my-6">Email</div>
<form method="POST" action="/auth/email/sign-in" class="space-y-3">
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
<label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Email</span>
<input name="email" type="email" required class="input input-bordered w-full bg-slate-950" placeholder="you@domain.com" />
<label class="form-control">
<span class="label-text font-medium mb-1">Email</span>
<input name="email" type="email" required class="input input-bordered w-full" placeholder="you@domain.com" />
</label>
<label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Password</span>
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full bg-slate-950" placeholder="••••••••" />
<label class="form-control">
<span class="label-text font-medium mb-1">Password</span>
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full" placeholder="••••••••" />
</label>
<button class="btn btn-primary w-full">Sign in with email</button>
<button class="btn btn-primary w-full shadow-sm hover:shadow">Sign in</button>
</form>
<div class="divider text-xs uppercase text-slate-400">Create account</div>
<div class="divider text-xs text-base-content/40 my-6">Create account</div>
<form method="POST" action="/auth/email/sign-up" class="space-y-3">
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
<label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Name</span>
<input name="name" required minlength="2" maxlength="80" class="input input-bordered w-full bg-slate-950" placeholder="Matiss" />
<label class="form-control">
<span class="label-text font-medium mb-1">Name</span>
<input name="name" required minlength="2" maxlength="80" class="input input-bordered w-full" placeholder="Matiss" />
</label>
<label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Email</span>
<input name="email" type="email" required class="input input-bordered w-full bg-slate-950" placeholder="you@domain.com" />
<label class="form-control">
<span class="label-text font-medium mb-1">Email</span>
<input name="email" type="email" required class="input input-bordered w-full" placeholder="you@domain.com" />
</label>
<label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Password</span>
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full bg-slate-950" placeholder="••••••••" />
<label class="form-control">
<span class="label-text font-medium mb-1">Password</span>
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full" placeholder="••••••••" />
</label>
<button class="btn btn-outline w-full">Create account</button>
<button class="btn btn-ghost border border-base-content/20 w-full">Create account</button>
</form>
<div class="text-center text-xs text-base-content/50 mt-2">
X sign-in requires X_OAUTH_CLIENT_ID and X_OAUTH_CLIENT_SECRET.
</div>
</div>
</div>
</div>
</div>
</section>
`,
});
}
function renderAppPage({ userId, summary, jobs, flash = null }) {
const flashMarkup = flash ? `<div role="alert" class="alert alert-info mb-4"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg><span>${escapeHtml(flash)}</span></div>` : "";
function renderAppPage({ userId, summary, jobs, flash = null, showDeveloperActions = true }) {
const flashMarkup = flash ? `<div role="alert" class="alert alert-info mb-6 flex items-center gap-2 text-sm shadow-sm"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg><span>${escapeHtml(flash)}</span></div>` : "";
const jobsMarkup = jobs.length === 0
? `<div class="text-center py-10 text-base-content/60 bg-base-200 rounded-box">
<p>No generated audiobooks yet.</p>
<p class="text-sm mt-1">Use the simulation form below or call the bot on X.</p>
</div>`
: `<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
${jobs.map((job) => `
<a class="card bg-base-100 shadow-md hover:shadow-xl transition-all border border-base-200" href="/audio/${job.assetId}">
<div class="card-body p-4">
<h3 class="font-bold text-lg truncate" title="${escapeHtml(job.article.title)}">${escapeHtml(job.article.title)}</h3>
<div class="flex justify-between items-center mt-2">
<span class="badge badge-sm badge-ghost">credits: ${job.creditsCharged}</span>
<span class="text-xs uppercase font-mono opacity-70">${escapeHtml(job.status)}</span>
? `<div class="text-center py-16 bg-base-100 border border-base-content/10 rounded-xl">
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-base-content/30" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
</div>
<h3 class="font-bold text-lg mb-1">No audiobooks yet</h3>
<p class="text-sm text-base-content/60">Use the simulation form below to generate your first audiobook.</p>
</div>`
: `<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
${jobs.map((job) => `
<a class="card bg-base-100 shadow-sm hover:shadow-md hover:border-primary/20 transition-all border border-base-content/10 group h-full" href="/audio/${job.assetId}">
<div class="card-body p-5">
<div class="flex justify-between items-start mb-2">
<div class="badge badge-sm badge-ghost font-mono text-xs opacity-70">${escapeHtml(job.status)}</div>
<span class="text-xs font-mono opacity-50 text-right">${job.creditsCharged} cr</span>
</div>
<h3 class="font-bold text-lg leading-tight group-hover:text-primary transition-colors line-clamp-2" title="${escapeHtml(job.article.title)}">${escapeHtml(job.article.title)}</h3>
</div>
</a>`).join("")}
</div>`;
const developerActionsMarkup = showDeveloperActions
? `<div class="space-y-6">
<div class="card bg-base-100 shadow-sm border border-base-content/10 sticky top-24">
<div class="card-body p-5">
<h2 class="card-title text-sm font-bold uppercase tracking-wider opacity-60 mb-2">Developer Actions</h2>
<form method="POST" action="/app/actions/topup" class="mb-4">
<label class="label text-xs font-medium pl-0">Top Up Credits</label>
<div class="join w-full">
<input name="amount" type="number" min="1" max="500" value="10" class="input input-bordered input-sm join-item w-full" />
<button class="btn btn-primary btn-sm join-item">Add</button>
</div>
</form>
<div class="divider my-2"></div>
<form method="POST" action="/app/actions/simulate-mention" class="flex flex-col gap-3">
<label class="label text-xs font-medium pl-0">Simulate Mention</label>
<input name="title" required class="input input-bordered input-sm w-full" placeholder="Article Title" />
<textarea name="body" required rows="3" class="textarea textarea-bordered textarea-sm w-full" placeholder="Paste article text..."></textarea>
<button class="btn btn-accent btn-sm w-full">Generate</button>
</form>
</div>
</div>
</div>`
: "";
return shell({
title: "Dashboard | XArtAudio",
user: { authenticated: true, userId },
content: `
${nav({ authenticated: true, userId })}
<div class="flex flex-col md:flex-row justify-between items-end mb-8 gap-4 border-b border-base-content/5 pb-6">
<div>
<h1 class="text-3xl font-bold mb-1">Dashboard</h1>
<p class="text-base-content/60">Manage your credits and audiobooks</p>
</div>
</div>
${flashMarkup}
<section class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200 mb-8">
<div class="stat">
<div class="stat-title">Credits</div>
<div class="stat-value text-primary">${summary.balance}</div>
</div>
<div class="stat">
<div class="stat-title">Audiobooks</div>
<div class="stat-value text-secondary">${summary.totalJobs}</div>
</div>
<div class="stat">
<div class="stat-title">Spent</div>
<div class="stat-value">${summary.totalCreditsSpent}</div>
</div>
</section>
<div class="grid gap-6 md:grid-cols-2 mb-8">
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">Top up credits</h2>
<p class="text-xs text-base-content/60 mb-2">Dev shortcut for MVP. Production uses Polar webhook.</p>
<form method="POST" action="/app/actions/topup" class="join w-full">
<input name="amount" type="number" min="1" max="500" value="10" class="input input-bordered join-item w-full" />
<button class="btn btn-primary join-item">Add</button>
</form>
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">Simulate mention</h2>
<p class="text-xs text-base-content/60 mb-2">Simulates a webhook mention event.</p>
<form method="POST" action="/app/actions/simulate-mention" class="flex flex-col gap-3">
<input name="title" required class="input input-bordered w-full" placeholder="Article title" />
<textarea name="body" required rows="3" class="textarea textarea-bordered w-full" placeholder="Paste article text..."></textarea>
<button class="btn btn-accent w-full">Generate audiobook</button>
</form>
</div>
<div class="stats shadow-sm border border-base-content/10 w-full bg-base-100 mb-8 rounded-xl overflow-hidden divide-x divide-base-content/5">
<div class="stat place-items-center py-6">
<div class="stat-title text-sm uppercase tracking-wider opacity-60">Available Credits</div>
<div class="stat-value text-primary mt-1 text-4xl">${summary.balance}</div>
<div class="stat-desc mt-2">For generating audio</div>
</div>
<div class="stat place-items-center py-6">
<div class="stat-title text-sm uppercase tracking-wider opacity-60">Audiobooks</div>
<div class="stat-value text-base-content mt-1 text-4xl">${summary.totalJobs}</div>
<div class="stat-desc mt-2">Generated so far</div>
</div>
<div class="stat place-items-center py-6">
<div class="stat-title text-sm uppercase tracking-wider opacity-60">Total Spent</div>
<div class="stat-value text-secondary mt-1 text-4xl">${summary.totalCreditsSpent}</div>
<div class="stat-desc mt-2">Lifetime credits</div>
</div>
</div>
<div class="grid gap-8 ${showDeveloperActions ? "md:grid-cols-3" : ""} mb-12">
<div class="${showDeveloperActions ? "md:col-span-2" : ""} space-y-8">
<section>
<h2 class="text-2xl font-bold mb-4">Recent audiobooks</h2>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
Recent Audiobooks
</h2>
</div>
${jobsMarkup}
</section>
</div>
${developerActionsMarkup}
</div>
`,
});
}
@@ -286,43 +375,68 @@ function renderAudioPage({ audio, accessDecision, userId, playbackUrl = null })
return shell({
title: "Audio not found",
compact: true,
content: `${nav({ authenticated: Boolean(userId), userId })}<div role="alert" class="alert alert-error"><span>Audio not found.</span></div>`,
user: { authenticated: Boolean(userId), userId },
content: `<div class="max-w-md mx-auto mt-12 text-center"><div role="alert" class="alert alert-error mb-4"><span>Audio not found.</span></div><a href="/app" class="btn btn-ghost">Back to Dashboard</a></div>`,
});
}
const action = accessDecision.allowed
? `<div role="alert" class="alert alert-success"><span>Access granted. This unlock is permanent for your account.</span></div>`
? `<div role="alert" class="alert alert-success shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span><span class="font-bold">Unlocked!</span> Permanent access granted.</span>
</div>`
: accessDecision.reason === "auth_required"
? `<div role="alert" class="alert alert-warning mb-4"><span>Sign in to continue.</span></div>
<a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary w-full">Sign in to unlock</a>`
: `<div role="alert" class="alert alert-warning mb-4"><span>Unlock required: ${accessDecision.creditsRequired} credits.</span></div>
<form method="POST" action="/audio/${audio.id}/unlock">
<button class="btn btn-primary w-full">Pay ${accessDecision.creditsRequired} credits and unlock forever</button>
</form>`;
? `<div class="card bg-base-200 border border-base-content/10">
<div class="card-body items-center text-center p-6">
<h3 class="font-bold text-lg mb-2">Sign in to listen</h3>
<p class="text-sm mb-4 text-base-content/70">You need an account to unlock this audiobook.</p>
<a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary w-full max-w-xs">Sign in to unlock</a>
</div>
</div>`
: `<div class="card bg-base-200 border border-base-content/10">
<div class="card-body items-center text-center p-6">
<h3 class="font-bold text-lg mb-2">Unlock Audiobook</h3>
<p class="text-sm mb-4 text-base-content/70">Unlock this audiobook permanently for <span class="font-bold text-base-content">${accessDecision.creditsRequired} credits</span>.</p>
<form method="POST" action="/audio/${audio.id}/unlock" class="w-full max-w-xs">
<button class="btn btn-primary w-full">Pay & Listen</button>
</form>
</div>
</div>`;
return shell({
title: `${escapeHtml(audio.articleTitle)} | XArtAudio`,
compact: true,
user: { authenticated: Boolean(userId), userId },
content: `
${nav({ authenticated: Boolean(userId), userId })}
<div class="card w-full max-w-2xl mx-auto shadow-2xl bg-base-100 border border-base-200">
<div class="card-body p-6">
<span class="badge badge-accent mb-2">Audiobook</span>
<h1 class="text-2xl sm:text-3xl font-bold mb-2 leading-tight">${escapeHtml(audio.articleTitle)}</h1>
<div class="text-sm text-base-content/60 mb-6 flex gap-3 text-mono">
<span>Duration ~ ${audio.durationSec}s</span>
<span>•</span>
<span>Asset ${escapeHtml(audio.id.substring(0, 8))}...</span>
<div class="mb-6">
<a href="/app" class="btn btn-ghost btn-sm gap-2 pl-0 hover:bg-transparent text-base-content/60 hover:text-base-content">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
Back to Dashboard
</a>
</div>
<div class="divider"></div>
<div class="card w-full shadow-xl bg-base-100 border border-base-content/10 overflow-hidden">
<div class="card-body p-6 md:p-8">
<div class="flex items-center gap-2 mb-4">
<span class="badge badge-accent">Audiobook</span>
<span class="text-xs font-mono text-base-content/50 uppercase tracking-widest">${escapeHtml(audio.id.substring(0, 8))}</span>
</div>
<h1 class="text-2xl md:text-3xl font-extrabold mb-4 leading-tight">${escapeHtml(audio.articleTitle)}</h1>
<div class="flex items-center gap-4 text-sm text-base-content/60 mb-8 border-b border-base-content/10 pb-6">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
${audio.durationSec}s duration
</span>
</div>
${action}
${accessDecision.allowed
? `<div class="mt-6">
<h3 class="font-bold mb-2">Direct Stream URL</h3>
<div class="mockup-code bg-neutral text-neutral-content p-4 text-sm overflow-x-auto">
? `<div class="mt-8 pt-6 border-t border-base-content/10">
<h3 class="font-bold mb-3 text-xs uppercase tracking-widest opacity-60">Direct Stream URL</h3>
<div class="mockup-code bg-base-300 text-base-content p-4 text-xs overflow-x-auto">
<pre><code>${escapeHtml(playbackUrl || `stream://${audio.storageKey}`)}</code></pre>
</div>
</div>`

View File

@@ -3,7 +3,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { buildApp } = require("../src/app");
const { hmacSHA256Hex } = require("../src/lib/signature");
const { hmacSHA256Hex, hmacSHA256Base64 } = require("../src/lib/signature");
function getTestCookieValue(cookieHeader, name) {
const parts = String(cookieHeader || "").split(";").map((part) => part.trim());
@@ -76,7 +76,7 @@ function createApp(options = {}) {
betterAuthBasePath: "/api/auth",
xOAuthClientId: "x-client-id",
xOAuthClientSecret: "x-client-secret",
internalApiToken: "",
internalApiToken: "internal-token",
convexDeploymentUrl: "",
convexAuthToken: "",
convexStateQuery: "state:getLatestSnapshot",
@@ -169,7 +169,7 @@ test("GET / renders landing page", async () => {
const app = createApp();
const response = await call(app, { method: "GET", path: "/" });
assert.equal(response.status, 200);
assert.match(response.body, /From X Article to audiobook/);
assert.match(response.body, /Listen to X Articles/);
});
test("GET /assets/styles.css serves compiled stylesheet", async () => {
@@ -229,7 +229,7 @@ test("authenticated dashboard topup + simulate mention flow", async () => {
headers: { cookie: cookieHeader },
});
assert.equal(dashboard.status, 200);
assert.match(dashboard.body, /Recent audiobooks/);
assert.match(dashboard.body, /Recent Audiobooks/);
assert.match(dashboard.body, /Hello/);
});
@@ -264,7 +264,7 @@ test("audio flow requires auth for unlock and supports permanent unlock", async
path: audioPath,
headers: { cookie: "xartaudio_user=viewer" },
});
assert.match(beforeUnlock.body, /Unlock required: 1 credits/);
assert.match(beforeUnlock.body, /Unlock this audiobook permanently for/);
const unlock = await call(app, {
method: "POST",
@@ -278,7 +278,7 @@ test("audio flow requires auth for unlock and supports permanent unlock", async
path: audioPath,
headers: { cookie: "xartaudio_user=viewer" },
});
assert.match(afterUnlock.body, /Access granted/);
assert.match(afterUnlock.body, /Unlocked!/);
const wallet = await call(app, {
method: "GET",
@@ -381,6 +381,7 @@ test("/api/x/mentions returns upstream mentions when configured", async () => {
const response = await call(app, {
method: "GET",
path: "/api/x/mentions",
headers: { "x-internal-token": "internal-token" },
query: { sinceId: "100" },
});
@@ -390,6 +391,61 @@ test("/api/x/mentions returns upstream mentions when configured", async () => {
assert.equal(body.mentions[0].id, "m1");
});
test("/api/x/mentions requires internal token", async () => {
const app = createApp({
xAdapter: {
isConfigured() {
return true;
},
async listMentions() {
return [];
},
},
});
const response = await call(app, {
method: "GET",
path: "/api/x/mentions",
query: { sinceId: "1" },
});
assert.equal(response.status, 401);
});
test("cross-site browser posts are blocked", async () => {
const app = createApp();
const response = await call(app, {
method: "POST",
path: "/app/actions/topup",
headers: {
cookie: "xartaudio_user=alice",
origin: "https://evil.example",
"sec-fetch-site": "cross-site",
},
body: "amount=5",
});
assert.equal(response.status, 403);
assert.match(response.body, /csrf_blocked|invalid_origin/);
});
test("dev dashboard routes can be disabled", async () => {
const app = createApp({
config: {
enableDevRoutes: false,
},
});
const response = await call(app, {
method: "POST",
path: "/app/actions/topup",
headers: { cookie: "xartaudio_user=alice" },
body: "amount=5",
});
assert.equal(response.status, 404);
});
test("simulate mention schedules background audio generation when service is configured", async () => {
const queued = [];
const app = createApp({
@@ -573,7 +629,11 @@ test("internal retention endpoint prunes stale content and assets", async () =>
});
test("internal endpoints are disabled when no token configured", async () => {
const app = createApp();
const app = createApp({
config: {
internalApiToken: "",
},
});
const response = await call(app, {
method: "POST",
path: "/internal/retention/run",
@@ -638,6 +698,33 @@ test("X webhook invalid signature is rejected", async () => {
assert.equal(response.status, 401);
});
test("X webhook accepts x-twitter-webhooks-signature header", async () => {
const app = createApp();
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt-twitter-sig" }, "polar-secret");
const payload = {
mentionPostId: "m-twitter-header",
callerUserId: "u1",
parentPost: {
id: "p1",
authorId: "author",
article: { id: "a1", title: "T", body: "body text" },
},
};
const rawBody = JSON.stringify(payload);
const signature = hmacSHA256Base64(rawBody, "x-secret");
const response = await call(app, {
method: "POST",
path: "/api/webhooks/x",
headers: { "x-twitter-webhooks-signature": `sha256=${signature}` },
body: rawBody,
});
assert.equal(response.status, 200);
assert.equal(JSON.parse(response.body).status, "completed");
});
test("X webhook valid flow processes article", async () => {
const app = createApp();

View File

@@ -31,6 +31,7 @@ function withTempEnv(patch, run) {
test("config uses defaults when env is missing", () => {
withTempEnv({
NODE_ENV: "",
PORT: "",
LOG_LEVEL: "",
APP_BASE_URL: "",
@@ -42,8 +43,10 @@ test("config uses defaults when env is missing", () => {
MINIO_SIGNED_URL_TTL_SEC: "",
MINIO_USE_SSL: "",
WEBHOOK_RPM: "",
ALLOW_IN_MEMORY_STATE_FALLBACK: "",
}, () => {
const { config } = require("../src/config");
assert.equal(config.nodeEnv, "development");
assert.equal(config.port, 3000);
assert.equal(config.logLevel, "info");
assert.equal(config.appBaseUrl, "http://localhost:3000");
@@ -55,6 +58,8 @@ test("config uses defaults when env is missing", () => {
assert.equal(config.minioSignedUrlTtlSec, 3600);
assert.equal(config.minioUseSSL, true);
assert.equal(config.rateLimits.webhookPerMinute, 120);
assert.equal(config.allowInMemoryStateFallback, true);
assert.equal(config.enableDevRoutes, true);
assert.equal(config.abuse.maxJobsPerUserPerDay, 0);
assert.equal(config.abuse.cooldownSec, 0);
assert.deepEqual(config.abuse.denyUserIds, []);
@@ -63,6 +68,7 @@ test("config uses defaults when env is missing", () => {
test("config reads convex/qwen/minio overrides", () => {
withTempEnv({
NODE_ENV: "production",
PORT: "8080",
LOG_LEVEL: "debug",
APP_BASE_URL: "https://xartaudio.app",
@@ -86,8 +92,10 @@ test("config reads convex/qwen/minio overrides", () => {
ABUSE_MAX_JOBS_PER_USER_PER_DAY: "5",
ABUSE_COOLDOWN_SEC: "120",
ABUSE_DENY_USER_IDS: "u1,u2",
ALLOW_IN_MEMORY_STATE_FALLBACK: "",
}, () => {
const { config } = require("../src/config");
assert.equal(config.nodeEnv, "production");
assert.equal(config.port, 8080);
assert.equal(config.logLevel, "debug");
assert.equal(config.appBaseUrl, "https://xartaudio.app");
@@ -109,6 +117,18 @@ test("config reads convex/qwen/minio overrides", () => {
assert.equal(config.abuse.maxJobsPerUserPerDay, 5);
assert.equal(config.abuse.cooldownSec, 120);
assert.deepEqual(config.abuse.denyUserIds, ["u1", "u2"]);
assert.equal(config.allowInMemoryStateFallback, false);
assert.equal(config.enableDevRoutes, false);
});
});
test("allow in-memory fallback can be explicitly enabled in production", () => {
withTempEnv({
NODE_ENV: "production",
ALLOW_IN_MEMORY_STATE_FALLBACK: "true",
}, () => {
const { config } = require("../src/config");
assert.equal(config.allowInMemoryStateFallback, true);
});
});

View File

@@ -7,8 +7,26 @@ const fs = require("node:fs");
test("convex state functions are present for configured function names", () => {
const schema = fs.readFileSync("convex/schema.ts", "utf8");
const state = fs.readFileSync("convex/state.ts", "utf8");
const domain = fs.readFileSync("convex/domain.ts", "utf8");
assert.match(schema, /users: defineTable/);
assert.match(schema, /wallets: defineTable/);
assert.match(schema, /wallet_transactions: defineTable/);
assert.match(schema, /mention_events: defineTable/);
assert.match(schema, /articles: defineTable/);
assert.match(schema, /audio_jobs: defineTable/);
assert.match(schema, /audio_assets: defineTable/);
assert.match(schema, /audio_access_grants: defineTable/);
assert.match(schema, /payment_events: defineTable/);
assert.match(schema, /state_snapshots/);
assert.match(state, /export const getLatestSnapshot = query/);
assert.match(state, /export const saveSnapshot = mutation/);
assert.match(domain, /export const upsertUser = mutation/);
assert.match(domain, /export const applyWalletTransaction = mutation/);
assert.match(domain, /export const recordMentionEvent = mutation/);
assert.match(domain, /export const upsertArticle = mutation/);
assert.match(domain, /export const createAudioJob = mutation/);
assert.match(domain, /export const createAudioAsset = mutation/);
assert.match(domain, /export const grantAudioAccess = mutation/);
assert.match(domain, /export const recordPaymentEvent = mutation/);
});

View File

@@ -11,6 +11,7 @@ const {
function createRuntimeConfig() {
return {
nodeEnv: "test",
port: 3000,
logLevel: "info",
appBaseUrl: "http://localhost:3000",
@@ -60,6 +61,7 @@ function createRuntimeConfig() {
stepCredits: 1,
maxCharsPerArticle: 120000,
},
allowInMemoryStateFallback: true,
};
}
@@ -153,3 +155,39 @@ test("createRuntime falls back to in-memory state when initial load fails", asyn
assert.equal(response.status, 303);
await runtime.persister.flush();
});
test("createRuntime fails startup when fallback is disabled", async () => {
const runtimeConfig = createRuntimeConfig();
runtimeConfig.allowInMemoryStateFallback = false;
await assert.rejects(
createRuntime({
runtimeConfig,
logger: { info() {}, warn() {}, error() {} },
stateStore: {
async load() {
throw new Error("state_load_failed");
},
async save() {},
},
}),
/state_store_unavailable_without_fallback/,
);
});
test("createMutationPersister surfaces save errors", async () => {
const persister = createMutationPersister({
stateStore: {
async save() {
throw new Error("persist_failed");
},
},
logger: { error() {} },
});
await assert.rejects(
persister.enqueue({}),
/persist_failed/,
);
assert.equal(persister.getLastError()?.message, "persist_failed");
});

View File

@@ -2,7 +2,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { hmacSHA256Hex, verifySignature } = require("../src/lib/signature");
const { hmacSHA256Hex, hmacSHA256Base64, verifySignature } = require("../src/lib/signature");
test("verifies valid signature", () => {
const payload = JSON.stringify({ hello: "world" });
@@ -31,3 +31,19 @@ test("rejects missing signature", () => {
assert.equal(verifySignature({ payload, secret, signature: "" }), false);
});
test("verifies valid base64 signature", () => {
const payload = JSON.stringify({ hello: "world" });
const secret = "topsecret";
const sig = hmacSHA256Base64(payload, secret);
assert.equal(
verifySignature({
payload,
secret,
signature: `sha256=${sig}`,
encoding: "base64",
}),
true,
);
});

View File

@@ -19,7 +19,7 @@ test("shell includes daisyui and pwa tags", () => {
test("landing page renders hero and flow sections", () => {
const html = renderLandingPage({ authenticated: false, userId: null });
assert.match(html, /From X Article to audiobook/);
assert.match(html, /Listen to X Articles/);
assert.match(html, /id="how"/);
assert.match(html, /id="pricing"/);
});
@@ -40,11 +40,23 @@ test("app page renders stats and forms", () => {
jobs: [{ assetId: "1", status: "completed", article: { title: "Hello" }, creditsCharged: 1 }],
});
assert.match(html, /Top up credits/);
assert.match(html, /Simulate mention/);
assert.match(html, /Top Up Credits/);
assert.match(html, /Simulate Mention/);
assert.match(html, /Hello/);
});
test("app page can hide developer actions", () => {
const html = renderAppPage({
userId: "u1",
summary: { balance: 4, totalJobs: 2, totalCreditsSpent: 2 },
jobs: [],
showDeveloperActions: false,
});
assert.doesNotMatch(html, /Developer Actions/);
assert.doesNotMatch(html, /\/app\/actions\/topup/);
});
test("audio page shows unlock action when payment is required", () => {
const html = renderAudioPage({
audio: { id: "1", storageKey: "audio/1.mp3", articleTitle: "A", durationSec: 30 },
@@ -52,5 +64,6 @@ test("audio page shows unlock action when payment is required", () => {
userId: "u2",
});
assert.match(html, /Pay 3 credits and unlock forever/);
assert.match(html, /3 credits/);
assert.match(html, /Pay & Listen/);
});