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)}" />
|
||||
|
||||
Reference in New Issue
Block a user