Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 37 additions & 0 deletions apps/web/__tests__/unit/signup-space.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getSignupSpaceId } from "../../../../packages/database/auth/signup-space";

afterEach(() => vi.unstubAllEnvs());

describe("signup domain space configuration", () => {
it("is disabled by default", () => {
vi.stubEnv("CAP_SIGNUP_DOMAIN_SPACE_MAP", "");
expect(getSignupSpaceId("user@customer.example")).toBeNull();
});
it("matches normalized exact domains only", () => {
vi.stubEnv(
"CAP_SIGNUP_DOMAIN_SPACE_MAP",
'{"Customer.Example":" space-1 "}',
);
expect(getSignupSpaceId("User@CUSTOMER.EXAMPLE")).toBe("space-1");
for (const email of [
"user@sub.customer.example",
"user@customer.example.evil",
"user@internal.example",
"user@@customer.example",
])
expect(getSignupSpaceId(email)).toBeNull();
});
it.each([
"null",
"[]",
"invalid",
'{"customer.example":42}',
'{"customer.example":""}',
'{"customer.example":"a","CUSTOMER.EXAMPLE":"b"}',
'{"@customer.example":"a"}',
])("rejects invalid configuration %s", (raw) => {
vi.stubEnv("CAP_SIGNUP_DOMAIN_SPACE_MAP", raw);
expect(() => getSignupSpaceId("user@customer.example")).toThrow();
});
});
123 changes: 119 additions & 4 deletions apps/web/__tests__/unit/take3-signup-organization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import {
organizationInvites,
organizationMembers,
organizations,
spaceMembers,
users,
} from "@cap/database/schema";
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import type { MySql2Database } from "drizzle-orm/mysql2";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getFirstIncompleteOnboardingStep } from "@/app/(org)/onboarding/[...steps]/layout";
Expand Down Expand Up @@ -31,8 +34,12 @@ type Operation =

const originalDefaultSignupOrganizationId =
process.env.CAP_DEFAULT_SIGNUP_ORGANIZATION_ID;
const originalSpaceMap = process.env.CAP_SIGNUP_DOMAIN_SPACE_MAP;

