From 1fa23addffa9f6cd7d5c8a6f2a3b622080b7c88f Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 25 Sep 2025 02:58:46 +0800 Subject: [PATCH 1/4] use Org and Space policies to protect space pages --- apps/web/actions/organization/create-space.ts | 4 +- apps/web/app/(org)/dashboard/Contexts.tsx | 2 +- .../dashboard/spaces/[spaceId]/actions.ts | 8 +- .../[spaceId]/folder/[folderId]/page.tsx | 15 +- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 136 ++++-------------- .../(org)/dashboard/spaces/[spaceId]/utils.ts | 48 +++++++ apps/web/lib/server.ts | 4 + packages/database/schema.ts | 14 +- packages/web-backend/src/Auth.ts | 6 +- packages/web-backend/src/Folders/index.ts | 4 +- .../src/Organisations/OrganisationsPolicy.ts | 29 ++++ .../src/Organisations/OrganisationsRepo.ts | 20 ++- packages/web-backend/src/Rpcs.ts | 2 +- .../web-backend/src/Spaces/SpacesPolicy.ts | 29 ++++ packages/web-backend/src/Spaces/SpacesRepo.ts | 49 +++++-- packages/web-backend/src/Spaces/index.ts | 0 .../web-backend/src/Videos/VideosPolicy.ts | 4 +- packages/web-backend/src/Videos/index.ts | 20 +-- packages/web-backend/src/index.ts | 2 + packages/web-domain/src/Authentication.ts | 2 +- packages/web-domain/src/Policy.ts | 20 ++- 21 files changed, 249 insertions(+), 169 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts create mode 100644 packages/web-backend/src/Organisations/OrganisationsPolicy.ts create mode 100644 packages/web-backend/src/Spaces/SpacesPolicy.ts create mode 100644 packages/web-backend/src/Spaces/index.ts diff --git a/apps/web/actions/organization/create-space.ts b/apps/web/actions/organization/create-space.ts index 0a32efe285..552344f1a4 100644 --- a/apps/web/actions/organization/create-space.ts +++ b/apps/web/actions/organization/create-space.ts @@ -172,7 +172,9 @@ export async function createSpace( if (!userId) return null; // Creator is always Owner, others are Member const role = - email.toLowerCase() === creatorEmail ? "Admin" : "Member"; + email.toLowerCase() === creatorEmail + ? ("Admin" as const) + : ("member" as const); return { id: uuidv4().substring(0, nanoIdLength), spaceId, diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index 922117d905..3bd54d2b5d 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -94,7 +94,7 @@ export function DashboardContexts({ (member) => member.userId === user.id && member.organizationId === space.organizationId && - member.role === "MEMBER", + member.role === "member", ), ) || null; diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts b/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts index a8c9339d88..e641b35217 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts @@ -9,16 +9,18 @@ import { revalidatePath } from "next/cache"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; +const spaceRole = z.union([z.literal("Admin"), z.literal("member")]); + const addSpaceMemberSchema = z.object({ spaceId: z.string(), userId: z.string(), - role: z.string(), + role: spaceRole, }); const addSpaceMembersSchema = z.object({ spaceId: z.string(), userIds: z.array(z.string()), - role: z.string(), + role: spaceRole, }); export async function addSpaceMember( @@ -149,7 +151,7 @@ export async function removeSpaceMember( const setSpaceMembersSchema = z.object({ spaceId: z.string(), userIds: z.array(z.string()), - role: z.string().default("member"), + role: spaceRole.default("member"), }); export async function setSpaceMembers( diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx index 41e602b598..419bb0e60f 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx @@ -1,32 +1,41 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { serverEnv } from "@cap/env"; -import type { Folder } from "@cap/web-domain"; +import { CurrentUser, type Folder } from "@cap/web-domain"; +import { Effect } from "effect"; +import { notFound } from "next/navigation"; import FolderCard from "@/app/(org)/dashboard/caps/components/Folder"; import { getChildFolders, getFolderBreadcrumb, getVideosByFolderId, } from "@/lib/folder"; +import { runPromise } from "@/lib/server"; import { BreadcrumbItem, ClientMyCapsLink, NewSubfolderButton, } from "../../../../folder/[id]/components"; import FolderVideosSection from "../../../../folder/[id]/components/FolderVideosSection"; +import { getSpaceOrOrg } from "../../utils"; const FolderPage = async (props: { params: Promise<{ spaceId: string; folderId: Folder.FolderId }>; }) => { const params = await props.params; const user = await getCurrentUser(); - if (!user) return; + if (!user) return notFound(); + + await getSpaceOrOrg(params.spaceId).pipe( + Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())), + Effect.provideService(CurrentUser, user), + runPromise, + ); const [childFolders, breadcrumb, videosData] = await Promise.all([ getChildFolders(params.folderId), getFolderBreadcrumb(params.folderId), getVideosByFolderId(params.folderId), ]); - const userId = user?.id as string; return (
diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index aa94d6dbe1..507ba02580 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -4,39 +4,27 @@ import { comments, folders, organizationMembers, - organizations, sharedVideos, spaceMembers, - spaces, spaceVideos, users, videos, videoUploads, } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { Video } from "@cap/web-domain"; +import { CurrentUser, Video } from "@cap/web-domain"; import { and, count, desc, eq, isNull, sql } from "drizzle-orm"; +import { Effect } from "effect"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { runPromise } from "@/lib/server"; import { SharedCaps } from "./SharedCaps"; +import { getSpaceOrOrg } from "./utils"; export const metadata: Metadata = { title: "Shared Caps — Cap", }; -type SpaceData = { - id: string; - name: string; - organizationId: string; - createdById: string; -}; - -type OrganizationData = { - id: string; - name: string; - ownerId: string; -}; - export type SpaceMemberData = { id: string; userId: string; @@ -47,31 +35,6 @@ export type SpaceMemberData = { }; // --- Helper functions --- -async function fetchSpaceData(id: string) { - return db() - .select({ - id: spaces.id, - name: spaces.name, - organizationId: spaces.organizationId, - createdById: spaces.createdById, - }) - .from(spaces) - .where(eq(spaces.id, id)) - .limit(1); -} - -async function fetchOrganizationData(id: string) { - return db() - .select({ - id: organizations.id, - name: organizations.name, - ownerId: organizations.ownerId, - }) - .from(organizations) - .where(eq(organizations.id, id)) - .limit(1); -} - async function fetchFolders(spaceId: string) { return db() .select({ @@ -127,60 +90,25 @@ export default async function SharedCapsPage(props: { const page = Number(searchParams.page) || 1; const limit = Number(searchParams.limit) || 15; const user = await getCurrentUser(); - const userId = user?.id as string; - // this is just how it work atm - const spaceOrOrgId = params.spaceId; - - // Parallelize fetching space and org data - const [spaceData, organizationData] = await Promise.all([ - fetchSpaceData(spaceOrOrgId), - fetchOrganizationData(spaceOrOrgId), - ]); + if (!user) notFound(); - // organizationData assignment handled above - if (spaceData.length === 0 && organizationData.length === 0) { - notFound(); - } + const spaceOrOrg = await getSpaceOrOrg(params.spaceId).pipe( + Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())), + Effect.provideService(CurrentUser, user), + runPromise, + ); - const isSpace = spaceData.length > 0; + if (!spaceOrOrg) notFound(); - if (isSpace) { - const space = spaceData[0] as SpaceData; - const isSpaceCreator = space.createdById === userId; - let hasAccess = isSpaceCreator; - if (!isSpaceCreator) { - const [spaceMembership, orgMembership] = await Promise.all([ - db() - .select({ id: spaceMembers.id }) - .from(spaceMembers) - .where( - and( - eq(spaceMembers.userId, userId), - eq(spaceMembers.spaceId, spaceOrOrgId), - ), - ) - .limit(1), - db() - .select({ id: organizationMembers.id }) - .from(organizationMembers) - .where( - and( - eq(organizationMembers.userId, userId), - eq(organizationMembers.organizationId, space.organizationId), - ), - ) - .limit(1), - ]); - hasAccess = spaceMembership.length > 0 || orgMembership.length > 0; - } - if (!hasAccess) notFound(); + if (spaceOrOrg.variant === "space") { + const { space } = spaceOrOrg; // Fetch members in parallel const [spaceMembersData, organizationMembersData, foldersData] = await Promise.all([ - fetchSpaceMembers(spaceOrOrgId), + fetchSpaceMembers(space.id), fetchOrganizationMembers(space.organizationId), - fetchFolders(spaceOrOrgId), + fetchFolders(space.id), ]); async function fetchSpaceVideos( @@ -244,7 +172,7 @@ export default async function SharedCapsPage(props: { // Fetch videos and count in parallel const { videos: spaceVideoData, totalCount } = await fetchSpaceVideos( - spaceOrOrgId, + space.id, page, limit, ); @@ -268,30 +196,14 @@ export default async function SharedCapsPage(props: { dubApiKeyEnabled={!!serverEnv().DUB_API_KEY} spaceMembers={spaceMembersData} organizationMembers={organizationMembersData} - currentUserId={userId} + currentUserId={user.id} folders={foldersData} /> ); - } else { - const organization = organizationData[0] as OrganizationData; - const isOrgOwner = organization.ownerId === userId; - - if (!isOrgOwner) { - const orgMembership = await db() - .select({ id: organizationMembers.id }) - .from(organizationMembers) - .where( - and( - eq(organizationMembers.userId, userId), - eq(organizationMembers.organizationId, spaceOrOrgId), - ), - ) - .limit(1); + } - if (orgMembership.length === 0) { - notFound(); - } - } + if (spaceOrOrg.variant === "organization") { + const { organization } = spaceOrOrg; async function fetchOrganizationVideos( orgId: string, @@ -364,9 +276,9 @@ export default async function SharedCapsPage(props: { const [organizationVideos, organizationMembersData, foldersData] = await Promise.all([ - fetchOrganizationVideos(spaceOrOrgId, page, limit), - fetchOrganizationMembers(spaceOrOrgId), - fetchFolders(spaceOrOrgId), + fetchOrganizationVideos(organization.id, page, limit), + fetchOrganizationMembers(organization.id), + fetchFolders(organization.id), ]); const { videos: orgVideoData, totalCount } = organizationVideos; @@ -390,7 +302,7 @@ export default async function SharedCapsPage(props: { organizationData={organization} dubApiKeyEnabled={!!serverEnv().DUB_API_KEY} organizationMembers={organizationMembersData} - currentUserId={userId} + currentUserId={user.id} folders={foldersData} /> ); diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts b/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts new file mode 100644 index 0000000000..df6205f9af --- /dev/null +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts @@ -0,0 +1,48 @@ +import { organizations, spaces } from "@cap/database/schema"; +import { Database, OrganisationsPolicy, SpacesPolicy } from "@cap/web-backend"; +import { Policy } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { Effect } from "effect"; + +export const getSpaceOrOrg = Effect.fn(function* (spaceOrOrgId: string) { + const db = yield* Database; + const spacesPolicy = yield* SpacesPolicy; + const orgsPolicy = yield* OrganisationsPolicy; + + const [[space], [organization]] = yield* Effect.all([ + db.execute((db) => + db + .select({ + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + createdById: spaces.createdById, + }) + .from(spaces) + .where(eq(spaces.id, spaceOrOrgId)) + .limit(1), + ), + db.execute((db) => + db + .select({ + id: organizations.id, + name: organizations.name, + ownerId: organizations.ownerId, + }) + .from(organizations) + .where(eq(organizations.id, spaceOrOrgId)) + .limit(1), + ), + ]); + + if (space) + return yield* Effect.succeed({ variant: "space" as const, space }).pipe( + Policy.withPolicy(spacesPolicy.isMember(space.id)), + ); + + if (organization) + return yield* Effect.succeed({ + variant: "organization" as const, + organization, + }).pipe(Policy.withPolicy(orgsPolicy.isMember(organization.id))); +}); diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts index a6a3b9a856..cee4927f44 100644 --- a/apps/web/lib/server.ts +++ b/apps/web/lib/server.ts @@ -5,7 +5,9 @@ import { Database, Folders, HttpAuthMiddlewareLive, + OrganisationsPolicy, S3Buckets, + SpacesPolicy, Videos, VideosPolicy, } from "@cap/web-backend"; @@ -43,6 +45,8 @@ export const Dependencies = Layer.mergeAll( Videos.Default, VideosPolicy.Default, Folders.Default, + SpacesPolicy.Default, + OrganisationsPolicy.Default, ).pipe( Layer.provideMerge( Layer.mergeAll(Database.Default, TracingLayer, FetchHttpClient.layer), diff --git a/packages/database/schema.ts b/packages/database/schema.ts index ccf63a2cce..c4e4e6d87d 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -168,13 +168,16 @@ export const organizations = mysqlTable( }), ); +export type OrganisationMemberRole = "owner" | "member"; export const organizationMembers = mysqlTable( "organization_members", { id: nanoId("id").notNull().primaryKey().unique(), userId: nanoId("userId").notNull(), organizationId: nanoId("organizationId").notNull(), - role: varchar("role", { length: 255 }).notNull(), + role: varchar("role", { length: 255 }) + .notNull() + .$type(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), }, @@ -195,7 +198,9 @@ export const organizationInvites = mysqlTable( organizationId: nanoId("organizationId").notNull(), invitedEmail: varchar("invitedEmail", { length: 255 }).notNull(), invitedByUserId: nanoId("invitedByUserId").notNull(), - role: varchar("role", { length: 255 }).notNull(), + role: varchar("role", { length: 255 }) + .notNull() + .$type(), status: varchar("status", { length: 255 }).notNull().default("pending"), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), @@ -552,7 +557,10 @@ export const spaceMembers = mysqlTable( id: nanoId("id").notNull().primaryKey().unique(), spaceId: nanoId("spaceId").notNull(), userId: nanoId("userId").notNull(), - role: varchar("role", { length: 255 }).notNull().default("member"), + role: varchar("role", { length: 255 }) + .notNull() + .default("member") + .$type<"member" | "Admin">(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), }, diff --git a/packages/web-backend/src/Auth.ts b/packages/web-backend/src/Auth.ts index 46e0b39bac..e2b008b861 100644 --- a/packages/web-backend/src/Auth.ts +++ b/packages/web-backend/src/Auth.ts @@ -1,6 +1,6 @@ import { getServerSession } from "@cap/database/auth/auth-options"; import * as Db from "@cap/database/schema"; -import { CurrentUser, HttpAuthMiddleware } from "@cap/web-domain"; +import { CurrentUser, HttpAuthMiddleware, Policy } from "@cap/web-domain"; import { HttpApiError, HttpServerRequest } from "@effect/platform"; import * as Dz from "drizzle-orm"; import { type Cause, Effect, Layer, Option, Schema } from "effect"; @@ -65,7 +65,7 @@ export const HttpAuthMiddlewareLive = Layer.effect( Option.map((user) => ({ id: user.id, email: user.email, - activeOrgId: user.activeOrganizationId, + activeOrganizationId: user.activeOrganizationId, })), Effect.catchTag( "NoSuchElementException", @@ -98,7 +98,7 @@ export const provideOptionalAuth = ( CurrentUser.context({ id: user.id, email: user.email, - activeOrgId: user.activeOrganizationId, + activeOrganizationId: user.activeOrganizationId, }), ), Option.match({ diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index bf4b2c10f9..d402ef2154 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -79,7 +79,7 @@ export class Folders extends Effect.Service()("Folders", { .where( Dz.and( Dz.eq(Db.folders.id, parentId), - Dz.eq(Db.folders.organizationId, user.activeOrgId), + Dz.eq(Db.folders.organizationId, user.activeOrganizationId), ), ), ); @@ -91,7 +91,7 @@ export class Folders extends Effect.Service()("Folders", { id: Folder.FolderId.make(nanoId()), name: data.name, color: data.color, - organizationId: user.activeOrgId, + organizationId: user.activeOrganizationId, createdById: user.id, spaceId: data.spaceId, parentId: data.parentId, diff --git a/packages/web-backend/src/Organisations/OrganisationsPolicy.ts b/packages/web-backend/src/Organisations/OrganisationsPolicy.ts new file mode 100644 index 0000000000..4d1e414b8c --- /dev/null +++ b/packages/web-backend/src/Organisations/OrganisationsPolicy.ts @@ -0,0 +1,29 @@ +import { Policy } from "@cap/web-domain"; +import { Effect, Option } from "effect"; + +import { Database } from "../Database.ts"; +import { OrganisationsRepo } from "../Organisations/OrganisationsRepo.ts"; +import { SpacesRepo } from "../Spaces/SpacesRepo.ts"; + +export class OrganisationsPolicy extends Effect.Service()( + "OrganisationsPolicy", + { + effect: Effect.gen(function* () { + const repo = yield* OrganisationsRepo; + + const isMember = (orgId: string) => + Policy.policy( + Effect.fn(function* (user) { + return Option.isSome(yield* repo.membership(user.id, orgId)); + }), + ); + + return { isMember }; + }), + dependencies: [ + OrganisationsRepo.Default, + SpacesRepo.Default, + Database.Default, + ], + }, +) {} diff --git a/packages/web-backend/src/Organisations/OrganisationsRepo.ts b/packages/web-backend/src/Organisations/OrganisationsRepo.ts index 73a8c02fe4..3626864ae6 100644 --- a/packages/web-backend/src/Organisations/OrganisationsRepo.ts +++ b/packages/web-backend/src/Organisations/OrganisationsRepo.ts @@ -1,7 +1,7 @@ import * as Db from "@cap/database/schema"; import type { Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; -import { Effect } from "effect"; +import { Array, Effect } from "effect"; import { Database } from "../Database.ts"; @@ -31,6 +31,24 @@ export class OrganisationsRepo extends Effect.Service()( ), ), ), + + membership: (userId: string, orgId: string) => + db + .execute((db) => + db + .select({ + membershipId: Db.organizationMembers.id, + role: Db.organizationMembers.role, + }) + .from(Db.organizationMembers) + .where( + Dz.and( + Dz.eq(Db.organizationMembers.userId, userId), + Dz.eq(Db.organizationMembers.organizationId, orgId), + ), + ), + ) + .pipe(Effect.map(Array.get(0))), }; }), dependencies: [Database.Default], diff --git a/packages/web-backend/src/Rpcs.ts b/packages/web-backend/src/Rpcs.ts index 0cced04caf..5f45ea0c67 100644 --- a/packages/web-backend/src/Rpcs.ts +++ b/packages/web-backend/src/Rpcs.ts @@ -28,7 +28,7 @@ export const RpcAuthMiddlewareLive = Layer.effect( Effect.succeed({ id: user.id, email: user.email, - activeOrgId: user.activeOrganizationId, + activeOrganizationId: user.activeOrganizationId, }), }), ), diff --git a/packages/web-backend/src/Spaces/SpacesPolicy.ts b/packages/web-backend/src/Spaces/SpacesPolicy.ts new file mode 100644 index 0000000000..910a106862 --- /dev/null +++ b/packages/web-backend/src/Spaces/SpacesPolicy.ts @@ -0,0 +1,29 @@ +import { Policy } from "@cap/web-domain"; +import { Effect, Option } from "effect"; + +import { Database } from "../Database.ts"; +import { OrganisationsRepo } from "../Organisations/OrganisationsRepo.ts"; +import { SpacesRepo } from "../Spaces/SpacesRepo.ts"; + +export class SpacesPolicy extends Effect.Service()( + "SpacesPolicy", + { + effect: Effect.gen(function* () { + const repo = yield* SpacesRepo; + + const isMember = (spaceId: string) => + Policy.policy( + Effect.fn(function* (user) { + return Option.isSome(yield* repo.membership(user.id, spaceId)); + }), + ); + + return { isMember }; + }), + dependencies: [ + OrganisationsRepo.Default, + SpacesRepo.Default, + Database.Default, + ], + }, +) {} diff --git a/packages/web-backend/src/Spaces/SpacesRepo.ts b/packages/web-backend/src/Spaces/SpacesRepo.ts index bb77e42f5a..886ec1f3b8 100644 --- a/packages/web-backend/src/Spaces/SpacesRepo.ts +++ b/packages/web-backend/src/Spaces/SpacesRepo.ts @@ -1,7 +1,7 @@ import * as Db from "@cap/database/schema"; import type { Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; -import { Effect } from "effect"; +import { Array, Effect } from "effect"; import { Database } from "../Database.ts"; @@ -11,21 +11,40 @@ export class SpacesRepo extends Effect.Service()("SpacesRepo", { return { membershipForVideo: (userId: string, videoId: Video.VideoId) => - db.execute((db) => - db - .select({ membershipId: Db.spaceMembers.id }) - .from(Db.spaceMembers) - .leftJoin( - Db.spaceVideos, - Dz.eq(Db.spaceMembers.spaceId, Db.spaceVideos.spaceId), - ) - .where( - Dz.and( - Dz.eq(Db.spaceMembers.userId, userId), - Dz.eq(Db.spaceVideos.videoId, videoId), + db + .execute((db) => + db + .select({ membershipId: Db.spaceMembers.id }) + .from(Db.spaceMembers) + .leftJoin( + Db.spaceVideos, + Dz.eq(Db.spaceMembers.spaceId, Db.spaceVideos.spaceId), + ) + .where( + Dz.and( + Dz.eq(Db.spaceMembers.userId, userId), + Dz.eq(Db.spaceVideos.videoId, videoId), + ), ), - ), - ), + ) + .pipe(Effect.map(Array.get(0))), + membership: (userId: string, spaceId: string) => + db + .execute((db) => + db + .select({ + membershipId: Db.spaceMembers.id, + role: Db.spaceMembers.role, + }) + .from(Db.spaceMembers) + .where( + Dz.and( + Dz.eq(Db.spaceMembers.userId, userId), + Dz.eq(Db.spaceMembers.spaceId, spaceId), + ), + ), + ) + .pipe(Effect.map(Array.get(0))), }; }), dependencies: [Database.Default], diff --git a/packages/web-backend/src/Spaces/index.ts b/packages/web-backend/src/Spaces/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/web-backend/src/Videos/VideosPolicy.ts b/packages/web-backend/src/Videos/VideosPolicy.ts index 9e3aa6f6f4..17bf7886f0 100644 --- a/packages/web-backend/src/Videos/VideosPolicy.ts +++ b/packages/web-backend/src/Videos/VideosPolicy.ts @@ -36,9 +36,7 @@ export class VideosPolicy extends Effect.Service()( orgsRepo .membershipForVideo(userId, video.id) .pipe(Effect.map(Array.get(0))), - spacesRepo - .membershipForVideo(userId, video.id) - .pipe(Effect.map(Array.get(0))), + spacesRepo.membershipForVideo(userId, video.id), ]); if ( diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index fc77017804..d06a7422b8 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -11,6 +11,7 @@ import { VideosRepo } from "./VideosRepo.ts"; export class Videos extends Effect.Service()("Videos", { effect: Effect.gen(function* () { + const db = yield* Database; const repo = yield* VideosRepo; const policy = yield* VideosPolicy; const s3Buckets = yield* S3Buckets; @@ -107,8 +108,6 @@ export class Videos extends Effect.Service()("Videos", { getUploadProgress: Effect.fn("Videos.getUploadProgress")(function* ( videoId: Video.VideoId, ) { - const db = yield* Database; - const [result] = yield* db .execute((db) => db @@ -135,14 +134,17 @@ export class Videos extends Effect.Service()("Videos", { getDownloadInfo: Effect.fn("Videos.getDownloadInfo")(function* ( videoId: Video.VideoId, ) { - const [video] = yield* getById(videoId).pipe( - Effect.flatMap( - Effect.catchTag( - "NoSuchElementException", - () => new Video.NotFoundError(), + const [video] = yield* repo + .getById(videoId) + .pipe( + Effect.flatMap( + Effect.catchTag( + "NoSuchElementException", + () => new Video.NotFoundError(), + ), ), - ), - ); + Policy.withPublicPolicy(policy.canView(videoId)), + ); const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId); diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index 4ad253f168..badfc188db 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -2,8 +2,10 @@ export * from "./Auth.ts"; export * from "./Database.ts"; export { Folders } from "./Folders/index.ts"; export * from "./Loom/index.ts"; +export { OrganisationsPolicy } from "./Organisations/OrganisationsPolicy.ts"; export * from "./Rpcs.ts"; export { S3Buckets } from "./S3Buckets/index.ts"; +export { SpacesPolicy } from "./Spaces/SpacesPolicy.ts"; export { Videos } from "./Videos/index.ts"; export { VideosPolicy } from "./Videos/VideosPolicy.ts"; export * as Workflows from "./Workflows.ts"; diff --git a/packages/web-domain/src/Authentication.ts b/packages/web-domain/src/Authentication.ts index b4ef466d3e..d69ed5d5c6 100644 --- a/packages/web-domain/src/Authentication.ts +++ b/packages/web-domain/src/Authentication.ts @@ -6,7 +6,7 @@ import { InternalError } from "./Errors.ts"; export class CurrentUser extends Context.Tag("CurrentUser")< CurrentUser, - { id: string; email: string; activeOrgId: string } + { id: string; email: string; activeOrganizationId: string } >() {} export class HttpAuthMiddleware extends HttpApiMiddleware.Tag()( diff --git a/packages/web-domain/src/Policy.ts b/packages/web-domain/src/Policy.ts index d159eafd14..cbdcf6cfe0 100644 --- a/packages/web-domain/src/Policy.ts +++ b/packages/web-domain/src/Policy.ts @@ -1,19 +1,17 @@ // shoutout https://lucas-barake.github.io/building-a-composable-policy-system/ -import { Context, Data, Effect, type Option, Schema } from "effect"; +import { type Brand, Context, Data, Effect, type Option, Schema } from "effect"; import { CurrentUser } from "./Authentication.ts"; -export type Policy = Effect.Effect< - void, - PolicyDeniedError | E, - CurrentUser | R +export type Policy = Brand.Branded< + Effect.Effect, + "Private" >; -export type PublicPolicy = Effect.Effect< - void, - PolicyDeniedError | E, - R +export type PublicPolicy = Brand.Branded< + Effect.Effect, + "Public" >; export class PolicyDeniedError extends Schema.TaggedError()( @@ -36,7 +34,7 @@ export const policy = ( ), (result) => (result ? Effect.void : Effect.fail(new PolicyDeniedError())), ), - ); + ) as Policy; /** * Creates a policy from a predicate function that may evaluate the current user, @@ -54,7 +52,7 @@ export const publicPolicy = ( return yield* Effect.flatMap(predicate(user), (result) => result ? Effect.void : Effect.fail(new PolicyDeniedError()), ); - }); + }) as PublicPolicy; export class DenyAccess extends Data.TaggedError("DenyAccess")<{}> {} From d00f8fb7ab00f3513fd833d26529dcde3c8433e4 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 25 Sep 2025 03:27:35 +0800 Subject: [PATCH 2/4] effectify some more --- .../app/(org)/dashboard/folder/[id]/page.tsx | 116 ++++---- .../[spaceId]/folder/[folderId]/page.tsx | 116 ++++---- apps/web/lib/folder.ts | 254 ++++++++++-------- 3 files changed, 271 insertions(+), 215 deletions(-) diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index e44389f06c..ea13ffc3d4 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -1,5 +1,5 @@ import { serverEnv } from "@cap/env"; -import type { Folder } from "@cap/web-domain"; +import { CurrentUser, type Folder } from "@cap/web-domain"; import { getChildFolders, getFolderBreadcrumb, @@ -13,67 +13,75 @@ import { NewSubfolderButton, } from "./components"; import FolderVideosSection from "./components/FolderVideosSection"; +import { Effect } from "effect"; +import { runPromise } from "@/lib/server"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { notFound } from "next/navigation"; -const FolderPage = async (props: { - params: Promise<{ id: Folder.FolderId }>; -}) => { - const params = await props.params; - const [childFolders, breadcrumb, videosData] = await Promise.all([ - getChildFolders(params.id), - getFolderBreadcrumb(params.id), - getVideosByFolderId(params.id), - ]); +const FolderPage = async ({ params }: { params: { id: Folder.FolderId } }) => { + const user = await getCurrentUser(); + if (!user) return notFound(); - return ( -
-
- - -
-
-
- + Effect.gen(function* () { + const [childFolders, breadcrumb, videosData] = yield* Effect.all([ + getChildFolders(params.id, { variant: "user" }), + getFolderBreadcrumb(params.id), + getVideosByFolderId(params.id), + ]); - {breadcrumb.map((folder, index) => ( -
-

/

- -
- ))} + return ( +
+
+ +
-
+
+
+ - {/* Display Child Folders */} - {childFolders.length > 0 && ( - <> -

Subfolders

-
- {childFolders.map((folder) => ( - + {breadcrumb.map((folder, index) => ( +
+

/

+ +
))}
- - )} +
- {/* Display Videos */} - -
- ); + {/* Display Child Folders */} + {childFolders.length > 0 && ( + <> +

+ Subfolders +

+
+ {childFolders.map((folder) => ( + + ))} +
+ + )} + + {/* Display Videos */} + +
+ ); + }).pipe(Effect.provideService(CurrentUser, user), runPromise); }; export default FolderPage; diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx index 419bb0e60f..0bf4d07871 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx @@ -25,64 +25,74 @@ const FolderPage = async (props: { const user = await getCurrentUser(); if (!user) return notFound(); - await getSpaceOrOrg(params.spaceId).pipe( - Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())), - Effect.provideService(CurrentUser, user), - runPromise, - ); + return await Effect.gen(function* () { + const spaceOrOrg = yield* getSpaceOrOrg(params.spaceId); + if (!spaceOrOrg) notFound(); - const [childFolders, breadcrumb, videosData] = await Promise.all([ - getChildFolders(params.folderId), - getFolderBreadcrumb(params.folderId), - getVideosByFolderId(params.folderId), - ]); + const [childFolders, breadcrumb, videosData] = yield* Effect.all([ + getChildFolders( + params.folderId, + spaceOrOrg.variant === "space" + ? { variant: "space", spaceId: spaceOrOrg.space.id } + : { variant: "org", organizationId: spaceOrOrg.organization.id }, + ), + getFolderBreadcrumb(params.folderId), + getVideosByFolderId(params.folderId), + ]); - return ( -
-
- -
-
-
- - {breadcrumb.map((folder, index) => ( -
-

/

- -
- ))} + return ( +
+
+
-
- {/* Display Child Folders */} - {childFolders.length > 0 && ( - <> -

Subfolders

-
- {childFolders.map((folder) => ( - +
+
+ + {breadcrumb.map((folder, index) => ( +
+

/

+ +
))}
- - )} - {/* Display Videos */} - -
+
+ {/* Display Child Folders */} + {childFolders.length > 0 && ( + <> +

+ Subfolders +

+
+ {childFolders.map((folder) => ( + + ))} +
+ + )} + {/* Display Videos */} + +
+ ); + }).pipe( + Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())), + Effect.provideService(CurrentUser, user), + runPromise, ); }; diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index c2eb8d01c8..ef6d7c260e 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -13,26 +13,33 @@ import { videos, videoUploads, } from "@cap/database/schema"; +import { Database } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; -import { Folder } from "@cap/web-domain"; +import { CurrentUser, Folder } from "@cap/web-domain"; import { and, desc, eq } from "drizzle-orm"; import { sql } from "drizzle-orm/sql"; +import { Effect } from "effect"; import { revalidatePath } from "next/cache"; -export async function getFolderById(folderId: string | undefined) { +export const getFolderById = Effect.fn(function* (folderId: string) { if (!folderId) throw new Error("Folder ID is required"); + const db = yield* Database; - const [folder] = await db() - .select() - .from(folders) - .where(eq(folders.id, Folder.FolderId.make(folderId))); + const [folder] = yield* db.execute((db) => + db + .select() + .from(folders) + .where(eq(folders.id, Folder.FolderId.make(folderId))), + ); if (!folder) throw new Error("Folder not found"); return folder; -} +}); -export async function getFolderBreadcrumb(folderId: Folder.FolderId) { +export const getFolderBreadcrumb = Effect.fn(function* ( + folderId: Folder.FolderId, +) { const breadcrumb: Array<{ id: Folder.FolderId; name: string; @@ -41,7 +48,7 @@ export async function getFolderBreadcrumb(folderId: Folder.FolderId) { let currentFolderId = folderId; while (currentFolderId) { - const folder = await getFolderById(currentFolderId); + const folder = yield* getFolderById(currentFolderId); if (!folder) break; breadcrumb.unshift({ @@ -55,48 +62,58 @@ export async function getFolderBreadcrumb(folderId: Folder.FolderId) { } return breadcrumb; -} +}); // Helper function to fetch shared spaces data for videos -async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { +const getSharedSpacesForVideos = Effect.fn(function* ( + videoIds: Video.VideoId[], +) { if (videoIds.length === 0) return {}; + const db = yield* Database; // Fetch space-level sharing - const spaceSharing = await db() - .select({ - videoId: spaceVideos.videoId, - id: spaces.id, - name: spaces.name, - organizationId: spaces.organizationId, - iconUrl: organizations.iconUrl, - }) - .from(spaceVideos) - .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) - .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) - .where( - sql`${spaceVideos.videoId} IN (${sql.join( - videoIds.map((id) => sql`${id}`), - sql`, `, - )})`, - ); + const spaceSharing = yield* db.execute((db) => + db + .select({ + videoId: spaceVideos.videoId, + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + iconUrl: organizations.iconUrl, + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .where( + sql`${spaceVideos.videoId} IN (${sql.join( + videoIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ), + ); // Fetch organization-level sharing - const orgSharing = await db() - .select({ - videoId: sharedVideos.videoId, - id: organizations.id, - name: organizations.name, - organizationId: organizations.id, - iconUrl: organizations.iconUrl, - }) - .from(sharedVideos) - .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) - .where( - sql`${sharedVideos.videoId} IN (${sql.join( - videoIds.map((id) => sql`${id}`), - sql`, `, - )})`, - ); + const orgSharing = yield* db.execute((db) => + db + .select({ + videoId: sharedVideos.videoId, + id: organizations.id, + name: organizations.name, + organizationId: organizations.id, + iconUrl: organizations.iconUrl, + }) + .from(sharedVideos) + .innerJoin( + organizations, + eq(sharedVideos.organizationId, organizations.id), + ) + .where( + sql`${sharedVideos.videoId} IN (${sql.join( + videoIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ), + ); // Combine and group by videoId const sharedSpacesMap: Record< @@ -138,23 +155,29 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { }); return sharedSpacesMap; -} +}); -export async function getVideosByFolderId(folderId: Folder.FolderId) { +export const getVideosByFolderId = Effect.fn(function* ( + folderId: Folder.FolderId, +) { if (!folderId) throw new Error("Folder ID is required"); + const db = yield* Database; - const videoData = await db() - .select({ - id: videos.id, - ownerId: videos.ownerId, - name: videos.name, - createdAt: videos.createdAt, - public: videos.public, - metadata: videos.metadata, - duration: videos.duration, - totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, - totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, - sharedOrganizations: sql<{ id: string; name: string; iconUrl: string }[]>` + const videoData = yield* db.execute((db) => + db + .select({ + id: videos.id, + ownerId: videos.ownerId, + name: videos.name, + createdAt: videos.createdAt, + public: videos.public, + metadata: videos.metadata, + duration: videos.duration, + totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, + totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, + sharedOrganizations: sql< + { id: string; name: string; iconUrl: string }[] + >` COALESCE( JSON_ARRAYAGG( JSON_OBJECT( @@ -167,44 +190,48 @@ export async function getVideosByFolderId(folderId: Folder.FolderId) { ) `, - ownerName: users.name, - effectiveDate: sql` + ownerName: users.name, + effectiveDate: sql` COALESCE( JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), ${videos.createdAt} ) `, - hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean, - ), - }) - .from(videos) - .leftJoin(comments, eq(videos.id, comments.videoId)) - .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .leftJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) - .leftJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) - .where(eq(videos.folderId, folderId)) - .groupBy( - videos.id, - videos.ownerId, - videos.name, - videos.createdAt, - videos.public, - videos.metadata, - users.name, - ) - .orderBy( - desc(sql`COALESCE( + hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), + hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( + Boolean, + ), + }) + .from(videos) + .leftJoin(comments, eq(videos.id, comments.videoId)) + .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .leftJoin( + organizations, + eq(sharedVideos.organizationId, organizations.id), + ) + .leftJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .where(eq(videos.folderId, folderId)) + .groupBy( + videos.id, + videos.ownerId, + videos.name, + videos.createdAt, + videos.public, + videos.metadata, + users.name, + ) + .orderBy( + desc(sql`COALESCE( JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), ${videos.createdAt} )`), - ); + ), + ); // Fetch shared spaces data for all videos const videoIds = videoData.map((video) => video.id); - const sharedSpacesMap = await getSharedSpacesForVideos(videoIds); + const sharedSpacesMap = yield* getSharedSpacesForVideos(videoIds); // Process the video data to match the expected format const processedVideoData = videoData.map((video) => { @@ -238,31 +265,42 @@ export async function getVideosByFolderId(folderId: Folder.FolderId) { }); return processedVideoData; -} +}); + +export const getChildFolders = Effect.fn(function* ( + folderId: Folder.FolderId, + root: + | { variant: "user" } + | { variant: "space"; spaceId: string } + | { variant: "org"; organizationId: string }, +) { + const db = yield* Database; -export async function getChildFolders(folderId: Folder.FolderId) { - const user = await getCurrentUser(); - if (!user || !user.activeOrganizationId) - throw new Error("Unauthorized or no active organization"); + const user = yield* CurrentUser; + if (!user.activeOrganizationId) throw new Error("No active organization"); - const childFolders = await db() - .select({ - id: folders.id, - name: folders.name, - color: folders.color, - parentId: folders.parentId, - organizationId: folders.organizationId, - videoCount: sql`( - SELECT COUNT(*) FROM videos WHERE videos.folderId = folders.id - )`, - }) - .from(folders) - .where( - and( - eq(folders.parentId, folderId), - eq(folders.organizationId, user.activeOrganizationId), + const childFolders = yield* db.execute((db) => + db + .select({ + id: folders.id, + name: folders.name, + color: folders.color, + parentId: folders.parentId, + organizationId: folders.organizationId, + videoCount: sql`( + SELECT COUNT(*) FROM videos WHERE videos.folderId = folders.id + )`, + }) + .from(folders) + .where( + and( + eq(folders.parentId, folderId), + root.variant === "space" + ? eq(folders.spaceId, root.spaceId) + : undefined, + ), ), - ); + ); return childFolders; -} +}); From 991d0b8412bdd8d19b1f4381c0ccc48c7819485d Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 25 Sep 2025 03:30:56 +0800 Subject: [PATCH 3/4] formatting --- apps/web/app/(org)/dashboard/folder/[id]/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index ea13ffc3d4..cb5c5507b1 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -1,10 +1,14 @@ +import { getCurrentUser } from "@cap/database/auth/session"; import { serverEnv } from "@cap/env"; import { CurrentUser, type Folder } from "@cap/web-domain"; +import { Effect } from "effect"; +import { notFound } from "next/navigation"; import { getChildFolders, getFolderBreadcrumb, getVideosByFolderId, } from "@/lib/folder"; +import { runPromise } from "@/lib/server"; import { UploadCapButton } from "../../caps/components"; import FolderCard from "../../caps/components/Folder"; import { @@ -13,10 +17,6 @@ import { NewSubfolderButton, } from "./components"; import FolderVideosSection from "./components/FolderVideosSection"; -import { Effect } from "effect"; -import { runPromise } from "@/lib/server"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { notFound } from "next/navigation"; const FolderPage = async ({ params }: { params: { id: Folder.FolderId } }) => { const user = await getCurrentUser(); From b7edb25fb0df41498650934df088228bc05a67bd Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 25 Sep 2025 11:06:56 +0800 Subject: [PATCH 4/4] fix --- apps/web/app/(org)/dashboard/folder/[id]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index cb5c5507b1..302be68ece 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -20,9 +20,9 @@ import FolderVideosSection from "./components/FolderVideosSection"; const FolderPage = async ({ params }: { params: { id: Folder.FolderId } }) => { const user = await getCurrentUser(); - if (!user) return notFound(); + if (!user || !user.activeOrganizationId) return notFound(); - Effect.gen(function* () { + return Effect.gen(function* () { const [childFolders, breadcrumb, videosData] = yield* Effect.all([ getChildFolders(params.id, { variant: "user" }), getFolderBreadcrumb(params.id),