Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cba1228
feat(database): add space viewer settings and password columns
richiemcilroy May 10, 2026
a740e6b
feat(web-domain): verify share access against multiple password hashes
richiemcilroy May 10, 2026
4476bc5
feat(web-backend): add effective video rules resolution helpers
richiemcilroy May 10, 2026
9d913ce
feat(web-backend): expose space passwords and settings in queries
richiemcilroy May 10, 2026
c4d168a
feat(web-backend): enforce inherited space passwords in view policy
richiemcilroy May 10, 2026
ffd917c
feat(web): extend dashboard spaces with settings and password flags
richiemcilroy May 10, 2026
affd0cf
feat(web): resolve inherited rules in folder video queries
richiemcilroy May 10, 2026
1335fcd
feat(web): persist viewer rules and password on space creation
richiemcilroy May 10, 2026
5bdcc09
feat(web): support space passwords and stricter space updates
richiemcilroy May 10, 2026
0396784
feat(web): verify share session against space password hashes
richiemcilroy May 10, 2026
c86886a
feat(web): add space viewer rules and password controls to UI
richiemcilroy May 10, 2026
5799f49
feat(web): show inherited space rules on caps dashboard
richiemcilroy May 10, 2026
1d8048f
feat(web): reflect inherited viewer rules on space shared caps
richiemcilroy May 10, 2026
68ce15b
feat(web): honor effective viewer settings on share and embed pages
richiemcilroy May 10, 2026
18d6145
test(web): add effective video rules unit coverage
richiemcilroy May 10, 2026
0d9678a
test(web): cover space password handling in videos policy
richiemcilroy May 10, 2026
df3efea
fix: address space rule review feedback
richiemcilroy May 11, 2026
41d4ee5
fix: restore typecheck setup
richiemcilroy May 11, 2026
2d07b9d
Merge branch 'main' into spaces-sharing
richiemcilroy May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions apps/web/__tests__/unit/effective-video-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { resolveEffectiveVideoRules } from "@cap/web-backend";
import { describe, expect, it } from "vitest";

describe("resolveEffectiveVideoRules", () => {
it("uses space-disabled settings over video and organization settings", () => {
const rules = resolveEffectiveVideoRules({
videoSettings: { disableComments: false },
organizationSettings: { disableComments: false },
spaces: [
{
id: "space-1",
name: "Design",
settings: { disableComments: true },
},
],
});

expect(rules.settings.disableComments).toBe(true);
expect(rules.inheritedSettings.disableComments).toEqual([
{ id: "space-1", name: "Design" },
]);
});

it("keeps the inherited setting disabled when multiple spaces conflict", () => {
const rules = resolveEffectiveVideoRules({
videoSettings: { disableTranscript: false },
organizationSettings: { disableTranscript: false },
spaces: [
{
id: "space-1",
name: "Design",
settings: { disableTranscript: false },
},
{
id: "space-2",
name: "Legal",
settings: { disableTranscript: true },
},
],
});

expect(rules.settings.disableTranscript).toBe(true);
expect(rules.inheritedSettings.disableTranscript).toEqual([
{ id: "space-2", name: "Legal" },
]);
});

it("uses video settings before organization settings when there is no space rule", () => {
const rules = resolveEffectiveVideoRules({
videoSettings: { disableCaptions: false },
organizationSettings: { disableCaptions: true },
spaces: [],
});

expect(rules.settings.disableCaptions).toBe(false);
expect(rules.inheritedSettings.disableCaptions).toBeUndefined();
});

it("uses organization settings when video settings are unset", () => {
const rules = resolveEffectiveVideoRules({
videoSettings: {},
organizationSettings: { disableSummary: true },
spaces: [],
});

expect(rules.settings.disableSummary).toBe(true);
});

it("reports inherited password sources", () => {
const rules = resolveEffectiveVideoRules({
videoSettings: {},
organizationSettings: {},
spaces: [
{ id: "space-1", name: "Design", hasPassword: true },
{ id: "space-2", name: "Marketing", hasPassword: false },
],
});

expect(rules.hasInheritedPassword).toBe(true);
expect(rules.inheritedPasswordSources).toEqual([
{ id: "space-1", name: "Design" },
]);
});
});
92 changes: 89 additions & 3 deletions apps/web/__tests__/unit/videos-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ function makeVideo(
function makeDeps(config: {
video: Video.Video | null;
password?: Option.Option<string>;
spacePasswords?: string[];
orgMembership?: boolean;
spaceMembership?: boolean;
allowedEmailDomain?: Option.Option<string>;
}): VideosPolicyDeps {
const {
video,
password = Option.none<string>(),
spacePasswords = [],
orgMembership = false,
spaceMembership = false,
allowedEmailDomain = Option.none<string>(),
Expand All @@ -74,27 +76,41 @@ function makeDeps(config: {
? Option.some({ membershipId: "smem-1" })
: Option.none(),
),
passwordsForVideo: () =>
Effect.succeed(spacePasswords.map((password) => ({ password }))),
},
};
}

function runCanView(
deps: VideosPolicyDeps,
user: Option.Option<CurrentUser["Type"]>,
): Promise<"allowed" | "denied"> {
attachedPassword: Option.Option<string> = Option.none(),
): Promise<"allowed" | "denied" | "password"> {
const policy = buildCanView(deps, TEST_VIDEO_ID);

const program = Effect.zipRight(
policy,
Effect.succeed("allowed" as const),
).pipe(
Effect.catchTag("PolicyDenied", () => Effect.succeed("denied" as const)),
Effect.catchTag("VerifyVideoPasswordError", () =>
Effect.succeed("password" as const),
),
);

const withPassword = Option.match(attachedPassword, {
onNone: () => program,
onSome: (password) =>
Effect.provideService(program, Video.VideoPasswordAttachment, {
password: Option.some(password),
}),
});

const withUser = user.pipe(
Option.match({
onNone: () => program,
onSome: (u) => Effect.provideService(program, CurrentUser, u),
onNone: () => withPassword,
onSome: (u) => Effect.provideService(withPassword, CurrentUser, u),
}),
);

Expand Down Expand Up @@ -135,6 +151,17 @@ describe("VideosPolicy.canView", () => {

expect(await runCanView(deps, owner)).toBe("allowed");
});

it("does not load inherited passwords for owner bypass", async () => {
const deps = makeDeps({
video: makeVideo({ public: false }),
});
deps.spacesRepo.passwordsForVideo = () =>
Effect.die(new Error("password lookup should not run"));
const owner = makeUser("owner@anything.com", TEST_OWNER_ID);

expect(await runCanView(deps, owner)).toBe("allowed");
});
});

