Compare commits
10 Commits
76f991e690
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa5fa72615 | ||
|
|
f672677d4f | ||
|
|
4814342156 | ||
|
|
331b66506a | ||
|
|
737aac1ef0 | ||
|
|
6393dd11ae | ||
|
|
609aaecf56 | ||
|
|
489972c6dc | ||
|
|
d1fbff481b | ||
|
|
fa2ee57595 |
@@ -3,6 +3,8 @@ NODE_ENV=production
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
APP_BASE_URL=https://xartaudio.example.com
|
APP_BASE_URL=https://xartaudio.example.com
|
||||||
|
ENABLE_DEV_ROUTES=false
|
||||||
|
ALLOW_IN_MEMORY_STATE_FALLBACK=false
|
||||||
|
|
||||||
# Better Auth
|
# Better Auth
|
||||||
BETTER_AUTH_SECRET=replace-me
|
BETTER_AUTH_SECRET=replace-me
|
||||||
|
|||||||
819
convex/domain.ts
Normal file
819
convex/domain.ts
Normal 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),
|
||||||
|
});
|
||||||
121
convex/schema.ts
121
convex/schema.ts
@@ -2,6 +2,127 @@ import { defineSchema, defineTable } from "convex/server";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
export default defineSchema({
|
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({
|
state_snapshots: defineTable({
|
||||||
snapshot: v.any(),
|
snapshot: v.any(),
|
||||||
updatedAt: v.string(),
|
updatedAt: v.string(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { mutation, query } from "./_generated/server";
|
import { mutation, query } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
import { syncFromEngineSnapshot } from "./domain";
|
||||||
|
|
||||||
export const getLatestSnapshot = query({
|
export const getLatestSnapshot = query({
|
||||||
args: {},
|
args: {},
|
||||||
@@ -22,6 +23,7 @@ export const saveSnapshot = mutation({
|
|||||||
args: {
|
args: {
|
||||||
snapshot: v.any(),
|
snapshot: v.any(),
|
||||||
updatedAt: v.string(),
|
updatedAt: v.string(),
|
||||||
|
syncToDomain: v.optional(v.boolean()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const latest = await ctx.db
|
const latest = await ctx.db
|
||||||
@@ -29,17 +31,29 @@ export const saveSnapshot = mutation({
|
|||||||
.order("desc")
|
.order("desc")
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
|
const shouldSync = Boolean(args.syncToDomain);
|
||||||
|
const syncSummary = shouldSync
|
||||||
|
? await syncFromEngineSnapshot(ctx, args.snapshot)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (latest) {
|
if (latest) {
|
||||||
await ctx.db.patch(latest._id, {
|
await ctx.db.patch(latest._id, {
|
||||||
snapshot: args.snapshot,
|
snapshot: args.snapshot,
|
||||||
updatedAt: args.updatedAt,
|
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,
|
snapshot: args.snapshot,
|
||||||
updatedAt: args.updatedAt,
|
updatedAt: args.updatedAt,
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
snapshotId,
|
||||||
|
syncSummary,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
179
src/app.js
179
src/app.js
@@ -73,6 +73,9 @@ function buildApp({
|
|||||||
});
|
});
|
||||||
const rateLimits = config.rateLimits || {};
|
const rateLimits = config.rateLimits || {};
|
||||||
const abusePolicy = config.abuse || {};
|
const abusePolicy = config.abuse || {};
|
||||||
|
const devRoutesEnabled = config.enableDevRoutes !== undefined
|
||||||
|
? Boolean(config.enableDevRoutes)
|
||||||
|
: true;
|
||||||
const polar = polarAdapter || createPolarAdapter({
|
const polar = polarAdapter || createPolarAdapter({
|
||||||
accessToken: config.polarAccessToken,
|
accessToken: config.polarAccessToken,
|
||||||
server: config.polarServer,
|
server: config.polarServer,
|
||||||
@@ -126,20 +129,15 @@ function buildApp({
|
|||||||
windowMs: 60_000,
|
windowMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
function persistMutation() {
|
async function persistMutation() {
|
||||||
if (!onMutation) {
|
if (!onMutation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await onMutation({
|
||||||
try {
|
version: 1,
|
||||||
onMutation({
|
updatedAt: new Date().toISOString(),
|
||||||
version: 1,
|
engine: engine.exportState(),
|
||||||
updatedAt: new Date().toISOString(),
|
});
|
||||||
engine: engine.exportState(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "failed to persist mutation");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientAddressFromHeaders(headers) {
|
function clientAddressFromHeaders(headers) {
|
||||||
@@ -174,7 +172,9 @@ function buildApp({
|
|||||||
if (!generationService || !generationService.isConfigured()) {
|
if (!generationService || !generationService.isConfigured()) {
|
||||||
try {
|
try {
|
||||||
engine.completeJob(job.id);
|
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) {
|
} catch (error) {
|
||||||
logger.error({ err: error, jobId: job.id }, "failed to mark job as completed without generation worker");
|
logger.error({ err: error, jobId: job.id }, "failed to mark job as completed without generation worker");
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,9 @@ function buildApp({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
engine.startJob(job.id);
|
engine.startJob(job.id);
|
||||||
persistMutation();
|
void persistMutation().catch((error) => {
|
||||||
|
logger.error({ err: error, jobId: job.id }, "failed to persist job start");
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error, jobId: job.id }, "failed to start audio generation job");
|
logger.error({ err: error, jobId: job.id }, "failed to start audio generation job");
|
||||||
return;
|
return;
|
||||||
@@ -196,8 +198,13 @@ function buildApp({
|
|||||||
onCompleted: (audioMeta) => {
|
onCompleted: (audioMeta) => {
|
||||||
try {
|
try {
|
||||||
engine.completeJob(job.id, audioMeta);
|
engine.completeJob(job.id, audioMeta);
|
||||||
persistMutation();
|
void persistMutation()
|
||||||
logger.info({ assetId: job.assetId, jobId: job.id }, "audio generation completed");
|
.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) {
|
} catch (error) {
|
||||||
logger.error({ err: error, assetId: job.assetId }, "failed to apply generated audio metadata");
|
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",
|
error: error && error.message ? error.message : "audio_generation_failed",
|
||||||
refund: true,
|
refund: true,
|
||||||
});
|
});
|
||||||
persistMutation();
|
void persistMutation().catch((persistError) => {
|
||||||
|
logger.error({ err: persistError, jobId: job.id }, "failed to persist failed job state");
|
||||||
|
});
|
||||||
} catch (failureError) {
|
} catch (failureError) {
|
||||||
logger.error({ err: failureError, jobId: job.id }, "failed to mark generation failure");
|
logger.error({ err: failureError, jobId: job.id }, "failed to mark generation failure");
|
||||||
}
|
}
|
||||||
@@ -243,6 +252,49 @@ function buildApp({
|
|||||||
return null;
|
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) {
|
function getAbuseDecision(callerUserId) {
|
||||||
if (!callerUserId) {
|
if (!callerUserId) {
|
||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
@@ -362,12 +414,29 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleXWebhook(headers, rawBody) {
|
async function handleXWebhook(headers, rawBody) {
|
||||||
const signature = headers["x-signature"];
|
const twitterSignature = headers["x-twitter-webhooks-signature"];
|
||||||
const isValid = verifySignature({
|
const legacySignature = headers["x-signature"];
|
||||||
payload: rawBody,
|
|
||||||
secret: config.xWebhookSecret,
|
let isValid = false;
|
||||||
signature,
|
if (twitterSignature) {
|
||||||
});
|
isValid = verifySignature({
|
||||||
|
payload: rawBody,
|
||||||
|
secret: config.xWebhookSecret,
|
||||||
|
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) {
|
if (!isValid) {
|
||||||
return json(401, { error: "invalid_signature" });
|
return json(401, { error: "invalid_signature" });
|
||||||
@@ -392,7 +461,7 @@ function buildApp({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
scheduleAudioGeneration(result.job);
|
scheduleAudioGeneration(result.job);
|
||||||
const replyMessage = result.reply
|
const replyMessage = result.reply
|
||||||
? result.reply.message
|
? result.reply.message
|
||||||
@@ -413,7 +482,7 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePolarWebhook(headers, rawBody) {
|
async function handlePolarWebhook(headers, rawBody) {
|
||||||
try {
|
try {
|
||||||
let payload;
|
let payload;
|
||||||
|
|
||||||
@@ -441,7 +510,7 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
|
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { status: "credited" });
|
return json(200, { status: "credited" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ err: error }, "polar webhook request failed");
|
logger.warn({ err: error }, "polar webhook request failed");
|
||||||
@@ -523,6 +592,10 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/auth/email/sign-in") {
|
if (method === "POST" && path === "/auth/email/sign-in") {
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -549,6 +622,10 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/auth/email/sign-up") {
|
if (method === "POST" && path === "/auth/email/sign-up") {
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -575,6 +652,10 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/auth/x") {
|
if (method === "POST" && path === "/auth/x") {
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -605,6 +686,10 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/auth/logout") {
|
if (method === "POST" && path === "/auth/logout") {
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const signOut = await auth.signOut(safeHeaders);
|
const signOut = await auth.signOut(safeHeaders);
|
||||||
return redirect("/", signOut.setCookie
|
return redirect("/", signOut.setCookie
|
||||||
? { "set-cookie": signOut.setCookie }
|
? { "set-cookie": signOut.setCookie }
|
||||||
@@ -622,10 +707,18 @@ function buildApp({
|
|||||||
summary: engine.getUserSummary(userId),
|
summary: engine.getUserSummary(userId),
|
||||||
jobs: engine.listJobsForUser(userId),
|
jobs: engine.listJobsForUser(userId),
|
||||||
flash: safeQuery.flash || null,
|
flash: safeQuery.flash || null,
|
||||||
|
showDeveloperActions: devRoutesEnabled,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/app/actions/topup") {
|
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");
|
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app");
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -645,11 +738,18 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
engine.topUpCredits(userId, amount, `app-topup:${userId}:${randomUUID()}`);
|
engine.topUpCredits(userId, amount, `app-topup:${userId}:${randomUUID()}`);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return redirect(withQuery("/app", { flash: `Added ${amount} credits` }));
|
return redirect(withQuery("/app", { flash: `Added ${amount} credits` }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/app/actions/simulate-mention") {
|
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");
|
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app");
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -701,7 +801,7 @@ function buildApp({
|
|||||||
return redirect(withQuery("/app", { flash: "Parent post is not an article" }));
|
return redirect(withQuery("/app", { flash: "Parent post is not an article" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
scheduleAudioGeneration(result.job);
|
scheduleAudioGeneration(result.job);
|
||||||
return redirect(withQuery(`/audio/${result.job.assetId}`, {
|
return redirect(withQuery(`/audio/${result.job.assetId}`, {
|
||||||
flash: "Audiobook generated",
|
flash: "Audiobook generated",
|
||||||
@@ -712,6 +812,10 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path.startsWith("/audio/") && path.endsWith("/unlock")) {
|
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", ""));
|
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, path.replace("/unlock", ""));
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -725,7 +829,7 @@ function buildApp({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
engine.unlockAudio(assetId, userId);
|
engine.unlockAudio(assetId, userId);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return redirect(withQuery(`/audio/${assetId}`, { flash: "Unlocked" }));
|
return redirect(withQuery(`/audio/${assetId}`, { flash: "Unlocked" }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return redirect(withQuery(`/audio/${assetId}`, { flash: `Unlock failed: ${error.message}` }));
|
return redirect(withQuery(`/audio/${assetId}`, { flash: `Unlock failed: ${error.message}` }));
|
||||||
@@ -797,6 +901,11 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "GET" && path === "/api/x/mentions") {
|
if (method === "GET" && path === "/api/x/mentions") {
|
||||||
|
const authResponse = ensureInternalAuth(safeHeaders);
|
||||||
|
if (authResponse) {
|
||||||
|
return authResponse;
|
||||||
|
}
|
||||||
|
|
||||||
if (!x.isConfigured()) {
|
if (!x.isConfigured()) {
|
||||||
return json(503, { error: "x_api_not_configured" });
|
return json(503, { error: "x_api_not_configured" });
|
||||||
}
|
}
|
||||||
@@ -821,7 +930,7 @@ function buildApp({
|
|||||||
rawArticleHours: Number.isFinite(payload.rawArticleHours) ? payload.rawArticleHours : 24,
|
rawArticleHours: Number.isFinite(payload.rawArticleHours) ? payload.rawArticleHours : 24,
|
||||||
audioDays: Number.isFinite(payload.audioDays) ? payload.audioDays : 90,
|
audioDays: Number.isFinite(payload.audioDays) ? payload.audioDays : 90,
|
||||||
});
|
});
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { status: "ok", summary });
|
return json(200, { status: "ok", summary });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,7 +939,7 @@ function buildApp({
|
|||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
}
|
}
|
||||||
return handlePolarWebhook(safeHeaders, rawBody);
|
return await handlePolarWebhook(safeHeaders, rawBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/api/payments/create-checkout") {
|
if (method === "POST" && path === "/api/payments/create-checkout") {
|
||||||
@@ -895,7 +1004,7 @@ function buildApp({
|
|||||||
const assetId = path.slice("/api/audio/".length, -"/unlock".length);
|
const assetId = path.slice("/api/audio/".length, -"/unlock".length);
|
||||||
try {
|
try {
|
||||||
const result = engine.unlockAudio(assetId, userId);
|
const result = engine.unlockAudio(assetId, userId);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, result);
|
return json(200, result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(400, { error: error.message });
|
return json(400, { error: error.message });
|
||||||
@@ -910,7 +1019,7 @@ function buildApp({
|
|||||||
const assetId = path.slice("/api/audio/".length);
|
const assetId = path.slice("/api/audio/".length);
|
||||||
try {
|
try {
|
||||||
const deleted = engine.takedownAudio(assetId, userId);
|
const deleted = engine.takedownAudio(assetId, userId);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { status: "deleted", assetId: deleted.id });
|
return json(200, { status: "deleted", assetId: deleted.id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error.message === "forbidden" ? 403 : 400;
|
const status = error.message === "forbidden" ? 403 : 400;
|
||||||
@@ -927,7 +1036,7 @@ function buildApp({
|
|||||||
const jobId = path.slice("/internal/jobs/".length, -"/start".length);
|
const jobId = path.slice("/internal/jobs/".length, -"/start".length);
|
||||||
try {
|
try {
|
||||||
const job = engine.startJob(jobId);
|
const job = engine.startJob(jobId);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { job });
|
return json(200, { job });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(400, { error: error.message });
|
return json(400, { error: error.message });
|
||||||
@@ -945,7 +1054,7 @@ function buildApp({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const job = engine.completeJob(jobId, payload.asset || {});
|
const job = engine.completeJob(jobId, payload.asset || {});
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { job });
|
return json(200, { job });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(400, { error: error.message });
|
return json(400, { error: error.message });
|
||||||
@@ -967,7 +1076,7 @@ function buildApp({
|
|||||||
error: payload.error || "generation_failed",
|
error: payload.error || "generation_failed",
|
||||||
refund: shouldRefund,
|
refund: shouldRefund,
|
||||||
});
|
});
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { job });
|
return json(200, { job });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(400, { error: error.message });
|
return json(400, { error: error.message });
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function boolFromEnv(name, fallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsed = {
|
const parsed = {
|
||||||
|
nodeEnv: strFromEnv("NODE_ENV", "development"),
|
||||||
port: intFromEnv("PORT", 3000),
|
port: intFromEnv("PORT", 3000),
|
||||||
logLevel: strFromEnv("LOG_LEVEL", "info"),
|
logLevel: strFromEnv("LOG_LEVEL", "info"),
|
||||||
appBaseUrl: strFromEnv("APP_BASE_URL", "http://localhost:3000"),
|
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({
|
const ConfigSchema = z.object({
|
||||||
|
nodeEnv: z.string().min(1),
|
||||||
port: z.number().int().positive(),
|
port: z.number().int().positive(),
|
||||||
logLevel: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]),
|
logLevel: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]),
|
||||||
appBaseUrl: z.string().min(1),
|
appBaseUrl: z.string().min(1),
|
||||||
@@ -150,6 +161,8 @@ const ConfigSchema = z.object({
|
|||||||
stepCredits: z.number().int().positive(),
|
stepCredits: z.number().int().positive(),
|
||||||
maxCharsPerArticle: z.number().int().positive(),
|
maxCharsPerArticle: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
|
allowInMemoryStateFallback: z.boolean(),
|
||||||
|
enableDevRoutes: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = ConfigSchema.parse(parsed);
|
const config = ConfigSchema.parse(parsed);
|
||||||
|
|||||||
@@ -2,14 +2,28 @@
|
|||||||
|
|
||||||
const crypto = require("node:crypto");
|
const crypto = require("node:crypto");
|
||||||
|
|
||||||
function hmacSHA256Hex(payload, secret) {
|
function hmacSHA256(payload, secret, encoding = "hex") {
|
||||||
return crypto
|
return crypto
|
||||||
.createHmac("sha256", secret)
|
.createHmac("sha256", secret)
|
||||||
.update(payload, "utf8")
|
.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) {
|
if (!secret) {
|
||||||
throw new Error("webhook_secret_required");
|
throw new Error("webhook_secret_required");
|
||||||
}
|
}
|
||||||
@@ -18,14 +32,15 @@ function verifySignature({ payload, secret, signature }) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = signature.startsWith("sha256=")
|
const normalized = prefix && signature.startsWith(prefix)
|
||||||
? signature.slice("sha256=".length)
|
? signature.slice(prefix.length)
|
||||||
: signature;
|
: 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 expectedBuf = Buffer.from(expected, binaryEncoding);
|
||||||
const givenBuf = Buffer.from(normalized, "hex");
|
const givenBuf = Buffer.from(normalized, binaryEncoding);
|
||||||
|
|
||||||
if (expectedBuf.length !== givenBuf.length) {
|
if (expectedBuf.length !== givenBuf.length) {
|
||||||
return false;
|
return false;
|
||||||
@@ -35,6 +50,8 @@ function verifySignature({ payload, secret, signature }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
hmacSHA256,
|
||||||
hmacSHA256Hex,
|
hmacSHA256Hex,
|
||||||
|
hmacSHA256Base64,
|
||||||
verifySignature,
|
verifySignature,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,13 +64,19 @@ function createHttpServer({ app }) {
|
|||||||
|
|
||||||
function createMutationPersister({ stateStore, logger = console }) {
|
function createMutationPersister({ stateStore, logger = console }) {
|
||||||
let queue = Promise.resolve();
|
let queue = Promise.resolve();
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enqueue(state) {
|
enqueue(state) {
|
||||||
queue = queue
|
queue = queue
|
||||||
.then(() => stateStore.save(state))
|
.then(
|
||||||
|
() => stateStore.save(state),
|
||||||
|
() => stateStore.save(state),
|
||||||
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
lastError = error;
|
||||||
logger.error({ err: error }, "failed to persist state");
|
logger.error({ err: error }, "failed to persist state");
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
return queue;
|
return queue;
|
||||||
@@ -78,6 +84,9 @@ function createMutationPersister({ stateStore, logger = console }) {
|
|||||||
flush() {
|
flush() {
|
||||||
return queue;
|
return queue;
|
||||||
},
|
},
|
||||||
|
getLastError() {
|
||||||
|
return lastError;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +101,15 @@ async function createRuntime({ runtimeConfig = config, logger = console, stateSt
|
|||||||
try {
|
try {
|
||||||
initialState = await effectiveStateStore.load();
|
initialState = await effectiveStateStore.load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
const allowFallback = runtimeConfig.allowInMemoryStateFallback !== undefined
|
||||||
{ err: error },
|
? Boolean(runtimeConfig.allowInMemoryStateFallback)
|
||||||
"failed to initialize configured state store; falling back to in-memory state",
|
: 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();
|
effectiveStateStore = new InMemoryStateStore();
|
||||||
initialState = await effectiveStateStore.load();
|
initialState = await effectiveStateStore.load();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@plugin "daisyui" {
|
@plugin "daisyui" {
|
||||||
themes: dim --default;
|
themes: black --default, light;
|
||||||
logs: false;
|
logs: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", sans-serif;
|
||||||
|
}
|
||||||
@@ -9,28 +9,86 @@ function escapeHtml(value) {
|
|||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function shell({ title, content, compact = false }) {
|
function shell({ title, content, compact = false, user = null }) {
|
||||||
const container = compact ? "max-w-xl" : "max-w-5xl";
|
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>
|
return `<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class="scroll-smooth">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>${escapeHtml(title)}</title>
|
<title>${escapeHtml(title)}</title>
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link href="/assets/styles.css" rel="stylesheet" type="text/css" />
|
<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>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
<body class="min-h-screen bg-base-100 text-base-content antialiased flex flex-col">
|
||||||
<div class="fixed inset-0 -z-10 overflow-hidden">
|
<div class="fixed inset-0 -z-10 bg-base-100 selection:bg-primary selection:text-primary-content"></div>
|
||||||
<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>
|
<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="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(30,41,59,.65),rgba(2,6,23,1)_45%)]"></div>
|
<div class="navbar-start">
|
||||||
</div>
|
<a href="/" class="btn btn-ghost text-xl font-bold tracking-tight px-0 hover:bg-transparent">XArtAudio</a>
|
||||||
<main class="${container} mx-auto px-4 py-6 md:py-10">
|
</div>
|
||||||
|
<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}
|
${content}
|
||||||
</main>
|
</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>
|
<script>
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
@@ -40,113 +98,103 @@ function shell({ title, content, compact = false }) {
|
|||||||
</html>`;
|
</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 }) {
|
function renderLandingPage({ authenticated, userId }) {
|
||||||
const primaryCta = authenticated
|
const primaryCta = authenticated
|
||||||
? `<a href="/app" class="btn btn-primary btn-lg">Open Dashboard</a>`
|
? `<a href="/app" class="btn btn-primary btn-lg font-bold shadow-lg hover:shadow-xl transition-all">Open Dashboard</a>`
|
||||||
: `<a href="/login" class="btn btn-primary btn-lg">Start Free</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({
|
return shell({
|
||||||
title: "XArtAudio | Turn X Articles into Audiobooks",
|
title: "XArtAudio | Turn X Articles into Audiobooks",
|
||||||
|
user: { authenticated, userId },
|
||||||
content: `
|
content: `
|
||||||
${nav({ authenticated, userId })}
|
<section class="py-16 md:py-24 text-center max-w-3xl mx-auto">
|
||||||
<section class="hero min-h-[60vh] bg-base-200 rounded-box mb-12">
|
<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">
|
||||||
<div class="hero-content flex-col lg:flex-row-reverse gap-8 p-8">
|
<span class="badge badge-primary badge-xs">New</span> Webhook-first automation
|
||||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
|
</div>
|
||||||
<div class="card-body">
|
<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">
|
||||||
<h2 class="card-title justify-center mb-4">Live flow</h2>
|
Listen to X Articles.
|
||||||
<ul class="steps steps-vertical w-full">
|
</h1>
|
||||||
<li class="step step-primary">User mentions <code>@YourBot</code></li>
|
<p class="text-xl md:text-2xl text-base-content/70 mb-10 leading-relaxed max-w-2xl mx-auto">
|
||||||
<li class="step step-primary">Webhook charges credits</li>
|
Mention our bot under any long-form X post. We'll convert it to audio and send you a link instantly.
|
||||||
<li class="step step-primary">Audio generated</li>
|
</p>
|
||||||
<li class="step">Unlock & Listen</li>
|
${primaryCta}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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 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>
|
</ul>
|
||||||
|
<a href="/login" class="btn btn-primary w-full shadow-md hover:shadow-lg">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="bg-base-300/50 p-8 md:p-12 flex flex-col justify-center">
|
||||||
<div class="text-center lg:text-left">
|
<h4 class="font-bold mb-4">Credit Logic</h4>
|
||||||
<span class="badge badge-accent mb-4">Webhook-first automation</span>
|
<div class="space-y-4 text-sm">
|
||||||
<h1 class="text-5xl font-bold">From X Article to audiobook.</h1>
|
<div class="flex justify-between border-b border-base-content/10 pb-2">
|
||||||
<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>
|
<span>Base (up to 25k chars)</span>
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
|
<span class="font-mono font-bold">1 credit</span>
|
||||||
${primaryCta}
|
</div>
|
||||||
<a href="/app" class="btn btn-outline btn-lg">See Product</a>
|
<div class="flex justify-between border-b border-base-content/10 pb-2">
|
||||||
</div>
|
<span>Additional 10k chars</span>
|
||||||
<p class="text-xs text-base-content/60 mt-4">Caller pays credits. ID: ${escapeHtml(userId || 'guest')}</p>
|
<span class="font-mono font-bold">+1 credit</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex justify-between text-base-content/60">
|
||||||
</section>
|
<span>Maximum article size</span>
|
||||||
|
<span class="font-mono">120k chars</span>
|
||||||
<section id="how" class="grid gap-6 md:grid-cols-3 mb-12">
|
</div>
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
</div>
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,127 +204,168 @@ function renderLandingPage({ authenticated, userId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderLoginPage({ returnTo = "/app", error = null }) {
|
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({
|
return shell({
|
||||||
title: "Sign in | XArtAudio",
|
title: "Sign in | XArtAudio",
|
||||||
compact: true,
|
user: null,
|
||||||
content: `
|
content: `
|
||||||
${nav({ authenticated: false, userId: null })}
|
<div class="flex flex-col items-center justify-center min-h-[60vh]">
|
||||||
<section class="card bg-slate-900/80 border border-slate-700 shadow-xl">
|
<div class="w-full max-w-sm">
|
||||||
<div class="card-body">
|
<div class="text-center mb-8">
|
||||||
<h1 class="text-2xl font-bold">Sign in</h1>
|
<h1 class="text-3xl font-bold mb-2">Welcome back</h1>
|
||||||
<p class="text-sm text-slate-300">Use your email account or continue with X.</p>
|
<p class="text-base-content/60">Sign in to your account to continue</p>
|
||||||
${errorBlock}
|
</div>
|
||||||
<form method="POST" action="/auth/x" class="mb-4">
|
|
||||||
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
|
<div class="card bg-base-100 border border-base-content/10 shadow-sm">
|
||||||
<button class="btn btn-accent w-full">Continue with X</button>
|
<div class="card-body p-6">
|
||||||
</form>
|
${errorBlock}
|
||||||
<div class="divider text-xs uppercase text-slate-400">Email</div>
|
<form method="POST" action="/auth/x" class="mb-6">
|
||||||
<form method="POST" action="/auth/email/sign-in" class="space-y-3">
|
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
|
||||||
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
|
<button class="btn btn-outline w-full">Continue with X</button>
|
||||||
<label class="form-control w-full">
|
</form>
|
||||||
<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" />
|
<div class="divider text-xs text-base-content/40 my-6">Email</div>
|
||||||
</label>
|
|
||||||
<label class="form-control w-full">
|
<form method="POST" action="/auth/email/sign-in" class="space-y-3">
|
||||||
<span class="label-text text-slate-200 mb-1">Password</span>
|
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
|
||||||
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full bg-slate-950" placeholder="••••••••" />
|
<label class="form-control">
|
||||||
</label>
|
<span class="label-text font-medium mb-1">Email</span>
|
||||||
<button class="btn btn-primary w-full">Sign in with email</button>
|
<input name="email" type="email" required class="input input-bordered w-full" placeholder="you@domain.com" />
|
||||||
</form>
|
</label>
|
||||||
<div class="divider text-xs uppercase text-slate-400">Create account</div>
|
<label class="form-control">
|
||||||
<form method="POST" action="/auth/email/sign-up" class="space-y-3">
|
<span class="label-text font-medium mb-1">Password</span>
|
||||||
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
|
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full" placeholder="••••••••" />
|
||||||
<label class="form-control w-full">
|
</label>
|
||||||
<span class="label-text text-slate-200 mb-1">Name</span>
|
<button class="btn btn-primary w-full shadow-sm hover:shadow">Sign in</button>
|
||||||
<input name="name" required minlength="2" maxlength="80" class="input input-bordered w-full bg-slate-950" placeholder="Matiss" />
|
</form>
|
||||||
</label>
|
|
||||||
<label class="form-control w-full">
|
<div class="divider text-xs text-base-content/40 my-6">Create account</div>
|
||||||
<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" />
|
<form method="POST" action="/auth/email/sign-up" class="space-y-3">
|
||||||
</label>
|
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
|
||||||
<label class="form-control w-full">
|
<label class="form-control">
|
||||||
<span class="label-text text-slate-200 mb-1">Password</span>
|
<span class="label-text font-medium mb-1">Name</span>
|
||||||
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full bg-slate-950" placeholder="••••••••" />
|
<input name="name" required minlength="2" maxlength="80" class="input input-bordered w-full" placeholder="Matiss" />
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-outline w-full">Create account</button>
|
<label class="form-control">
|
||||||
</form>
|
<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">
|
||||||
|
<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-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>
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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
|
const jobsMarkup = jobs.length === 0
|
||||||
? `<div class="text-center py-10 text-base-content/60 bg-base-200 rounded-box">
|
? `<div class="text-center py-16 bg-base-100 border border-base-content/10 rounded-xl">
|
||||||
<p>No generated audiobooks yet.</p>
|
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<p class="text-sm mt-1">Use the simulation form below or call the bot on X.</p>
|
<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>`
|
</div>
|
||||||
: `<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<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) => `
|
${jobs.map((job) => `
|
||||||
<a class="card bg-base-100 shadow-md hover:shadow-xl transition-all border border-base-200" href="/audio/${job.assetId}">
|
<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-4">
|
<div class="card-body p-5">
|
||||||
<h3 class="font-bold text-lg truncate" title="${escapeHtml(job.article.title)}">${escapeHtml(job.article.title)}</h3>
|
<div class="flex justify-between items-start mb-2">
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="badge badge-sm badge-ghost font-mono text-xs opacity-70">${escapeHtml(job.status)}</div>
|
||||||
<span class="badge badge-sm badge-ghost">credits: ${job.creditsCharged}</span>
|
<span class="text-xs font-mono opacity-50 text-right">${job.creditsCharged} cr</span>
|
||||||
<span class="text-xs uppercase font-mono opacity-70">${escapeHtml(job.status)}</span>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</a>`).join("")}
|
</a>`).join("")}
|
||||||
</div>`;
|
</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({
|
return shell({
|
||||||
title: "Dashboard | XArtAudio",
|
title: "Dashboard | XArtAudio",
|
||||||
|
user: { authenticated: true, userId },
|
||||||
content: `
|
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">
|
||||||
${flashMarkup}
|
<div>
|
||||||
<section class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200 mb-8">
|
<h1 class="text-3xl font-bold mb-1">Dashboard</h1>
|
||||||
<div class="stat">
|
<p class="text-base-content/60">Manage your credits and audiobooks</p>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
${flashMarkup}
|
||||||
<h2 class="text-2xl font-bold mb-4">Recent audiobooks</h2>
|
|
||||||
${jobsMarkup}
|
<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">
|
||||||
</section>
|
<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>
|
||||||
|
<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({
|
return shell({
|
||||||
title: "Audio not found",
|
title: "Audio not found",
|
||||||
compact: true,
|
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
|
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"
|
: accessDecision.reason === "auth_required"
|
||||||
? `<div role="alert" class="alert alert-warning mb-4"><span>Sign in to continue.</span></div>
|
? `<div class="card bg-base-200 border border-base-content/10">
|
||||||
<a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary w-full">Sign in to unlock</a>`
|
<div class="card-body items-center text-center p-6">
|
||||||
: `<div role="alert" class="alert alert-warning mb-4"><span>Unlock required: ${accessDecision.creditsRequired} credits.</span></div>
|
<h3 class="font-bold text-lg mb-2">Sign in to listen</h3>
|
||||||
<form method="POST" action="/audio/${audio.id}/unlock">
|
<p class="text-sm mb-4 text-base-content/70">You need an account to unlock this audiobook.</p>
|
||||||
<button class="btn btn-primary w-full">Pay ${accessDecision.creditsRequired} credits and unlock forever</button>
|
<a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary w-full max-w-xs">Sign in to unlock</a>
|
||||||
</form>`;
|
</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({
|
return shell({
|
||||||
title: `${escapeHtml(audio.articleTitle)} | XArtAudio`,
|
title: `${escapeHtml(audio.articleTitle)} | XArtAudio`,
|
||||||
compact: true,
|
compact: true,
|
||||||
|
user: { authenticated: Boolean(userId), userId },
|
||||||
content: `
|
content: `
|
||||||
${nav({ authenticated: Boolean(userId), userId })}
|
<div class="mb-6">
|
||||||
<div class="card w-full max-w-2xl mx-auto shadow-2xl bg-base-100 border border-base-200">
|
<a href="/app" class="btn btn-ghost btn-sm gap-2 pl-0 hover:bg-transparent text-base-content/60 hover:text-base-content">
|
||||||
<div class="card-body p-6">
|
<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>
|
||||||
<span class="badge badge-accent mb-2">Audiobook</span>
|
Back to Dashboard
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold mb-2 leading-tight">${escapeHtml(audio.articleTitle)}</h1>
|
</a>
|
||||||
<div class="text-sm text-base-content/60 mb-6 flex gap-3 text-mono">
|
</div>
|
||||||
<span>Duration ~ ${audio.durationSec}s</span>
|
|
||||||
<span>•</span>
|
<div class="card w-full shadow-xl bg-base-100 border border-base-content/10 overflow-hidden">
|
||||||
<span>Asset ${escapeHtml(audio.id.substring(0, 8))}...</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></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}
|
${action}
|
||||||
|
|
||||||
${accessDecision.allowed
|
${accessDecision.allowed
|
||||||
? `<div class="mt-6">
|
? `<div class="mt-8 pt-6 border-t border-base-content/10">
|
||||||
<h3 class="font-bold mb-2">Direct Stream URL</h3>
|
<h3 class="font-bold mb-3 text-xs uppercase tracking-widest opacity-60">Direct Stream URL</h3>
|
||||||
<div class="mockup-code bg-neutral text-neutral-content p-4 text-sm overflow-x-auto">
|
<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>
|
<pre><code>${escapeHtml(playbackUrl || `stream://${audio.storageKey}`)}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
|
|||||||
101
test/app.test.js
101
test/app.test.js
@@ -3,7 +3,7 @@
|
|||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const { buildApp } = require("../src/app");
|
const { buildApp } = require("../src/app");
|
||||||
const { hmacSHA256Hex } = require("../src/lib/signature");
|
const { hmacSHA256Hex, hmacSHA256Base64 } = require("../src/lib/signature");
|
||||||
|
|
||||||
function getTestCookieValue(cookieHeader, name) {
|
function getTestCookieValue(cookieHeader, name) {
|
||||||
const parts = String(cookieHeader || "").split(";").map((part) => part.trim());
|
const parts = String(cookieHeader || "").split(";").map((part) => part.trim());
|
||||||
@@ -76,7 +76,7 @@ function createApp(options = {}) {
|
|||||||
betterAuthBasePath: "/api/auth",
|
betterAuthBasePath: "/api/auth",
|
||||||
xOAuthClientId: "x-client-id",
|
xOAuthClientId: "x-client-id",
|
||||||
xOAuthClientSecret: "x-client-secret",
|
xOAuthClientSecret: "x-client-secret",
|
||||||
internalApiToken: "",
|
internalApiToken: "internal-token",
|
||||||
convexDeploymentUrl: "",
|
convexDeploymentUrl: "",
|
||||||
convexAuthToken: "",
|
convexAuthToken: "",
|
||||||
convexStateQuery: "state:getLatestSnapshot",
|
convexStateQuery: "state:getLatestSnapshot",
|
||||||
@@ -169,7 +169,7 @@ test("GET / renders landing page", async () => {
|
|||||||
const app = createApp();
|
const app = createApp();
|
||||||
const response = await 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/);
|
assert.match(response.body, /Listen to X Articles/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("GET /assets/styles.css serves compiled stylesheet", async () => {
|
test("GET /assets/styles.css serves compiled stylesheet", async () => {
|
||||||
@@ -229,7 +229,7 @@ test("authenticated dashboard topup + simulate mention flow", async () => {
|
|||||||
headers: { cookie: cookieHeader },
|
headers: { cookie: cookieHeader },
|
||||||
});
|
});
|
||||||
assert.equal(dashboard.status, 200);
|
assert.equal(dashboard.status, 200);
|
||||||
assert.match(dashboard.body, /Recent audiobooks/);
|
assert.match(dashboard.body, /Recent Audiobooks/);
|
||||||
assert.match(dashboard.body, /Hello/);
|
assert.match(dashboard.body, /Hello/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@ test("audio flow requires auth for unlock and supports permanent unlock", async
|
|||||||
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 this audiobook permanently for/);
|
||||||
|
|
||||||
const unlock = await call(app, {
|
const unlock = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -278,7 +278,7 @@ test("audio flow requires auth for unlock and supports permanent unlock", async
|
|||||||
path: audioPath,
|
path: audioPath,
|
||||||
headers: { cookie: "xartaudio_user=viewer" },
|
headers: { cookie: "xartaudio_user=viewer" },
|
||||||
});
|
});
|
||||||
assert.match(afterUnlock.body, /Access granted/);
|
assert.match(afterUnlock.body, /Unlocked!/);
|
||||||
|
|
||||||
const wallet = await call(app, {
|
const wallet = await call(app, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -381,6 +381,7 @@ test("/api/x/mentions returns upstream mentions when configured", async () => {
|
|||||||
const response = await call(app, {
|
const response = await call(app, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/api/x/mentions",
|
path: "/api/x/mentions",
|
||||||
|
headers: { "x-internal-token": "internal-token" },
|
||||||
query: { sinceId: "100" },
|
query: { sinceId: "100" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -390,6 +391,61 @@ test("/api/x/mentions returns upstream mentions when configured", async () => {
|
|||||||
assert.equal(body.mentions[0].id, "m1");
|
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 () => {
|
test("simulate mention schedules background audio generation when service is configured", async () => {
|
||||||
const queued = [];
|
const queued = [];
|
||||||
const app = createApp({
|
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 () => {
|
test("internal endpoints are disabled when no token configured", async () => {
|
||||||
const app = createApp();
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
internalApiToken: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
const response = await call(app, {
|
const response = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/internal/retention/run",
|
path: "/internal/retention/run",
|
||||||
@@ -638,6 +698,33 @@ test("X webhook invalid signature is rejected", async () => {
|
|||||||
assert.equal(response.status, 401);
|
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 () => {
|
test("X webhook valid flow processes article", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function withTempEnv(patch, run) {
|
|||||||
|
|
||||||
test("config uses defaults when env is missing", () => {
|
test("config uses defaults when env is missing", () => {
|
||||||
withTempEnv({
|
withTempEnv({
|
||||||
|
NODE_ENV: "",
|
||||||
PORT: "",
|
PORT: "",
|
||||||
LOG_LEVEL: "",
|
LOG_LEVEL: "",
|
||||||
APP_BASE_URL: "",
|
APP_BASE_URL: "",
|
||||||
@@ -42,8 +43,10 @@ test("config uses defaults when env is missing", () => {
|
|||||||
MINIO_SIGNED_URL_TTL_SEC: "",
|
MINIO_SIGNED_URL_TTL_SEC: "",
|
||||||
MINIO_USE_SSL: "",
|
MINIO_USE_SSL: "",
|
||||||
WEBHOOK_RPM: "",
|
WEBHOOK_RPM: "",
|
||||||
|
ALLOW_IN_MEMORY_STATE_FALLBACK: "",
|
||||||
}, () => {
|
}, () => {
|
||||||
const { config } = require("../src/config");
|
const { config } = require("../src/config");
|
||||||
|
assert.equal(config.nodeEnv, "development");
|
||||||
assert.equal(config.port, 3000);
|
assert.equal(config.port, 3000);
|
||||||
assert.equal(config.logLevel, "info");
|
assert.equal(config.logLevel, "info");
|
||||||
assert.equal(config.appBaseUrl, "http://localhost:3000");
|
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.minioSignedUrlTtlSec, 3600);
|
||||||
assert.equal(config.minioUseSSL, true);
|
assert.equal(config.minioUseSSL, true);
|
||||||
assert.equal(config.rateLimits.webhookPerMinute, 120);
|
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.maxJobsPerUserPerDay, 0);
|
||||||
assert.equal(config.abuse.cooldownSec, 0);
|
assert.equal(config.abuse.cooldownSec, 0);
|
||||||
assert.deepEqual(config.abuse.denyUserIds, []);
|
assert.deepEqual(config.abuse.denyUserIds, []);
|
||||||
@@ -63,6 +68,7 @@ test("config uses defaults when env is missing", () => {
|
|||||||
|
|
||||||
test("config reads convex/qwen/minio overrides", () => {
|
test("config reads convex/qwen/minio overrides", () => {
|
||||||
withTempEnv({
|
withTempEnv({
|
||||||
|
NODE_ENV: "production",
|
||||||
PORT: "8080",
|
PORT: "8080",
|
||||||
LOG_LEVEL: "debug",
|
LOG_LEVEL: "debug",
|
||||||
APP_BASE_URL: "https://xartaudio.app",
|
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_MAX_JOBS_PER_USER_PER_DAY: "5",
|
||||||
ABUSE_COOLDOWN_SEC: "120",
|
ABUSE_COOLDOWN_SEC: "120",
|
||||||
ABUSE_DENY_USER_IDS: "u1,u2",
|
ABUSE_DENY_USER_IDS: "u1,u2",
|
||||||
|
ALLOW_IN_MEMORY_STATE_FALLBACK: "",
|
||||||
}, () => {
|
}, () => {
|
||||||
const { config } = require("../src/config");
|
const { config } = require("../src/config");
|
||||||
|
assert.equal(config.nodeEnv, "production");
|
||||||
assert.equal(config.port, 8080);
|
assert.equal(config.port, 8080);
|
||||||
assert.equal(config.logLevel, "debug");
|
assert.equal(config.logLevel, "debug");
|
||||||
assert.equal(config.appBaseUrl, "https://xartaudio.app");
|
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.maxJobsPerUserPerDay, 5);
|
||||||
assert.equal(config.abuse.cooldownSec, 120);
|
assert.equal(config.abuse.cooldownSec, 120);
|
||||||
assert.deepEqual(config.abuse.denyUserIds, ["u1", "u2"]);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,26 @@ const fs = require("node:fs");
|
|||||||
test("convex state functions are present for configured function names", () => {
|
test("convex state functions are present for configured function names", () => {
|
||||||
const schema = fs.readFileSync("convex/schema.ts", "utf8");
|
const schema = fs.readFileSync("convex/schema.ts", "utf8");
|
||||||
const state = fs.readFileSync("convex/state.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(schema, /state_snapshots/);
|
||||||
assert.match(state, /export const getLatestSnapshot = query/);
|
assert.match(state, /export const getLatestSnapshot = query/);
|
||||||
assert.match(state, /export const saveSnapshot = mutation/);
|
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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const {
|
|||||||
|
|
||||||
function createRuntimeConfig() {
|
function createRuntimeConfig() {
|
||||||
return {
|
return {
|
||||||
|
nodeEnv: "test",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
appBaseUrl: "http://localhost:3000",
|
appBaseUrl: "http://localhost:3000",
|
||||||
@@ -60,6 +61,7 @@ function createRuntimeConfig() {
|
|||||||
stepCredits: 1,
|
stepCredits: 1,
|
||||||
maxCharsPerArticle: 120000,
|
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);
|
assert.equal(response.status, 303);
|
||||||
await runtime.persister.flush();
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
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", () => {
|
test("verifies valid signature", () => {
|
||||||
const payload = JSON.stringify({ hello: "world" });
|
const payload = JSON.stringify({ hello: "world" });
|
||||||
@@ -31,3 +31,19 @@ test("rejects missing signature", () => {
|
|||||||
|
|
||||||
assert.equal(verifySignature({ payload, secret, signature: "" }), false);
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test("shell includes daisyui and pwa tags", () => {
|
|||||||
|
|
||||||
test("landing page renders hero and flow sections", () => {
|
test("landing page renders hero and flow sections", () => {
|
||||||
const html = renderLandingPage({ authenticated: false, userId: null });
|
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="how"/);
|
||||||
assert.match(html, /id="pricing"/);
|
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 }],
|
jobs: [{ assetId: "1", status: "completed", article: { title: "Hello" }, creditsCharged: 1 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.match(html, /Top up credits/);
|
assert.match(html, /Top Up Credits/);
|
||||||
assert.match(html, /Simulate mention/);
|
assert.match(html, /Simulate Mention/);
|
||||||
assert.match(html, /Hello/);
|
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", () => {
|
test("audio page shows unlock action when payment is required", () => {
|
||||||
const html = renderAudioPage({
|
const html = renderAudioPage({
|
||||||
audio: { id: "1", storageKey: "audio/1.mp3", articleTitle: "A", durationSec: 30 },
|
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",
|
userId: "u2",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.match(html, /Pay 3 credits and unlock forever/);
|
assert.match(html, /3 credits/);
|
||||||
|
assert.match(html, /Pay & Listen/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user