session state simplification

This commit is contained in:
Michael Freno
2026-01-12 09:24:58 -05:00
parent ed16b277f7
commit f68f1f462a
32 changed files with 132 additions and 381 deletions

View File

@@ -306,14 +306,11 @@ export const authRouter = createTRPCRouter({
}
}
const isAdmin = userId === env.ADMIN_ID;
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
await createAuthSession(
getH3Event(ctx),
userId,
isAdmin,
true, // OAuth defaults to remember
clientIP,
userAgent
@@ -521,15 +518,12 @@ export const authRouter = createTRPCRouter({
}
}
const isAdmin = userId === env.ADMIN_ID;
// Create session with Vinxi (OAuth defaults to remember me)
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
await createAuthSession(
getH3Event(ctx),
userId,
isAdmin,
true, // OAuth defaults to remember
clientIP,
userAgent
@@ -647,7 +641,6 @@ export const authRouter = createTRPCRouter({
}
const userId = (res.rows[0] as unknown as User).id;
const isAdmin = userId === env.ADMIN_ID;
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
@@ -655,7 +648,6 @@ export const authRouter = createTRPCRouter({
await createAuthSession(
getH3Event(ctx),
userId,
isAdmin,
rememberMe,
clientIP,
userAgent
@@ -780,7 +772,6 @@ export const authRouter = createTRPCRouter({
}
const userId = (res.rows[0] as unknown as User).id;
const isAdmin = userId === env.ADMIN_ID;
// Use rememberMe from JWT if not provided in input, default to false
const shouldRemember =
@@ -791,7 +782,6 @@ export const authRouter = createTRPCRouter({
await createAuthSession(
getH3Event(ctx),
userId,
isAdmin,
shouldRemember,
clientIP,
userAgent
@@ -983,12 +973,10 @@ export const authRouter = createTRPCRouter({
// Create session with client info
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
const isAdmin = userId === env.ADMIN_ID;
await createAuthSession(
getH3Event(ctx),
userId,
isAdmin,
true, // Always use persistent sessions
clientIP,
userAgent
@@ -1150,14 +1138,11 @@ export const authRouter = createTRPCRouter({
// Reset rate limits on successful login
await resetLoginRateLimits(email, clientIP);
const isAdmin = user.id === env.ADMIN_ID;
// Create session with Vinxi
const userAgent = getUserAgent(getH3Event(ctx));
await createAuthSession(
getH3Event(ctx),
user.id,
isAdmin,
rememberMe ?? false, // Default to session cookie (expires on browser close)
clientIP,
userAgent

View File

@@ -7,9 +7,9 @@ import { CACHE_CONFIG } from "~/config";
const BLOG_CACHE_TTL = CACHE_CONFIG.BLOG_CACHE_TTL_MS;
const getAllPostsData = async (privilegeLevel: string) => {
const getAllPostsData = async (isAdmin: boolean) => {
return withCacheAndStale(
`blog-posts-${privilegeLevel}`,
`blog-posts-${isAdmin ? "admin" : "public"}`,
BLOG_CACHE_TTL,
async () => {
const conn = ConnectionFactory();
@@ -34,7 +34,7 @@ const getAllPostsData = async (privilegeLevel: string) => {
LEFT JOIN Comment c ON p.id = c.post_id
`;
if (privilegeLevel !== "admin") {
if (!isAdmin) {
postsQuery += ` WHERE p.published = TRUE`;
}
@@ -48,7 +48,7 @@ const getAllPostsData = async (privilegeLevel: string) => {
SELECT t.value, t.post_id
FROM Tag t
JOIN Post p ON t.post_id = p.id
${privilegeLevel !== "admin" ? "WHERE p.published = TRUE" : ""}
${!isAdmin ? "WHERE p.published = TRUE" : ""}
ORDER BY t.value ASC
`;
@@ -64,21 +64,21 @@ const getAllPostsData = async (privilegeLevel: string) => {
tagMap[key] = (tagMap[key] || 0) + 1;
});
return { posts, tags, tagMap, privilegeLevel };
return { posts, tags, tagMap, isAdmin };
}
);
};
export const blogRouter = createTRPCRouter({
getRecentPosts: publicProcedure.query(async ({ ctx }) => {
const allPostsData = await getAllPostsData("public");
const allPostsData = await getAllPostsData(false);
return allPostsData.posts.slice(0, 3);
}),
getPosts: publicProcedure.query(async ({ ctx }) => {
const privilegeLevel = ctx.privilegeLevel;
return getAllPostsData(privilegeLevel);
const isAdmin = ctx.isAdmin;
return getAllPostsData(isAdmin);
}),
incrementPostRead: publicProcedure

View File

@@ -144,7 +144,7 @@ export const databaseRouter = createTRPCRouter({
commentID: input.commentID,
deletionType: input.deletionType,
userId: ctx.userId,
privilegeLevel: ctx.privilegeLevel
isAdmin: ctx.isAdmin
});
const commentQuery = await conn.execute({
@@ -161,7 +161,7 @@ export const databaseRouter = createTRPCRouter({
}
const isOwner = comment.commenter_id === ctx.userId;
const isAdmin = ctx.privilegeLevel === "admin";
const isAdmin = ctx.isAdmin;
console.log("[deleteComment] Authorization check:", {
isOwner,

View File

@@ -3,7 +3,7 @@ import { env } from "~/env/server";
export const infillRouter = createTRPCRouter({
getConfig: publicProcedure.query(({ ctx }) => {
if (ctx.privilegeLevel !== "admin") {
if (!ctx.isAdmin) {
return { endpoint: null, token: null };
}

View File

@@ -8,7 +8,7 @@ import { getAuthSession } from "~/server/session-helpers";
export type Context = {
event: APIEvent;
userId: string | null;
privilegeLevel: "anonymous" | "user" | "admin";
isAdmin: boolean;
};
async function createContextInner(event: APIEvent): Promise<Context> {
@@ -16,11 +16,11 @@ async function createContextInner(event: APIEvent): Promise<Context> {
const session = await getAuthSession(event.nativeEvent);
let userId: string | null = null;
let privilegeLevel: "anonymous" | "user" | "admin" = "anonymous";
let isAdmin = false;
if (session && session.userId) {
userId = session.userId;
privilegeLevel = session.isAdmin ? "admin" : "user";
isAdmin = session.isAdmin;
}
const req = event.nativeEvent.node?.req || event.nativeEvent;
@@ -56,7 +56,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
return {
event,
userId,
privilegeLevel
isAdmin
};
}
@@ -70,7 +70,7 @@ export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId || ctx.privilegeLevel === "anonymous") {
if (!ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
}
return next({
@@ -82,7 +82,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
});
const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
if (ctx.privilegeLevel !== "admin") {
if (!ctx.isAdmin) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required"

View File

@@ -6,7 +6,6 @@ import { getAuthSession } from "./session-helpers";
/**
* Check authentication status
* Consolidates getUserID, getPrivilegeLevel, and checkAuthStatus into single function
* @param event - H3Event
* @returns Object with isAuthenticated, userId, and isAdmin flags
*/

View File

@@ -7,7 +7,7 @@ import {
describe("parseConditionals", () => {
const baseContext: ConditionalContext = {
isAuthenticated: true,
privilegeLevel: "user",
isAdmin: false,
userId: "test-user",
currentDate: new Date("2025-06-01"),
featureFlags: { "beta-feature": true },
@@ -34,7 +34,7 @@ describe("parseConditionals", () => {
const anonContext: ConditionalContext = {
...baseContext,
isAuthenticated: false,
privilegeLevel: "anonymous"
isAdmin: false
};
const result = parseConditionals(html, anonContext);
expect(result).not.toContain("Secret content");
@@ -51,7 +51,7 @@ describe("parseConditionals", () => {
const adminContext: ConditionalContext = {
...baseContext,
privilegeLevel: "admin"
isAdmin: true
};
const adminResult = parseConditionals(html, adminContext);
expect(adminResult).toContain("Admin panel");
@@ -119,7 +119,7 @@ describe("parseConditionals", () => {
const anonContext: ConditionalContext = {
...baseContext,
isAuthenticated: false,
privilegeLevel: "anonymous"
isAdmin: false
};
const anonResult = parseConditionals(html, anonContext);
expect(anonResult).toContain("Not authenticated content");

View File

@@ -11,7 +11,7 @@ export function getSafeEnvVariables(): Record<string, string | undefined> {
export interface ConditionalContext {
isAuthenticated: boolean;
privilegeLevel: "admin" | "user" | "anonymous";
isAdmin: boolean;
userId: string | null;
currentDate: Date;
featureFlags: Record<string, boolean>;
@@ -194,7 +194,16 @@ function evaluatePrivilegeCondition(
value: string,
context: ConditionalContext
): boolean {
return context.privilegeLevel === value;
switch (value) {
case "admin":
return context.isAdmin;
case "user":
return context.isAuthenticated && !context.isAdmin;
case "anonymous":
return !context.isAuthenticated;
default:
return false;
}
}
/**

View File

@@ -1,228 +0,0 @@
import { ConnectionFactory } from "./database";
import { v4 as uuidV4 } from "uuid";
/**
* Migration script to add multi-provider and enhanced session support
* Run this script once to migrate existing database
*/
export async function migrateMultiAuth() {
const conn = ConnectionFactory();
try {
// Step 1: Check if UserProvider table exists
const tableCheck = await conn.execute({
sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='UserProvider'"
});
if (tableCheck.rows.length > 0) {
} else {
await conn.execute(`
CREATE TABLE UserProvider (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
provider_user_id TEXT,
email TEXT,
display_name TEXT,
image TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
)
`);
await conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id)"
);
await conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email)"
);
}
// Step 2: Check if Session table has device columns
const sessionColumnsCheck = await conn.execute({
sql: "PRAGMA table_info(Session)"
});
const hasDeviceName = sessionColumnsCheck.rows.some(
(row: any) => row.name === "device_name"
);
if (hasDeviceName) {
} else {
await conn.execute("ALTER TABLE Session ADD COLUMN device_name TEXT");
await conn.execute("ALTER TABLE Session ADD COLUMN device_type TEXT");
await conn.execute("ALTER TABLE Session ADD COLUMN browser TEXT");
await conn.execute("ALTER TABLE Session ADD COLUMN os TEXT");
// SQLite doesn't support non-constant defaults in ALTER TABLE
// Add column with NULL default, then update existing rows
await conn.execute("ALTER TABLE Session ADD COLUMN last_active_at TEXT");
// Update existing rows to set last_active_at = last_used
await conn.execute(
"UPDATE Session SET last_active_at = COALESCE(last_used, created_at) WHERE last_active_at IS NULL"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at)"
);
}
// Step 3: Migrate existing users to UserProvider table
const usersResult = await conn.execute({
sql: "SELECT id, email, provider, display_name, image, apple_user_string FROM User WHERE provider IS NOT NULL"
});
let migratedCount = 0;
for (const row of usersResult.rows) {
const user = row as any;
// Skip apple provider users (they're for Life and Lineage mobile app, not website auth)
if (user.provider === "apple") {
continue;
}
// Check if already migrated
const existingProvider = await conn.execute({
sql: "SELECT id FROM UserProvider WHERE user_id = ? AND provider = ?",
args: [user.id, user.provider || "email"]
});
if (existingProvider.rows.length > 0) {
continue;
}
// Determine provider_user_id based on provider type
let providerUserId: string | null = null;
if (user.provider === "github") {
providerUserId = user.display_name;
} else if (user.provider === "google") {
providerUserId = user.email;
} else {
providerUserId = user.email;
}
try {
await conn.execute({
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
uuidV4(),
user.id,
user.provider || "email",
providerUserId,
user.email,
user.display_name,
user.image
]
});
migratedCount++;
} catch (error: any) {
console.error(
`[Migration] Failed to migrate user ${user.id}:`,
error.message
);
}
}
// Determine provider_user_id based on provider type
let providerUserId: string | null = null;
if (user.provider === "github") {
providerUserId = user.display_name;
} else if (user.provider === "google") {
providerUserId = user.email;
} else if (user.provider === "apple") {
providerUserId = user.apple_user_string;
} else {
providerUserId = user.email;
}
try {
await conn.execute({
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
uuidV4(),
user.id,
user.provider || "email",
providerUserId,
user.email,
user.display_name,
user.image
]
});
migratedCount++;
} catch (error: any) {
console.error(
`[Migration] Failed to migrate user ${user.id}:`,
error.message
);
}
}
// Step 4: Verification
const providerCount = await conn.execute({
sql: "SELECT COUNT(*) as count FROM UserProvider"
});
const multiProviderUsers = await conn.execute({
sql: `SELECT COUNT(*) as count FROM (
SELECT user_id FROM UserProvider GROUP BY user_id HAVING COUNT(*) > 1
)`
});
return {
success: true,
migratedUsers: migratedCount,
totalProviders: (providerCount.rows[0] as any).count
};
} catch (error) {
throw error;
}
}
// Run migration if called directly
if (require.main === module) {
migrateMultiAuth()
.then((result) => {
process.exit(0);
})
.catch((error) => {
process.exit(1);
});
}

View File

@@ -90,7 +90,6 @@ export function hashRefreshToken(token: string): string {
* Create a new session in database and Vinxi session
* @param event - H3Event
* @param userId - User ID
* @param isAdmin - Whether user is admin
* @param rememberMe - Whether to use extended session duration
* @param ipAddress - Client IP address
* @param userAgent - Client user agent string
@@ -101,7 +100,6 @@ export function hashRefreshToken(token: string): string {
export async function createAuthSession(
event: H3Event,
userId: string,
isAdmin: boolean,
rememberMe: boolean,
ipAddress: string,
userAgent: string,
@@ -109,6 +107,19 @@ export async function createAuthSession(
tokenFamily: string | null = null
): Promise<SessionData> {
const conn = ConnectionFactory();
// Fetch is_admin from database
const userResult = await conn.execute({
sql: "SELECT is_admin FROM User WHERE id = ?",
args: [userId]
});
if (userResult.rows.length === 0) {
throw new Error(`User not found: ${userId}`);
}
const isAdmin = userResult.rows[0].is_admin === 1;
const sessionId = uuidV4();
const family = tokenFamily || uuidV4();
const refreshToken = generateRefreshToken();
@@ -374,10 +385,10 @@ async function restoreSessionFromDB(
try {
const conn = ConnectionFactory();
// Query DB for session with all necessary data
// Query DB for session with all necessary data including is_admin
const result = await conn.execute({
sql: `SELECT s.id, s.user_id, s.token_family, s.refresh_token_hash,
s.revoked, s.expires_at, u.isAdmin
s.revoked, s.expires_at, u.is_admin
FROM Session s
JOIN User u ON s.user_id = u.id
WHERE s.id = ?`,
@@ -412,7 +423,6 @@ async function restoreSessionFromDB(
const newSession = await createAuthSession(
event,
dbSession.user_id as string,
dbSession.isAdmin === 1,
true, // Assume rememberMe=true for restoration
ipAddress,
userAgent,
@@ -678,7 +688,6 @@ export async function rotateAuthSession(
const newSessionData = await createAuthSession(
event,
oldSessionData.userId,
oldSessionData.isAdmin,
oldSessionData.rememberMe,
ipAddress,
userAgent,