ubiquitous-invention/apps/web/server/lib/resolve-workspace.ts

104 lines
3.1 KiB
TypeScript
Raw Normal View History

multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
import { TRPCError } from "@trpc/server";
import { and, eq, or } from "drizzle-orm";
import { workspaces, workspaceMembers } from "@tasks/database/schema";
import { db as defaultDb } from "@tasks/database";
/**
* Cheap UUID v4-ish detector. We only need to differentiate "this looks like a
* UUID" from "this looks like a slug" so the resolver can pick the right column.
*/
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export type WorkspaceContext = {
id: string;
slug: string;
name: string;
ownerUserId: string;
/** Caller's role inside the workspace, or "owner" if they own it directly. */
role: string;
};
/**
* Resolve a workspace handle (UUID or slug) to a full workspace record AND
* authorize the caller against it. Throws NOT_FOUND if the handle doesn't
* resolve, FORBIDDEN if the user isn't a member or owner.
*
* Used by the `workspaceProcedure` middleware and by Server Components at the
* `app/(app)/[workspaceSlug]/...` layout boundary.
*/
export async function resolveWorkspace(args: {
handle: string;
userId: string;
db?: typeof defaultDb;
}): Promise<WorkspaceContext> {
const db = args.db ?? defaultDb;
const handle = args.handle.trim();
if (!handle) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace handle required" });
}
const lookupCondition = UUID_RE.test(handle)
? eq(workspaces.id, handle)
: eq(workspaces.slug, handle);
const [row] = await db
.select({
id: workspaces.id,
slug: workspaces.slug,
name: workspaces.name,
ownerUserId: workspaces.ownerUserId,
memberRole: workspaceMembers.role,
})
.from(workspaces)
.leftJoin(
workspaceMembers,
and(
eq(workspaceMembers.workspaceId, workspaces.id),
eq(workspaceMembers.userId, args.userId),
),
)
.where(lookupCondition)
.limit(1);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found" });
}
const isOwner = row.ownerUserId === args.userId;
if (!isOwner && !row.memberRole) {
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this workspace" });
}
return {
id: row.id,
slug: row.slug,
name: row.name,
ownerUserId: row.ownerUserId,
role: isOwner ? "owner" : (row.memberRole ?? "member"),
};
}
/**
* Look up a workspace by either UUID or slug WITHOUT authorizing the caller.
* Used for the public form-fill flow and for routes that explicitly want to
* peek at workspace existence (e.g. URL backcompat redirects).
*/
export async function findWorkspaceByHandle(
handle: string,
db = defaultDb,
): Promise<{ id: string; slug: string; name: string } | null> {
const cleaned = handle.trim();
if (!cleaned) return null;
const cond = UUID_RE.test(cleaned)
? eq(workspaces.id, cleaned)
: eq(workspaces.slug, cleaned);
const [row] = await db
.select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name })
.from(workspaces)
.where(cond)
.limit(1);
return row ?? null;
}
export { UUID_RE as WORKSPACE_HANDLE_UUID_RE };