feat: migrate auth and state flows to better-auth and convex
This commit is contained in:
58
src/app.js
58
src/app.js
@@ -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") {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
273
src/integrations/better-auth.js
Normal file
273
src/integrations/better-auth.js
Normal 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,
|
||||||
|
};
|
||||||
57
src/lib/convex-state-store.js
Normal file
57
src/lib/convex-state-store.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)}" />
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
105
test/better-auth-integration.test.js
Normal file
105
test/better-auth-integration.test.js
Normal 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/);
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
59
test/convex-state-store.test.js
Normal file
59
test/convex-state-store.test.js
Normal 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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user