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

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

View File

@@ -20,11 +20,6 @@ const {
parseFormUrlEncoded,
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") {

View File

@@ -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(),

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
const http = require("node:http");
const { 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,

View File

@@ -120,7 +120,7 @@ function renderLoginPage({ returnTo = "/app", error = null }) {
<section class="card bg-slate-900/80 border border-slate-700 shadow-xl">
<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)}" />