diff --git a/apps/web/__tests__/unit/signup-space.test.ts b/apps/web/__tests__/unit/signup-space.test.ts new file mode 100644 index 00000000000..edbc50a593b --- /dev/null +++ b/apps/web/__tests__/unit/signup-space.test.ts @@ -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(); + }); +}); diff --git a/apps/web/__tests__/unit/take3-signup-organization.test.ts b/apps/web/__tests__/unit/take3-signup-organization.test.ts index 426e8e0e138..c739ae36e07 100644 --- a/apps/web/__tests__/unit/take3-signup-organization.test.ts +++ b/apps/web/__tests__/unit/take3-signup-organization.test.ts @@ -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"; @@ -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 { @@ -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) => ({ @@ -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 = { @@ -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]]); diff --git a/apps/web/__tests__/unit/videos-policy.test.ts b/apps/web/__tests__/unit/videos-policy.test.ts index 8929c3ab5f9..f1c36fabe0d 100644 --- a/apps/web/__tests__/unit/videos-policy.test.ts +++ b/apps/web/__tests__/unit/videos-policy.test.ts @@ -131,6 +131,44 @@ function makeUser( const noUser = Option.none(); 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({ diff --git a/deploy/domain-space-rollout.md b/deploy/domain-space-rollout.md new file mode 100644 index 00000000000..6c4aba9bf8a --- /dev/null +++ b/deploy/domain-space-rollout.md @@ -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":""}` 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": "", + "spaceId": "", + "spaceName": "", + "creatorId": "", + "domain": "customer.example", + "sourceSpaceIds": [] +} +``` + +With an authorized `DATABASE_URL` supplied securely, run `node scripts/migrate-domain-space.mjs ` 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. diff --git a/packages/database/auth/drizzle-adapter.ts b/packages/database/auth/drizzle-adapter.ts index f7ad30df233..fe9ce154402 100644 --- a/packages/database/auth/drizzle-adapter.ts +++ b/packages/database/auth/drizzle-adapter.ts @@ -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(); @@ -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 }) @@ -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, @@ -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()); diff --git a/packages/database/auth/signup-space.ts b/packages/database/auth/signup-space.ts new file mode 100644 index 00000000000..756b655f168 --- /dev/null +++ b/packages/database/auth/signup-space.ts @@ -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(); + 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; +} diff --git a/packages/env/server.ts b/packages/env/server.ts index 8ecc58e824a..d4f7c700fd6 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -95,6 +95,12 @@ function createServerEnv() { .describe( "Existing organization id that new non-invite signups should join", ), + CAP_SIGNUP_DOMAIN_SPACE_MAP: z + .string() + .optional() + .describe( + "JSON object mapping exact signup email domains to existing private space ids", + ), /// AI providers AI_GATEWAY_BASE_URL: z diff --git a/scripts/domain-space-migration.mjs b/scripts/domain-space-migration.mjs new file mode 100644 index 00000000000..54373e91d78 --- /dev/null +++ b/scripts/domain-space-migration.mjs @@ -0,0 +1,156 @@ +import { randomBytes } from "node:crypto"; + +const nanoId = () => randomBytes(8).toString("hex").slice(0, 15); + +export async function migrateDomainSpace(connection, options) { + const { + organizationId, + spaceId, + spaceName, + creatorId, + apply = false, + } = options; + const domain = options.domain?.trim().toLowerCase(); + if ( + options.sourceSpaceIds !== undefined && + !Array.isArray(options.sourceSpaceIds) + ) { + throw new Error("sourceSpaceIds must be an array"); + } + const sourceSpaceIds = [...new Set(options.sourceSpaceIds ?? [])]; + if ( + ![organizationId, spaceId, spaceName, creatorId].every( + (value) => typeof value === "string" && value.trim(), + ) || + ![organizationId, spaceId, creatorId, ...sourceSpaceIds].every( + (value) => + typeof value === "string" && /^[a-zA-Z0-9-]{1,15}$/.test(value), + ) || + spaceName.length > 255 || + !/^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/.test(domain ?? "") || + sourceSpaceIds.includes(spaceId) || + sourceSpaceIds.includes(organizationId) + ) { + throw new Error( + "Explicit organization, space, creator, domain and distinct real source spaces are required", + ); + } + const rows = async (sql, params = []) => { + const [result] = await connection.execute(sql, params); + return result; + }; + await connection.query( + apply ? "START TRANSACTION" : "START TRANSACTION READ ONLY", + ); + try { + const [org] = await rows( + `SELECT id FROM organizations WHERE id = ? AND tombstoneAt IS NULL${apply ? " FOR UPDATE" : ""}`, + [organizationId], + ); + if (!org) throw new Error("Active organization not found"); + const [creator] = await rows( + "SELECT u.id FROM users u INNER JOIN organizations o ON o.id = ? LEFT JOIN organization_members m ON m.organizationId = o.id AND m.userId = u.id WHERE u.id = ? AND (o.ownerId = u.id OR m.role IN ('owner', 'admin'))", + [organizationId, creatorId], + ); + if (!creator) + throw new Error("Creator must be an existing organization administrator"); + const targets = await rows( + `SELECT id, name, organizationId, privacy, public FROM spaces WHERE id = ? OR (organizationId = ? AND name = ?)${apply ? " FOR UPDATE" : ""}`, + [spaceId, organizationId, spaceName], + ); + if (targets.length > 1) throw new Error("Ambiguous target space"); + const target = targets[0]; + if ( + target && + (target.id !== spaceId || + target.organizationId !== organizationId || + target.name !== spaceName || + target.privacy !== "Private" || + Number(target.public) !== 0) + ) { + throw new Error( + "Target must match the supplied ID, name, organization and private settings", + ); + } + const sources = []; + for (const sourceId of sourceSpaceIds) { + const [source] = await rows( + `SELECT id, createdById FROM spaces WHERE id = ? AND organizationId = ?${apply ? " FOR UPDATE" : ""}`, + [sourceId, organizationId], + ); + if (!source) + throw new Error("Source must be a real space in the same organization"); + sources.push(source); + } + const members = await rows( + "SELECT DISTINCT u.id FROM users u INNER JOIN organization_members m ON m.userId = u.id WHERE m.organizationId = ? AND LOWER(SUBSTRING_INDEX(u.email, '@', -1)) = ? AND LENGTH(u.email) - LENGTH(REPLACE(u.email, '@', '')) = 1", + [organizationId, domain], + ); + const additions = []; + const removals = []; + for (const member of members) { + if (sources.some((source) => source.createdById === member.id)) { + throw new Error("Source creator access requires a separate decision"); + } + const existing = await rows( + "SELECT id FROM space_members WHERE spaceId = ? AND userId = ?", + [spaceId, member.id], + ); + if (existing.length === 0) additions.push(member.id); + for (const sourceId of sourceSpaceIds) { + const sourceMembers = await rows( + `SELECT id, role FROM space_members WHERE spaceId = ? AND userId = ?${apply ? " FOR UPDATE" : ""}`, + [sourceId, member.id], + ); + if (sourceMembers.some((entry) => entry.role !== "member")) + throw new Error( + "Source admin membership requires a separate decision", + ); + removals.push(...sourceMembers.map((entry) => entry.id)); + } + } + const [shared] = await rows( + "SELECT COUNT(*) AS count FROM shared_videos WHERE organizationId = ?", + [organizationId], + ); + const [publicSpaces] = await rows( + "SELECT COUNT(*) AS count FROM spaces WHERE organizationId = ? AND (privacy = 'Public' OR public = 1)", + [organizationId], + ); + const plan = { + mode: apply ? "apply" : "dry-run", + spaceId, + createSpace: !target, + matchedMembers: members.length, + addMembers: additions.length, + removeMemberships: removals.length, + organizationSharedVideos: Number(shared.count), + publicSpaces: Number(publicSpaces.count), + isolationVerified: false, + }; + if (apply) { + if (!target) + await rows( + "INSERT INTO spaces (id, name, organizationId, createdById, privacy, public) VALUES (?, ?, ?, ?, 'Private', 0)", + [spaceId, spaceName, organizationId, creatorId], + ); + for (const userId of additions) + await rows( + "INSERT INTO space_members (id, spaceId, userId, role) VALUES (?, ?, ?, 'member') ON DUPLICATE KEY UPDATE id = id", + [nanoId(), spaceId, userId], + ); + for (const id of removals) + await rows( + "DELETE FROM space_members WHERE id = ? AND role = 'member'", + [id], + ); + await connection.commit(); + } else { + await connection.rollback(); + } + return plan; + } catch (error) { + await connection.rollback(); + throw error; + } +} diff --git a/scripts/domain-space-migration.test.mjs b/scripts/domain-space-migration.test.mjs new file mode 100644 index 00000000000..c9fed1812a3 --- /dev/null +++ b/scripts/domain-space-migration.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { migrateDomainSpace } from "./domain-space-migration.mjs"; + +const options = { + organizationId: "org-1", + spaceId: "space-1", + spaceName: "Customer", + creatorId: "admin-1", + domain: "customer.example", +}; +function fixture({ + target = [], + existing = false, + sourceRole = "member", +} = {}) { + const calls = []; + const connection = { + query: async (sql) => { + calls.push(sql); + }, + commit: async () => { + calls.push("COMMIT"); + }, + rollback: async () => { + calls.push("ROLLBACK"); + }, + execute: async (sql, params) => { + calls.push(sql); + if (sql.startsWith("SELECT id FROM organizations")) + return [[{ id: "org-1" }]]; + if (sql.startsWith("SELECT u.id")) return [[{ id: "admin-1" }]]; + if (sql.startsWith("SELECT id, name")) return [target]; + if (sql.startsWith("SELECT id, createdById FROM spaces")) + return [[{ id: "source-1" }]]; + if (sql.startsWith("SELECT DISTINCT")) + return [[{ id: "user-1" }, { id: "user-2" }]]; + if (sql.startsWith("SELECT id, role")) + return [[{ id: "membership-1", role: sourceRole }]]; + if (sql.startsWith("SELECT id FROM space_members")) + return [existing ? [{ id: params[1] }] : []]; + if (sql.startsWith("SELECT COUNT")) return [[{ count: 2 }]]; + return [{}]; + }, + }; + return { connection, calls }; +} +test("default dry-run is read-only and reports residual organization access", async () => { + const { connection, calls } = fixture(); + const plan = await migrateDomainSpace(connection, options); + assert.equal(plan.addMembers, 2); + assert.equal(plan.isolationVerified, false); + assert.equal(plan.organizationSharedVideos, 2); + assert.equal(calls[0], "START TRANSACTION READ ONLY"); + assert.equal( + calls.some((sql) => /^(INSERT|DELETE|UPDATE)/.test(sql)), + false, + ); +}); +test("apply creates a private space and two member assignments", async () => { + const { connection, calls } = fixture(); + await migrateDomainSpace(connection, { ...options, apply: true }); + assert.equal( + calls.filter((sql) => sql.startsWith("INSERT INTO spaces")).length, + 1, + ); + assert.equal( + calls.filter((sql) => sql.startsWith("INSERT INTO space_members")).length, + 2, + ); + assert.equal(calls.at(-1), "COMMIT"); +}); +test("rerun preserves existing membership roles and creates nothing", async () => { + const { connection, calls } = fixture({ + target: [ + { + id: "space-1", + name: "Customer", + organizationId: "org-1", + privacy: "Private", + public: 0, + }, + ], + existing: true, + }); + const plan = await migrateDomainSpace(connection, { + ...options, + apply: true, + }); + assert.equal(plan.createSpace, false); + assert.equal(plan.addMembers, 0); + assert.equal( + calls.some((sql) => sql.startsWith("INSERT")), + false, + ); +}); +test("rejects target collision and rolls back without mutation", async () => { + const { connection, calls } = fixture({ target: [{ id: "foreign" }] }); + await assert.rejects( + migrateDomainSpace(connection, { ...options, apply: true }), + /Target must match/, + ); + assert.equal(calls.at(-1), "ROLLBACK"); + assert.equal( + calls.some((sql) => sql.startsWith("INSERT")), + false, + ); +}); +test("rejects synthetic org entry as a source", async () => { + const { connection } = fixture(); + await assert.rejects( + migrateDomainSpace(connection, { ...options, sourceSpaceIds: ["org-1"] }), + ); +}); +test("requires an explicit decision for source admins", async () => { + const { connection, calls } = fixture({ sourceRole: "admin" }); + await assert.rejects( + migrateDomainSpace(connection, { + ...options, + apply: true, + sourceSpaceIds: ["source-1"], + }), + /separate decision/, + ); + assert.equal( + calls.some((sql) => sql.startsWith("INSERT")), + false, + ); +}); diff --git a/scripts/migrate-domain-space.mjs b/scripts/migrate-domain-space.mjs new file mode 100644 index 00000000000..4c0efe166ec --- /dev/null +++ b/scripts/migrate-domain-space.mjs @@ -0,0 +1,22 @@ +import { readFile } from "node:fs/promises"; +import mysql from "mysql2/promise"; +import { migrateDomainSpace } from "./domain-space-migration.mjs"; + +const args = process.argv.slice(2); +if (args.length < 1 || args.length > 2 || (args[1] && args[1] !== "--apply")) { + throw new Error( + "Usage: node scripts/migrate-domain-space.mjs [--apply]", + ); +} +if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required"); +const options = JSON.parse(await readFile(args[0], "utf8")); +const connection = await mysql.createConnection(process.env.DATABASE_URL); +try { + const plan = await migrateDomainSpace(connection, { + ...options, + apply: args[1] === "--apply", + }); + console.log(JSON.stringify(plan, null, 2)); +} finally { + await connection.end(); +}