diff --git a/src/app.js b/src/app.js index 6becc78..30966f8 100644 --- a/src/app.js +++ b/src/app.js @@ -20,11 +20,6 @@ const { parseFormUrlEncoded, withQuery, } = require("./lib/http"); -const { - getAuthenticatedUserId, - serializeUserCookie, - clearUserCookie, -} = require("./lib/auth"); const { FixedWindowRateLimiter } = require("./lib/rate-limit"); const { XWebhookPayloadSchema, @@ -39,6 +34,7 @@ const { createTTSAdapter } = require("./integrations/tts-client"); const { createStorageAdapter } = require("./integrations/storage-client"); const { createXAdapter } = require("./integrations/x-client"); const { createAudioGenerationService } = require("./services/audio-generation"); +const { createBetterAuthAdapter } = require("./integrations/better-auth"); const STYLESHEET_PATH = pathLib.join(__dirname, "public", "styles.css"); @@ -67,6 +63,7 @@ function buildApp({ xAdapter = null, ttsAdapter = null, storageAdapter = null, + authAdapter = null, audioGenerationService = null, }) { const engine = new XArtAudioEngine({ @@ -85,18 +82,28 @@ function buildApp({ botUserId: config.xBotUserId, }); const tts = ttsAdapter || createTTSAdapter({ - apiKey: config.ttsApiKey, - baseURL: config.ttsBaseUrl || undefined, - model: config.ttsModel, - voice: config.ttsVoice, + apiKey: config.qwenTtsApiKey, + baseURL: config.qwenTtsBaseUrl, + model: config.qwenTtsModel, + voice: config.qwenTtsVoice, + format: config.qwenTtsFormat, }); const storage = storageAdapter || createStorageAdapter({ - bucket: config.s3Bucket, - region: config.s3Region, - endpoint: config.s3Endpoint || undefined, - accessKeyId: config.s3AccessKeyId, - secretAccessKey: config.s3SecretAccessKey, - signedUrlTtlSec: config.s3SignedUrlTtlSec, + bucket: config.minioBucket, + endPoint: config.minioEndPoint, + port: config.minioPort, + useSSL: config.minioUseSSL, + region: config.minioRegion, + 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({ tts, @@ -283,7 +290,7 @@ function buildApp({ async function handleRequest({ method, path, headers, rawBody, query }) { const safeHeaders = headers || {}; const safeQuery = query || {}; - const userId = getAuthenticatedUserId(safeHeaders); + const userId = await auth.getAuthenticatedUserId(safeHeaders); const clientAddress = clientAddressFromHeaders(safeHeaders); 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 === "/") { return html(200, renderLandingPage({ authenticated: Boolean(userId), userId })); } @@ -354,8 +370,9 @@ function buildApp({ try { const login = parseOrThrow(LoginFormSchema, form); const nextPath = sanitizeReturnTo(login.returnTo, "/app"); + const authSession = await auth.signInDevUser(login.userId); return redirect(nextPath, { - "set-cookie": serializeUserCookie(login.userId), + "set-cookie": authSession.setCookie, }); } catch (error) { return redirect(withQuery("/login", { @@ -366,9 +383,10 @@ function buildApp({ } if (method === "POST" && path === "/auth/logout") { - return redirect("/", { - "set-cookie": clearUserCookie(), - }); + const signOut = await auth.signOut(safeHeaders); + return redirect("/", signOut.setCookie + ? { "set-cookie": signOut.setCookie } + : undefined); } if (method === "GET" && path === "/app") { diff --git a/src/config.js b/src/config.js index 0eaecf4..af9229c 100644 --- a/src/config.js +++ b/src/config.js @@ -31,11 +31,33 @@ function listFromEnv(name, fallback = []) { .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 = { port: intFromEnv("PORT", 3000), - stateFilePath: strFromEnv("STATE_FILE_PATH", "./data/state.json"), logLevel: strFromEnv("LOG_LEVEL", "info"), 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", xBearerToken: strFromEnv("X_BEARER_TOKEN", ""), xBotUserId: strFromEnv("X_BOT_USER_ID", ""), @@ -43,16 +65,19 @@ const parsed = { polarAccessToken: strFromEnv("POLAR_ACCESS_TOKEN", ""), polarServer: strFromEnv("POLAR_SERVER", "production"), polarProductIds: listFromEnv("POLAR_PRODUCT_IDS", []), - ttsApiKey: strFromEnv("TTS_API_KEY", ""), - ttsBaseUrl: strFromEnv("TTS_BASE_URL", ""), - ttsModel: strFromEnv("TTS_MODEL", "gpt-4o-mini-tts"), - ttsVoice: strFromEnv("TTS_VOICE", "alloy"), - s3Bucket: strFromEnv("S3_BUCKET", ""), - s3Region: strFromEnv("S3_REGION", ""), - s3Endpoint: strFromEnv("S3_ENDPOINT", ""), - s3AccessKeyId: strFromEnv("S3_ACCESS_KEY_ID", ""), - s3SecretAccessKey: strFromEnv("S3_SECRET_ACCESS_KEY", ""), - s3SignedUrlTtlSec: intFromEnv("S3_SIGNED_URL_TTL_SEC", 3600), + qwenTtsApiKey: strFromEnv("QWEN_TTS_API_KEY", ""), + qwenTtsBaseUrl: strFromEnv("QWEN_TTS_BASE_URL", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"), + qwenTtsModel: strFromEnv("QWEN_TTS_MODEL", "qwen-tts-latest"), + qwenTtsVoice: strFromEnv("QWEN_TTS_VOICE", "Cherry"), + qwenTtsFormat: strFromEnv("QWEN_TTS_FORMAT", "mp3"), + minioEndPoint: strFromEnv("MINIO_ENDPOINT", ""), + minioPort: intFromEnv("MINIO_PORT", 443), + minioUseSSL: boolFromEnv("MINIO_USE_SSL", true), + minioBucket: strFromEnv("MINIO_BUCKET", ""), + 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: { webhookPerMinute: intFromEnv("WEBHOOK_RPM", 120), authPerMinute: intFromEnv("AUTH_RPM", 30), @@ -69,9 +94,15 @@ const parsed = { const ConfigSchema = z.object({ port: z.number().int().positive(), - stateFilePath: z.string().min(1), logLevel: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]), 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), xBearerToken: z.string(), xBotUserId: z.string(), @@ -79,16 +110,19 @@ const ConfigSchema = z.object({ polarAccessToken: z.string(), polarServer: z.enum(["production", "sandbox"]), polarProductIds: z.array(z.string().min(1)), - ttsApiKey: z.string(), - ttsBaseUrl: z.string(), - ttsModel: z.string().min(1), - ttsVoice: z.string().min(1), - s3Bucket: z.string(), - s3Region: z.string(), - s3Endpoint: z.string(), - s3AccessKeyId: z.string(), - s3SecretAccessKey: z.string(), - s3SignedUrlTtlSec: z.number().int().positive(), + qwenTtsApiKey: z.string(), + qwenTtsBaseUrl: z.string().min(1), + qwenTtsModel: z.string().min(1), + qwenTtsVoice: z.string().min(1), + qwenTtsFormat: z.string().min(1), + minioEndPoint: z.string(), + minioPort: z.number().int().positive(), + minioUseSSL: z.boolean(), + minioBucket: z.string(), + minioRegion: z.string(), + minioAccessKey: z.string(), + minioSecretKey: z.string(), + minioSignedUrlTtlSec: z.number().int().positive(), rateLimits: z.object({ webhookPerMinute: z.number().int().positive(), authPerMinute: z.number().int().positive(), diff --git a/src/integrations/better-auth.js b/src/integrations/better-auth.js new file mode 100644 index 0000000..ad02206 --- /dev/null +++ b/src/integrations/better-auth.js @@ -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, +}; diff --git a/src/lib/convex-state-store.js b/src/lib/convex-state-store.js new file mode 100644 index 0000000..ed452a8 --- /dev/null +++ b/src/lib/convex-state-store.js @@ -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, +}; diff --git a/src/server.js b/src/server.js index 323f556..538bf47 100644 --- a/src/server.js +++ b/src/server.js @@ -3,7 +3,7 @@ const http = require("node:http"); const { buildApp } = require("./app"); const { config } = require("./config"); -const { JsonFileStateStore } = require("./lib/state-store"); +const { ConvexStateStore } = require("./lib/convex-state-store"); const { createLogger } = require("./lib/logger"); function readBody(req) { @@ -80,10 +80,15 @@ function createMutationPersister({ stateStore, logger = console }) { }; } -async function createRuntime({ runtimeConfig = config, logger = console } = {}) { - const stateStore = new JsonFileStateStore(runtimeConfig.stateFilePath); - const initialState = await stateStore.load(); - const persister = createMutationPersister({ stateStore, logger }); +async function createRuntime({ runtimeConfig = config, logger = console, stateStore = null } = {}) { + const effectiveStateStore = stateStore || new ConvexStateStore({ + deploymentUrl: runtimeConfig.convexDeploymentUrl, + authToken: runtimeConfig.convexAuthToken, + readFunction: runtimeConfig.convexStateQuery, + writeFunction: runtimeConfig.convexStateMutation, + }); + const initialState = await effectiveStateStore.load(); + const persister = createMutationPersister({ stateStore: effectiveStateStore, logger }); const app = buildApp({ config: runtimeConfig, diff --git a/src/views/pages.js b/src/views/pages.js index 8c70b63..cfd9777 100644 --- a/src/views/pages.js +++ b/src/views/pages.js @@ -120,7 +120,7 @@ function renderLoginPage({ returnTo = "/app", error = null }) {

Sign in

-

MVP login. Enter a username to create a local session cookie.

+

Dev sign-in powered by Better Auth. Enter a username to create a session.

${errorBlock}
diff --git a/test/app.test.js b/test/app.test.js index bea055b..b3aae7b 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -15,16 +15,26 @@ function createApp(options = {}) { polarServer: "production", polarProductIds: [], appBaseUrl: "http://localhost:3000", - ttsApiKey: "", - ttsBaseUrl: "", - ttsModel: "gpt-4o-mini-tts", - ttsVoice: "alloy", - s3Bucket: "", - s3Region: "", - s3Endpoint: "", - s3AccessKeyId: "", - s3SecretAccessKey: "", - s3SignedUrlTtlSec: 3600, + betterAuthSecret: "test-better-auth-secret", + betterAuthBasePath: "/api/auth", + betterAuthDevPassword: "xartaudio-dev-password", + convexDeploymentUrl: "", + convexAuthToken: "", + convexStateQuery: "state:getLatestSnapshot", + convexStateMutation: "state:saveSnapshot", + qwenTtsApiKey: "", + qwenTtsBaseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + qwenTtsModel: "qwen-tts-latest", + qwenTtsVoice: "Cherry", + qwenTtsFormat: "mp3", + minioBucket: "", + minioEndPoint: "", + minioPort: 443, + minioUseSSL: true, + minioRegion: "us-east-1", + minioAccessKey: "", + minioSecretKey: "", + minioSignedUrlTtlSec: 3600, rateLimits: { webhookPerMinute: 120, authPerMinute: 30, @@ -117,7 +127,7 @@ test("POST /auth/dev-login sets cookie and redirects", async () => { assert.equal(response.status, 303); 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 () => { diff --git a/test/better-auth-integration.test.js b/test/better-auth-integration.test.js new file mode 100644 index 0000000..1638fc3 --- /dev/null +++ b/test/better-auth-integration.test.js @@ -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/); +}); diff --git a/test/config.test.js b/test/config.test.js index ed20b99..1d09a79 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -3,201 +3,91 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +function withTempEnv(patch, run) { + const previous = {}; + for (const key of Object.keys(patch)) { + previous[key] = process.env[key]; + if (patch[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = patch[key]; + } + } + + try { + delete require.cache[require.resolve("../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", () => { - const previous = { - PORT: process.env.PORT, - STATE_FILE_PATH: process.env.STATE_FILE_PATH, - LOG_LEVEL: process.env.LOG_LEVEL, - APP_BASE_URL: process.env.APP_BASE_URL, - TTS_MODEL: process.env.TTS_MODEL, - S3_SIGNED_URL_TTL_SEC: process.env.S3_SIGNED_URL_TTL_SEC, - 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; - - delete require.cache[require.resolve("../src/config")]; - const { config } = require("../src/config"); - - assert.equal(config.port, 3000); - assert.equal(config.stateFilePath, "./data/state.json"); - assert.equal(config.logLevel, "info"); - assert.equal(config.appBaseUrl, "http://localhost:3000"); - assert.equal(config.ttsModel, "gpt-4o-mini-tts"); - assert.equal(config.s3SignedUrlTtlSec, 3600); - assert.equal(config.xBotUserId, ""); - assert.equal(config.polarServer, "production"); - assert.deepEqual(config.polarProductIds, []); - 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; - } + 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.logLevel, "info"); + assert.equal(config.appBaseUrl, "http://localhost:3000"); + assert.equal(config.betterAuthBasePath, "/api/auth"); + assert.equal(config.qwenTtsModel, "qwen-tts-latest"); + assert.equal(config.minioSignedUrlTtlSec, 3600); + assert.equal(config.minioUseSSL, true); + assert.equal(config.rateLimits.webhookPerMinute, 120); + }); }); -test("config reads state path and numeric env overrides", () => { - const previous = { - PORT: process.env.PORT, - STATE_FILE_PATH: process.env.STATE_FILE_PATH, - LOG_LEVEL: process.env.LOG_LEVEL, - WEBHOOK_RPM: process.env.WEBHOOK_RPM, - APP_BASE_URL: process.env.APP_BASE_URL, - TTS_MODEL: process.env.TTS_MODEL, - S3_SIGNED_URL_TTL_SEC: process.env.S3_SIGNED_URL_TTL_SEC, - X_BOT_USER_ID: process.env.X_BOT_USER_ID, - POLAR_SERVER: process.env.POLAR_SERVER, - POLAR_PRODUCT_IDS: process.env.POLAR_PRODUCT_IDS, - }; - - process.env.PORT = "8080"; - process.env.STATE_FILE_PATH = "/data/prod-state.json"; - process.env.LOG_LEVEL = "debug"; - process.env.APP_BASE_URL = "https://xartaudio.app"; - process.env.TTS_MODEL = "custom-tts"; - 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"); - - assert.equal(config.port, 8080); - assert.equal(config.stateFilePath, "/data/prod-state.json"); - assert.equal(config.logLevel, "debug"); - assert.equal(config.appBaseUrl, "https://xartaudio.app"); - assert.equal(config.ttsModel, "custom-tts"); - assert.equal(config.s3SignedUrlTtlSec, 7200); - assert.equal(config.xBotUserId, "bot-user-id"); - assert.equal(config.polarServer, "sandbox"); - assert.deepEqual(config.polarProductIds, ["prod_1", "prod_2"]); - 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; - } +test("config reads convex/qwen/minio overrides", () => { + withTempEnv({ + PORT: "8080", + LOG_LEVEL: "debug", + APP_BASE_URL: "https://xartaudio.app", + BETTER_AUTH_SECRET: "prod-secret", + BETTER_AUTH_BASE_PATH: "/api/auth", + BETTER_AUTH_DEV_PASSWORD: "xartaudio-dev-password", + CONVEX_DEPLOYMENT_URL: "https://example.convex.cloud", + CONVEX_AUTH_TOKEN: "convex-token", + CONVEX_STATE_QUERY: "state:get", + CONVEX_STATE_MUTATION: "state:put", + QWEN_TTS_MODEL: "qwen3-tts", + MINIO_ENDPOINT: "minio.internal", + MINIO_PORT: "9000", + MINIO_USE_SSL: "false", + MINIO_BUCKET: "audio", + MINIO_SIGNED_URL_TTL_SEC: "7200", + WEBHOOK_RPM: "77", + }, () => { + const { config } = require("../src/config"); + assert.equal(config.port, 8080); + assert.equal(config.logLevel, "debug"); + assert.equal(config.appBaseUrl, "https://xartaudio.app"); + assert.equal(config.betterAuthSecret, "prod-secret"); + assert.equal(config.convexDeploymentUrl, "https://example.convex.cloud"); + assert.equal(config.convexAuthToken, "convex-token"); + assert.equal(config.convexStateQuery, "state:get"); + 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); + }); }); diff --git a/test/convex-state-store.test.js b/test/convex-state-store.test.js new file mode 100644 index 0000000..7c94b4a --- /dev/null +++ b/test/convex-state-store.test.js @@ -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/); +});