diff --git a/src/__tests__/integration/api/orgs-schematic.test.ts b/src/__tests__/integration/api/orgs-schematic.test.ts index e3215ae0aa..0218691086 100644 --- a/src/__tests__/integration/api/orgs-schematic.test.ts +++ b/src/__tests__/integration/api/orgs-schematic.test.ts @@ -6,7 +6,7 @@ import { createPutRequest, generateUniqueId, } from "@/__tests__/support/helpers"; -import { createTestUser } from "@/__tests__/support/factories"; +import { createTestUser, createTestWorkspace } from "@/__tests__/support/factories"; import { db } from "@/lib/db"; import { GET, PUT } from "@/app/api/orgs/[githubLogin]/schematic/route"; import { WorkspaceRole } from "@prisma/client"; @@ -77,6 +77,26 @@ afterEach(async () => { } }); +async function createOrgWithUserWorkspace(githubLogin: string) { + const org = await createOrg(githubLogin); + createdOrgIds.push(org.id); + + const user = await createTestUser({ + email: `schematic-${generateUniqueId()}@example.com`, + idempotent: false, + }); + createdUserIds.push(user.id); + + const workspace = await createTestWorkspace({ + ownerId: user.id, + sourceControlOrgId: org.id, + slug: `ws-${generateUniqueId()}`, + }); + createdWorkspaceIds.push(workspace.id); + + return { org, user, workspace }; +} + // ─── GET /api/orgs/[githubLogin]/schematic ──────────────────────────────────── describe("GET /api/orgs/[githubLogin]/schematic", () => { @@ -92,17 +112,7 @@ describe("GET /api/orgs/[githubLogin]/schematic", () => { it("returns { schematic: null } for org with no schematic (authorized member)", async () => { const githubLogin = `test-org-${generateUniqueId()}`; - const org = await createOrg(githubLogin); - createdOrgIds.push(org.id); - - const user = await createTestUser({ - email: `schematic-get-${generateUniqueId()}@example.com`, - idempotent: false, - }); - createdUserIds.push(user.id); - - const ws = await createWorkspaceInOrg(user.id, org.id); - createdWorkspaceIds.push(ws.id); + const { user } = await createOrgWithUserWorkspace(githubLogin); const req = createAuthenticatedGetRequest( `/api/orgs/${githubLogin}/schematic`, @@ -115,8 +125,7 @@ describe("GET /api/orgs/[githubLogin]/schematic", () => { it("returns saved schematic after update (authorized member)", async () => { const githubLogin = `test-org-${generateUniqueId()}`; - const org = await createOrg(githubLogin); - createdOrgIds.push(org.id); + const { org, user } = await createOrgWithUserWorkspace(githubLogin); const mermaidBody = "graph TD\n A --> B"; await db.sourceControlOrg.update({ @@ -124,15 +133,6 @@ describe("GET /api/orgs/[githubLogin]/schematic", () => { data: { schematic: mermaidBody }, }); - const user = await createTestUser({ - email: `schematic-get2-${generateUniqueId()}@example.com`, - idempotent: false, - }); - createdUserIds.push(user.id); - - const ws = await createWorkspaceInOrg(user.id, org.id); - createdWorkspaceIds.push(ws.id); - const req = createAuthenticatedGetRequest( `/api/orgs/${githubLogin}/schematic`, { id: user.id, email: user.email!, name: user.name! } @@ -215,17 +215,7 @@ describe("PUT /api/orgs/[githubLogin]/schematic", () => { it("persists value and returns it (workspace OWNER)", async () => { const githubLogin = `test-org-${generateUniqueId()}`; - const org = await createOrg(githubLogin); - createdOrgIds.push(org.id); - - const user = await createTestUser({ - email: `schematic-put-${generateUniqueId()}@example.com`, - idempotent: false, - }); - createdUserIds.push(user.id); - - const ws = await createWorkspaceInOrg(user.id, org.id); - createdWorkspaceIds.push(ws.id); + const { org, user } = await createOrgWithUserWorkspace(githubLogin); const mermaidBody = "graph LR\n X --> Y\n Y --> Z"; diff --git a/src/__tests__/integration/api/stakwork/webhook-halted-notification.test.ts b/src/__tests__/integration/api/stakwork/webhook-halted-notification.test.ts index 44cb228173..e4fee7ccc8 100644 --- a/src/__tests__/integration/api/stakwork/webhook-halted-notification.test.ts +++ b/src/__tests__/integration/api/stakwork/webhook-halted-notification.test.ts @@ -133,6 +133,7 @@ describe("POST /api/stakwork/webhook — WORKFLOW_HALTED notification", () => { const record = await waitForNotification({ notificationType: NotificationTriggerType.WORKFLOW_HALTED, featureId: feature.id, + sendAfter: { not: null }, }); expect(record).not.toBeNull(); diff --git a/src/__tests__/unit/app/w/graph-admin/utils.test.ts b/src/__tests__/unit/app/w/graph-admin/utils.test.ts new file mode 100644 index 0000000000..6022debd8e --- /dev/null +++ b/src/__tests__/unit/app/w/graph-admin/utils.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { extractPubkey } from "@/app/w/[slug]/graph-admin/utils"; + +describe("extractPubkey", () => { + it("strips routeHint and channelId suffix from full string", () => { + expect(extractPubkey("03324c8cabc_034bcc1122_529771090553929734")).toBe("03324c8cabc"); + }); + + it("returns bare pubkey unchanged when no underscore present", () => { + expect(extractPubkey("02abc123def456")).toBe("02abc123def456"); + }); + + it("trims leading and trailing whitespace", () => { + expect(extractPubkey(" 03abc456 ")).toBe("03abc456"); + }); + + it("trims whitespace from pubkey when routeHint suffix is present", () => { + expect(extractPubkey(" 03abc456_routeHint ")).toBe("03abc456"); + }); +}); diff --git a/src/app/api/ask/quick/route.ts b/src/app/api/ask/quick/route.ts index 5c16eee0ee..8d4bac590c 100644 --- a/src/app/api/ask/quick/route.ts +++ b/src/app/api/ask/quick/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse, after } from "next/server"; -import { validationError, serverError, isApiError } from "@/types/errors"; +import { validationError, serverError, isApiError, forbiddenError } from "@/types/errors"; import { getQuickAskPrefixMessages, getMultiWorkspacePrefixMessages } from "@/lib/constants/prompt"; import { askTools, listConcepts, createHasEndMarkerCondition } from "@/lib/ai/askTools"; import { askToolsMulti } from "@/lib/ai/askToolsMulti"; import { buildWorkspaceConfigs, fetchConceptsForWorkspaces } from "@/lib/ai/workspaceConfig"; +import { db } from "@/lib/db"; import { buildConnectionTools } from "@/lib/ai/connectionTools"; import { buildCanvasTools } from "@/lib/ai/canvasTools"; import { streamText, ModelMessage, generateObject, ToolSet } from "ai"; @@ -154,6 +155,22 @@ export async function POST(request: NextRequest) { // agent to pick based on intent (document-an-integration vs // draw-a-diagram). if (orgId) { + // Verify the caller actually belongs to the supplied orgId by + // confirming it is linked to at least one workspace they already + // have verified access to (workspaceConfigs only contains + // workspaces that passed validateWorkspaceAccess above). + const verifiedWorkspaceIds = workspaceConfigs.map((c) => c.workspaceId); + const orgWorkspace = await db.workspace.findFirst({ + where: { + id: { in: verifiedWorkspaceIds }, + sourceControlOrgId: orgId, + }, + select: { id: true }, + }); + if (!orgWorkspace) { + throw forbiddenError("Access denied for org"); + } + tools = { ...tools, ...buildConnectionTools(orgId, userOrResponse.id), diff --git a/src/app/api/orgs/[githubLogin]/canvas/[ref]/route.ts b/src/app/api/orgs/[githubLogin]/canvas/[ref]/route.ts index 4302d4a462..4d3bacbe1e 100644 --- a/src/app/api/orgs/[githubLogin]/canvas/[ref]/route.ts +++ b/src/app/api/orgs/[githubLogin]/canvas/[ref]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getMiddlewareContext, requireAuth } from "@/lib/middleware/utils"; import { db } from "@/lib/db"; import { readCanvas, writeCanvas } from "@/lib/canvas"; +import { getUserOrgAccess } from "@/services/workspace"; /** Refs are user-chosen opaque strings; we refuse empty + over-long. */ function validateRef(ref: string): boolean { @@ -19,13 +20,6 @@ function validateCanvasData(value: unknown): value is { return true; } -async function findOrg(githubLogin: string) { - return db.sourceControlOrg.findUnique({ - where: { githubLogin }, - select: { id: true }, - }); -} - /** Fetch a merged sub-canvas (authored blob + live projection). */ export async function GET( request: NextRequest, @@ -42,7 +36,7 @@ export async function GET( } try { - const org = await findOrg(githubLogin); + const org = await getUserOrgAccess(userOrResponse.id, githubLogin); if (!org) { return NextResponse.json({ error: "Organization not found" }, { status: 404 }); } @@ -77,7 +71,7 @@ export async function PUT( return NextResponse.json({ error: "Invalid canvas data" }, { status: 400 }); } - const org = await findOrg(githubLogin); + const org = await getUserOrgAccess(userOrResponse.id, githubLogin); if (!org) { return NextResponse.json({ error: "Organization not found" }, { status: 404 }); } diff --git a/src/app/api/orgs/[githubLogin]/canvas/route.ts b/src/app/api/orgs/[githubLogin]/canvas/route.ts index 05e4c32e07..4d92f80c46 100644 --- a/src/app/api/orgs/[githubLogin]/canvas/route.ts +++ b/src/app/api/orgs/[githubLogin]/canvas/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getMiddlewareContext, requireAuth } from "@/lib/middleware/utils"; import { db } from "@/lib/db"; import { readCanvas, writeCanvas, ROOT_REF } from "@/lib/canvas"; +import { getUserOrgAccess } from "@/services/workspace"; /** Reject anything that isn't a JSON object matching the CanvasData shape. */ function validateCanvasData(value: unknown): value is { @@ -15,13 +16,6 @@ function validateCanvasData(value: unknown): value is { return true; } -async function findOrg(githubLogin: string) { - return db.sourceControlOrg.findUnique({ - where: { githubLogin }, - select: { id: true }, - }); -} - /** * Fetch the root canvas for an org. Returns the merged `CanvasData` — * authored content plus projected live nodes (workspaces, etc.). The @@ -39,7 +33,7 @@ export async function GET( const { githubLogin } = await params; try { - const org = await findOrg(githubLogin); + const org = await getUserOrgAccess(userOrResponse.id, githubLogin); if (!org) { return NextResponse.json({ error: "Organization not found" }, { status: 404 }); } @@ -74,7 +68,7 @@ export async function PUT( return NextResponse.json({ error: "Invalid canvas data" }, { status: 400 }); } - const org = await findOrg(githubLogin); + const org = await getUserOrgAccess(userOrResponse.id, githubLogin); if (!org) { return NextResponse.json({ error: "Organization not found" }, { status: 404 }); } diff --git a/src/app/api/orgs/[githubLogin]/schematic/route.ts b/src/app/api/orgs/[githubLogin]/schematic/route.ts index 2ecf25c59d..b260b14474 100644 --- a/src/app/api/orgs/[githubLogin]/schematic/route.ts +++ b/src/app/api/orgs/[githubLogin]/schematic/route.ts @@ -25,12 +25,12 @@ export async function GET( ); } - const org = await db.sourceControlOrg.findUnique({ + const row = await db.sourceControlOrg.findUnique({ where: { id: orgId }, select: { schematic: true }, }); - return NextResponse.json({ schematic: org?.schematic ?? null }); + return NextResponse.json({ schematic: row?.schematic ?? null }); } catch (error) { console.error("[GET /api/orgs/[githubLogin]/schematic] Error:", error); return NextResponse.json({ error: "Failed to fetch schematic" }, { status: 500 }); @@ -71,13 +71,13 @@ export async function PUT( ); } - const org = await db.sourceControlOrg.update({ + const updated = await db.sourceControlOrg.update({ where: { id: orgId }, data: { schematic }, select: { schematic: true }, }); - return NextResponse.json({ schematic: org.schematic }); + return NextResponse.json({ schematic: updated.schematic }); } catch (error) { console.error("[PUT /api/orgs/[githubLogin]/schematic] Error:", error); return NextResponse.json({ error: "Failed to update schematic" }, { status: 500 }); diff --git a/src/app/w/[slug]/graph-admin/client.tsx b/src/app/w/[slug]/graph-admin/client.tsx index f8bdd2ab4b..a6fb786768 100644 --- a/src/app/w/[slug]/graph-admin/client.tsx +++ b/src/app/w/[slug]/graph-admin/client.tsx @@ -11,7 +11,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ExternalLink, Loader2, AlertCircle, RefreshCw, Zap, Plus, Pencil, Check, Globe, Lock } from "lucide-react"; import { toast } from "sonner"; import type { PaidEndpoint, BoltwallUser, GraphAdminClientProps, SecondBrainAbout } from "./types"; -import { postGraphAdminCmd, roleToNumber } from "./utils"; +import { postGraphAdminCmd, roleToNumber, extractPubkey } from "./utils"; import { CopyButton, UserRow, UserFormDialog, SetOwnerDialog } from "./components"; export function GraphAdminClient({ swarmUrl, workspaceSlug, workspaceName }: GraphAdminClientProps) { @@ -267,19 +267,20 @@ export function GraphAdminClient({ swarmUrl, workspaceSlug, workspaceName }: Gra // ── Users: save (add/edit) ── async function handleSaveUser(data: { pubkey: string; name: string; role: string }) { const roleNum = roleToNumber(data.role); + const pubkey = extractPubkey(data.pubkey); if (editingUser) { await postGraphAdminCmd(workspaceSlug, { type: "Swarm", data: { cmd: "UpdateUser", - content: { id: editingUser.id!, pubkey: data.pubkey, name: data.name, role: roleNum }, + content: { id: editingUser.id!, pubkey, name: data.name, role: roleNum }, }, }); toast.success("User updated"); } else { await postGraphAdminCmd(workspaceSlug, { type: "Swarm", - data: { cmd: "AddBoltwallUser", content: { pubkey: data.pubkey, name: data.name, role: roleNum } }, + data: { cmd: "AddBoltwallUser", content: { pubkey, name: data.name, role: roleNum } }, }); toast.success("User added"); } @@ -291,7 +292,7 @@ export function GraphAdminClient({ swarmUrl, workspaceSlug, workspaceName }: Gra async function handleSetOwner(data: { pubkey: string; name: string }) { await postGraphAdminCmd(workspaceSlug, { type: "Swarm", - data: { cmd: "AddBoltwallAdminPubkey", content: { pubkey: data.pubkey, name: data.name } }, + data: { cmd: "AddBoltwallAdminPubkey", content: { pubkey: extractPubkey(data.pubkey), name: data.name } }, }); toast.success("Owner set"); await fetchUsers(); @@ -302,7 +303,7 @@ export function GraphAdminClient({ swarmUrl, workspaceSlug, workspaceName }: Gra if (!deleteTarget?.pubkey) return; await postGraphAdminCmd(workspaceSlug, { type: "Swarm", - data: { cmd: "DeleteSubAdmin", content: deleteTarget.pubkey }, + data: { cmd: "DeleteSubAdmin", content: extractPubkey(deleteTarget.pubkey) }, }); toast.success("User removed"); setDeleteTarget(null); diff --git a/src/app/w/[slug]/graph-admin/utils.ts b/src/app/w/[slug]/graph-admin/utils.ts index 157e3b8d46..77bd1367e7 100644 --- a/src/app/w/[slug]/graph-admin/utils.ts +++ b/src/app/w/[slug]/graph-admin/utils.ts @@ -33,3 +33,8 @@ export function getInitials(name: string | null, pubkey: string | null): string if (pubkey) return pubkey.slice(0, 2).toUpperCase(); return "??"; } + +/** Strips any _routeHint suffix, returning only the bare pubkey. */ +export function extractPubkey(raw: string): string { + return raw.split("_")[0].trim(); +} diff --git a/src/services/workspace.ts b/src/services/workspace.ts index b91e981c4c..7c76a790af 100644 --- a/src/services/workspace.ts +++ b/src/services/workspace.ts @@ -1438,6 +1438,33 @@ export async function updateWorkspace( * Gets all organizations the user has access to via workspace membership or ownership. * Returns deduplicated list of SourceControlOrg records. */ +/** + * Returns the org if the user owns or is a member of at least one workspace + * in that org. Returns null when the user has no access, so callers can + * return 403/404 as appropriate without leaking org existence. + */ +export async function getUserOrgAccess( + userId: string, + githubLogin: string, +): Promise<{ id: string; githubLogin: string } | null> { + const org = await db.sourceControlOrg.findUnique({ + where: { + githubLogin, + workspaces: { + some: { + deleted: false, + OR: [ + { ownerId: userId }, + { members: { some: { userId, leftAt: null } } }, + ], + }, + }, + }, + select: { id: true, githubLogin: true }, + }); + return org ?? null; +} + export async function getUserOrganizations(userId: string): Promise { const orgs = await db.sourceControlOrg.findMany({ where: {