Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
47 changes: 0 additions & 47 deletions src/app/api/workspaces/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { db, workspaces } from "@/lib/db/client";
import { eq, and, ne } from "drizzle-orm";
import { loadWorkspaceState } from "@/lib/workspace/workspace-state-read";
import {
requireAuth,
verifyWorkspaceOwnership,
verifyWorkspaceAccess,
withErrorHandling,
} from "@/lib/api/workspace-helpers";
import { generateSlug } from "@/lib/workspace/slug";

/**
* GET /api/workspaces/[id]
* Get a specific workspace with its state
* Supports owner and collaborators
*/
async function handleGET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
// Start independent operations in parallel
const paramsPromise = params;
const authPromise = requireAuth();

const { id } = await paramsPromise;
const userId = await authPromise;

// Check access (owner or collaborator)
const accessInfo = await verifyWorkspaceAccess(id, userId, "viewer");

// Get workspace data
const [workspace] = await db
.select()
.from(workspaces)
.where(eq(workspaces.id, id))
.limit(1);

if (!workspace) {
return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
}

// Get current workspace state from projection tables
const state = await loadWorkspaceState(id, { userId });

return NextResponse.json({
workspace: {
...workspace,
state,
isShared: !accessInfo.isOwner,
permissionLevel: accessInfo.permissionLevel,
},
});
}

export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]");

