feat: migrate auth and state flows to better-auth and convex

This commit is contained in:
Codex
2026-02-18 13:58:42 +00:00
parent 445e5725b3
commit b1eed7fa2c
10 changed files with 704 additions and 253 deletions

View File

@@ -20,11 +20,6 @@ const {
parseFormUrlEncoded, parseFormUrlEncoded,
withQuery, withQuery,
} = require("./lib/http"); } = require("./lib/http");
const {
getAuthenticatedUserId,
serializeUserCookie,
clearUserCookie,
} = require("./lib/auth");
const { FixedWindowRateLimiter } = require("./lib/rate-limit"); const { FixedWindowRateLimiter } = require("./lib/rate-limit");
const { const {
XWebhookPayloadSchema, XWebhookPayloadSchema,
@@ -39,6 +34,7 @@ const { createTTSAdapter } = require("./integrations/tts-client");
const { createStorageAdapter } = require("./integrations/storage-client"); const { createStorageAdapter } = require("./integrations/storage-client");
const { createXAdapter } = require("./integrations/x-client"); const { createXAdapter } = require("./integrations/x-client");
const { createAudioGenerationService } = require("./services/audio-generation"); const { createAudioGenerationService } = require("./services/audio-generation");
const { createBetterAuthAdapter } = require("./integrations/better-auth");
const STYLESHEET_PATH = pathLib.join(__dirname, "public", "styles.css"); const STYLESHEET_PATH = pathLib.join(__dirname, "public", "styles.css");
@@ -67,6 +63,7 @@ function buildApp({
xAdapter = null, xAdapter = null,
ttsAdapter = null, ttsAdapter = null,
storageAdapter = null, storageAdapter = null,
authAdapter = null,
audioGenerationService = null, audioGenerationService = null,
}) { }) {
const engine = new XArtAudioEngine({ const engine = new XArtAudioEngine({
@@ -85,18 +82,28 @@ function buildApp({
botUserId: config.xBotUserId, botUserId: config.xBotUserId,
}); });
const tts = ttsAdapter || createTTSAdapter({ const tts = ttsAdapter || createTTSAdapter({
apiKey: config.ttsApiKey, apiKey: config.qwenTtsApiKey,
baseURL: config.ttsBaseUrl || undefined, baseURL: config.qwenTtsBaseUrl,
model: config.ttsModel, model: config.qwenTtsModel,
voice: config.ttsVoice, voice: config.qwenTtsVoice,
format: config.qwenTtsFormat,
}); });
const storage = storageAdapter || createStorageAdapter({ const storage = storageAdapter || createStorageAdapter({
bucket: config.s3Bucket, bucket: config.minioBucket,
region: config.s3Region, endPoint: config.minioEndPoint,
endpoint: config.s3Endpoint || undefined, port: config.minioPort,
accessKeyId: config.s3AccessKeyId, useSSL: config.minioUseSSL,
secretAccessKey: config.s3SecretAccessKey, region: config.minioRegion,
signedUrlTtlSec: config.s3SignedUrlTtlSec, accessKey: config.minioAccessKey,
secretKey: config.minioSecretKey,
signedUrlTtlSec: config.minioSignedUrlTtlSec,
});
const auth = authAdapter || createBetterAuthAdapter({
appBaseUrl: config.appBaseUrl,
basePath: config.betterAuthBasePath,
secret: config.betterAuthSecret,
devPassword: config.betterAuthDevPassword,
logger,
}); });
const generationService = audioGenerationService || createAudioGenerationService({ const generationService = audioGenerationService || createAudioGenerationService({
tts, tts,
@@ -283,7 +290,7 @@ function buildApp({
async function handleRequest({ method, path, headers, rawBody, query }) { async function handleRequest({ method, path, headers, rawBody, query }) {
const safeHeaders = headers || {}; const safeHeaders = headers || {};
const safeQuery = query || {}; const safeQuery = query || {};
const userId = getAuthenticatedUserId(safeHeaders); const userId = await auth.getAuthenticatedUserId(safeHeaders);
const clientAddress = clientAddressFromHeaders(safeHeaders); const clientAddress = clientAddressFromHeaders(safeHeaders);
if (method === "GET" && path === "/health") { if (method === "GET" && path === "/health") {
@@ -329,6 +336,15 @@ function buildApp({
} }
} }
if (auth.handlesPath(path)) {
return auth.handleRoute({
method,
path,
headers: safeHeaders,
rawBody,
});
}
if (method === "GET" && path === "/") { if (method === "GET" && path === "/") {
return html(200, renderLandingPage({ authenticated: Boolean(userId), userId })); return html(200, renderLandingPage({ authenticated: Boolean(userId), userId }));
} }
@@ -354,8 +370,9 @@ function buildApp({
try { try {
const login = parseOrThrow(LoginFormSchema, form); const login = parseOrThrow(LoginFormSchema, form);
const nextPath = sanitizeReturnTo(login.returnTo, "/app"); const nextPath = sanitizeReturnTo(login.returnTo, "/app");
const authSession = await auth.signInDevUser(login.userId);
return redirect(nextPath, { return redirect(nextPath, {
"set-cookie": serializeUserCookie(login.userId), "set-cookie": authSession.setCookie,
}); });
} catch (error) { } catch (error) {
return redirect(withQuery("/login", { return redirect(withQuery("/login", {
@@ -366,9 +383,10 @@ function buildApp({
} }
if (method === "POST" && path === "/auth/logout") { if (method === "POST" && path === "/auth/logout") {
return redirect("/", { const signOut = await auth.signOut(safeHeaders);
"set-cookie": clearUserCookie(), return redirect("/", signOut.setCookie
}); ? { "set-cookie": signOut.setCookie }
: undefined);
} }
if (method === "GET" && path === "/app") { if (method === "GET" && path === "/app") {

View File

@@ -31,11 +31,33 @@ function listFromEnv(name, fallback = []) {
.filter(Boolean); .filter(Boolean);
} }
function boolFromEnv(name, fallback) {
const raw = process.env[name];
if (!raw) {
return fallback;
}
const normalized = String(raw).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return fallback;
}
const parsed = { const parsed = {
port: intFromEnv("PORT", 3000), port: intFromEnv("PORT", 3000),
stateFilePath: strFromEnv("STATE_FILE_PATH", "./data/state.json"),
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"),
betterAuthSecret: strFromEnv("BETTER_AUTH_SECRET", "dev-better-auth-secret"),
betterAuthBasePath: strFromEnv("BETTER_AUTH_BASE_PATH", "/api/auth"),
betterAuthDevPassword: strFromEnv("BETTER_AUTH_DEV_PASSWORD", "xartaudio-dev-password"),
convexDeploymentUrl: strFromEnv("CONVEX_DEPLOYMENT_URL", ""),
convexAuthToken: strFromEnv("CONVEX_AUTH_TOKEN", ""),
convexStateQuery: strFromEnv("CONVEX_STATE_QUERY", "state:getLatestSnapshot"),
convexStateMutation: strFromEnv("CONVEX_STATE_MUTATION", "state:saveSnapshot"),
xWebhookSecret: process.env.X_WEBHOOK_SECRET || "dev-x-secret", xWebhookSecret: process.env.X_WEBHOOK_SECRET || "dev-x-secret",
xBearerToken: strFromEnv("X_BEARER_TOKEN", ""), xBearerToken: strFromEnv("X_BEARER_TOKEN", ""),
xBotUserId: strFromEnv("X_BOT_USER_ID", ""), xBotUserId: strFromEnv("X_BOT_USER_ID", ""),
@@ -43,16 +65,19 @@ const parsed = {
polarAccessToken: strFromEnv("POLAR_ACCESS_TOKEN", ""), polarAccessToken: strFromEnv("POLAR_ACCESS_TOKEN", ""),
polarServer: strFromEnv("POLAR_SERVER", "production"), polarServer: strFromEnv("POLAR_SERVER", "production"),
polarProductIds: listFromEnv("POLAR_PRODUCT_IDS", []), polarProductIds: listFromEnv("POLAR_PRODUCT_IDS", []),
ttsApiKey: strFromEnv("TTS_API_KEY", ""), qwenTtsApiKey: strFromEnv("QWEN_TTS_API_KEY", ""),
ttsBaseUrl: strFromEnv("TTS_BASE_URL", ""), qwenTtsBaseUrl: strFromEnv("QWEN_TTS_BASE_URL", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
ttsModel: strFromEnv("TTS_MODEL", "gpt-4o-mini-tts"), qwenTtsModel: strFromEnv("QWEN_TTS_MODEL", "qwen-tts-latest"),
ttsVoice: strFromEnv("TTS_VOICE", "alloy"), qwenTtsVoice: strFromEnv("QWEN_TTS_VOICE", "Cherry"),
s3Bucket: strFromEnv("S3_BUCKET", ""), qwenTtsFormat: strFromEnv("QWEN_TTS_FORMAT", "mp3"),
s3Region: strFromEnv("S3_REGION", ""), minioEndPoint: strFromEnv("MINIO_ENDPOINT", ""),
s3Endpoint: strFromEnv("S3_ENDPOINT", ""), minioPort: intFromEnv("MINIO_PORT", 443),
s3AccessKeyId: strFromEnv("S3_ACCESS_KEY_ID", ""), minioUseSSL: boolFromEnv("MINIO_USE_SSL", true),
s3SecretAccessKey: strFromEnv("S3_SECRET_ACCESS_KEY", ""), minioBucket: strFromEnv("MINIO_BUCKET", ""),
s3SignedUrlTtlSec: intFromEnv("S3_SIGNED_URL_TTL_SEC", 3600), minioRegion: strFromEnv("MINIO_REGION", "us-east-1"),
minioAccessKey: strFromEnv("MINIO_ACCESS_KEY", ""),
minioSecretKey: strFromEnv("MINIO_SECRET_KEY", ""),
minioSignedUrlTtlSec: intFromEnv("MINIO_SIGNED_URL_TTL_SEC", 3600),
rateLimits: { rateLimits: {
webhookPerMinute: intFromEnv("WEBHOOK_RPM", 120), webhookPerMinute: intFromEnv("WEBHOOK_RPM", 120),
authPerMinute: intFromEnv("AUTH_RPM", 30), authPerMinute: intFromEnv("AUTH_RPM", 30),
@@ -69,9 +94,15 @@ const parsed = {
const ConfigSchema = z.object({ const ConfigSchema = z.object({
port: z.number().int().positive(), port: z.number().int().positive(),
stateFilePath: z.string().min(1),
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),
betterAuthSecret: z.string().min(1),
betterAuthBasePath: z.string().min(1),
betterAuthDevPassword: z.string().min(8),
convexDeploymentUrl: z.string(),
convexAuthToken: z.string(),
convexStateQuery: z.string().min(1),
convexStateMutation: z.string().min(1),
xWebhookSecret: z.string().min(1), xWebhookSecret: z.string().min(1),
xBearerToken: z.string(), xBearerToken: z.string(),
xBotUserId: z.string(), xBotUserId: z.string(),
@@ -79,16 +110,19 @@ const ConfigSchema = z.object({
polarAccessToken: z.string(), polarAccessToken: z.string(),
polarServer: z.enum(["production", "sandbox"]), polarServer: z.enum(["production", "sandbox"]),
polarProductIds: z.array(z.string().min(1)), polarProductIds: z.array(z.string().min(1)),
ttsApiKey: z.string(), qwenTtsApiKey: z.string(),
ttsBaseUrl: z.string(), qwenTtsBaseUrl: z.string().min(1),
ttsModel: z.string().min(1), qwenTtsModel: z.string().min(1),
ttsVoice: z.string().min(1), qwenTtsVoice: z.string().min(1),
s3Bucket: z.string(), qwenTtsFormat: z.string().min(1),
s3Region: z.string(), minioEndPoint: z.string(),
s3Endpoint: z.string(), minioPort: z.number().int().positive(),
s3AccessKeyId: z.string(), minioUseSSL: z.boolean(),
s3SecretAccessKey: z.string(), minioBucket: z.string(),
s3SignedUrlTtlSec: z.number().int().positive(), minioRegion: z.string(),
minioAccessKey: z.string(),
minioSecretKey: z.string(),
minioSignedUrlTtlSec: z.number().int().positive(),
rateLimits: z.object({ rateLimits: z.object({
webhookPerMinute: z.number().int().positive(), webhookPerMinute: z.number().int().positive(),
authPerMinute: z.number().int().positive(), authPerMinute: z.number().int().positive(),

View File

@@ -0,0 +1,273 @@
"use strict";
function sanitizeUserId(userId) {
return String(userId || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function resolveEmailFromUserId(userId) {
const raw = String(userId || "").trim();
if (raw.includes("@")) {
return raw.toLowerCase();
}
const safe = sanitizeUserId(raw) || "user";
return `${safe}@xartaudio.local`;
}
function extractLegacyUserCookie(cookieHeader) {
const match = String(cookieHeader || "").match(/(?:^|;\s*)xartaudio_user=([^;]+)/);
if (!match) {
return null;
}
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
}
function headersFromObject(rawHeaders = {}) {
const headers = new Headers();
for (const [key, value] of Object.entries(rawHeaders)) {
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
headers.append(key, String(item));
}
continue;
}
headers.set(key, String(value));
}
return headers;
}
function responseHeadersToObject(responseHeaders) {
const headers = {};
for (const [key, value] of responseHeaders.entries()) {
headers[key] = value;
}
if (typeof responseHeaders.getSetCookie === "function") {
const cookies = responseHeaders.getSetCookie();
if (cookies && cookies.length > 0) {
headers["set-cookie"] = cookies.length === 1 ? cookies[0] : cookies;
}
} else {
const cookie = responseHeaders.get("set-cookie");
if (cookie) {
headers["set-cookie"] = cookie;
}
}
return headers;
}
function createBetterAuthAdapter({
appBaseUrl,
basePath = "/api/auth",
secret,
devPassword = "xartaudio-dev-password",
authHandler,
logger = console,
} = {}) {
const normalizedBasePath = basePath.startsWith("/") ? basePath : `/${basePath}`;
const memoryDb = {
user: [],
session: [],
account: [],
verification: [],
};
let handlerPromise = null;
async function resolveHandler() {
if (authHandler) {
return authHandler;
}
if (!handlerPromise) {
handlerPromise = (async () => {
const [{ betterAuth }, { memoryAdapter }] = await Promise.all([
import("better-auth"),
import("better-auth/adapters/memory"),
]);
const auth = betterAuth({
appName: "XArtAudio",
baseURL: appBaseUrl,
basePath: normalizedBasePath,
secret,
trustedOrigins: [appBaseUrl],
database: memoryAdapter(memoryDb),
emailAndPassword: {
enabled: true,
autoSignIn: true,
requireEmailVerification: false,
minPasswordLength: 8,
},
});
return auth.handler;
})().catch((error) => {
logger.error({ err: error }, "failed to initialize better-auth");
handlerPromise = null;
throw error;
});
}
return handlerPromise;
}
async function invoke({ method, path, headers, rawBody = "" }) {
const handler = await resolveHandler();
const requestHeaders = headersFromObject(headers || {});
const url = new URL(path, appBaseUrl).toString();
const body = method === "GET" || method === "HEAD" ? undefined : rawBody;
const response = await handler(new Request(url, {
method,
headers: requestHeaders,
body,
}));
return response;
}
return {
isConfigured() {
return Boolean(appBaseUrl && secret);
},
handlesPath(path) {
return path === normalizedBasePath || path.startsWith(`${normalizedBasePath}/`);
},
async handleRoute({ method, path, headers, rawBody }) {
const response = await invoke({ method, path, headers, rawBody });
const responseBody = await response.text();
return {
status: response.status,
headers: responseHeadersToObject(response.headers),
body: responseBody,
};
},
async getAuthenticatedUserId(headers = {}) {
if (headers["x-user-id"]) {
return String(headers["x-user-id"]);
}
const cookieHeader = headers.cookie || "";
if (!cookieHeader) {
return null;
}
try {
const response = await invoke({
method: "GET",
path: `${normalizedBasePath}/get-session`,
headers: {
cookie: cookieHeader,
accept: "application/json",
},
});
if (!response.ok) {
return null;
}
const payload = await response.json().catch(() => null);
const user = payload && payload.user ? payload.user : null;
if (!user) {
return extractLegacyUserCookie(cookieHeader);
}
return user.name || user.email || user.id || null;
} catch {
return extractLegacyUserCookie(cookieHeader);
}
},
async signInDevUser(userId) {
const email = resolveEmailFromUserId(userId);
const signInBody = JSON.stringify({
email,
password: devPassword,
rememberMe: true,
});
let response = await invoke({
method: "POST",
path: `${normalizedBasePath}/sign-in/email`,
headers: {
"content-type": "application/json",
accept: "application/json",
},
rawBody: signInBody,
});
if (!response.ok) {
response = await invoke({
method: "POST",
path: `${normalizedBasePath}/sign-up/email`,
headers: {
"content-type": "application/json",
accept: "application/json",
},
rawBody: JSON.stringify({
name: String(userId),
email,
password: devPassword,
rememberMe: true,
}),
});
}
if (!response.ok) {
const details = await response.text().catch(() => "");
throw new Error(`auth_sign_in_failed:${response.status}:${details}`);
}
const responseHeaders = responseHeadersToObject(response.headers);
if (!responseHeaders["set-cookie"]) {
throw new Error("auth_set_cookie_missing");
}
return {
setCookie: responseHeaders["set-cookie"],
};
},
async signOut(headers = {}) {
const response = await invoke({
method: "POST",
path: `${normalizedBasePath}/sign-out`,
headers: {
cookie: headers.cookie || "",
accept: "application/json",
},
});
const responseHeaders = responseHeadersToObject(response.headers);
return {
ok: response.ok,
setCookie: responseHeaders["set-cookie"] || null,
};
},
};
}
module.exports = {
createBetterAuthAdapter,
};

View File

@@ -0,0 +1,57 @@
"use strict";
const { ConvexHttpClient } = require("convex/browser");
class ConvexStateStore {
constructor({
deploymentUrl,
authToken = "",
readFunction = "state:getLatestSnapshot",
writeFunction = "state:saveSnapshot",
client,
}) {
this.readFunction = readFunction;
this.writeFunction = writeFunction;
this.client = client || (deploymentUrl ? new ConvexHttpClient(deploymentUrl) : null);
if (this.client && authToken && typeof this.client.setAuth === "function") {
this.client.setAuth(authToken);
}
}
isConfigured() {
return Boolean(this.client && this.readFunction && this.writeFunction);
}
async load() {
if (!this.isConfigured()) {
throw new Error("convex_state_store_not_configured");
}
const snapshot = await this.client.query(this.readFunction, {});
if (!snapshot) {
return null;
}
if (snapshot && typeof snapshot === "object" && snapshot.snapshot) {
return snapshot.snapshot;
}
return snapshot;
}
async save(state) {
if (!this.isConfigured()) {
throw new Error("convex_state_store_not_configured");
}
await this.client.mutation(this.writeFunction, {
snapshot: state,
updatedAt: new Date().toISOString(),
});
}
}
module.exports = {
ConvexStateStore,
};

View File

@@ -3,7 +3,7 @@
const http = require("node:http"); const http = require("node:http");
const { buildApp } = require("./app"); const { buildApp } = require("./app");
const { config } = require("./config"); const { config } = require("./config");
const { JsonFileStateStore } = require("./lib/state-store"); const { ConvexStateStore } = require("./lib/convex-state-store");
const { createLogger } = require("./lib/logger"); const { createLogger } = require("./lib/logger");
function readBody(req) { function readBody(req) {
@@ -80,10 +80,15 @@ function createMutationPersister({ stateStore, logger = console }) {
}; };
} }
async function createRuntime({ runtimeConfig = config, logger = console } = {}) { async function createRuntime({ runtimeConfig = config, logger = console, stateStore = null } = {}) {
const stateStore = new JsonFileStateStore(runtimeConfig.stateFilePath); const effectiveStateStore = stateStore || new ConvexStateStore({
const initialState = await stateStore.load(); deploymentUrl: runtimeConfig.convexDeploymentUrl,
const persister = createMutationPersister({ stateStore, logger }); authToken: runtimeConfig.convexAuthToken,
readFunction: runtimeConfig.convexStateQuery,
writeFunction: runtimeConfig.convexStateMutation,
});
const initialState = await effectiveStateStore.load();
const persister = createMutationPersister({ stateStore: effectiveStateStore, logger });
const app = buildApp({ const app = buildApp({
config: runtimeConfig, config: runtimeConfig,

View File

@@ -120,7 +120,7 @@ function renderLoginPage({ returnTo = "/app", error = null }) {
<section class="card bg-slate-900/80 border border-slate-700 shadow-xl"> <section class="card bg-slate-900/80 border border-slate-700 shadow-xl">
<div class="card-body"> <div class="card-body">
<h1 class="text-2xl font-bold">Sign in</h1> <h1 class="text-2xl font-bold">Sign in</h1>
<p class="text-sm text-slate-300">MVP login. Enter a username to create a local session cookie.</p> <p class="text-sm text-slate-300">Dev sign-in powered by Better Auth. Enter a username to create a session.</p>
${errorBlock} ${errorBlock}
<form method="POST" action="/auth/dev-login" class="space-y-3"> <form method="POST" action="/auth/dev-login" class="space-y-3">
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" /> <input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />

View File

@@ -15,16 +15,26 @@ function createApp(options = {}) {
polarServer: "production", polarServer: "production",
polarProductIds: [], polarProductIds: [],
appBaseUrl: "http://localhost:3000", appBaseUrl: "http://localhost:3000",
ttsApiKey: "", betterAuthSecret: "test-better-auth-secret",
ttsBaseUrl: "", betterAuthBasePath: "/api/auth",
ttsModel: "gpt-4o-mini-tts", betterAuthDevPassword: "xartaudio-dev-password",
ttsVoice: "alloy", convexDeploymentUrl: "",
s3Bucket: "", convexAuthToken: "",
s3Region: "", convexStateQuery: "state:getLatestSnapshot",
s3Endpoint: "", convexStateMutation: "state:saveSnapshot",
s3AccessKeyId: "", qwenTtsApiKey: "",
s3SecretAccessKey: "", qwenTtsBaseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
s3SignedUrlTtlSec: 3600, qwenTtsModel: "qwen-tts-latest",
qwenTtsVoice: "Cherry",
qwenTtsFormat: "mp3",
minioBucket: "",
minioEndPoint: "",
minioPort: 443,
minioUseSSL: true,
minioRegion: "us-east-1",
minioAccessKey: "",
minioSecretKey: "",
minioSignedUrlTtlSec: 3600,
rateLimits: { rateLimits: {
webhookPerMinute: 120, webhookPerMinute: 120,
authPerMinute: 30, authPerMinute: 30,
@@ -117,7 +127,7 @@ test("POST /auth/dev-login sets cookie and redirects", async () => {
assert.equal(response.status, 303); assert.equal(response.status, 303);
assert.equal(response.headers.location, "/app"); assert.equal(response.headers.location, "/app");
assert.match(response.headers["set-cookie"], /^xartaudio_user=matiss/); assert.match(String(response.headers["set-cookie"]), /HttpOnly/);
}); });
test("authenticated dashboard topup + simulate mention flow", async () => { test("authenticated dashboard topup + simulate mention flow", async () => {

View File

@@ -0,0 +1,105 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createBetterAuthAdapter } = require("../src/integrations/better-auth");
function createMockAuthHandler() {
const signedIn = new Set();
return async function handler(request) {
const url = new URL(request.url);
const path = url.pathname;
if (path.endsWith("/sign-in/email")) {
return new Response(JSON.stringify({ ok: false }), { status: 401 });
}
if (path.endsWith("/sign-up/email")) {
const body = await request.json();
const userId = String(body.name);
signedIn.add(userId);
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": `xartaudio_better_auth=${userId}; Path=/; HttpOnly`,
},
});
}
if (path.endsWith("/get-session")) {
const cookie = request.headers.get("cookie") || "";
const match = cookie.match(/xartaudio_better_auth=([^;]+)/);
if (!match) {
return new Response(JSON.stringify(null), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify({
user: {
id: match[1],
name: match[1],
},
}), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (path.endsWith("/sign-out")) {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"set-cookie": "xartaudio_better_auth=; Path=/; Max-Age=0",
},
});
}
return new Response("not_found", { status: 404 });
};
}
test("signInDevUser signs up and returns cookie when user does not exist", async () => {
const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000",
secret: "test-secret",
authHandler: createMockAuthHandler(),
});
const result = await adapter.signInDevUser("alice");
assert.match(String(result.setCookie), /xartaudio_better_auth=alice/);
});
test("getAuthenticatedUserId resolves session user from better-auth endpoint", async () => {
const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000",
secret: "test-secret",
authHandler: createMockAuthHandler(),
});
const userId = await adapter.getAuthenticatedUserId({
cookie: "xartaudio_better_auth=bob",
});
assert.equal(userId, "bob");
});
test("handleRoute proxies requests into better-auth handler", async () => {
const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000",
secret: "test-secret",
authHandler: createMockAuthHandler(),
});
const response = await adapter.handleRoute({
method: "POST",
path: "/api/auth/sign-out",
headers: { cookie: "xartaudio_better_auth=alice" },
rawBody: "",
});
assert.equal(response.status, 200);
assert.match(String(response.headers["set-cookie"]), /Max-Age=0/);
});

View File

@@ -3,201 +3,91 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
test("config uses defaults when env is missing", () => { function withTempEnv(patch, run) {
const previous = { const previous = {};
PORT: process.env.PORT, for (const key of Object.keys(patch)) {
STATE_FILE_PATH: process.env.STATE_FILE_PATH, previous[key] = process.env[key];
LOG_LEVEL: process.env.LOG_LEVEL, if (patch[key] === undefined) {
APP_BASE_URL: process.env.APP_BASE_URL, delete process.env[key];
TTS_MODEL: process.env.TTS_MODEL, } else {
S3_SIGNED_URL_TTL_SEC: process.env.S3_SIGNED_URL_TTL_SEC, process.env[key] = patch[key];
X_BOT_USER_ID: process.env.X_BOT_USER_ID, }
WEBHOOK_RPM: process.env.WEBHOOK_RPM, }
POLAR_SERVER: process.env.POLAR_SERVER,
POLAR_PRODUCT_IDS: process.env.POLAR_PRODUCT_IDS,
};
delete process.env.PORT;
delete process.env.STATE_FILE_PATH;
delete process.env.LOG_LEVEL;
delete process.env.APP_BASE_URL;
delete process.env.TTS_MODEL;
delete process.env.S3_SIGNED_URL_TTL_SEC;
delete process.env.X_BOT_USER_ID;
delete process.env.WEBHOOK_RPM;
delete process.env.POLAR_SERVER;
delete process.env.POLAR_PRODUCT_IDS;
try {
delete require.cache[require.resolve("../src/config")]; delete require.cache[require.resolve("../src/config")];
const { config } = require("../src/config"); run();
} finally {
for (const key of Object.keys(patch)) {
if (previous[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = previous[key];
}
}
delete require.cache[require.resolve("../src/config")];
}
}
test("config uses defaults when env is missing", () => {
withTempEnv({
PORT: undefined,
LOG_LEVEL: undefined,
APP_BASE_URL: undefined,
BETTER_AUTH_SECRET: undefined,
BETTER_AUTH_BASE_PATH: undefined,
QWEN_TTS_MODEL: undefined,
MINIO_SIGNED_URL_TTL_SEC: undefined,
MINIO_USE_SSL: undefined,
WEBHOOK_RPM: undefined,
}, () => {
const { config } = require("../src/config");
assert.equal(config.port, 3000); assert.equal(config.port, 3000);
assert.equal(config.stateFilePath, "./data/state.json");
assert.equal(config.logLevel, "info"); assert.equal(config.logLevel, "info");
assert.equal(config.appBaseUrl, "http://localhost:3000"); assert.equal(config.appBaseUrl, "http://localhost:3000");
assert.equal(config.ttsModel, "gpt-4o-mini-tts"); assert.equal(config.betterAuthBasePath, "/api/auth");
assert.equal(config.s3SignedUrlTtlSec, 3600); assert.equal(config.qwenTtsModel, "qwen-tts-latest");
assert.equal(config.xBotUserId, ""); assert.equal(config.minioSignedUrlTtlSec, 3600);
assert.equal(config.polarServer, "production"); assert.equal(config.minioUseSSL, true);
assert.deepEqual(config.polarProductIds, []);
assert.equal(config.rateLimits.webhookPerMinute, 120); assert.equal(config.rateLimits.webhookPerMinute, 120);
});
if (previous.PORT === undefined) {
delete process.env.PORT;
} else {
process.env.PORT = previous.PORT;
}
if (previous.STATE_FILE_PATH === undefined) {
delete process.env.STATE_FILE_PATH;
} else {
process.env.STATE_FILE_PATH = previous.STATE_FILE_PATH;
}
if (previous.LOG_LEVEL === undefined) {
delete process.env.LOG_LEVEL;
} else {
process.env.LOG_LEVEL = previous.LOG_LEVEL;
}
if (previous.APP_BASE_URL === undefined) {
delete process.env.APP_BASE_URL;
} else {
process.env.APP_BASE_URL = previous.APP_BASE_URL;
}
if (previous.TTS_MODEL === undefined) {
delete process.env.TTS_MODEL;
} else {
process.env.TTS_MODEL = previous.TTS_MODEL;
}
if (previous.S3_SIGNED_URL_TTL_SEC === undefined) {
delete process.env.S3_SIGNED_URL_TTL_SEC;
} else {
process.env.S3_SIGNED_URL_TTL_SEC = previous.S3_SIGNED_URL_TTL_SEC;
}
if (previous.X_BOT_USER_ID === undefined) {
delete process.env.X_BOT_USER_ID;
} else {
process.env.X_BOT_USER_ID = previous.X_BOT_USER_ID;
}
if (previous.WEBHOOK_RPM === undefined) {
delete process.env.WEBHOOK_RPM;
} else {
process.env.WEBHOOK_RPM = previous.WEBHOOK_RPM;
}
if (previous.POLAR_SERVER === undefined) {
delete process.env.POLAR_SERVER;
} else {
process.env.POLAR_SERVER = previous.POLAR_SERVER;
}
if (previous.POLAR_PRODUCT_IDS === undefined) {
delete process.env.POLAR_PRODUCT_IDS;
} else {
process.env.POLAR_PRODUCT_IDS = previous.POLAR_PRODUCT_IDS;
}
}); });
test("config reads state path and numeric env overrides", () => { test("config reads convex/qwen/minio overrides", () => {
const previous = { withTempEnv({
PORT: process.env.PORT, PORT: "8080",
STATE_FILE_PATH: process.env.STATE_FILE_PATH, LOG_LEVEL: "debug",
LOG_LEVEL: process.env.LOG_LEVEL, APP_BASE_URL: "https://xartaudio.app",
WEBHOOK_RPM: process.env.WEBHOOK_RPM, BETTER_AUTH_SECRET: "prod-secret",
APP_BASE_URL: process.env.APP_BASE_URL, BETTER_AUTH_BASE_PATH: "/api/auth",
TTS_MODEL: process.env.TTS_MODEL, BETTER_AUTH_DEV_PASSWORD: "xartaudio-dev-password",
S3_SIGNED_URL_TTL_SEC: process.env.S3_SIGNED_URL_TTL_SEC, CONVEX_DEPLOYMENT_URL: "https://example.convex.cloud",
X_BOT_USER_ID: process.env.X_BOT_USER_ID, CONVEX_AUTH_TOKEN: "convex-token",
POLAR_SERVER: process.env.POLAR_SERVER, CONVEX_STATE_QUERY: "state:get",
POLAR_PRODUCT_IDS: process.env.POLAR_PRODUCT_IDS, CONVEX_STATE_MUTATION: "state:put",
}; QWEN_TTS_MODEL: "qwen3-tts",
MINIO_ENDPOINT: "minio.internal",
process.env.PORT = "8080"; MINIO_PORT: "9000",
process.env.STATE_FILE_PATH = "/data/prod-state.json"; MINIO_USE_SSL: "false",
process.env.LOG_LEVEL = "debug"; MINIO_BUCKET: "audio",
process.env.APP_BASE_URL = "https://xartaudio.app"; MINIO_SIGNED_URL_TTL_SEC: "7200",
process.env.TTS_MODEL = "custom-tts"; WEBHOOK_RPM: "77",
process.env.S3_SIGNED_URL_TTL_SEC = "7200"; }, () => {
process.env.X_BOT_USER_ID = "bot-user-id";
process.env.WEBHOOK_RPM = "77";
process.env.POLAR_SERVER = "sandbox";
process.env.POLAR_PRODUCT_IDS = "prod_1,prod_2";
delete require.cache[require.resolve("../src/config")];
const { config } = require("../src/config"); const { config } = require("../src/config");
assert.equal(config.port, 8080); assert.equal(config.port, 8080);
assert.equal(config.stateFilePath, "/data/prod-state.json");
assert.equal(config.logLevel, "debug"); assert.equal(config.logLevel, "debug");
assert.equal(config.appBaseUrl, "https://xartaudio.app"); assert.equal(config.appBaseUrl, "https://xartaudio.app");
assert.equal(config.ttsModel, "custom-tts"); assert.equal(config.betterAuthSecret, "prod-secret");
assert.equal(config.s3SignedUrlTtlSec, 7200); assert.equal(config.convexDeploymentUrl, "https://example.convex.cloud");
assert.equal(config.xBotUserId, "bot-user-id"); assert.equal(config.convexAuthToken, "convex-token");
assert.equal(config.polarServer, "sandbox"); assert.equal(config.convexStateQuery, "state:get");
assert.deepEqual(config.polarProductIds, ["prod_1", "prod_2"]); assert.equal(config.convexStateMutation, "state:put");
assert.equal(config.qwenTtsModel, "qwen3-tts");
assert.equal(config.minioEndPoint, "minio.internal");
assert.equal(config.minioPort, 9000);
assert.equal(config.minioUseSSL, false);
assert.equal(config.minioBucket, "audio");
assert.equal(config.minioSignedUrlTtlSec, 7200);
assert.equal(config.rateLimits.webhookPerMinute, 77); assert.equal(config.rateLimits.webhookPerMinute, 77);
});
if (previous.PORT === undefined) {
delete process.env.PORT;
} else {
process.env.PORT = previous.PORT;
}
if (previous.STATE_FILE_PATH === undefined) {
delete process.env.STATE_FILE_PATH;
} else {
process.env.STATE_FILE_PATH = previous.STATE_FILE_PATH;
}
if (previous.LOG_LEVEL === undefined) {
delete process.env.LOG_LEVEL;
} else {
process.env.LOG_LEVEL = previous.LOG_LEVEL;
}
if (previous.APP_BASE_URL === undefined) {
delete process.env.APP_BASE_URL;
} else {
process.env.APP_BASE_URL = previous.APP_BASE_URL;
}
if (previous.TTS_MODEL === undefined) {
delete process.env.TTS_MODEL;
} else {
process.env.TTS_MODEL = previous.TTS_MODEL;
}
if (previous.S3_SIGNED_URL_TTL_SEC === undefined) {
delete process.env.S3_SIGNED_URL_TTL_SEC;
} else {
process.env.S3_SIGNED_URL_TTL_SEC = previous.S3_SIGNED_URL_TTL_SEC;
}
if (previous.X_BOT_USER_ID === undefined) {
delete process.env.X_BOT_USER_ID;
} else {
process.env.X_BOT_USER_ID = previous.X_BOT_USER_ID;
}
if (previous.WEBHOOK_RPM === undefined) {
delete process.env.WEBHOOK_RPM;
} else {
process.env.WEBHOOK_RPM = previous.WEBHOOK_RPM;
}
if (previous.POLAR_SERVER === undefined) {
delete process.env.POLAR_SERVER;
} else {
process.env.POLAR_SERVER = previous.POLAR_SERVER;
}
if (previous.POLAR_PRODUCT_IDS === undefined) {
delete process.env.POLAR_PRODUCT_IDS;
} else {
process.env.POLAR_PRODUCT_IDS = previous.POLAR_PRODUCT_IDS;
}
}); });

View File

@@ -0,0 +1,59 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { ConvexStateStore } = require("../src/lib/convex-state-store");
test("load returns null when convex query returns null", async () => {
const store = new ConvexStateStore({
client: {
async query() {
return null;
},
async mutation() {},
},
});
const state = await store.load();
assert.equal(state, null);
});
test("load unwraps snapshot envelope payload", async () => {
const store = new ConvexStateStore({
client: {
async query(functionName) {
assert.equal(functionName, "state:getLatestSnapshot");
return {
snapshot: { engine: { jobs: [] } },
};
},
async mutation() {},
},
});
const state = await store.load();
assert.deepEqual(state, { engine: { jobs: [] } });
});
test("save writes state to configured mutation function", async () => {
const calls = [];
const store = new ConvexStateStore({
writeFunction: "state:saveSnapshot",
client: {
async query() {
return null;
},
async mutation(functionName, args) {
calls.push({ functionName, args });
},
},
});
const state = { version: 1 };
await store.save(state);
assert.equal(calls.length, 1);
assert.equal(calls[0].functionName, "state:saveSnapshot");
assert.deepEqual(calls[0].args.snapshot, state);
assert.match(String(calls[0].args.updatedAt), /^\d{4}-\d{2}-\d{2}T/);
});