yap-blog/src/lib/auth/session.ts
matt 6d84ae1224 Add author accounts with per-tag posting rights
The seeded account is the single admin; it can create author accounts
on the new /admin/users page (username + password) and grant each one
access to specific tags. Authors sign in to a Posts-only panel where
they can write, edit, publish, and unpublish their own posts — every
post must carry at least one granted tag, tags outside the grants are
rejected server-side, and only the admin can create tags or delete
posts (or anything else: pages, comments, settings, and backups stay
admin-only). Admin-only URLs bounce authors to their post list, and
foreign post editors 404.

posts.author_id records ownership; deleting an account keeps its posts
as unowned, admin-managed rows and signs the account out everywhere.
Backups (export v3) store the owner's username per post and re-attach
ownership on import when the account still exists.

Also fixes a latent form bug: a missing newTags field (author forms
don't render it) failed zod validation with an invisible error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:10:02 -04:00

58 lines
2 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import { eq, lt } from "drizzle-orm";
import { db } from "@/db";
import { sessions, type UserRole, users } from "@/db/schema";
// Deliberately free of next/* imports so it can be exercised directly by
// integration tests; cookie handling lives in cookies.ts.
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export type SessionUser = { id: number; username: string; role: UserRole };
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
/** Creates a DB session and returns the raw bearer token for the cookie. */
export async function createSession(
userId: number,
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await db.insert(sessions).values({ id: hashToken(token), userId, expiresAt });
return { token, expiresAt };
}
/** Resolves a raw token to its user, treating expired sessions as absent. */
export async function validateSessionToken(token: string): Promise<SessionUser | null> {
if (!token) return null;
const id = hashToken(token);
const [row] = await db
.select({
userId: users.id,
username: users.username,
role: users.role,
expiresAt: sessions.expiresAt,
})
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(eq(sessions.id, id))
.limit(1);
if (!row) return null;
if (row.expiresAt.getTime() <= Date.now()) {
await db.delete(sessions).where(eq(sessions.id, id));
return null;
}
return { id: row.userId, username: row.username, role: row.role };
}
export async function deleteSession(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, hashToken(token)));
}
/** Opportunistic cleanup, called on login so the table cannot grow unbounded. */
export async function deleteExpiredSessions(): Promise<void> {
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
}