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
|
||||||
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
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -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:
|
||||||
|
|||||||
72
src/app.js
72
src/app.js
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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: {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|
||||||
|
|||||||
@@ -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"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user