feat: migrate auth and state flows to better-auth and convex
This commit is contained in:
@@ -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");
|
||||
|
||||
function withTempEnv(patch, run) {
|
||||
const previous = {};
|
||||
for (const key of Object.keys(patch)) {
|
||||
previous[key] = process.env[key];
|
||||
if (patch[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = patch[key];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
delete require.cache[require.resolve("../src/config")];
|
||||
run();
|
||||
} finally {
|
||||
for (const key of Object.keys(patch)) {
|
||||
if (previous[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = previous[key];
|
||||
}
|
||||
}
|
||||
delete require.cache[require.resolve("../src/config")];
|
||||
}
|
||||
}
|
||||
|
||||
test("config uses defaults when env is missing", () => {
|
||||
const previous = {
|
||||
PORT: process.env.PORT,
|
||||
STATE_FILE_PATH: process.env.STATE_FILE_PATH,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
APP_BASE_URL: process.env.APP_BASE_URL,
|
||||
TTS_MODEL: process.env.TTS_MODEL,
|
||||
S3_SIGNED_URL_TTL_SEC: process.env.S3_SIGNED_URL_TTL_SEC,
|
||||
X_BOT_USER_ID: process.env.X_BOT_USER_ID,
|
||||
WEBHOOK_RPM: process.env.WEBHOOK_RPM,
|
||||
POLAR_SERVER: process.env.POLAR_SERVER,
|
||||
POLAR_PRODUCT_IDS: process.env.POLAR_PRODUCT_IDS,
|
||||
};
|
||||
|
||||
delete process.env.PORT;
|
||||
delete process.env.STATE_FILE_PATH;
|
||||
delete process.env.LOG_LEVEL;
|
||||
delete process.env.APP_BASE_URL;
|
||||
delete process.env.TTS_MODEL;
|
||||
delete process.env.S3_SIGNED_URL_TTL_SEC;
|
||||
delete process.env.X_BOT_USER_ID;
|
||||
delete process.env.WEBHOOK_RPM;
|
||||
delete process.env.POLAR_SERVER;
|
||||
delete process.env.POLAR_PRODUCT_IDS;
|
||||
|
||||
delete require.cache[require.resolve("../src/config")];
|
||||
const { config } = require("../src/config");
|
||||
|
||||
assert.equal(config.port, 3000);
|
||||
assert.equal(config.stateFilePath, "./data/state.json");
|
||||
assert.equal(config.logLevel, "info");
|
||||
assert.equal(config.appBaseUrl, "http://localhost:3000");
|
||||
assert.equal(config.ttsModel, "gpt-4o-mini-tts");
|
||||
assert.equal(config.s3SignedUrlTtlSec, 3600);
|
||||
assert.equal(config.xBotUserId, "");
|
||||
assert.equal(config.polarServer, "production");
|
||||
assert.deepEqual(config.polarProductIds, []);
|
||||
assert.equal(config.rateLimits.webhookPerMinute, 120);
|
||||
|
||||
if (previous.PORT === undefined) {
|
||||
delete process.env.PORT;
|
||||
} else {
|
||||
process.env.PORT = previous.PORT;
|
||||
}
|
||||
|
||||
if (previous.STATE_FILE_PATH === undefined) {
|
||||
delete process.env.STATE_FILE_PATH;
|
||||
} else {
|
||||
process.env.STATE_FILE_PATH = previous.STATE_FILE_PATH;
|
||||
}
|
||||
|
||||
if (previous.LOG_LEVEL === undefined) {
|
||||
delete process.env.LOG_LEVEL;
|
||||
} else {
|
||||
process.env.LOG_LEVEL = previous.LOG_LEVEL;
|
||||
}
|
||||
|
||||
if (previous.APP_BASE_URL === undefined) {
|
||||
delete process.env.APP_BASE_URL;
|
||||
} else {
|
||||
process.env.APP_BASE_URL = previous.APP_BASE_URL;
|
||||
}
|
||||
|
||||
if (previous.TTS_MODEL === undefined) {
|
||||
delete process.env.TTS_MODEL;
|
||||
} else {
|
||||
process.env.TTS_MODEL = previous.TTS_MODEL;
|
||||
}
|
||||
|
||||
if (previous.S3_SIGNED_URL_TTL_SEC === undefined) {
|
||||
delete process.env.S3_SIGNED_URL_TTL_SEC;
|
||||
} else {
|
||||
process.env.S3_SIGNED_URL_TTL_SEC = previous.S3_SIGNED_URL_TTL_SEC;
|
||||
}
|
||||
|
||||
if (previous.X_BOT_USER_ID === undefined) {
|
||||
delete process.env.X_BOT_USER_ID;
|
||||
} else {
|
||||
process.env.X_BOT_USER_ID = previous.X_BOT_USER_ID;
|
||||
}
|
||||
|
||||
if (previous.WEBHOOK_RPM === undefined) {
|
||||
delete process.env.WEBHOOK_RPM;
|
||||
} else {
|
||||
process.env.WEBHOOK_RPM = previous.WEBHOOK_RPM;
|
||||
}
|
||||
|
||||
if (previous.POLAR_SERVER === undefined) {
|
||||
delete process.env.POLAR_SERVER;
|
||||
} else {
|
||||
process.env.POLAR_SERVER = previous.POLAR_SERVER;
|
||||
}
|
||||
|
||||
if (previous.POLAR_PRODUCT_IDS === undefined) {
|
||||
delete process.env.POLAR_PRODUCT_IDS;
|
||||
} else {
|
||||
process.env.POLAR_PRODUCT_IDS = previous.POLAR_PRODUCT_IDS;
|
||||
}
|
||||
withTempEnv({
|
||||
PORT: undefined,
|
||||
LOG_LEVEL: undefined,
|
||||
APP_BASE_URL: undefined,
|
||||
BETTER_AUTH_SECRET: undefined,
|
||||
BETTER_AUTH_BASE_PATH: undefined,
|
||||
QWEN_TTS_MODEL: undefined,
|
||||
MINIO_SIGNED_URL_TTL_SEC: undefined,
|
||||
MINIO_USE_SSL: undefined,
|
||||
WEBHOOK_RPM: undefined,
|
||||
}, () => {
|
||||
const { config } = require("../src/config");
|
||||
assert.equal(config.port, 3000);
|
||||
assert.equal(config.logLevel, "info");
|
||||
assert.equal(config.appBaseUrl, "http://localhost:3000");
|
||||
assert.equal(config.betterAuthBasePath, "/api/auth");
|
||||
assert.equal(config.qwenTtsModel, "qwen-tts-latest");
|
||||
assert.equal(config.minioSignedUrlTtlSec, 3600);
|
||||
assert.equal(config.minioUseSSL, true);
|
||||
assert.equal(config.rateLimits.webhookPerMinute, 120);
|
||||
});
|
||||
});
|
||||
|
||||
test("config reads state path and numeric env overrides", () => {
|
||||
const previous = {
|
||||
PORT: process.env.PORT,
|
||||
STATE_FILE_PATH: process.env.STATE_FILE_PATH,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
WEBHOOK_RPM: process.env.WEBHOOK_RPM,
|
||||
APP_BASE_URL: process.env.APP_BASE_URL,
|
||||
TTS_MODEL: process.env.TTS_MODEL,
|
||||
S3_SIGNED_URL_TTL_SEC: process.env.S3_SIGNED_URL_TTL_SEC,
|
||||
X_BOT_USER_ID: process.env.X_BOT_USER_ID,
|
||||
POLAR_SERVER: process.env.POLAR_SERVER,
|
||||
POLAR_PRODUCT_IDS: process.env.POLAR_PRODUCT_IDS,
|
||||
};
|
||||
|
||||
process.env.PORT = "8080";
|
||||
process.env.STATE_FILE_PATH = "/data/prod-state.json";
|
||||
process.env.LOG_LEVEL = "debug";
|
||||
process.env.APP_BASE_URL = "https://xartaudio.app";
|
||||
process.env.TTS_MODEL = "custom-tts";
|
||||
process.env.S3_SIGNED_URL_TTL_SEC = "7200";
|
||||
process.env.X_BOT_USER_ID = "bot-user-id";
|
||||
process.env.WEBHOOK_RPM = "77";
|
||||
process.env.POLAR_SERVER = "sandbox";
|
||||
process.env.POLAR_PRODUCT_IDS = "prod_1,prod_2";
|
||||
|
||||
delete require.cache[require.resolve("../src/config")];
|
||||
const { config } = require("../src/config");
|
||||
|
||||
assert.equal(config.port, 8080);
|
||||
assert.equal(config.stateFilePath, "/data/prod-state.json");
|
||||
assert.equal(config.logLevel, "debug");
|
||||
assert.equal(config.appBaseUrl, "https://xartaudio.app");
|
||||
assert.equal(config.ttsModel, "custom-tts");
|
||||
assert.equal(config.s3SignedUrlTtlSec, 7200);
|
||||
assert.equal(config.xBotUserId, "bot-user-id");
|
||||
assert.equal(config.polarServer, "sandbox");
|
||||
assert.deepEqual(config.polarProductIds, ["prod_1", "prod_2"]);
|
||||
assert.equal(config.rateLimits.webhookPerMinute, 77);
|
||||
|
||||
if (previous.PORT === undefined) {
|
||||
delete process.env.PORT;
|
||||
} else {
|
||||
process.env.PORT = previous.PORT;
|
||||
}
|
||||
|
||||
if (previous.STATE_FILE_PATH === undefined) {
|
||||
delete process.env.STATE_FILE_PATH;
|
||||
} else {
|
||||
process.env.STATE_FILE_PATH = previous.STATE_FILE_PATH;
|
||||
}
|
||||
if (previous.LOG_LEVEL === undefined) {
|
||||
delete process.env.LOG_LEVEL;
|
||||
} else {
|
||||
process.env.LOG_LEVEL = previous.LOG_LEVEL;
|
||||
}
|
||||
|
||||
if (previous.APP_BASE_URL === undefined) {
|
||||
delete process.env.APP_BASE_URL;
|
||||
} else {
|
||||
process.env.APP_BASE_URL = previous.APP_BASE_URL;
|
||||
}
|
||||
|
||||
if (previous.TTS_MODEL === undefined) {
|
||||
delete process.env.TTS_MODEL;
|
||||
} else {
|
||||
process.env.TTS_MODEL = previous.TTS_MODEL;
|
||||
}
|
||||
|
||||
if (previous.S3_SIGNED_URL_TTL_SEC === undefined) {
|
||||
delete process.env.S3_SIGNED_URL_TTL_SEC;
|
||||
} else {
|
||||
process.env.S3_SIGNED_URL_TTL_SEC = previous.S3_SIGNED_URL_TTL_SEC;
|
||||
}
|
||||
|
||||
if (previous.X_BOT_USER_ID === undefined) {
|
||||
delete process.env.X_BOT_USER_ID;
|
||||
} else {
|
||||
process.env.X_BOT_USER_ID = previous.X_BOT_USER_ID;
|
||||
}
|
||||
|
||||
if (previous.WEBHOOK_RPM === undefined) {
|
||||
delete process.env.WEBHOOK_RPM;
|
||||
} else {
|
||||
process.env.WEBHOOK_RPM = previous.WEBHOOK_RPM;
|
||||
}
|
||||
|
||||
if (previous.POLAR_SERVER === undefined) {
|
||||
delete process.env.POLAR_SERVER;
|
||||
} else {
|
||||
process.env.POLAR_SERVER = previous.POLAR_SERVER;
|
||||
}
|
||||
|
||||
if (previous.POLAR_PRODUCT_IDS === undefined) {
|
||||
delete process.env.POLAR_PRODUCT_IDS;
|
||||
} else {
|
||||
process.env.POLAR_PRODUCT_IDS = previous.POLAR_PRODUCT_IDS;
|
||||
}
|
||||
test("config reads convex/qwen/minio overrides", () => {
|
||||
withTempEnv({
|
||||
PORT: "8080",
|
||||
LOG_LEVEL: "debug",
|
||||
APP_BASE_URL: "https://xartaudio.app",
|
||||
BETTER_AUTH_SECRET: "prod-secret",
|
||||
BETTER_AUTH_BASE_PATH: "/api/auth",
|
||||
BETTER_AUTH_DEV_PASSWORD: "xartaudio-dev-password",
|
||||
CONVEX_DEPLOYMENT_URL: "https://example.convex.cloud",
|
||||
CONVEX_AUTH_TOKEN: "convex-token",
|
||||
CONVEX_STATE_QUERY: "state:get",
|
||||
CONVEX_STATE_MUTATION: "state:put",
|
||||
QWEN_TTS_MODEL: "qwen3-tts",
|
||||
MINIO_ENDPOINT: "minio.internal",
|
||||
MINIO_PORT: "9000",
|
||||
MINIO_USE_SSL: "false",
|
||||
MINIO_BUCKET: "audio",
|
||||
MINIO_SIGNED_URL_TTL_SEC: "7200",
|
||||
WEBHOOK_RPM: "77",
|
||||
}, () => {
|
||||
const { config } = require("../src/config");
|
||||
assert.equal(config.port, 8080);
|
||||
assert.equal(config.logLevel, "debug");
|
||||
assert.equal(config.appBaseUrl, "https://xartaudio.app");
|
||||
assert.equal(config.betterAuthSecret, "prod-secret");
|
||||
assert.equal(config.convexDeploymentUrl, "https://example.convex.cloud");
|
||||
assert.equal(config.convexAuthToken, "convex-token");
|
||||
assert.equal(config.convexStateQuery, "state:get");
|
||||
assert.equal(config.convexStateMutation, "state:put");
|
||||
assert.equal(config.qwenTtsModel, "qwen3-tts");
|
||||
assert.equal(config.minioEndPoint, "minio.internal");
|
||||
assert.equal(config.minioPort, 9000);
|
||||
assert.equal(config.minioUseSSL, false);
|
||||
assert.equal(config.minioBucket, "audio");
|
||||
assert.equal(config.minioSignedUrlTtlSec, 7200);
|
||||
assert.equal(config.rateLimits.webhookPerMinute, 77);
|
||||
});
|
||||
});
|
||||
|
||||
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