forked from bai/curriculum-project-hub
feat(hub): add Feishu OAuth session for org admin
Introduce signed cookie sessions, Feishu web OAuth, requireOrgRole guards, and the first org admin APIs (summary + project settings).
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* HTTP guards for org admin APIs (ADR-0021).
|
||||
*
|
||||
* Platform admin is a separate control plane — not modeled here.
|
||||
*/
|
||||
import type { Organization, OrganizationMemberRole, PrismaClient, User } from "@prisma/client";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
verifySession,
|
||||
type SessionPayload,
|
||||
} from "./session.js";
|
||||
|
||||
export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
|
||||
|
||||
export interface AuthContext {
|
||||
readonly session: SessionPayload;
|
||||
readonly user: User;
|
||||
}
|
||||
|
||||
export interface OrgAuthContext extends AuthContext {
|
||||
readonly organization: Organization;
|
||||
readonly membershipRole: OrganizationMemberRole;
|
||||
}
|
||||
|
||||
export interface GuardDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly statusCode: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(statusCode: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireSession(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
deps: GuardDeps,
|
||||
): Promise<AuthContext | null> {
|
||||
const raw = request.cookies[SESSION_COOKIE_NAME];
|
||||
if (raw === undefined || raw === "") {
|
||||
await sendError(reply, 401, "unauthenticated", "login required");
|
||||
return null;
|
||||
}
|
||||
const session = verifySession(raw, deps.sessionSecret);
|
||||
if (session === null) {
|
||||
await sendError(reply, 401, "unauthenticated", "session invalid or expired");
|
||||
return null;
|
||||
}
|
||||
const user = await deps.prisma.user.findUnique({ where: { id: session.userId } });
|
||||
if (user === null || user.feishuOpenId !== session.feishuOpenId) {
|
||||
await sendError(reply, 401, "unauthenticated", "session user not found");
|
||||
return null;
|
||||
}
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export async function requireOrgRole(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
deps: GuardDeps,
|
||||
options: {
|
||||
readonly orgSlug: string;
|
||||
readonly roles?: readonly OrganizationMemberRole[];
|
||||
},
|
||||
): Promise<OrgAuthContext | null> {
|
||||
const auth = await requireSession(request, reply, deps);
|
||||
if (auth === null) return null;
|
||||
|
||||
const allowed = options.roles ?? ORG_ADMIN_ROLES;
|
||||
const organization = await deps.prisma.organization.findUnique({
|
||||
where: { slug: options.orgSlug },
|
||||
});
|
||||
if (organization === null) {
|
||||
await sendError(reply, 404, "org_not_found", `organization not found: ${options.orgSlug}`);
|
||||
return null;
|
||||
}
|
||||
if (organization.status !== "ACTIVE") {
|
||||
await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const membership = await deps.prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
userId: auth.user.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { role: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
await sendError(reply, 403, "forbidden", "not a member of this organization");
|
||||
return null;
|
||||
}
|
||||
if (!allowed.includes(membership.role)) {
|
||||
await sendError(reply, 403, "forbidden", `requires role: ${allowed.join("|")}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...auth,
|
||||
organization,
|
||||
membershipRole: membership.role,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireOrgProject(
|
||||
deps: GuardDeps,
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
): Promise<{ readonly id: string; readonly organizationId: string; readonly name: string }> {
|
||||
const project = await deps.prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, organizationId: true, name: true, archivedAt: true },
|
||||
});
|
||||
if (project === null || project.organizationId !== organizationId) {
|
||||
throw new HttpError(404, "project_not_found", `project not found: ${projectId}`);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function sendError(
|
||||
reply: FastifyReply,
|
||||
statusCode: number,
|
||||
code: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await reply.status(statusCode).send({ error: { code, message } });
|
||||
}
|
||||
Reference in New Issue
Block a user