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
BETTER_AUTH_SECRET=replace-me BETTER_AUTH_SECRET=replace-me
BETTER_AUTH_BASE_PATH=/api/auth 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 INTERNAL_API_TOKEN=replace-me
# Convex # Convex

View File

@@ -346,8 +346,8 @@ This repository now contains a deployable production-style app (single container
### Authentication model ### Authentication model
1. Browser flow is powered by Better Auth under `/api/auth/*`. 1. Browser flow is powered by Better Auth under `/api/auth/*`.
2. `/auth/dev-login` bootstraps a Better Auth session for local/dev testing. 2. Supported sign-in methods are Email/Password and X OAuth.
3. API calls also support `x-user-id` header for scripted usage/testing. 3. All authenticated browser sessions are resolved from Better Auth session cookies.
### Runtime endpoints ### Runtime endpoints
1. Public: 1. Public:
@@ -355,7 +355,9 @@ This repository now contains a deployable production-style app (single container
- `GET /login` - `GET /login`
- `GET /audio/:id` - `GET /audio/:id`
2. Browser actions: 2. Browser actions:
- `POST /auth/dev-login` - `POST /auth/email/sign-in`
- `POST /auth/email/sign-up`
- `POST /auth/x`
- `POST /auth/logout` - `POST /auth/logout`
- `POST /app/actions/topup` - `POST /app/actions/topup`
- `POST /app/actions/simulate-mention` - `POST /app/actions/simulate-mention`
@@ -392,7 +394,8 @@ Use `.env.example` as the source of truth.
2. Auth + state: 2. Auth + state:
- `BETTER_AUTH_SECRET` - `BETTER_AUTH_SECRET`
- `BETTER_AUTH_BASE_PATH` - `BETTER_AUTH_BASE_PATH`
- `BETTER_AUTH_DEV_PASSWORD` - `X_OAUTH_CLIENT_ID`
- `X_OAUTH_CLIENT_SECRET`
- `INTERNAL_API_TOKEN` - `INTERNAL_API_TOKEN`
- `CONVEX_DEPLOYMENT_URL` - `CONVEX_DEPLOYMENT_URL`
- `CONVEX_AUTH_TOKEN` - `CONVEX_AUTH_TOKEN`
@@ -448,7 +451,7 @@ Use `.env.example` as the source of truth.
## Production Checklist ## 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. 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`. 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: - 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 { FixedWindowRateLimiter } = require("./lib/rate-limit");
const { const {
PolarWebhookPayloadSchema, PolarWebhookPayloadSchema,
LoginFormSchema, EmailSignInFormSchema,
EmailSignUpFormSchema,
TopUpFormSchema, TopUpFormSchema,
SimulateMentionFormSchema, SimulateMentionFormSchema,
parseOrThrow, parseOrThrow,
@@ -103,7 +104,8 @@ function buildApp({
appBaseUrl: config.appBaseUrl, appBaseUrl: config.appBaseUrl,
basePath: config.betterAuthBasePath, basePath: config.betterAuthBasePath,
secret: config.betterAuthSecret, secret: config.betterAuthSecret,
devPassword: config.betterAuthDevPassword, xOAuthClientId: config.xOAuthClientId,
xOAuthClientSecret: config.xOAuthClientSecret,
logger, logger,
}); });
const generationService = audioGenerationService || createAudioGenerationService({ 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"); const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
if (rateLimited) { if (rateLimited) {
return rateLimited; return rateLimited;
@@ -528,9 +530,13 @@ function buildApp({
const form = parseFormUrlEncoded(rawBody); const form = parseFormUrlEncoded(rawBody);
try { try {
const login = parseOrThrow(LoginFormSchema, form); const login = parseOrThrow(EmailSignInFormSchema, form);
const nextPath = sanitizeReturnTo(login.returnTo, "/app"); 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, { return redirect(nextPath, {
"set-cookie": authSession.setCookie, "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") { if (method === "POST" && path === "/auth/logout") {
const signOut = await auth.signOut(safeHeaders); const signOut = await auth.signOut(safeHeaders);
return redirect("/", signOut.setCookie return redirect("/", signOut.setCookie

View File

@@ -54,7 +54,8 @@ const parsed = {
appBaseUrl: strFromEnv("APP_BASE_URL", "http://localhost:3000"), appBaseUrl: strFromEnv("APP_BASE_URL", "http://localhost:3000"),
betterAuthSecret: strFromEnv("BETTER_AUTH_SECRET", "dev-better-auth-secret"), betterAuthSecret: strFromEnv("BETTER_AUTH_SECRET", "dev-better-auth-secret"),
betterAuthBasePath: strFromEnv("BETTER_AUTH_BASE_PATH", "/api/auth"), 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", ""), internalApiToken: strFromEnv("INTERNAL_API_TOKEN", ""),
convexDeploymentUrl: strFromEnv("CONVEX_DEPLOYMENT_URL", strFromEnv("CONVEX_URL", "")), convexDeploymentUrl: strFromEnv("CONVEX_DEPLOYMENT_URL", strFromEnv("CONVEX_URL", "")),
convexAuthToken: strFromEnv("CONVEX_AUTH_TOKEN", ""), convexAuthToken: strFromEnv("CONVEX_AUTH_TOKEN", ""),
@@ -105,7 +106,8 @@ const ConfigSchema = z.object({
appBaseUrl: z.string().min(1), appBaseUrl: z.string().min(1),
betterAuthSecret: z.string().min(1), betterAuthSecret: z.string().min(1),
betterAuthBasePath: z.string().min(1), betterAuthBasePath: z.string().min(1),
betterAuthDevPassword: z.string().min(8), xOAuthClientId: z.string(),
xOAuthClientSecret: z.string(),
internalApiToken: z.string(), internalApiToken: z.string(),
convexDeploymentUrl: z.string(), convexDeploymentUrl: z.string(),
convexAuthToken: z.string(), convexAuthToken: z.string(),

View File

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

View File

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

View File

@@ -2,8 +2,6 @@
const { z } = require("zod"); const { z } = require("zod");
const usernameRegex = /^[a-zA-Z0-9_-]{2,40}$/;
const XWebhookPayloadSchema = z.object({ const XWebhookPayloadSchema = z.object({
mentionPostId: z.string().min(1), mentionPostId: z.string().min(1),
callerUserId: z.string().min(1), callerUserId: z.string().min(1),
@@ -16,8 +14,16 @@ const PolarWebhookPayloadSchema = z.object({
eventId: z.string().min(1), eventId: z.string().min(1),
}); });
const LoginFormSchema = z.object({ const EmailSignInFormSchema = z.object({
userId: z.string().regex(usernameRegex, "Username must be 2-40 characters using letters, numbers, _ or -"), 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(), returnTo: z.string().optional(),
}); });
@@ -42,7 +48,8 @@ function parseOrThrow(schema, payload, errorMessage) {
module.exports = { module.exports = {
XWebhookPayloadSchema, XWebhookPayloadSchema,
PolarWebhookPayloadSchema, PolarWebhookPayloadSchema,
LoginFormSchema, EmailSignInFormSchema,
EmailSignUpFormSchema,
TopUpFormSchema, TopUpFormSchema,
SimulateMentionFormSchema, SimulateMentionFormSchema,
parseOrThrow, parseOrThrow,

View File

@@ -182,22 +182,31 @@ function renderLoginPage({ returnTo = "/app", error = null }) {
<label class="form-control w-full"> <label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Password</span> <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="••••••••" /> <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">
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
<div class="form-control">
<label class="label">
<span class="label-text">Username</span>
</label> </label>
<input name="userId" required minlength="2" maxlength="40" class="input input-bordered w-full" placeholder="matiss" /> <button class="btn btn-primary w-full">Sign in with email</button>
</div> </form>
<button class="btn btn-primary w-full">Continue</button> <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)}" />
<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> </form>
</div> </div>
</div> </section>
`, `,
}); });
} }
function renderAppPage({ userId, summary, jobs, flash = null }) { 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>` : ""; 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({ return shell({
title: "Audio not found", title: "Audio not found",
compact: true, 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 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" : accessDecision.reason === "auth_required"
? `<div class="alert alert-warning mb-3">Sign in to continue.</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">Sign in to unlock</a>` <a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary w-full">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>Unlock required: ${accessDecision.creditsRequired} credits.</span></div>
<form method="POST" action="/audio/${audio.id}/unlock"> <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>`; </form>`;
return shell({ return shell({
@@ -296,16 +305,30 @@ function renderAudioPage({ audio, accessDecision, userId, playbackUrl = null })
compact: true, compact: true,
content: ` content: `
${nav({ authenticated: Boolean(userId), userId })} ${nav({ authenticated: Boolean(userId), userId })}
<section class="card bg-slate-900/85 border border-slate-700"> <div class="card w-full max-w-2xl mx-auto shadow-2xl bg-base-100 border border-base-200">
<div class="card-body p-4"> <div class="card-body p-6">
<h1 class="text-xl font-bold">${escapeHtml(audio.articleTitle)}</h1> <span class="badge badge-accent mb-2">Audiobook</span>
<div class="text-xs text-slate-400">Duration ~ ${audio.durationSec}s • Asset ${escapeHtml(audio.id)}</div> <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} ${action}
${accessDecision.allowed ${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> </div>
</section> </div>
`, `,
}); });
} }

View File

@@ -5,6 +5,63 @@ const assert = require("node:assert/strict");
const { buildApp } = require("../src/app"); const { buildApp } = require("../src/app");
const { hmacSHA256Hex } = require("../src/lib/signature"); 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 = {}) { function createApp(options = {}) {
const baseConfig = { const baseConfig = {
xWebhookSecret: "x-secret", xWebhookSecret: "x-secret",
@@ -17,7 +74,8 @@ function createApp(options = {}) {
appBaseUrl: "http://localhost:3000", appBaseUrl: "http://localhost:3000",
betterAuthSecret: "test-better-auth-secret", betterAuthSecret: "test-better-auth-secret",
betterAuthBasePath: "/api/auth", betterAuthBasePath: "/api/auth",
betterAuthDevPassword: "xartaudio-dev-password", xOAuthClientId: "x-client-id",
xOAuthClientSecret: "x-client-secret",
internalApiToken: "", internalApiToken: "",
convexDeploymentUrl: "", convexDeploymentUrl: "",
convexAuthToken: "", convexAuthToken: "",
@@ -75,6 +133,9 @@ function createApp(options = {}) {
const appOptions = { ...options }; const appOptions = { ...options };
delete appOptions.config; delete appOptions.config;
if (!appOptions.authAdapter) {
appOptions.authAdapter = createTestAuthAdapter();
}
return buildApp({ return buildApp({
config: mergedConfig, config: mergedConfig,
@@ -108,7 +169,7 @@ test("GET / renders landing page", async () => {
const app = createApp(); const app = createApp();
const response = await call(app, { method: "GET", path: "/" }); const response = await call(app, { method: "GET", path: "/" });
assert.equal(response.status, 200); 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 () => { 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/); 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 app = createApp();
const response = await call(app, { const response = await call(app, {
method: "POST", method: "POST",
path: "/auth/dev-login", path: "/auth/email/sign-in",
body: "userId=matiss&returnTo=%2Fapp", body: "email=matiss%40example.com&password=password123&returnTo=%2Fapp",
}); });
assert.equal(response.status, 303); assert.equal(response.status, 303);
assert.equal(response.headers.location, "/app"); 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 () => { 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, { const first = await call(app, {
method: "POST", method: "POST",
path: "/auth/dev-login", path: "/auth/email/sign-in",
headers: { "x-forwarded-for": "5.5.5.5" }, 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, { const second = await call(app, {
method: "POST", method: "POST",
path: "/auth/dev-login", path: "/auth/email/sign-in",
headers: { "x-forwarded-for": "5.5.5.5" }, 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); assert.equal(first.status, 303);

View File

@@ -30,13 +30,12 @@ test("clearUserCookie expires session cookie", () => {
assert.match(cookie, /Max-Age=0/); assert.match(cookie, /Max-Age=0/);
}); });
test("getAuthenticatedUserId prefers x-user-id header", () => { test("getAuthenticatedUserId resolves from cookie only", () => {
const userId = getAuthenticatedUserId({ const userId = getAuthenticatedUserId({
"x-user-id": "header-user",
cookie: "xartaudio_user=cookie-user", cookie: "xartaudio_user=cookie-user",
}); });
assert.equal(userId, "header-user"); assert.equal(userId, "cookie-user");
}); });
test("getAuthenticatedUserId falls back to cookie", () => { 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"); const { createBetterAuthAdapter } = require("../src/integrations/better-auth");
function createMockAuthHandler() { function createMockAuthHandler() {
const signedIn = new Set();
return async function handler(request) { return async function handler(request) {
const url = new URL(request.url); const url = new URL(request.url);
const path = url.pathname; const path = url.pathname;
if (path.endsWith("/sign-in/email")) { 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 body = await request.json();
const userId = String(body.name); const userId = String(body.email);
signedIn.add(userId);
return new Response(JSON.stringify({ ok: true }), { return new Response(JSON.stringify({ ok: true }), {
status: 200, status: 200,
headers: { 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")) { if (path.endsWith("/get-session")) {
const cookie = request.headers.get("cookie") || ""; const cookie = request.headers.get("cookie") || "";
const match = cookie.match(/xartaudio_better_auth=([^;]+)/); 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({ const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000", appBaseUrl: "http://localhost:3000",
secret: "test-secret", secret: "test-secret",
authHandler: createMockAuthHandler(), authHandler: createMockAuthHandler(),
}); });
const result = await adapter.signInDevUser("alice"); const result = await adapter.signInWithEmail({
assert.match(String(result.setCookie), /xartaudio_better_auth=alice/); 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 () => { 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"); 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 () => { test("handleRoute proxies requests into better-auth handler", async () => {
const adapter = createBetterAuthAdapter({ const adapter = createBetterAuthAdapter({
appBaseUrl: "http://localhost:3000", 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.logLevel, "info");
assert.equal(config.appBaseUrl, "http://localhost:3000"); assert.equal(config.appBaseUrl, "http://localhost:3000");
assert.equal(config.betterAuthBasePath, "/api/auth"); assert.equal(config.betterAuthBasePath, "/api/auth");
assert.equal(config.xOAuthClientId, "");
assert.equal(config.xOAuthClientSecret, "");
assert.equal(config.internalApiToken, ""); assert.equal(config.internalApiToken, "");
assert.equal(config.qwenTtsModel, "qwen-tts-latest"); assert.equal(config.qwenTtsModel, "qwen-tts-latest");
assert.equal(config.minioSignedUrlTtlSec, 3600); assert.equal(config.minioSignedUrlTtlSec, 3600);
@@ -66,7 +68,8 @@ test("config reads convex/qwen/minio overrides", () => {
APP_BASE_URL: "https://xartaudio.app", APP_BASE_URL: "https://xartaudio.app",
BETTER_AUTH_SECRET: "prod-secret", BETTER_AUTH_SECRET: "prod-secret",
BETTER_AUTH_BASE_PATH: "/api/auth", 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", INTERNAL_API_TOKEN: "internal-token",
CONVEX_DEPLOYMENT_URL: "https://example.convex.cloud", CONVEX_DEPLOYMENT_URL: "https://example.convex.cloud",
CONVEX_URL: "https://should-not-win.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.logLevel, "debug");
assert.equal(config.appBaseUrl, "https://xartaudio.app"); assert.equal(config.appBaseUrl, "https://xartaudio.app");
assert.equal(config.betterAuthSecret, "prod-secret"); 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.internalApiToken, "internal-token");
assert.equal(config.convexDeploymentUrl, "https://example.convex.cloud"); assert.equal(config.convexDeploymentUrl, "https://example.convex.cloud");
assert.equal(config.convexAuthToken, "convex-token"); assert.equal(config.convexAuthToken, "convex-token");

View File

@@ -16,7 +16,8 @@ function createRuntimeConfig() {
appBaseUrl: "http://localhost:3000", appBaseUrl: "http://localhost:3000",
betterAuthSecret: "test-better-auth-secret", betterAuthSecret: "test-better-auth-secret",
betterAuthBasePath: "/api/auth", betterAuthBasePath: "/api/auth",
betterAuthDevPassword: "xartaudio-dev-password", xOAuthClientId: "",
xOAuthClientSecret: "",
internalApiToken: "", internalApiToken: "",
convexDeploymentUrl: "", convexDeploymentUrl: "",
convexAuthToken: "", convexAuthToken: "",
@@ -132,10 +133,19 @@ test("createRuntime falls back to in-memory state when initial load fails", asyn
assert.equal(warnings.length, 1); assert.equal(warnings.length, 1);
assert.match(String(warnings[0].message), /falling back to in-memory state/); 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({ const response = await runtime.app.handleRequest({
method: "POST", method: "POST",
path: "/app/actions/topup", path: "/app/actions/topup",
headers: { "x-user-id": "u1" }, headers: { cookie: signUp.headers["set-cookie"] },
rawBody: "amount=3", rawBody: "amount=3",
query: {}, query: {},
}); });

View File

@@ -5,7 +5,8 @@ const assert = require("node:assert/strict");
const { const {
XWebhookPayloadSchema, XWebhookPayloadSchema,
PolarWebhookPayloadSchema, PolarWebhookPayloadSchema,
LoginFormSchema, EmailSignInFormSchema,
EmailSignUpFormSchema,
TopUpFormSchema, TopUpFormSchema,
SimulateMentionFormSchema, SimulateMentionFormSchema,
parseOrThrow, parseOrThrow,
@@ -31,13 +32,24 @@ test("validates Polar webhook payload with numeric coercion", () => {
assert.equal(parsed.credits, 12); assert.equal(parsed.credits, 12);
}); });
test("rejects invalid login username", () => { test("rejects invalid email sign-in payload", () => {
assert.throws( assert.throws(
() => parseOrThrow(LoginFormSchema, { userId: "!" }), () => parseOrThrow(EmailSignInFormSchema, { email: "not-an-email", password: "12345678" }),
/Username must be 2-40 characters using letters, numbers, _ or -/, /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", () => { test("validates topup amount range", () => {
assert.throws(() => parseOrThrow(TopUpFormSchema, { amount: "999" }), /Too big/); 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", () => { test("landing page renders hero and flow sections", () => {
const html = renderLandingPage({ authenticated: false, userId: null }); 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="how"/);
assert.match(html, /id="pricing"/); 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" }); const html = renderLoginPage({ returnTo: "/audio/1" });
assert.match(html, /action="\/auth\/dev-login"/); assert.match(html, /action="\/auth\/x"/);
assert.match(html, /name="userId"/); 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"/); assert.match(html, /value="\/audio\/1"/);
}); });