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,
|
||||
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") {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
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 { 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,
|
||||
|
||||
@@ -120,7 +120,7 @@ function renderLoginPage({ returnTo = "/app", error = null }) {
|
||||
<section class="card bg-slate-900/80 border border-slate-700 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h1 class="text-2xl font-bold">Sign in</h1>
|
||||
<p class="text-sm text-slate-300">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}
|
||||
<form method="POST" action="/auth/dev-login" class="space-y-3">
|
||||
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
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 assert = require("node:assert/strict");
|
||||
|
||||
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;
|
||||
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")];
|
||||
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.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.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);
|
||||
|
||||
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", () => {
|
||||
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")];
|
||||
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.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.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);
|
||||
|
||||
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