feat: Implement email/password and X OAuth authentication, replacing the dev-login mechanism.

This commit is contained in:
Codex
2026-02-18 14:54:28 +00:00
parent c92032eb72
commit 76f991e690
15 changed files with 410 additions and 147 deletions

View File

@@ -7,7 +7,8 @@ APP_BASE_URL=https://xartaudio.example.com
# Better Auth
BETTER_AUTH_SECRET=replace-me
BETTER_AUTH_BASE_PATH=/api/auth
BETTER_AUTH_DEV_PASSWORD=replace-me
X_OAUTH_CLIENT_ID=replace-me
X_OAUTH_CLIENT_SECRET=replace-me
INTERNAL_API_TOKEN=replace-me
# Convex

View File

@@ -346,8 +346,8 @@ This repository now contains a deployable production-style app (single container
### Authentication model
1. Browser flow is powered by Better Auth under `/api/auth/*`.
2. `/auth/dev-login` bootstraps a Better Auth session for local/dev testing.
3. API calls also support `x-user-id` header for scripted usage/testing.
2. Supported sign-in methods are Email/Password and X OAuth.
3. All authenticated browser sessions are resolved from Better Auth session cookies.
### Runtime endpoints
1. Public:
@@ -355,7 +355,9 @@ This repository now contains a deployable production-style app (single container
- `GET /login`
- `GET /audio/:id`
2. Browser actions:
- `POST /auth/dev-login`
- `POST /auth/email/sign-in`
- `POST /auth/email/sign-up`
- `POST /auth/x`
- `POST /auth/logout`
- `POST /app/actions/topup`
- `POST /app/actions/simulate-mention`
@@ -392,7 +394,8 @@ Use `.env.example` as the source of truth.
2. Auth + state:
- `BETTER_AUTH_SECRET`
- `BETTER_AUTH_BASE_PATH`
- `BETTER_AUTH_DEV_PASSWORD`
- `X_OAUTH_CLIENT_ID`
- `X_OAUTH_CLIENT_SECRET`
- `INTERNAL_API_TOKEN`
- `CONVEX_DEPLOYMENT_URL`
- `CONVEX_AUTH_TOKEN`
@@ -448,7 +451,7 @@ Use `.env.example` as the source of truth.
## Production Checklist
1. Replace `/auth/dev-login` with direct Better Auth UI/OAuth sign-in for public launch.
1. Configure Better Auth credentials for Email auth and X OAuth (`X_OAUTH_CLIENT_ID` / `X_OAUTH_CLIENT_SECRET`).
2. Populate integration keys in Coolify environment for X, Polar, Qwen3 TTS, MinIO, and Convex.
3. Implement Convex functions named by `CONVEX_STATE_QUERY` and `CONVEX_STATE_MUTATION`.
- This repository includes `convex/state.ts` and `convex/schema.ts` for defaults:

View File

@@ -24,7 +24,8 @@ const {
const { FixedWindowRateLimiter } = require("./lib/rate-limit");
const {
PolarWebhookPayloadSchema,
LoginFormSchema,
EmailSignInFormSchema,
EmailSignUpFormSchema,
TopUpFormSchema,
SimulateMentionFormSchema,
parseOrThrow,
@@ -103,7 +104,8 @@ function buildApp({
appBaseUrl: config.appBaseUrl,
basePath: config.betterAuthBasePath,
secret: config.betterAuthSecret,
devPassword: config.betterAuthDevPassword,
xOAuthClientId: config.xOAuthClientId,
xOAuthClientSecret: config.xOAuthClientSecret,
logger,
});
const generationService = audioGenerationService || createAudioGenerationService({
@@ -520,7 +522,7 @@ function buildApp({
}));
}
if (method === "POST" && path === "/auth/dev-login") {
if (method === "POST" && path === "/auth/email/sign-in") {
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
if (rateLimited) {
return rateLimited;
@@ -528,9 +530,13 @@ function buildApp({
const form = parseFormUrlEncoded(rawBody);
try {
const login = parseOrThrow(LoginFormSchema, form);
const login = parseOrThrow(EmailSignInFormSchema, form);
const nextPath = sanitizeReturnTo(login.returnTo, "/app");
const authSession = await auth.signInDevUser(login.userId);
const authSession = await auth.signInWithEmail({
email: login.email,
password: login.password,
callbackURL: `${config.appBaseUrl}${nextPath}`,
});
return redirect(nextPath, {
"set-cookie": authSession.setCookie,
});
@@ -542,6 +548,62 @@ function buildApp({
}
}
if (method === "POST" && path === "/auth/email/sign-up") {
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
if (rateLimited) {
return rateLimited;
}
const form = parseFormUrlEncoded(rawBody);
try {
const signUp = parseOrThrow(EmailSignUpFormSchema, form);
const nextPath = sanitizeReturnTo(signUp.returnTo, "/app");
const authSession = await auth.signUpWithEmail({
name: signUp.name,
email: signUp.email,
password: signUp.password,
});
return redirect(nextPath, {
"set-cookie": authSession.setCookie,
});
} catch (error) {
return redirect(withQuery("/login", {
returnTo: sanitizeReturnTo(form.returnTo, "/app"),
error: error.message,
}));
}
}
if (method === "POST" && path === "/auth/x") {
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
if (rateLimited) {
return rateLimited;
}
const form = parseFormUrlEncoded(rawBody);
const nextPath = sanitizeReturnTo(form.returnTo, "/app");
if (!auth.isXOAuthConfigured || !auth.isXOAuthConfigured()) {
return redirect(withQuery("/login", {
returnTo: nextPath,
error: "X sign-in is not configured",
}));
}
try {
const signIn = await auth.getXAuthorizationUrl({
callbackURL: `${config.appBaseUrl}${nextPath}`,
});
return redirect(signIn.url, signIn.setCookie
? { "set-cookie": signIn.setCookie }
: undefined);
} catch (error) {
return redirect(withQuery("/login", {
returnTo: nextPath,
error: error.message,
}));
}
}
if (method === "POST" && path === "/auth/logout") {
const signOut = await auth.signOut(safeHeaders);
return redirect("/", signOut.setCookie

View File

@@ -54,7 +54,8 @@ const parsed = {
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"),
xOAuthClientId: strFromEnv("X_OAUTH_CLIENT_ID", ""),
xOAuthClientSecret: strFromEnv("X_OAUTH_CLIENT_SECRET", ""),
internalApiToken: strFromEnv("INTERNAL_API_TOKEN", ""),
convexDeploymentUrl: strFromEnv("CONVEX_DEPLOYMENT_URL", strFromEnv("CONVEX_URL", "")),
convexAuthToken: strFromEnv("CONVEX_AUTH_TOKEN", ""),
@@ -105,7 +106,8 @@ const ConfigSchema = z.object({
appBaseUrl: z.string().min(1),
betterAuthSecret: z.string().min(1),
betterAuthBasePath: z.string().min(1),
betterAuthDevPassword: z.string().min(8),
xOAuthClientId: z.string(),
xOAuthClientSecret: z.string(),
internalApiToken: z.string(),
convexDeploymentUrl: z.string(),
convexAuthToken: z.string(),

View File

@@ -1,37 +1,5 @@
"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)) {
@@ -72,11 +40,17 @@ function responseHeadersToObject(responseHeaders) {
return headers;
}
async function parseResponseErrorDetails(response) {
const text = await response.text().catch(() => "");
return text || "request_failed";
}
function createBetterAuthAdapter({
appBaseUrl,
basePath = "/api/auth",
secret,
devPassword = "xartaudio-dev-password",
xOAuthClientId = "",
xOAuthClientSecret = "",
authHandler,
logger = console,
} = {}) {
@@ -102,6 +76,14 @@ function createBetterAuthAdapter({
import("better-auth/adapters/memory"),
]);
const socialProviders = {};
if (xOAuthClientId && xOAuthClientSecret) {
socialProviders.twitter = {
clientId: xOAuthClientId,
clientSecret: xOAuthClientSecret,
};
}
const auth = betterAuth({
appName: "XArtAudio",
baseURL: appBaseUrl,
@@ -115,6 +97,7 @@ function createBetterAuthAdapter({
requireEmailVerification: false,
minPasswordLength: 8,
},
socialProviders,
});
return auth.handler;
@@ -148,6 +131,10 @@ function createBetterAuthAdapter({
return Boolean(appBaseUrl && secret);
},
isXOAuthConfigured() {
return Boolean(xOAuthClientId && xOAuthClientSecret);
},
handlesPath(path) {
return path === normalizedBasePath || path.startsWith(`${normalizedBasePath}/`);
},
@@ -164,10 +151,6 @@ function createBetterAuthAdapter({
},
async getAuthenticatedUserId(headers = {}) {
if (headers["x-user-id"]) {
return String(headers["x-user-id"]);
}
const cookieHeader = headers.cookie || "";
if (!cookieHeader) {
return null;
@@ -190,52 +173,33 @@ function createBetterAuthAdapter({
const payload = await response.json().catch(() => null);
const user = payload && payload.user ? payload.user : null;
if (!user) {
return extractLegacyUserCookie(cookieHeader);
return null;
}
return user.name || user.email || user.id || null;
} catch {
return extractLegacyUserCookie(cookieHeader);
return null;
}
},
async signInDevUser(userId) {
const email = resolveEmailFromUserId(userId);
const signInBody = JSON.stringify({
email,
password: devPassword,
rememberMe: true,
});
let response = await invoke({
async signInWithEmail({ email, password, callbackURL } = {}) {
const response = await invoke({
method: "POST",
path: `${normalizedBasePath}/sign-in/email`,
headers: {
"content-type": "application/json",
accept: "application/json",
},
rawBody: signInBody,
rawBody: JSON.stringify({
email,
password,
callbackURL,
rememberMe: true,
}),
});
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(() => "");
const details = await parseResponseErrorDetails(response);
throw new Error(`auth_sign_in_failed:${response.status}:${details}`);
}
@@ -249,6 +213,73 @@ function createBetterAuthAdapter({
};
},
async signUpWithEmail({ name, email, password } = {}) {
const response = await invoke({
method: "POST",
path: `${normalizedBasePath}/sign-up/email`,
headers: {
"content-type": "application/json",
accept: "application/json",
},
rawBody: JSON.stringify({
name,
email,
password,
rememberMe: true,
}),
});
if (!response.ok) {
const details = await parseResponseErrorDetails(response);
throw new Error(`auth_sign_up_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 getXAuthorizationUrl({ callbackURL } = {}) {
if (!xOAuthClientId || !xOAuthClientSecret) {
throw new Error("x_oauth_not_configured");
}
const response = await invoke({
method: "POST",
path: `${normalizedBasePath}/sign-in/social`,
headers: {
"content-type": "application/json",
accept: "application/json",
},
rawBody: JSON.stringify({
provider: "twitter",
callbackURL,
disableRedirect: true,
}),
});
if (!response.ok) {
const details = await parseResponseErrorDetails(response);
throw new Error(`auth_x_sign_in_failed:${response.status}:${details}`);
}
const payload = await response.json().catch(() => null);
if (!payload || typeof payload.url !== "string" || !payload.url) {
throw new Error("auth_x_url_missing");
}
const responseHeaders = responseHeadersToObject(response.headers);
return {
url: payload.url,
setCookie: responseHeaders["set-cookie"] || null,
};
},
async signOut(headers = {}) {
const response = await invoke({
method: "POST",

View File

@@ -39,11 +39,6 @@ function clearUserCookie() {
function getAuthenticatedUserId(headers) {
const safeHeaders = headers || {};
if (safeHeaders["x-user-id"]) {
return safeHeaders["x-user-id"];
}
const cookies = parseCookies(safeHeaders.cookie || "");
return cookies[COOKIE_NAME] || null;
}

View File

@@ -2,8 +2,6 @@
const { z } = require("zod");
const usernameRegex = /^[a-zA-Z0-9_-]{2,40}$/;
const XWebhookPayloadSchema = z.object({
mentionPostId: z.string().min(1),
callerUserId: z.string().min(1),
@@ -16,8 +14,16 @@ const PolarWebhookPayloadSchema = z.object({
eventId: z.string().min(1),
});
const LoginFormSchema = z.object({
userId: z.string().regex(usernameRegex, "Username must be 2-40 characters using letters, numbers, _ or -"),
const EmailSignInFormSchema = z.object({
email: z.string().trim().email("Enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters").max(128),
returnTo: z.string().optional(),
});
const EmailSignUpFormSchema = z.object({
name: z.string().trim().min(2, "Name must be at least 2 characters").max(80),
email: z.string().trim().email("Enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters").max(128),
returnTo: z.string().optional(),
});
@@ -42,7 +48,8 @@ function parseOrThrow(schema, payload, errorMessage) {
module.exports = {
XWebhookPayloadSchema,
PolarWebhookPayloadSchema,
LoginFormSchema,
EmailSignInFormSchema,
EmailSignUpFormSchema,
TopUpFormSchema,
SimulateMentionFormSchema,
parseOrThrow,

View File

@@ -182,22 +182,31 @@ function renderLoginPage({ returnTo = "/app", error = null }) {
<label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Password</span>
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full bg-slate-950" placeholder="••••••••" />
<form method="POST" action="/auth/dev-login" class="space-y-4">
</label>
<button class="btn btn-primary w-full">Sign in with email</button>
</form>
<div class="divider text-xs uppercase text-slate-400">Create account</div>
<form method="POST" action="/auth/email/sign-up" class="space-y-3">
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
<div class="form-control">
<label class="label">
<span class="label-text">Username</span>
</label>
<input name="userId" required minlength="2" maxlength="40" class="input input-bordered w-full" placeholder="matiss" />
</div>
<button class="btn btn-primary w-full">Continue</button>
<label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Name</span>
<input name="name" required minlength="2" maxlength="80" class="input input-bordered w-full bg-slate-950" placeholder="Matiss" />
</label>
<label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Email</span>
<input name="email" type="email" required class="input input-bordered w-full bg-slate-950" placeholder="you@domain.com" />
</label>
<label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Password</span>
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full bg-slate-950" placeholder="••••••••" />
</label>
<button class="btn btn-outline w-full">Create account</button>
</form>
</div>
</div>
</section>
`,
});
}
function renderAppPage({ userId, summary, jobs, flash = null }) {
const flashMarkup = flash ? `<div role="alert" class="alert alert-info mb-4"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg><span>${escapeHtml(flash)}</span></div>` : "";
@@ -277,18 +286,18 @@ function renderAudioPage({ audio, accessDecision, userId, playbackUrl = null })
return shell({
title: "Audio not found",
compact: true,
content: `${nav({ authenticated: Boolean(userId), userId })}<div class="alert alert-error">Audio not found.</div>`,
content: `${nav({ authenticated: Boolean(userId), userId })}<div role="alert" class="alert alert-error"><span>Audio not found.</span></div>`,
});
}
const action = accessDecision.allowed
? `<div class="alert alert-success">Access granted. This unlock is permanent for your account.</div>`
? `<div role="alert" class="alert alert-success"><span>Access granted. This unlock is permanent for your account.</span></div>`
: accessDecision.reason === "auth_required"
? `<div class="alert alert-warning mb-3">Sign in to continue.</div>
<a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary">Sign in to unlock</a>`
: `<div class="alert alert-warning mb-3">Unlock required: ${accessDecision.creditsRequired} credits.</div>
? `<div role="alert" class="alert alert-warning mb-4"><span>Sign in to continue.</span></div>
<a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary w-full">Sign in to unlock</a>`
: `<div role="alert" class="alert alert-warning mb-4"><span>Unlock required: ${accessDecision.creditsRequired} credits.</span></div>
<form method="POST" action="/audio/${audio.id}/unlock">
<button class="btn btn-primary">Pay ${accessDecision.creditsRequired} credits and unlock forever</button>
<button class="btn btn-primary w-full">Pay ${accessDecision.creditsRequired} credits and unlock forever</button>
</form>`;
return shell({
@@ -296,16 +305,30 @@ function renderAudioPage({ audio, accessDecision, userId, playbackUrl = null })
compact: true,
content: `
${nav({ authenticated: Boolean(userId), userId })}
<section class="card bg-slate-900/85 border border-slate-700">
<div class="card-body p-4">
<h1 class="text-xl font-bold">${escapeHtml(audio.articleTitle)}</h1>
<div class="text-xs text-slate-400">Duration ~ ${audio.durationSec}s • Asset ${escapeHtml(audio.id)}</div>
<div class="card w-full max-w-2xl mx-auto shadow-2xl bg-base-100 border border-base-200">
<div class="card-body p-6">
<span class="badge badge-accent mb-2">Audiobook</span>
<h1 class="text-2xl sm:text-3xl font-bold mb-2 leading-tight">${escapeHtml(audio.articleTitle)}</h1>
<div class="text-sm text-base-content/60 mb-6 flex gap-3 text-mono">
<span>Duration ~ ${audio.durationSec}s</span>
<span>•</span>
<span>Asset ${escapeHtml(audio.id.substring(0, 8))}...</span>
</div>
<div class="divider"></div>
${action}
${accessDecision.allowed
? `<div class="mockup-code mt-3"><pre><code>${escapeHtml(playbackUrl || `stream://${audio.storageKey}`)}</code></pre></div>`
? `<div class="mt-6">
<h3 class="font-bold mb-2">Direct Stream URL</h3>
<div class="mockup-code bg-neutral text-neutral-content p-4 text-sm overflow-x-auto">
<pre><code>${escapeHtml(playbackUrl || `stream://${audio.storageKey}`)}</code></pre>
</div>
</div>`
: ""}
</div>
</section>
</div>
`,
});
}

View File

@@ -5,6 +5,63 @@ const assert = require("node:assert/strict");
const { buildApp } = require("../src/app");
const { hmacSHA256Hex } = require("../src/lib/signature");
function getTestCookieValue(cookieHeader, name) {
const parts = String(cookieHeader || "").split(";").map((part) => part.trim());
const prefix = `${name}=`;
const valuePart = parts.find((part) => part.startsWith(prefix));
if (!valuePart) {
return null;
}
try {
return decodeURIComponent(valuePart.slice(prefix.length));
} catch {
return valuePart.slice(prefix.length);
}
}
function createTestAuthAdapter() {
return {
isConfigured() {
return true;
},
isXOAuthConfigured() {
return true;
},
handlesPath() {
return false;
},
async handleRoute() {
return { status: 404, headers: {}, body: "not_found" };
},
async getAuthenticatedUserId(headers = {}) {
return getTestCookieValue(headers.cookie, "xartaudio_user");
},
async signInWithEmail({ email }) {
return {
setCookie: `xartaudio_user=${encodeURIComponent(String(email))}; Path=/; HttpOnly`,
};
},
async signUpWithEmail({ email }) {
return {
setCookie: `xartaudio_user=${encodeURIComponent(String(email))}; Path=/; HttpOnly`,
};
},
async getXAuthorizationUrl() {
return {
url: "https://x.com/i/oauth2/authorize?state=test",
setCookie: "xartaudio_oauth_state=test; Path=/; HttpOnly",
};
},
async signOut() {
return {
ok: true,
setCookie: "xartaudio_user=; Path=/; Max-Age=0",
};
},
};
}
function createApp(options = {}) {
const baseConfig = {
xWebhookSecret: "x-secret",
@@ -17,7 +74,8 @@ function createApp(options = {}) {
appBaseUrl: "http://localhost:3000",
betterAuthSecret: "test-better-auth-secret",
betterAuthBasePath: "/api/auth",
betterAuthDevPassword: "xartaudio-dev-password",
xOAuthClientId: "x-client-id",
xOAuthClientSecret: "x-client-secret",
internalApiToken: "",
convexDeploymentUrl: "",
convexAuthToken: "",
@@ -75,6 +133,9 @@ function createApp(options = {}) {
const appOptions = { ...options };
delete appOptions.config;
if (!appOptions.authAdapter) {
appOptions.authAdapter = createTestAuthAdapter();
}
return buildApp({
config: mergedConfig,
@@ -108,7 +169,7 @@ test("GET / renders landing page", async () => {
const app = createApp();
const response = await call(app, { method: "GET", path: "/" });
assert.equal(response.status, 200);
assert.match(response.body, /From X Article to audiobook in one mention/);
assert.match(response.body, /From X Article to audiobook/);
});
test("GET /assets/styles.css serves compiled stylesheet", async () => {
@@ -127,17 +188,17 @@ test("unauthenticated /app redirects to /login with returnTo", async () => {
assert.match(response.headers.location, /returnTo=%2Fapp/);
});
test("POST /auth/dev-login sets cookie and redirects", async () => {
test("POST /auth/email/sign-in sets cookie and redirects", async () => {
const app = createApp();
const response = await call(app, {
method: "POST",
path: "/auth/dev-login",
body: "userId=matiss&returnTo=%2Fapp",
path: "/auth/email/sign-in",
body: "email=matiss%40example.com&password=password123&returnTo=%2Fapp",
});
assert.equal(response.status, 303);
assert.equal(response.headers.location, "/app");
assert.match(String(response.headers["set-cookie"]), /HttpOnly/);
assert.match(String(response.headers["set-cookie"]), /xartaudio_user=matiss%40example\.com/);
});
test("authenticated dashboard topup + simulate mention flow", async () => {
@@ -954,15 +1015,15 @@ test("rate limits repeated login attempts from same IP", async () => {
const first = await call(app, {
method: "POST",
path: "/auth/dev-login",
path: "/auth/email/sign-in",
headers: { "x-forwarded-for": "5.5.5.5" },
body: "userId=alice&returnTo=%2Fapp",
body: "email=alice%40example.com&password=password123&returnTo=%2Fapp",
});
const second = await call(app, {
method: "POST",
path: "/auth/dev-login",
path: "/auth/email/sign-in",
headers: { "x-forwarded-for": "5.5.5.5" },
body: "userId=alice&returnTo=%2Fapp",
body: "email=alice%40example.com&password=password123&returnTo=%2Fapp",
});
assert.equal(first.status, 303);

View File

@@ -30,13 +30,12 @@ test("clearUserCookie expires session cookie", () => {
assert.match(cookie, /Max-Age=0/);
});
test("getAuthenticatedUserId prefers x-user-id header", () => {
test("getAuthenticatedUserId resolves from cookie only", () => {
const userId = getAuthenticatedUserId({
"x-user-id": "header-user",
cookie: "xartaudio_user=cookie-user",
});
assert.equal(userId, "header-user");
assert.equal(userId, "cookie-user");
});
test("getAuthenticatedUserId falls back to cookie", () => {

View File

@@ -5,20 +5,13 @@ 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);
const userId = String(body.email);
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
@@ -28,6 +21,28 @@ function createMockAuthHandler() {
});
}
if (path.endsWith("/sign-up/email")) {
const body = await request.json();
const userId = String(body.email);
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("/sign-in/social")) {
return new Response(JSON.stringify({ url: "https://x.com/i/oauth2/authorize?state=test" }), {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": "xartaudio_oauth_state=test; Path=/; HttpOnly",
},
});
}
if (path.endsWith("/get-session")) {
const cookie = request.headers.get("cookie") || "";
const match = cookie.match(/xartaudio_better_auth=([^;]+)/);
@@ -62,15 +77,33 @@ function createMockAuthHandler() {
};
}
test("signInDevUser signs up and returns cookie when user does not exist", async () => {
test("signInWithEmail returns session cookie", 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/);
const result = await adapter.signInWithEmail({
email: "alice@example.com",
password: "password123",
});
assert.match(String(result.setCookie), /xartaudio_better_auth=alice@example\.com/);
});
test("signUpWithEmail returns session cookie", async () => {
const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000",
secret: "test-secret",
authHandler: createMockAuthHandler(),
});
const result = await adapter.signUpWithEmail({
name: "Alice",
email: "alice@example.com",
password: "password123",
});
assert.match(String(result.setCookie), /xartaudio_better_auth=alice@example\.com/);
});
test("getAuthenticatedUserId resolves session user from better-auth endpoint", async () => {
@@ -86,6 +119,23 @@ test("getAuthenticatedUserId resolves session user from better-auth endpoint", a
assert.equal(userId, "bob");
});
test("getXAuthorizationUrl returns provider url when x oauth is configured", async () => {
const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000",
secret: "test-secret",
xOAuthClientId: "x-client-id",
xOAuthClientSecret: "x-client-secret",
authHandler: createMockAuthHandler(),
});
const result = await adapter.getXAuthorizationUrl({
callbackURL: "http://localhost:3000/app",
});
assert.match(result.url, /^https:\/\/x\.com\/i\/oauth2\/authorize/);
assert.match(String(result.setCookie), /xartaudio_oauth_state=test/);
});
test("handleRoute proxies requests into better-auth handler", async () => {
const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000",

View File

@@ -48,6 +48,8 @@ test("config uses defaults when env is missing", () => {
assert.equal(config.logLevel, "info");
assert.equal(config.appBaseUrl, "http://localhost:3000");
assert.equal(config.betterAuthBasePath, "/api/auth");
assert.equal(config.xOAuthClientId, "");
assert.equal(config.xOAuthClientSecret, "");
assert.equal(config.internalApiToken, "");
assert.equal(config.qwenTtsModel, "qwen-tts-latest");
assert.equal(config.minioSignedUrlTtlSec, 3600);
@@ -66,7 +68,8 @@ test("config reads convex/qwen/minio overrides", () => {
APP_BASE_URL: "https://xartaudio.app",
BETTER_AUTH_SECRET: "prod-secret",
BETTER_AUTH_BASE_PATH: "/api/auth",
BETTER_AUTH_DEV_PASSWORD: "xartaudio-dev-password",
X_OAUTH_CLIENT_ID: "x-client-id",
X_OAUTH_CLIENT_SECRET: "x-client-secret",
INTERNAL_API_TOKEN: "internal-token",
CONVEX_DEPLOYMENT_URL: "https://example.convex.cloud",
CONVEX_URL: "https://should-not-win.convex.cloud",
@@ -89,6 +92,8 @@ test("config reads convex/qwen/minio overrides", () => {
assert.equal(config.logLevel, "debug");
assert.equal(config.appBaseUrl, "https://xartaudio.app");
assert.equal(config.betterAuthSecret, "prod-secret");
assert.equal(config.xOAuthClientId, "x-client-id");
assert.equal(config.xOAuthClientSecret, "x-client-secret");
assert.equal(config.internalApiToken, "internal-token");
assert.equal(config.convexDeploymentUrl, "https://example.convex.cloud");
assert.equal(config.convexAuthToken, "convex-token");

View File

@@ -16,7 +16,8 @@ function createRuntimeConfig() {
appBaseUrl: "http://localhost:3000",
betterAuthSecret: "test-better-auth-secret",
betterAuthBasePath: "/api/auth",
betterAuthDevPassword: "xartaudio-dev-password",
xOAuthClientId: "",
xOAuthClientSecret: "",
internalApiToken: "",
convexDeploymentUrl: "",
convexAuthToken: "",
@@ -132,10 +133,19 @@ test("createRuntime falls back to in-memory state when initial load fails", asyn
assert.equal(warnings.length, 1);
assert.match(String(warnings[0].message), /falling back to in-memory state/);
const signUp = await runtime.app.handleRequest({
method: "POST",
path: "/auth/email/sign-up",
headers: {},
rawBody: "name=User+One&email=u1%40example.com&password=password123&returnTo=%2Fapp",
query: {},
});
assert.equal(signUp.status, 303);
const response = await runtime.app.handleRequest({
method: "POST",
path: "/app/actions/topup",
headers: { "x-user-id": "u1" },
headers: { cookie: signUp.headers["set-cookie"] },
rawBody: "amount=3",
query: {},
});

View File

@@ -5,7 +5,8 @@ const assert = require("node:assert/strict");
const {
XWebhookPayloadSchema,
PolarWebhookPayloadSchema,
LoginFormSchema,
EmailSignInFormSchema,
EmailSignUpFormSchema,
TopUpFormSchema,
SimulateMentionFormSchema,
parseOrThrow,
@@ -31,13 +32,24 @@ test("validates Polar webhook payload with numeric coercion", () => {
assert.equal(parsed.credits, 12);
});
test("rejects invalid login username", () => {
test("rejects invalid email sign-in payload", () => {
assert.throws(
() => parseOrThrow(LoginFormSchema, { userId: "!" }),
/Username must be 2-40 characters using letters, numbers, _ or -/,
() => parseOrThrow(EmailSignInFormSchema, { email: "not-an-email", password: "12345678" }),
/Enter a valid email address/,
);
});
test("validates email sign-up payload", () => {
const parsed = parseOrThrow(EmailSignUpFormSchema, {
name: "Alice",
email: "alice@example.com",
password: "password123",
});
assert.equal(parsed.name, "Alice");
assert.equal(parsed.email, "alice@example.com");
});
test("validates topup amount range", () => {
assert.throws(() => parseOrThrow(TopUpFormSchema, { amount: "999" }), /Too big/);

View File

@@ -19,15 +19,17 @@ test("shell includes daisyui and pwa tags", () => {
test("landing page renders hero and flow sections", () => {
const html = renderLandingPage({ authenticated: false, userId: null });
assert.match(html, /From X Article to audiobook in one mention/);
assert.match(html, /From X Article to audiobook/);
assert.match(html, /id="how"/);
assert.match(html, /id="pricing"/);
});
test("login page renders username form", () => {
test("login page renders email and x auth forms", () => {
const html = renderLoginPage({ returnTo: "/audio/1" });
assert.match(html, /action="\/auth\/dev-login"/);
assert.match(html, /name="userId"/);
assert.match(html, /action="\/auth\/x"/);
assert.match(html, /action="\/auth\/email\/sign-in"/);
assert.match(html, /action="\/auth\/email\/sign-up"/);
assert.match(html, /name="email"/);
assert.match(html, /value="\/audio\/1"/);
});