Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b045c0a
Generated with Hive: Strip routeHint suffix from pubkey in admin user…
tomsmith8 Apr 22, 2026
f29f6d5
Generated with Hive: Fix orgs-schematic integration tests by creating…
tomsmith8 Apr 22, 2026
df6d22a
Merge remote-tracking branch 'origin/master' into bugfix/cmoa6vky5001…
tomsmith8 Apr 25, 2026
7119710
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 25, 2026
1c90c9e
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 25, 2026
b2f7146
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 25, 2026
f63443a
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 25, 2026
2faa88d
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 25, 2026
7f7fb41
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 25, 2026
d94d29e
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 26, 2026
020c93f
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 26, 2026
82ec527
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 26, 2026
4c66ebf
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 26, 2026
2a33eee
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 26, 2026
5ae277a
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 26, 2026
90d85dd
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 26, 2026
eb14c28
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 27, 2026
38ec982
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 27, 2026
bed9d09
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 27, 2026
30d31ee
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 27, 2026
4c91168
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 27, 2026
d18cf71
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 27, 2026
37e0b66
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 27, 2026
0ea206e
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 27, 2026
e4aa755
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 27, 2026
2d1e97d
Generated with Hive: Fix notification test to await non-null sendAfte…
tomsmith8 Apr 27, 2026
4500c91
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 27, 2026
c9bdb97
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 28, 2026
69d3e4f
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 28, 2026
c1e5bc0
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 28, 2026
9f8bfca
Merge branch 'master' into bugfix/cmoa6vky50010ld044081af85-strip-rou…
tomsmith8 Apr 28, 2026
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
58 changes: 24 additions & 34 deletions src/__tests__/integration/api/orgs-schematic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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`,
Expand All @@ -115,24 +125,14 @@ 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({
where: { id: org.id },
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! }
Expand Down Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/unit/app/w/graph-admin/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
19 changes: 18 additions & 1 deletion src/app/api/ask/quick/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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),
Expand Down
12 changes: 3 additions & 9 deletions src/app/api/orgs/[githubLogin]/canvas/[ref]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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 });
}
Expand Down
12 changes: 3 additions & 9 deletions src/app/api/orgs/[githubLogin]/canvas/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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 });
}
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/orgs/[githubLogin]/schematic/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down
11 changes: 6 additions & 5 deletions src/app/w/[slug]/graph-admin/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
}
Expand All @@ -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();
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/app/w/[slug]/graph-admin/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
27 changes: 27 additions & 0 deletions src/services/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrgResponse[]> {
const orgs = await db.sourceControlOrg.findMany({
where: {
Expand Down
Loading