afterEach(() => {
if (originalSpaceMap === undefined)
delete process.env.CAP_SIGNUP_DOMAIN_SPACE_MAP;
else process.env.CAP_SIGNUP_DOMAIN_SPACE_MAP = originalSpaceMap;
if (originalDefaultSignupOrganizationId === undefined) {
delete process.env.CAP_DEFAULT_SIGNUP_ORGANIZATION_ID;
} else {
Expand All @@ -46,18 +53,21 @@ const tableName = (table: unknown) => {
if (table === organizations) return "organizations";
if (table === organizationMembers) return "organization_members";
if (table === organizationInvites) return "organization_invites";
if (table === spaceMembers) return "space_members";
return "unknown";
};

function createMockDb(selectResults: unknown[][]) {
const operations: Operation[] = [];
const conditions: SQL[] = [];

const makeApi = () => ({
select: (_selection?: unknown) => ({
from: (_table: unknown) => ({
where: (_condition: unknown) => ({
limit: (_limit: number) => selectResults.shift() ?? [],
}),
where: (condition: SQL) => {
conditions.push(condition);
return { limit: (_limit: number) => selectResults.shift() ?? [] };
},
}),
}),
insert: (table: unknown) => ({
Expand All @@ -83,7 +93,7 @@ function createMockDb(selectResults: unknown[][]) {
) => callback(makeApi()),
};

return { db: api as unknown as MySql2Database, operations };
return { db: api as unknown as MySql2Database, operations, conditions };
}

const userRow = {
Expand All @@ -98,6 +108,111 @@ const userRow = {
};

describe("Take Three signup organization membership", () => {
it("assigns a matching domain to an existing private space as an ordinary member", async () => {
process.env.CAP_DEFAULT_SIGNUP_ORGANIZATION_ID = "org-1";
process.env.CAP_SIGNUP_DOMAIN_SPACE_MAP = '{"customer.example":"space-1"}';
const { db, operations, conditions } = createMockDb([
[],
[{ id: "org-1" }],
[{ id: "space-1" }],
[userRow],
]);
await DrizzleAdapter(db).createUser?.({
email: "New@Customer.Example",
emailVerified: null,
name: "User",
image: null,
});
expect(
operations.find((operation) => operation.table === "space_members")
?.values,
).toMatchObject({ spaceId: "space-1", role: "member" });
expect(
operations.some((operation) => operation.table === "organizations"),
).toBe(false);
const spaceGuard = new MySqlDialect().sqlToQuery(conditions[2] as SQL);
expect(spaceGuard.sql).toContain("`spaces`.`organizationId` = ?");
expect(spaceGuard.sql).toContain("`spaces`.`privacy` = ?");
expect(spaceGuard.sql).toContain("`spaces`.`public` = ?");
expect(spaceGuard.params).toEqual(["space-1", "org-1", "Private", false]);
});

it.each([null, "org-1"])(
"fails mapped signup when the default organization is unavailable: %s",
async (organizationId) => {
if (organizationId)
process.env.CAP_DEFAULT_SIGNUP_ORGANIZATION_ID = organizationId;
else delete process.env.CAP_DEFAULT_SIGNUP_ORGANIZATION_ID;
process.env.CAP_SIGNUP_DOMAIN_SPACE_MAP =
'{"customer.example":"space-1"}';
const { db, operations } = createMockDb([[], []]);
await expect(
DrizzleAdapter(db).createUser?.({
email: "user@customer.example",
emailVerified: null,
name: "User",
image: null,
}),
).rejects.toThrow();
expect(
operations.some(
(operation) =>
operation.table === "organizations" ||
operation.table === "organization_members" ||
operation.table === "space_members",
),
).toBe(false);
},
);

it("leaves the internal domain path unchanged with routing configured", async () => {
process.env.CAP_DEFAULT_SIGNUP_ORGANIZATION_ID = "org-1";
process.env.CAP_SIGNUP_DOMAIN_SPACE_MAP = '{"customer.example":"space-1"}';
const { db, operations } = createMockDb([[], [{ id: "org-1" }], [userRow]]);
await DrizzleAdapter(db).createUser?.({
email: userRow.email,
emailVerified: null,
name: "User",
image: null,
});
expect(
operations.some((operation) => operation.table === "space_members"),
).toBe(false);
});

it("rejects a missing, foreign, or non-private configured space without adding organization membership", async () => {
process.env.CAP_DEFAULT_SIGNUP_ORGANIZATION_ID = "org-1";
process.env.CAP_SIGNUP_DOMAIN_SPACE_MAP = '{"customer.example":"space-1"}';
const { db, operations } = createMockDb([[], [{ id: "org-1" }], []]);
await expect(
DrizzleAdapter(db).createUser?.({
email: "new@customer.example",
emailVerified: null,
name: "User",
image: null,
}),
).rejects.toThrow("Configured signup space");
expect(
operations.some(
(operation) => operation.table === "organization_members",
),
).toBe(false);
});

it("preserves pending invite handling for mapped domains", async () => {
process.env.CAP_DEFAULT_SIGNUP_ORGANIZATION_ID = "org-1";
process.env.CAP_SIGNUP_DOMAIN_SPACE_MAP = '{"customer.example":"space-1"}';
const { db, operations } = createMockDb([[{ id: "invite-1" }], [userRow]]);
await DrizzleAdapter(db).createUser?.({
email: "new@customer.example",
emailVerified: null,
name: "User",
image: null,
});
expect(
operations.some((operation) => operation.table === "space_members"),
).toBe(false);
});
it("creates a personal organization when no default signup organization is configured", async () => {
delete process.env.CAP_DEFAULT_SIGNUP_ORGANIZATION_ID;
const { db, operations } = createMockDb([[], [userRow]]);
Expand Down
38 changes: 38 additions & 0 deletions apps/web/__tests__/unit/videos-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,44 @@ function makeUser(
const noUser = Option.none<CurrentUser["Type"]>();

describe("VideosPolicy.canView", () => {
it.each([
{
public: false,
orgMembership: false,
spaceMembership: false,
expected: "denied",
},
{
public: false,
orgMembership: false,
spaceMembership: true,
expected: "allowed",
},
{
public: false,
orgMembership: true,
spaceMembership: false,
expected: "allowed",
},
{
public: true,
orgMembership: false,
spaceMembership: false,
expected: "allowed",
},
])(
"characterizes same-organization space isolation limits: %j",
async ({ public: isPublic, orgMembership, spaceMembership, expected }) => {
const deps = makeDeps({
video: makeVideo({ public: isPublic }),
orgMembership,
spaceMembership,
});
expect(await runCanView(deps, makeUser("member@customer.example"))).toBe(
expected,
);
},
);
describe("owner access", () => {
it("allows the video owner regardless of restrictions", async () => {
const deps = makeDeps({
Expand Down
46 changes: 46 additions & 0 deletions deploy/domain-space-rollout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Domain signup spaces (LM-16549)

This change adds an opt-in domain-to-space assignment under the existing default signup organization. It does not establish an isolation boundary for organization-wide content. Do not enable routing or execute the migration until the access decisions below are approved and verified. No production identifiers or customer membership details belong in this public repository.

## Configuration

Leave `CAP_SIGNUP_DOMAIN_SPACE_MAP` unset until a real private space exists and its ID is recorded privately. Then supply a JSON object such as `{"customer.example":"<live-space-id>"}` through runtime configuration, alongside the existing `CAP_DEFAULT_SIGNUP_ORGANIZATION_ID`. Exact domain matching is case-insensitive; subdomains do not inherit assignment. Other domains keep their current signup behavior. A mapped signup fails transactionally if the organization or private space is missing, belongs to another organization, or has internet collection sharing enabled. Membership role is `member`.

Pending organization invitations continue through the existing invitation workflow and bypass automatic assignment. Inventory and reconcile these separately before rollout; this change does not override invite destinations. Existing accounts are not reassigned on login.

For the Kubernetes chart, use the existing `web.extraEnv` list to inject the JSON string as `CAP_SIGNUP_DOMAIN_SPACE_MAP`. Do not add customer IDs to tracked production values in this public repository. The deployed configuration must remain unset until provisioning and access verification are complete.

## Provisioning and existing-user migration

Create a private configuration file outside the repository containing:

```json
{
"organizationId": "<existing-organization-id>",
"spaceId": "<reserved-15-character-space-id>",
"spaceName": "<approved-space-name>",
"creatorId": "<existing-organization-admin-id>",
"domain": "customer.example",
"sourceSpaceIds": []
}
```

With an authorized `DATABASE_URL` supplied securely, run `node scripts/migrate-domain-space.mjs <private-config.json>` for a read-only dry-run. Output contains counts and target space ID, not emails, names, or user IDs. Review the exact-domain member count against the private inventory, including the two existing customer accounts referenced in the internal ticket. The migration selects all matching members of the specified organization, rather than an embedded customer list.

An operator may add `--apply` only after the rollout decisions and separate live-change authorization. The tool validates the organization, administrator creator, existing target ID/name/privacy, and explicit source spaces before writing. It creates a private, non-public space if absent, adds only missing member rows, preserves existing target roles, and removes ordinary membership only from explicitly supplied real source spaces. Source admins stop the transaction. Repeated runs create no duplicate space or membership. Apply serializes on the organization row and commits all changes together. Retain the same reserved target ID for all runs.

No organization is created; organization membership, active/default organization, onboarding, owned videos, shared videos, folders, and public flags remain untouched. No customer content is moved automatically. The synthetic organization entry is not a real space and cannot be supplied as a source. If no real source spaces exist, keep `sourceSpaceIds` empty; the migration cannot remove the synthetic organization-wide access this way.

## Blocking access decisions

Decide whether customer members may see organization-shared videos and organization-public spaces. Today organization membership grants access to videos in `shared_videos`, including private videos, through `VideosPolicy`, the synthetic organization dashboard entry, and dashboard search. Moving space membership does not revoke that access. If organization-wide content must be hidden, approve and implement a consistent scoped-access policy across dashboard lists/search, folders, direct video pages, API/media/download authorization, and sharing actions before enabling this configuration. Hiding navigation alone is insufficient.

Decide whether existing internet-public video and collection links should remain public. `spaces.privacy` controls organization browsing; `spaces.public` controls public collections; video `public` controls direct links. A private space does not make its videos private. Specify staff/admin management and cross-space access, and whether future invitation acceptance must enforce domain assignment. Those choices can change existing internal-domain behavior and are not inferred here.

## Verification and rollback

Use generic fixtures locally. Verify internal-domain signup with routing configured, exact-domain customer signup, foreign/missing/public target failure with transaction rollback, pending invites, repeat migration, ambiguous target names, and ordinary/admin source memberships. Existing video-policy tests plus the added isolation matrix explicitly demonstrate the organization-share and public-link bypasses; they are characterization tests, not evidence of customer isolation.

Before production acceptance, exercise two customer members, an internal ordinary member, an internal admin, and an anonymous viewer against private/public spaces, organization shares, direct/public links, dashboard search, folder pages, media/download endpoints, and sharing mutations. Capture the results privately against the approved access matrix. Do not claim isolation until every applicable path passes. Record the live space ID only in the internal ticket/configuration after separately approved provisioning.

To stop future assignment, remove `CAP_SIGNUP_DOMAIN_SPACE_MAP`. This does not undo existing memberships. Restore source membership only from the privately retained preflight inventory and approved role decisions; do not delete a populated space or reverse content permissions automatically. No deployment or database mutation is part of this PR workflow.
37 changes: 37 additions & 0 deletions packages/database/auth/drizzle-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ import {
organizationMembers,
organizations,
sessions,
spaceMembers,
spaces,
users,
verificationTokens,
} from "../schema.ts";
import { getSignupSpaceId } from "./signup-space.ts";

export const getDefaultSignupOrganizationId = () => {
const value = process.env.CAP_DEFAULT_SIGNUP_ORGANIZATION_ID?.trim();
Expand Down Expand Up @@ -56,6 +59,12 @@ export function DrizzleAdapter(db: MySql2Database): Adapter {
}

const defaultSignupOrganizationId = getDefaultSignupOrganizationId();
const signupSpaceId = getSignupSpaceId(normalizedEmail);
if (signupSpaceId && !defaultSignupOrganizationId) {
throw new Error(
"Signup space assignment requires a default organization",
);
}
if (defaultSignupOrganizationId) {
const [defaultSignupOrganization] = await tx
.select({ id: organizations.id })
Expand All @@ -69,6 +78,31 @@ export function DrizzleAdapter(db: MySql2Database): Adapter {
.limit(1);

if (defaultSignupOrganization) {
if (signupSpaceId) {
const [signupSpace] = await tx
.select({ id: spaces.id })
.from(spaces)
.where(
and(
eq(spaces.id, signupSpaceId),
eq(spaces.organizationId, defaultSignupOrganization.id),
eq(spaces.privacy, "Private"),
eq(spaces.public, false),
),
)
.limit(1);
if (!signupSpace) {
throw new Error(
"Configured signup space must be private and belong to the default organization",
);
}
await tx.insert(spaceMembers).values({
id: nanoId(),
spaceId: signupSpace.id,
userId,
role: "member",
});
}
await tx.insert(organizationMembers).values({
id: nanoId(),
organizationId: defaultSignupOrganization.id,
Expand All @@ -91,6 +125,9 @@ export function DrizzleAdapter(db: MySql2Database): Adapter {

return;
}
if (signupSpaceId) {
throw new Error("Configured signup organization was not found");
}
}

const organizationId = Organisation.OrganisationId.make(nanoId());
Expand Down
29 changes: 29 additions & 0 deletions packages/database/auth/signup-space.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Space } from "@cap/web-domain";

export function getSignupSpaceId(email: string) {
const raw = process.env.CAP_SIGNUP_DOMAIN_SPACE_MAP?.trim();
if (!raw) return null;
const mappings: unknown = JSON.parse(raw);
if (!mappings || typeof mappings !== "object" || Array.isArray(mappings)) {
throw new Error(
"CAP_SIGNUP_DOMAIN_SPACE_MAP must be a domain-to-space object",
);
}
const normalized = new Map<string, string>();
for (const [domain, spaceId] of Object.entries(mappings)) {
const key = domain.trim().toLowerCase();
if (
!/^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/.test(key) ||
typeof spaceId !== "string" ||
!spaceId.trim() ||
normalized.has(key)
) {
throw new Error("Invalid or duplicate signup space mapping");
}
normalized.set(key, spaceId.trim());
}
const parts = email.trim().toLowerCase().split("@");
const id =
parts.length === 2 && parts[0] ? normalized.get(parts[1] ?? "") : null;
return id ? Space.SpaceId.make(id) : null;
}
Loading
Loading