describe("explicit org membership", () => {
Expand Down Expand Up @@ -207,6 +234,65 @@ describe("VideosPolicy.canView", () => {
});
});

describe("inherited space passwords", () => {
it("requires a space password for anonymous public-link viewers", async () => {
const deps = makeDeps({
video: makeVideo({ public: true }),
spacePasswords: ["space-hash"],
});

expect(await runCanView(deps, noUser)).toBe("password");
});

it("requires a space password for space members", async () => {
const deps = makeDeps({
video: makeVideo({ public: false }),
spaceMembership: true,
spacePasswords: ["space-hash"],
});

expect(await runCanView(deps, makeUser("member@company.com"))).toBe(
"password",
);
});

it("allows the owner without an inherited password attachment", async () => {
const deps = makeDeps({
video: makeVideo({ public: false }),
spacePasswords: ["space-hash"],
});
const owner = makeUser("owner@anything.com", TEST_OWNER_ID);

expect(await runCanView(deps, owner)).toBe("allowed");
});

it("allows access with any inherited space password hash", async () => {
const deps = makeDeps({
video: makeVideo({ public: true }),
spacePasswords: ["space-one-hash", "space-two-hash"],
});

expect(
await runCanView(deps, noUser, Option.some("space-two-hash")),
).toBe("allowed");
});

it("allows access with either video or space password hash", async () => {
const deps = makeDeps({
video: makeVideo({ public: true }),
password: Option.some("video-hash"),
spacePasswords: ["space-hash"],
});

expect(await runCanView(deps, noUser, Option.some("video-hash"))).toBe(
"allowed",
);
expect(await runCanView(deps, noUser, Option.some("space-hash"))).toBe(
"allowed",
);
});
});

describe("private video without membership", () => {
it("denies logged-in user without membership", async () => {
const deps = makeDeps({
Expand Down
57 changes: 57 additions & 0 deletions apps/web/actions/organization/create-space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { hashPassword } from "@cap/database/crypto";
import { nanoId } from "@cap/database/helpers";
import { spaceMembers, spaces } from "@cap/database/schema";
import { userIsPro } from "@cap/utils";
import {
type ImageUpload,
Space,
Expand All @@ -15,6 +17,30 @@
import { revalidatePath } from "next/cache";
import { uploadSpaceIcon } from "./upload-space-icon";

const settingKeys = [
"disableSummary",
"disableCaptions",
"disableChapters",
"disableReactions",
"disableTranscript",
"disableComments",
] as const;

const getSettingsFromFormData = (formData: FormData) =>
Object.fromEntries(
settingKeys.map((key) => [key, formData.get(key) === "true"]),
);

const proSettingKeys = [
"disableSummary",
"disableChapters",
"disableTranscript",
] as const;

const hasProSettingsEnabled = (
settings: ReturnType<typeof getSettingsFromFormData>,
) => proSettingKeys.some((key) => settings[key]);

interface CreateSpaceResponse {
success: boolean;
spaceId?: string;
Expand All @@ -29,7 +55,7 @@
try {
const user = await getCurrentUser();

if (!user || !user.activeOrganizationId) {

Check warning on line 58 in apps/web/actions/organization/create-space.ts

View workflow job for this annotation

GitHub Actions / Lint (Biome)

lint/complexity/useOptionalChain

Change to an optional chain.
return {
success: false,
error: "User not logged in or no active organization",
Expand All @@ -37,6 +63,10 @@
}

const name = formData.get("name") as string;
const passwordEnabled = formData.get("passwordEnabled") === "true";
const password = formData.get("password") as string | null;
const settings = getSettingsFromFormData(formData);
const canUseProFeatures = userIsPro(user);

if (!name) {
return {
Expand All @@ -45,6 +75,27 @@
};
}

if (passwordEnabled && !password?.trim()) {
return {
success: false,
error: "Space password is required",
};
}

if (!canUseProFeatures && passwordEnabled) {
return {
success: false,
error: "Upgrade required to protect a space with a password",
};
}

if (!canUseProFeatures && hasProSettingsEnabled(settings)) {
return {
success: false,
error: "Upgrade required to change these viewer rules",
};
}

// Check for duplicate space name in the same organization
const existingSpace = await db()
.select({ id: spaces.id })
Expand All @@ -67,6 +118,10 @@
// Generate the space ID early so we can use it in the file path
const spaceId = Space.SpaceId.make(nanoId());
let iconUrl: ImageUpload.ImageUrlOrKey | null = null;
const hashedPassword =
passwordEnabled && password?.trim()
? await hashPassword(password.trim())
: null;

await db().transaction(async (tx) => {
// Create the space first
Expand All @@ -76,6 +131,8 @@
organizationId: user.activeOrganizationId,
createdById: user.id,
iconUrl: null,
settings,
password: hashedPassword,
});

// --- Member Management Logic ---
Expand Down
Loading
Loading