/**
* PATCH /api/workspaces/[id]
* Update workspace metadata (owner only)
Expand Down
33 changes: 6 additions & 27 deletions src/app/api/workspaces/slug/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { db, workspaces } from "@/lib/db/client";
import { workspaceCollaborators } from "@/lib/db/schema";
import { eq, and, or, inArray } from "drizzle-orm";
import { loadWorkspaceState } from "@/lib/workspace/workspace-state-read";
import { eq, and, inArray } from "drizzle-orm";
import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers";

/**
* GET /api/workspaces/slug/[slug]
* Get a workspace by slug (more user-friendly than UUID)
* Supports owner and collaborators
*
* Query params:
* - metadata=true: Return only workspace metadata (faster, for initial load)
* Resolves a workspace by slug and returns its metadata
* (id, name, slug, icon, color, isShared, permissionLevel). Used by
* `WorkspaceContext` to map URL slug → workspace id so Zero can subscribe
* to items. Item state itself is sync'd by Zero, not returned here.
*/
async function handleGET(
request: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ slug: string }> },
) {
// Start independent operations in parallel
const paramsPromise = params;
const authPromise = requireAuth();

const { slug } = await paramsPromise;
const userId = await authPromise;

// Check if metadata-only mode is requested (faster path for initial workspace load)
const metadataOnly = request.nextUrl.searchParams.get("metadata") === "true";

// Get workspace by slug - first check ownership
const [ownedWorkspace] = await db
.select()
Expand Down Expand Up @@ -84,25 +79,9 @@ async function handleGET(
return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
}

// For metadata-only requests, return just the workspace record (no state loading)
// This is much faster and used for initial workspace identification
if (metadataOnly) {
return NextResponse.json({
workspace: {
...workspace,
isShared,
permissionLevel,
},
});
}

// Get current workspace state from projection tables
const state = await loadWorkspaceState(workspace.id, { userId });

return NextResponse.json({
workspace: {
...workspace,
state,
isShared,
permissionLevel,
},
Expand Down
220 changes: 122 additions & 98 deletions src/app/api/zero/query/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { mustGetQuery, type ReadonlyJSONValue } from "@rocicorp/zero";
import { handleQueryRequest } from "@rocicorp/zero/server";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db/client";
import { workspaceCollaborators, workspaces } from "@/lib/db/schema";
Expand All @@ -16,36 +16,72 @@ type QueryRequest = {
args?: readonly unknown[];
};

async function hasWorkspaceReadAccess(
workspaceId: string,
/**
* Fetch the set of workspace IDs (out of `candidateIds`) that `userId` is allowed
* to read. Done in two batched queries (owner + collaborator) so we don't fan out
* to N round trips when Zero batches queries on the client.
*/
async function getAllowedWorkspaceIds(
candidateIds: readonly string[],
userId: string,
): Promise<boolean> {
const [workspace] = await db
.select({ ownerId: workspaces.userId })
.from(workspaces)
.where(eq(workspaces.id, workspaceId))
.limit(1);

if (!workspace) {
return false;
): Promise<Set<string>> {
if (candidateIds.length === 0) {
return new Set();
}

if (workspace.ownerId === userId) {
return true;
}

const [collaborator] = await db
.select({ id: workspaceCollaborators.id })
.from(workspaceCollaborators)
.where(
and(
eq(workspaceCollaborators.workspaceId, workspaceId),
eq(workspaceCollaborators.userId, userId),
const [ownedRows, collaboratorRows] = await Promise.all([
db
.select({ id: workspaces.id })
.from(workspaces)
.where(
and(eq(workspaces.userId, userId), inArray(workspaces.id, candidateIds)),
),
)
.limit(1);
db
.select({ id: workspaceCollaborators.workspaceId })
.from(workspaceCollaborators)
.where(
and(
eq(workspaceCollaborators.userId, userId),
inArray(workspaceCollaborators.workspaceId, candidateIds),
),
),
]);

return !!collaborator;
const allowed = new Set<string>();
for (const row of ownedRows) allowed.add(row.id);
for (const row of collaboratorRows) allowed.add(row.id);
return allowed;
}

/** Reads `workspaceId` off a single arg object (the unwrapped first arg). */
function readWorkspaceId(arg: ReadonlyJSONValue | undefined): string | null {
if (
arg &&
typeof arg === "object" &&
!Array.isArray(arg) &&
"workspaceId" in arg &&
typeof (arg as { workspaceId?: unknown }).workspaceId === "string"
) {
return (arg as { workspaceId: string }).workspaceId;
}
return null;
}

/**
* Returns the workspaceId a query needs access to, or null to deny.
* Switch (not lookup table) keeps the dispatch bounded for CodeQL.
* New queries must be added here explicitly.
*/
function getRequiredWorkspaceId(
name: string,
arg: ReadonlyJSONValue | undefined,
): string | null {
switch (name) {
case "workspace.items":
return readWorkspaceId(arg);
default:
return null;
}
}

export async function POST(request: NextRequest) {
Expand All @@ -58,87 +94,75 @@ export async function POST(request: NextRequest) {
const userId = session.user.id;
const ctx = { userId };

let body: unknown;
try {
/**
* `handleQueryRequest` calls the `TransformQueryFunction` callback
* synchronously, so we cannot do async workspace access checks inside that
* callback. We must extract workspace IDs up front and verify access before
* handing the request to Zero.
*
* Zero query request bodies are encoded as:
* `[tag, [{ id, name, args: [arg0, ...] }, ...]]`.
*/
const body = (await request.json()) as unknown;
const queryRequests =
Array.isArray(body) && body.length > 1 && Array.isArray(body[1])
? (body[1] as QueryRequest[])
: [];

if (queryRequests.length === 0) {
console.warn(
"Zero query request body did not match expected protocol format",
{ body },
);
}

const workspaceIds = [
...new Set(
queryRequests.flatMap((queryRequest) => {
if (!queryRequest || typeof queryRequest !== "object") {
return [];
}

const args = Array.isArray(queryRequest.args)
? queryRequest.args
: [];
const firstArg = args?.[0];

if (
firstArg &&
typeof firstArg === "object" &&
"workspaceId" in firstArg &&
typeof firstArg.workspaceId === "string"
) {
return [firstArg.workspaceId];
}

if (args.length > 0) {
console.warn("Zero query request args missing workspaceId", {
queryRequest,
});
}

return [];
}),
),
];

// When workspace IDs are found, verify access. When none are found
// (e.g. Zero's initial connection handshake), pass through to
// handleQueryRequest — it will resolve the queries or return errors
// for any that require args.
for (const workspaceId of workspaceIds) {
const hasAccess = await hasWorkspaceReadAccess(workspaceId, userId);
if (!hasAccess) {
throw new Error(WORKSPACE_ACCESS_DENIED_ERROR);
}
}
body = await request.json();
} catch (error) {
console.error("[zero/query] Failed to parse body:", error);
return NextResponse.json({ error: "Bad request" }, { status: 400 });
}

// Body shape: `[tag, [{ id, name, args: [arg0, ...] }, ...]]`.
// Authz throws happen per-query inside the callback so a single denied
// workspace doesn't poison the whole batch.
const queryRequests =
Array.isArray(body) && body.length > 1 && Array.isArray(body[1])
? (body[1] as QueryRequest[])
: [];

const requestedWorkspaceIds = [
...new Set(
queryRequests.flatMap((req) => {
const name = typeof req?.name === "string" ? req.name : "";
const firstArg = Array.isArray(req?.args)
? (req.args[0] as ReadonlyJSONValue | undefined)
: undefined;
const id = getRequiredWorkspaceId(name, firstArg);
return id ? [id] : [];
}),
),
];

let allowedWorkspaceIds = new Set<string>();
try {
allowedWorkspaceIds = await getAllowedWorkspaceIds(
requestedWorkspaceIds,
userId,
);
} catch (error) {
console.error("[zero/query] Access check failed:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}

const deniedCount = requestedWorkspaceIds.filter(
(id) => !allowedWorkspaceIds.has(id),
).length;

if (deniedCount > 0) {
console.warn("[zero/query] Denied workspace access", {
requested: requestedWorkspaceIds.length,
denied: deniedCount,
});
}

try {
const result = await handleQueryRequest(
(name, args) => mustGetQuery(queries, name).fn({ args, ctx }),
(name, args) => {
const workspaceId = getRequiredWorkspaceId(name, args);
if (!workspaceId || !allowedWorkspaceIds.has(workspaceId)) {
throw new Error(WORKSPACE_ACCESS_DENIED_ERROR);
}
return mustGetQuery(queries, name).fn({ args, ctx });
},
schema,
body as ReadonlyJSONValue,
);

return NextResponse.json(result);
} catch (error) {
if (
error instanceof Error &&
error.message === WORKSPACE_ACCESS_DENIED_ERROR
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

console.error("[zero/query] Unhandled error:", error);
return NextResponse.json(
Comment on lines 165 to 167

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The old code had a dedicated catch for WORKSPACE_ACCESS_DENIED_ERROR that returned a 403 response. The new code removed that branch — the outer catch now converts all errors (including authz denials that handleQueryRequest re-throws) into a generic 500. If handleQueryRequest propagates the per-query throw new Error(WORKSPACE_ACCESS_DENIED_ERROR) instead of swallowing it per-query, the client sees 500 instead of 403, which breaks retry/redirect logic on the client and masks a permissions problem as a server error.

// src/app/api/zero/query/route.ts
  } catch (error) {
    console.error("[zero/query] Unhandled error:", error);
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 },
    );
  }

Re-add the WORKSPACE_ACCESS_DENIED_ERROR check before the fallback 500 to preserve the 403 semantics.

Suggested change
} catch (error) {
if (
error instanceof Error &&
error.message === WORKSPACE_ACCESS_DENIED_ERROR
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
console.error("[zero/query] Unhandled error:", error);
return NextResponse.json(
} catch (error) {
if (
error instanceof Error &&
error.message === WORKSPACE_ACCESS_DENIED_ERROR
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
console.error("[zero/query] Unhandled error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);

{ error: "Internal server error" },
Expand Down
Loading
Loading