mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
import NextAuth from 'next-auth';
|
|
import Credentials from 'next-auth/providers/credentials';
|
|
import Google from 'next-auth/providers/google';
|
|
import GitHub from 'next-auth/providers/github';
|
|
import { PrismaAdapter } from '@auth/prisma-adapter';
|
|
import bcrypt from 'bcryptjs';
|
|
import { db } from '@/lib/db';
|
|
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
|
import { hasBillingAccess } from '@/lib/billing';
|
|
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
|
import { isEmailVerificationEnabled } from '@/lib/email-verification';
|
|
|
|
// Dummy hash for timing-safe comparison when user doesn't exist
|
|
// This prevents user enumeration via timing attacks
|
|
const DUMMY_HASH = '$2a$12$000000000000000000000uGG3k3xK2CVTxXrT7VW2sGd1XrY6Ky';
|
|
|
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
// PrismaAdapter handles OAuth account linking and user creation in DB.
|
|
// JWT strategy is still used for sessions (no DB sessions table needed).
|
|
adapter: PrismaAdapter(db),
|
|
providers: [
|
|
Credentials({
|
|
name: 'credentials',
|
|
credentials: {
|
|
email: { label: 'Email', type: 'email' },
|
|
password: { label: 'Password', type: 'password' },
|
|
},
|
|
async authorize(credentials) {
|
|
if (!credentials?.email || !credentials?.password) {
|
|
return null;
|
|
}
|
|
|
|
const email = credentials.email as string;
|
|
const password = credentials.password as string;
|
|
|
|
// Find user by email
|
|
const user = await db.user.findUnique({
|
|
where: { email: email.toLowerCase() },
|
|
});
|
|
|
|
// Always perform bcrypt comparison to prevent timing attacks
|
|
// If user doesn't exist, compare against dummy hash
|
|
const hashToCompare = user?.password || DUMMY_HASH;
|
|
const isValidPassword = await bcrypt.compare(password, hashToCompare);
|
|
|
|
// Only return user if they exist AND password is valid
|
|
if (!user || !user.password || !isValidPassword) {
|
|
return null;
|
|
}
|
|
|
|
// Block sign-in when email verification is required but not yet completed
|
|
if (isEmailVerificationEnabled() && !user.emailVerified) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
image: user.image,
|
|
};
|
|
},
|
|
}),
|
|
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
|
? [
|
|
Google({
|
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
}),
|
|
]
|
|
: []),
|
|
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
|
|
? [
|
|
{
|
|
...GitHub({
|
|
clientId: process.env.GITHUB_CLIENT_ID,
|
|
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
}),
|
|
// GitHub sends iss=https://github.com/login/oauth in callbacks (RFC 9207).
|
|
// Auth.js v5 beta defaults to "https://authjs.dev" for OAuth providers, causing
|
|
// a mismatch. Setting the correct issuer here fixes the CallbackRouteError.
|
|
issuer: 'https://github.com/login/oauth',
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
session: {
|
|
strategy: 'jwt',
|
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
},
|
|
pages: {
|
|
signIn: '/login',
|
|
signOut: '/signout',
|
|
},
|
|
callbacks: {
|
|
async signIn({ account, profile }) {
|
|
// Credentials sign-in is handled by the authorize() function above
|
|
if (account?.provider === 'credentials') return true;
|
|
|
|
// Reject OAuth sign-ins where the provider email is not verified.
|
|
// Google always sets email_verified: true. GitHub does not guarantee it.
|
|
if (profile && profile.email_verified === false) {
|
|
return '/login?error=OAuthEmailNotVerified';
|
|
}
|
|
|
|
// OAuth sign-in: allow existing OAuth accounts regardless of invite setting
|
|
if (account?.providerAccountId && account?.provider) {
|
|
const existingAccount = await db.account.findUnique({
|
|
where: {
|
|
provider_providerAccountId: {
|
|
provider: account.provider,
|
|
providerAccountId: account.providerAccountId,
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (existingAccount) return true;
|
|
}
|
|
|
|
// New OAuth user: block when invite-only mode is active
|
|
if (isInviteCodeRequired()) {
|
|
return '/login?error=RegistrationClosed';
|
|
}
|
|
|
|
return true;
|
|
},
|
|
async session({ session, token }) {
|
|
if (token.sub && session.user) {
|
|
session.user.id = token.sub;
|
|
session.user.name = token.name || null;
|
|
session.user.isAdmin = token.isAdmin as boolean;
|
|
}
|
|
return session;
|
|
},
|
|
async jwt({ token, user }) {
|
|
if (user) {
|
|
token.sub = user.id;
|
|
token.name = user.name;
|
|
token.email = user.email; // explicitly ensure email is in the token
|
|
}
|
|
|
|
// Check if user is admin based on emails list on EVERY request to ensure env changes are picked up
|
|
if (token.email) {
|
|
const adminEmails = process.env.ADMIN_EMAILS
|
|
? process.env.ADMIN_EMAILS.split(',').map((e: string) => e.trim().toLowerCase())
|
|
: [];
|
|
token.isAdmin = adminEmails.includes((token.email as string).toLowerCase());
|
|
}
|
|
|
|
return token;
|
|
},
|
|
},
|
|
});
|
|
|
|
type ProjectAccessIntent = 'view' | 'manage' | 'delete';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fast-path: pre-fetch access data alongside any existing DB query so that
|
|
// computeProjectAccess() can resolve the result with zero extra round-trips.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Prisma include fragment to attach to any project fetch. */
|
|
export function projectAccessInclude(userId: string | undefined) {
|
|
return {
|
|
workspace: {
|
|
select: {
|
|
id: true,
|
|
ownerId: true,
|
|
owner: {
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
},
|
|
members: userId
|
|
? {
|
|
where: { userId },
|
|
take: 1,
|
|
orderBy: { createdAt: 'asc' as const },
|
|
select: { role: true },
|
|
}
|
|
: { take: 0, select: { role: true } },
|
|
},
|
|
},
|
|
members: userId
|
|
? {
|
|
where: { userId },
|
|
take: 1,
|
|
orderBy: { createdAt: 'asc' as const },
|
|
select: { role: true },
|
|
}
|
|
: { take: 0, select: { role: true } },
|
|
};
|
|
}
|
|
|
|
type ProjectAccessIncludes = ReturnType<typeof projectAccessInclude>;
|
|
type WorkspaceForAccess = ProjectAccessIncludes['workspace']['select'] extends object
|
|
? {
|
|
id: string;
|
|
ownerId: string;
|
|
owner: Parameters<typeof hasBillingAccess>[0] | null;
|
|
members: Array<{ role: WorkspaceMemberRole }>;
|
|
}
|
|
: never;
|
|
|
|
export type EnrichedProjectForAccess = {
|
|
id: string;
|
|
ownerId: string;
|
|
workspaceId: string;
|
|
visibility: string;
|
|
workspace: WorkspaceForAccess;
|
|
members: Array<{ role: ProjectMemberRole }>;
|
|
};
|
|
|
|
/**
|
|
* The project permission formulas, in one place.
|
|
*
|
|
* Two functions resolve the same six inputs by different routes:
|
|
* `computeProjectAccess` reads them off a project that was fetched with
|
|
* `projectAccessInclude()`, and `checkProjectAccess` queries for each relation.
|
|
* They then have to agree on what those inputs mean. Both used to carry a
|
|
* verbatim copy of the three formulas below, which is a silent-divergence
|
|
* hazard rather than a style complaint: change an authorization rule in one
|
|
* copy and not the other and a page renders for somebody the API would refuse.
|
|
*/
|
|
function resolveProjectPermissions(input: {
|
|
isOwner: boolean;
|
|
isPublic: boolean;
|
|
isProjectMember: boolean;
|
|
isProjectAdmin: boolean;
|
|
workspaceRole: WorkspaceMemberRole | 'OWNER' | null;
|
|
ownerBillingActive: boolean;
|
|
}) {
|
|
const { isOwner, isPublic, isProjectMember, isProjectAdmin, workspaceRole, ownerBillingActive } =
|
|
input;
|
|
|
|
const isWorkspaceMember = !!workspaceRole;
|
|
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
|
|
|
|
return {
|
|
isOwner,
|
|
isProjectMember,
|
|
isProjectAdmin,
|
|
isWorkspaceMember,
|
|
isWorkspaceAdmin,
|
|
hasAccess: ownerBillingActive && (isOwner || isProjectMember || isPublic || isWorkspaceMember),
|
|
canEdit: ownerBillingActive && (isOwner || isProjectAdmin || isWorkspaceAdmin),
|
|
canDelete: ownerBillingActive && (isOwner || workspaceRole === 'OWNER'),
|
|
ownerBillingActive,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The workspace permission formulas. Only one caller today, but it is kept
|
|
* beside its project twin and exported so it can be tested directly rather
|
|
* than only through whichever route happens to exercise it.
|
|
*/
|
|
export function resolveWorkspacePermissions(input: {
|
|
isOwner: boolean;
|
|
isMember: boolean;
|
|
isAdmin: boolean;
|
|
ownerBillingActive: boolean;
|
|
}) {
|
|
const { isOwner, isMember, isAdmin, ownerBillingActive } = input;
|
|
|
|
return {
|
|
isOwner,
|
|
isMember,
|
|
isAdmin,
|
|
hasAccess: ownerBillingActive && (isOwner || isMember),
|
|
canEdit: ownerBillingActive && (isOwner || isAdmin),
|
|
canDelete: ownerBillingActive && isOwner,
|
|
ownerBillingActive,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pure access computation, no DB queries.
|
|
* Use after fetching a project with `projectAccessInclude(userId)`.
|
|
*/
|
|
export function computeProjectAccess(
|
|
project: EnrichedProjectForAccess,
|
|
userId: string | undefined
|
|
) {
|
|
const isOwner = userId === project.ownerId;
|
|
const isPublic = project.visibility === 'PUBLIC';
|
|
|
|
const projectMember = project.members[0] ?? null;
|
|
const isProjectMember = !!projectMember;
|
|
const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN;
|
|
|
|
const workspaceOwnerBillingAccess = project.workspace.owner
|
|
? hasBillingAccess(project.workspace.owner)
|
|
: false;
|
|
|
|
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
|
|
if (userId === project.workspace.ownerId) {
|
|
workspaceRole = 'OWNER';
|
|
} else {
|
|
const wsMember = project.workspace.members[0] ?? null;
|
|
if (wsMember) workspaceRole = wsMember.role;
|
|
}
|
|
|
|
return resolveProjectPermissions({
|
|
isOwner,
|
|
isPublic,
|
|
isProjectMember,
|
|
isProjectAdmin,
|
|
workspaceRole,
|
|
ownerBillingActive: workspaceOwnerBillingAccess,
|
|
});
|
|
}
|
|
|
|
// Helper to check project access including workspace membership
|
|
export async function checkProjectAccess(
|
|
project: { id: string; ownerId: string; workspaceId: string; visibility: string },
|
|
userId: string | undefined,
|
|
options?: { intent?: ProjectAccessIntent }
|
|
) {
|
|
const intent = options?.intent ?? 'view';
|
|
const isOwner = userId === project.ownerId;
|
|
const isPublic = project.visibility === 'PUBLIC';
|
|
|
|
// Get project membership
|
|
const projectMember = userId
|
|
? await db.projectMember.findUnique({
|
|
where: { projectId_userId: { projectId: project.id, userId } },
|
|
})
|
|
: null;
|
|
const isProjectMember = !!projectMember;
|
|
const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN;
|
|
|
|
// The workspace role decides `canEdit`/`isWorkspaceMember`, not just whether the viewer
|
|
// gets in at all, so it has to be resolved for every signed-in non-owner. Skipping it
|
|
// once access was already granted some other way (public project, or an existing project
|
|
// membership) silently downgraded workspace admins to read-only on `intent: 'view'`,
|
|
// the intent that pages and GET routes use to decide which actions to render.
|
|
// Owners pass every check on their own; resolve their role only when they mutate.
|
|
const shouldLoadWorkspaceRole = !!userId && (!isOwner || intent !== 'view');
|
|
|
|
// Check workspace membership/role
|
|
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
|
|
let workspaceOwnerBillingAccess = false;
|
|
if (shouldLoadWorkspaceRole && userId) {
|
|
const [wsMember, wsOwner] = await Promise.all([
|
|
db.workspaceMember.findUnique({
|
|
where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } },
|
|
}),
|
|
db.workspace.findUnique({
|
|
where: { id: project.workspaceId },
|
|
select: {
|
|
ownerId: true,
|
|
owner: {
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
if (wsOwner?.ownerId === userId) {
|
|
workspaceRole = 'OWNER';
|
|
} else if (wsMember) {
|
|
workspaceRole = wsMember.role;
|
|
}
|
|
|
|
if (wsOwner?.owner) {
|
|
workspaceOwnerBillingAccess = hasBillingAccess(wsOwner.owner);
|
|
}
|
|
} else {
|
|
const wsOwner = await db.workspace.findUnique({
|
|
where: { id: project.workspaceId },
|
|
select: {
|
|
owner: {
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
workspaceOwnerBillingAccess = wsOwner?.owner ? hasBillingAccess(wsOwner.owner) : false;
|
|
}
|
|
return resolveProjectPermissions({
|
|
isOwner,
|
|
isPublic,
|
|
isProjectMember,
|
|
isProjectAdmin,
|
|
workspaceRole,
|
|
ownerBillingActive: workspaceOwnerBillingAccess,
|
|
});
|
|
}
|
|
|
|
// Helper to check workspace access
|
|
export async function checkWorkspaceAccess(
|
|
workspace: { id: string; ownerId: string },
|
|
userId: string | undefined
|
|
) {
|
|
const isOwner = userId === workspace.ownerId;
|
|
|
|
// Get workspace membership
|
|
const workspaceMember = userId
|
|
? await db.workspaceMember.findUnique({
|
|
where: { workspaceId_userId: { workspaceId: workspace.id, userId } },
|
|
})
|
|
: null;
|
|
const isMember = !!workspaceMember;
|
|
const isAdmin = workspaceMember?.role === WorkspaceMemberRole.ADMIN;
|
|
|
|
const owner = await db.user.findUnique({
|
|
where: { id: workspace.ownerId },
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
});
|
|
const ownerBillingActive = owner ? hasBillingAccess(owner) : false;
|
|
|
|
return resolveWorkspacePermissions({ isOwner, isMember, isAdmin, ownerBillingActive });
|
|
}
|