diff --git a/Dockerfile b/Dockerfile
index 391f3dd..ac22069 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,6 +6,14 @@ COPY package.json bun.lock ./
COPY prisma ./prisma
RUN bun install --frozen-lockfile
+# The tree the runner ships. The build needs eslint, vitest, playwright and the rest; the
+# running app does not, and copying the full tree put them all in the image.
+FROM base AS prod-deps
+COPY package.json bun.lock ./
+COPY prisma ./prisma
+RUN bun install --frozen-lockfile --production
+RUN bun run db:generate
+
FROM deps AS build
COPY app ./app
COPY components ./components
@@ -45,7 +53,10 @@ COPY --from=build /app/lib ./lib
COPY --from=build /app/app ./app
COPY --from=build /app/components ./components
COPY --from=build /app/types ./types
-COPY --from=build /app/node_modules ./node_modules
+COPY --from=prod-deps /app/node_modules ./node_modules
+# next.config.ts and prisma.config.ts are TypeScript, and both are loaded at startup, so
+# the compiler has to be present even though nothing else here needs it.
+COPY --from=deps /app/node_modules/typescript ./node_modules/typescript
COPY --from=build /app/.next ./.next
COPY --from=build /app/tsconfig.json ./tsconfig.json
COPY --from=build /app/postcss.config.mjs ./postcss.config.mjs
diff --git a/app/(auth)/login/login-form.tsx b/app/(auth)/login/login-form.tsx
index cc820e6..46ee1e0 100644
--- a/app/(auth)/login/login-form.tsx
+++ b/app/(auth)/login/login-form.tsx
@@ -124,10 +124,15 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {
+ {/*
+ `?registered=true` is only ever reached when email verification is off: the
+ register page sends a user who has to verify to /verify-email instead. Telling
+ this one to go and check a mailbox pointed them at a message that never arrives,
+ on a self-hosted deployment without SMTP, which is the documented default.
+ */}
{showSuccess && (
- Account created successfully! Please check your email to verify your address before
- signing in.
+ Account created successfully! You can sign in now.
)}
diff --git a/app/(dashboard)/projects/[projectId]/settings/project-settings-page-client.tsx b/app/(dashboard)/projects/[projectId]/settings/project-settings-page-client.tsx
index 5c8d424..0bf280d 100644
--- a/app/(dashboard)/projects/[projectId]/settings/project-settings-page-client.tsx
+++ b/app/(dashboard)/projects/[projectId]/settings/project-settings-page-client.tsx
@@ -438,20 +438,32 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
setEditTagColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border-0"
/>
setEditTagName(e.target.value)}
className="flex-1 h-8"
onKeyDown={(e) => e.key === 'Enter' && handleUpdateTag(tag.id)}
/>
-
+ {/* UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED, which belonged to none of the
+ buckets above and so were counted nowhere. */}
+
+
+ Unpaid or Incomplete
+
+
+
+
{stripeStats.otherStatusUsers}
+
+
>
)}
diff --git a/app/api/approvals/[requestId]/cancel/route.ts b/app/api/approvals/[requestId]/cancel/route.ts
index a481d83..c1c2d4c 100644
--- a/app/api/approvals/[requestId]/cancel/route.ts
+++ b/app/api/approvals/[requestId]/cancel/route.ts
@@ -40,11 +40,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
if (!approvalRequest) return apiErrors.notFound('Approval request');
- const access = await checkProjectAccess(
- approvalRequest.version.video.project,
- session.user.id,
- { intent: 'manage' }
- );
+ const access = await checkProjectAccess(approvalRequest.version.video.project, session.user.id);
const canCancel = approvalRequest.requestedById === session.user.id || access.canEdit;
if (!canCancel) return apiErrors.forbidden('Access denied');
diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts
index 04c103c..2156a86 100644
--- a/app/api/comments/[commentId]/route.ts
+++ b/app/api/comments/[commentId]/route.ts
@@ -133,7 +133,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const project = comment.version.video.project;
const userId = session?.user?.id ?? null;
- const access = await checkProjectAccess(project, userId ?? undefined, { intent: 'manage' });
+ const access = await checkProjectAccess(project, userId ?? undefined);
const isOwner = userId === project.ownerId;
const isAuthor = !!userId && comment.authorId === userId;
const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null;
@@ -295,7 +295,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const isAuthor = !!userId && comment.authorId === userId;
// Project owners/admins and workspace admins can delete any comment
- const access = userId ? await checkProjectAccess(project, userId, { intent: 'manage' }) : null;
+ const access = userId ? await checkProjectAccess(project, userId) : null;
const isPrivilegedUser = !!access?.canEdit;
let canDelete = isAuthor || isPrivilegedUser;
diff --git a/app/api/projects/[projectId]/approval-candidates/route.ts b/app/api/projects/[projectId]/approval-candidates/route.ts
index e80f76d..51cf427 100644
--- a/app/api/projects/[projectId]/approval-candidates/route.ts
+++ b/app/api/projects/[projectId]/approval-candidates/route.ts
@@ -20,7 +20,7 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
});
if (!project) return apiErrors.notFound('Project');
- const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(project, session.user.id);
if (!access.canEdit) return apiErrors.forbidden('Access denied');
const candidates = await getApprovalCandidatesForProject(projectId);
diff --git a/app/api/projects/[projectId]/members/[memberId]/route.ts b/app/api/projects/[projectId]/members/[memberId]/route.ts
index 5257e49..202b4aa 100644
--- a/app/api/projects/[projectId]/members/[memberId]/route.ts
+++ b/app/api/projects/[projectId]/members/[memberId]/route.ts
@@ -30,7 +30,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(project, session.user.id);
const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
@@ -93,7 +93,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(project, session.user.id);
const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
diff --git a/app/api/projects/[projectId]/members/invitations/[invitationId]/route.ts b/app/api/projects/[projectId]/members/invitations/[invitationId]/route.ts
index 06ba58b..7bbb706 100644
--- a/app/api/projects/[projectId]/members/invitations/[invitationId]/route.ts
+++ b/app/api/projects/[projectId]/members/invitations/[invitationId]/route.ts
@@ -30,7 +30,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(project, session.user.id);
const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
diff --git a/app/api/projects/[projectId]/members/route.ts b/app/api/projects/[projectId]/members/route.ts
index c5bc532..0a1bdc7 100644
--- a/app/api/projects/[projectId]/members/route.ts
+++ b/app/api/projects/[projectId]/members/route.ts
@@ -112,7 +112,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(project, session.user.id);
const isOwner = project.ownerId === session.user.id;
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts
index ae6a4ef..b4eb008 100644
--- a/app/api/projects/[projectId]/route.ts
+++ b/app/api/projects/[projectId]/route.ts
@@ -103,7 +103,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
const access = projectAccessTarget
- ? await checkProjectAccess(projectAccessTarget, session.user.id, { intent: 'manage' })
+ ? await checkProjectAccess(projectAccessTarget, session.user.id)
: null;
if (!access?.canEdit) {
return apiErrors.forbidden('Access denied');
@@ -181,7 +181,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, session.user.id, { intent: 'delete' });
+ const access = await checkProjectAccess(project, session.user.id);
if (!access.canDelete) {
return apiErrors.forbidden('Only the project owner can delete it');
}
diff --git a/app/api/projects/[projectId]/tags/[tagId]/route.ts b/app/api/projects/[projectId]/tags/[tagId]/route.ts
index d53ad9b..e3bdf88 100644
--- a/app/api/projects/[projectId]/tags/[tagId]/route.ts
+++ b/app/api/projects/[projectId]/tags/[tagId]/route.ts
@@ -28,7 +28,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(project, session.user.id);
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
@@ -98,7 +98,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(project, session.user.id);
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
diff --git a/app/api/projects/[projectId]/tags/route.ts b/app/api/projects/[projectId]/tags/route.ts
index 6c5ba61..c35fd5b 100644
--- a/app/api/projects/[projectId]/tags/route.ts
+++ b/app/api/projects/[projectId]/tags/route.ts
@@ -97,7 +97,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(project, session.user.id);
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts
index f01d0d0..2ec011c 100644
--- a/app/api/projects/[projectId]/videos/[videoId]/route.ts
+++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts
@@ -170,7 +170,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Video');
}
- const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(video.project, session.user.id);
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
@@ -251,7 +251,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Video');
}
- const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(video.project, session.user.id);
if (!access.canEdit) {
return apiErrors.forbidden('Only project owner or admin can delete videos');
}
diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts
index 9683386..91f12c3 100644
--- a/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts
+++ b/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts
@@ -32,7 +32,7 @@ async function getVersionWithAccess(
}
const project = version.video.project;
- const access = await checkProjectAccess(project, userId, { intent: 'manage' });
+ const access = await checkProjectAccess(project, userId);
return { version, canEdit: access.canEdit, isOwner: access.isOwner };
}
diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts
index 2ab73ea..771efb4 100644
--- a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts
+++ b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts
@@ -74,7 +74,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Video');
}
- const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(video.project, session.user.id);
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
diff --git a/app/api/projects/[projectId]/videos/bulk-delete/route.ts b/app/api/projects/[projectId]/videos/bulk-delete/route.ts
index e82456f..97eb2c5 100644
--- a/app/api/projects/[projectId]/videos/bulk-delete/route.ts
+++ b/app/api/projects/[projectId]/videos/bulk-delete/route.ts
@@ -5,7 +5,7 @@ import { db } from '@/lib/db';
import { logCleanupWarnings } from '@/lib/cleanup-warnings';
import { logError } from '@/lib/logger';
import { rateLimit } from '@/lib/rate-limit';
-import { deleteProjectVideosWithCleanup } from '@/lib/video-delete';
+import { deleteProjectVideosWithCleanup, VideoStorageCleanupError } from '@/lib/video-delete';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -32,7 +32,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(project, session.user.id);
if (!access.canEdit) {
return apiErrors.forbidden('Only project owner or admin can delete videos');
}
@@ -59,6 +59,17 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (error instanceof Error && error.message === 'VIDEO_NOT_FOUND') {
return apiErrors.badRequest('One or more selected videos do not belong to this project');
}
+ // Storage refused a delete, so nothing was removed and the videos are still there.
+ // Saying so lets the caller retry, which is the whole point of leaving the rows.
+ if (error instanceof VideoStorageCleanupError) {
+ logCleanupWarnings(
+ { entityType: 'video', entityId: `bulk:${normalizedIds.join(',')}` },
+ error.cleanupInput
+ );
+ return apiErrors.internalError(
+ 'Could not delete the stored media for these videos. Nothing was deleted; please try again.'
+ );
+ }
throw error;
}
diff --git a/app/api/projects/[projectId]/videos/bunny-init/route.ts b/app/api/projects/[projectId]/videos/bunny-init/route.ts
index 638d006..2eec2c9 100644
--- a/app/api/projects/[projectId]/videos/bunny-init/route.ts
+++ b/app/api/projects/[projectId]/videos/bunny-init/route.ts
@@ -27,7 +27,7 @@ async function getProjectWithEditAccess(projectId: string, userId: string) {
if (!project) return null;
- const access = await checkProjectAccess(project, userId, { intent: 'manage' });
+ const access = await checkProjectAccess(project, userId);
const canEdit = access.canEdit;
if (!canEdit) return null;
diff --git a/app/api/projects/[projectId]/videos/move/route.ts b/app/api/projects/[projectId]/videos/move/route.ts
index 0dd62e2..ab82f7f 100644
--- a/app/api/projects/[projectId]/videos/move/route.ts
+++ b/app/api/projects/[projectId]/videos/move/route.ts
@@ -38,7 +38,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, userId, { intent: 'manage' });
+ const access = await checkProjectAccess(project, userId);
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
@@ -141,8 +141,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
const [sourceAccess, targetAccess] = await Promise.all([
- checkProjectAccess(sourceProject, userId, { intent: 'manage' }),
- checkProjectAccess(targetProject, userId, { intent: 'manage' }),
+ checkProjectAccess(sourceProject, userId),
+ checkProjectAccess(targetProject, userId),
]);
if (!sourceAccess.canEdit) {
return apiErrors.forbidden('You cannot move videos out of this project');
diff --git a/app/api/projects/[projectId]/videos/r2-complete/route.ts b/app/api/projects/[projectId]/videos/r2-complete/route.ts
index 9d1c93f..d9d416a 100644
--- a/app/api/projects/[projectId]/videos/r2-complete/route.ts
+++ b/app/api/projects/[projectId]/videos/r2-complete/route.ts
@@ -26,7 +26,7 @@ async function getProjectWithEditAccess(projectId: string, userId: string) {
if (!project) return null;
- const access = await checkProjectAccess(project, userId, { intent: 'manage' });
+ const access = await checkProjectAccess(project, userId);
if (!access.canEdit) return null;
return project;
diff --git a/app/api/projects/[projectId]/videos/r2-init/route.ts b/app/api/projects/[projectId]/videos/r2-init/route.ts
index ad2d2f5..1adaf78 100644
--- a/app/api/projects/[projectId]/videos/r2-init/route.ts
+++ b/app/api/projects/[projectId]/videos/r2-init/route.ts
@@ -58,7 +58,7 @@ async function getProjectWithEditAccess(projectId: string, userId: string) {
if (!project) return null;
- const access = await checkProjectAccess(project, userId, { intent: 'manage' });
+ const access = await checkProjectAccess(project, userId);
if (!access.canEdit) return null;
return project;
diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts
index 86ce0fa..a56fa73 100644
--- a/app/api/projects/[projectId]/videos/route.ts
+++ b/app/api/projects/[projectId]/videos/route.ts
@@ -83,7 +83,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Project');
}
- const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
+ const access = await checkProjectAccess(project, session.user.id);
if (!access.canEdit) {
return apiErrors.forbidden('Access denied');
}
diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts
index fa70378..3c28ae0 100644
--- a/app/api/projects/route.ts
+++ b/app/api/projects/route.ts
@@ -42,22 +42,15 @@ export async function GET(request: NextRequest) {
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
}
- // Build base filter: user is owner OR a member
+ // Build base filter: user is the project owner, a project member, or a member of the
+ // workspace the project lives in. The third branch used to be dropped whenever a
+ // workspaceId was supplied, so filtering by their own workspace showed a workspace
+ // member an empty list while the unfiltered call returned the same project.
const baseFilter: Record = {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id } } },
- // Also include projects in workspaces where the user is a workspace member
- ...(workspaceId
- ? []
- : [
- {
- workspace: {
- owner: buildBillingAccessWhereInput(),
- members: { some: { userId: session.user.id } },
- },
- },
- ]),
+ { workspace: { members: { some: { userId: session.user.id } } } },
],
workspace: {
owner: buildBillingAccessWhereInput(),
diff --git a/app/api/search/route.ts b/app/api/search/route.ts
index 6aa12e0..4e55745 100644
--- a/app/api/search/route.ts
+++ b/app/api/search/route.ts
@@ -2,6 +2,7 @@ import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse } from '@/lib/api-response';
+import { buildBillingAccessWhereInput } from '@/lib/billing';
import { checkRateLimit, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
import { logError } from '@/lib/logger';
@@ -38,8 +39,15 @@ export async function GET(request: NextRequest) {
return apiErrors.badRequest('Query too long.');
}
- // Access filter reused across queries
+ // Access filter reused across queries. The billing condition is the same one every
+ // other read path carries (GET /api/projects, checkProjectAccess): without it search
+ // kept returning project names, descriptions and video titles for a tenant whose
+ // access had otherwise been cut off, which is a lapsed-tenant surface no other read
+ // path leaves open.
+ const ownerWithBillingAccess = buildBillingAccessWhereInput();
+
const projectAccessFilter = {
+ workspace: { owner: ownerWithBillingAccess },
OR: [
{ ownerId: userId },
{ members: { some: { userId } } },
@@ -48,6 +56,7 @@ export async function GET(request: NextRequest) {
};
const workspaceAccessFilter = {
+ owner: ownerWithBillingAccess,
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
};
diff --git a/app/api/versions/[versionId]/approvals/route.ts b/app/api/versions/[versionId]/approvals/route.ts
index 63f0134..c7d8af5 100644
--- a/app/api/versions/[versionId]/approvals/route.ts
+++ b/app/api/versions/[versionId]/approvals/route.ts
@@ -84,9 +84,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
if (!version) return apiErrors.notFound('Version');
- const access = await checkProjectAccess(version.video.project, session.user.id, {
- intent: 'manage',
- });
+ const access = await checkProjectAccess(version.video.project, session.user.id);
if (!access.canEdit) return apiErrors.forbidden('Access denied');
const body = (await request.json().catch(() => ({}))) as {
diff --git a/app/api/versions/[versionId]/download/route.ts b/app/api/versions/[versionId]/download/route.ts
index e66ad15..bebe967 100644
--- a/app/api/versions/[versionId]/download/route.ts
+++ b/app/api/versions/[versionId]/download/route.ts
@@ -308,6 +308,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload;
const canDownloadViaMembership = canDownloadProjectMedia(version.video.project, access);
if (!canDownloadViaMembership && !canDownloadViaShareLink) {
+ // A caller with no relationship to the project at all is told the version does not
+ // exist, matching the comment export route: answering 403 for an id belonging to
+ // another tenant confirms that the id exists. Anyone who does have a relationship,
+ // including an owner whose billing has lapsed, already knows it exists and gets the
+ // more informative 403.
+ const belongsToProject = access.isOwner || access.isProjectMember || access.isWorkspaceMember;
+ if (!belongsToProject && !shareAccess.hasAccess) {
+ return apiErrors.notFound('Version');
+ }
return apiErrors.forbidden('Access denied');
}
diff --git a/app/api/videos/[videoId]/assets/[assetId]/download/route.ts b/app/api/videos/[videoId]/assets/[assetId]/download/route.ts
index 2e887ed..82d2cc8 100644
--- a/app/api/videos/[videoId]/assets/[assetId]/download/route.ts
+++ b/app/api/videos/[videoId]/assets/[assetId]/download/route.ts
@@ -84,7 +84,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const { videoId, assetId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
- if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
+ // A caller with no relationship to the project is told the video does not exist.
+ // Answering 403 for an id belonging to another tenant confirms that the id exists,
+ // and the comment export route already answers 404 for the identical shape. Somebody
+ // who does belong, including an owner whose billing lapsed, gets the 403.
+ if (!context.hasViewAccess) {
+ return context.viewerBelongsToProject
+ ? apiErrors.forbidden('Access denied')
+ : apiErrors.notFound('Video');
+ }
if (!context.canDownloadAssets) {
return apiErrors.forbidden('Downloads are disabled for this project');
}
diff --git a/bun.lock b/bun.lock
index 7ee37c6..2923b5f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -62,7 +62,6 @@
"shadcn": "^3.8.3",
"tailwindcss": "^4",
"typescript": "^5",
- "vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.10",
},
},
@@ -1315,8 +1314,6 @@
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
- "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
-
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -2083,8 +2080,6 @@
"ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="],
- "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
-
"tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -2157,8 +2152,6 @@
"vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
- "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
-
"vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
diff --git a/components/guest-gate.tsx b/components/guest-gate.tsx
index 68e58a4..9bc52bf 100644
--- a/components/guest-gate.tsx
+++ b/components/guest-gate.tsx
@@ -26,8 +26,10 @@ export function GuestGate({ children }: { children: ReactNode }) {
}
const confirm = () => {
+ // The length check the input's own maxLength={100} already enforces is gone: it was
+ // unreachable, and an unreachable guard reads as protection that is not there.
const trimmed = guestName.trim();
- if (!trimmed || trimmed.length > 100) return;
+ if (!trimmed) return;
localStorage.setItem('openframe_guest_name', trimmed);
setConfirmed(true);
};
@@ -45,7 +47,11 @@ export function GuestGate({ children }: { children: ReactNode }) {
+
- 100}
- onClick={confirm}
- >
+
Continue
diff --git a/components/members-management-page.tsx b/components/members-management-page.tsx
index adae4a1..8c3c29a 100644
--- a/components/members-management-page.tsx
+++ b/components/members-management-page.tsx
@@ -105,6 +105,11 @@ export function MembersManagementPage({
router.push('/dashboard');
return;
}
+ // Any other status has to say so. Returning silently rendered "No members yet"
+ // and "No pending invitations" on a workspace that has both, and the user's next
+ // move was to re-invite somebody who is already a member, which answers 409 and
+ // reads as a second, unrelated bug.
+ setError('Failed to load members. Please refresh to try again.');
return;
}
const data = await res.json();
diff --git a/components/share-link-unlock.tsx b/components/share-link-unlock.tsx
index 13b71ba..8c9af37 100644
--- a/components/share-link-unlock.tsx
+++ b/components/share-link-unlock.tsx
@@ -59,7 +59,11 @@ export function ShareLinkUnlock({ videoId }: ShareLinkUnlockProps) {