feat: Implement email/password and X OAuth authentication, replacing the dev-login mechanism.
This commit is contained in:
@@ -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
|
||||
|
||||
13
README.md
13
README.md
@@ -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:
|
||||
|
||||
72
src/app.js
72
src/app.js
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user