Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"validate:e2e-migration": "tsx scripts/validate-e2e-migration.ts",
"rotate-keys": "tsx scripts/rotate-encryption-key.ts",
"seed:db": "tsx scripts/helpers/seed-database.ts",
"seed:local-swarm": "dotenv -e .env.local -- tsx scripts/seed-local-swarm.ts",
"seed:auto-seed": "tsx scripts/seed-from-github-account.ts",
"seed:prompts": "tsx scripts/seed-stakwork-prompts.ts",
"seed:action-nodes": "tsx scripts/helpers/seed-graph-action-nodes.ts",
Expand Down
112 changes: 112 additions & 0 deletions scripts/seed-local-swarm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Seed a local mock user + org + workspace + swarm wired for hive-relay.
*
* Mock login already creates the whole chain (via ensureMockWorkspaceForUser)
* but leaves the swarm WITHOUT a `swarmApiKey` and the org WITHOUT a
* `defaultWorkspaceId`. This script fills both so the relay-token flow works:
* - sets a known `swarmApiKey` (encrypted) + a localhost `swarmUrl`, and
* - makes the workspace the org's default (the Org Canvas resolves its
* swarm via org -> defaultWorkspace -> swarm).
*
* Run:
* npx dotenv -e .env.local -- npx tsx scripts/seed-local-swarm.ts
* Optional overrides:
* SEED_USERNAME=dev SEED_SWARM_API_KEY=<hex>
*
* Then start hive-relay with the printed SWARM_API_KEY. Idempotent.
*/
// Relative imports (matching the other scripts) so the editor resolves them;
// the `@/` aliases inside these modules still resolve at runtime under tsx.
import { WorkspaceRole } from "@prisma/client";
import { db } from "../src/lib/db";
import { ensureMockWorkspaceForUser } from "../src/utils/mockSetup";
import { saveOrUpdateSwarm } from "../src/services/swarm/db";

const USERNAME = process.env.SEED_USERNAME ?? "dev";
// A second member of the SAME org, so you can log in as two different users
// (in two browsers) and see each other's cursors on /org/<owner>.
const SECOND_USERNAME = process.env.SEED_SECOND_USERNAME ?? "dev2";
// Stable default so re-running the seed doesn't change the key (you set the
// relay's SWARM_API_KEY once). Override with SEED_SWARM_API_KEY for a custom one.
const SWARM_API_KEY = process.env.SEED_SWARM_API_KEY ?? "local-dev-swarm-key";

async function main() {
const userId = `mock-${USERNAME}`;

// 1. The mock user (mock login creates this on sign-in; we do it up-front
// so the seed is runnable before any login).
await db.user.upsert({
where: { id: userId },
update: {},
create: { id: userId, name: USERNAME, email: `${USERNAME}@mock.dev` },
});

// 2. Org + workspace + repository + swarm (the real mock setup). Idempotent:
// returns early if the user already has a workspace.
const slug = await ensureMockWorkspaceForUser(userId);
const workspace = await db.workspace.findUnique({
where: { slug },
select: { id: true, sourceControlOrgId: true },
});
if (!workspace) throw new Error(`workspace not found for slug ${slug}`);

// 3. Set a known swarmApiKey (encrypted by saveOrUpdateSwarm) + a localhost
// swarmUrl so getRelayUrl() -> http://localhost:3333.
await saveOrUpdateSwarm({
workspaceId: workspace.id,
swarmApiKey: SWARM_API_KEY,
swarmUrl: "http://localhost/api",
});

// 4. Make this workspace the org's default so the Org Canvas can resolve the
// swarm (org -> defaultWorkspace -> swarm).
let orgLogin = "(no org linked)";
if (workspace.sourceControlOrgId) {
const org = await db.sourceControlOrg.update({
where: { id: workspace.sourceControlOrgId },
data: { defaultWorkspaceId: workspace.id },
select: { githubLogin: true },
});
orgLogin = org.githubLogin;
}

// 5. A second user, added as a member of the same workspace → both belong to
// the org, so 2-user presence works on /org/<owner>.
const secondId = `mock-${SECOND_USERNAME}`;
await db.user.upsert({
where: { id: secondId },
update: {},
create: {
id: secondId,
name: SECOND_USERNAME,
email: `${SECOND_USERNAME}@mock.dev`,
},
});
await db.workspaceMember.upsert({
where: { workspaceId_userId: { workspaceId: workspace.id, userId: secondId } },
update: { role: WorkspaceRole.DEVELOPER, leftAt: null },
create: {
workspaceId: workspace.id,
userId: secondId,
role: WorkspaceRole.DEVELOPER,
},
});

console.log("\n✅ Local swarm ready for hive-relay\n");
console.log(" mock logins :", USERNAME, "(owner) &", SECOND_USERNAME, "(member)");
console.log(" workspace slug :", slug);
console.log(" org (githubLogin) :", orgLogin, " -> /org/" + orgLogin);
console.log(" SWARM_API_KEY :", SWARM_API_KEY);
console.log("\n Start hive-relay with:");
console.log(
` SWARM_API_KEY=${SWARM_API_KEY} CORS_ORIGIN=http://localhost:3000 PORT=3333 npm run dev`,
);
console.log("");
}

main()
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,9 @@ vi.mock("@/hooks/useWorkspace", () => ({
}),
}));

vi.mock("@/hooks/useCanvasCollaboration", () => ({
useCanvasCollaboration: () => ({
vi.mock("@/hooks/useCanvasCollaborationViaRelay", () => ({
useCanvasCollaborationViaRelay: () => ({
collaborators: [],
broadcastCursor: vi.fn(),
broadcastSelection: vi.fn(),
}),
}));

Expand Down
95 changes: 95 additions & 0 deletions src/app/api/orgs/[githubLogin]/canvas/relay-token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { validateUserBelongsToOrg } from "@/services/workspace";
import { getSwarmAccessByWorkspaceId } from "@/lib/helpers/swarm-access";
import { getMiddlewareContext, requireAuth } from "@/lib/middleware/utils";
import { signRelayToken } from "@/lib/relay-token";
import { getRelayUrl } from "@/lib/utils/swarm";

const TOKEN_TTL_SECONDS = 300;

/**
* GET /api/orgs/[githubLogin]/canvas/relay-token
*
* Issues a short-lived JWT the browser passes in the socket.io handshake
* to the per-swarm relay. Capability-scoped to exactly this org's canvas
* via the `resource` claim (`canvas:<githubLogin>`), so the relay only
* lets the socket join that one room. Signed HS256 with the org's home
* swarm's `swarmApiKey` (org -> defaultWorkspace -> swarm).
*
* Returns { token, url, expiresInSeconds }.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ githubLogin: string }> },
) {
try {
const context = getMiddlewareContext(request);
const userOrResponse = requireAuth(context);
if (userOrResponse instanceof NextResponse) return userOrResponse;

const { githubLogin } = await params;

const isMember = await validateUserBelongsToOrg(githubLogin, userOrResponse.id);
if (!isMember) {
return NextResponse.json(
{ error: "Organization not found" },
{ status: 404 },
);
}

const org = await db.sourceControlOrg.findUnique({
where: { githubLogin },
select: { defaultWorkspaceId: true },
});
if (!org?.defaultWorkspaceId) {
return NextResponse.json(
{ error: "Relay unavailable", reason: "NO_DEFAULT_WORKSPACE" },
{ status: 503 },
);
}

const swarmAccess = await getSwarmAccessByWorkspaceId(org.defaultWorkspaceId);
if (!swarmAccess.success) {
return NextResponse.json(
{ error: "Relay unavailable", reason: swarmAccess.error.type },
{ status: 503 },
);
}
if (!swarmAccess.data.swarmApiKey) {
return NextResponse.json(
{ error: "Relay unavailable", reason: "SWARM_API_KEY_MISSING" },
{ status: 503 },
);
}
if (!swarmAccess.data.swarmName) {
return NextResponse.json(
{ error: "Relay unavailable", reason: "SWARM_NAME_MISSING" },
{ status: 503 },
);
}

const token = signRelayToken(
{
userId: userOrResponse.id,
name: userOrResponse.name,
image: null,
resource: `canvas:${githubLogin}`,
},
swarmAccess.data.swarmApiKey,
TOKEN_TTL_SECONDS,
);

return NextResponse.json({
token,
url: getRelayUrl(swarmAccess.data.swarmName, swarmAccess.data.swarmUrl),
expiresInSeconds: TOKEN_TTL_SECONDS,
});
} catch (error) {
console.error("Error issuing canvas relay token:", error);
return NextResponse.json(
{ error: "Failed to issue relay token" },
{ status: 500 },
);
}
}
4 changes: 2 additions & 2 deletions src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { CreateServiceCanvasDialog } from "../_components/CreateServiceCanvasDia
import { categoryAllowedOnScope, CATEGORY_REGISTRY } from "./canvas-categories";
import { useCanvasChatStore } from "../_state/canvasChatStore";
import { useSession } from "next-auth/react";
import { useCanvasCollaboration } from "@/hooks/useCanvasCollaboration";
import { useCanvasCollaborationViaRelay } from "@/hooks/useCanvasCollaborationViaRelay";
import { toast } from "sonner";
import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -737,7 +737,7 @@ export function OrgCanvasBackground({
});

// Real-time canvas presence — cursors, selection halos, conflict flash
const { collaborators } = useCanvasCollaboration({
const { collaborators } = useCanvasCollaborationViaRelay({
githubLogin,
canvasRef: currentRef,
userId: session?.user?.id ?? "",
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useCanvasCollaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface CollaboratorState extends Omit<CanvasCollaboratorInfo, "selectedNodeId
lastSeenAt: number;
}

interface UseCanvasCollaborationOptions {
export interface UseCanvasCollaborationOptions {
githubLogin: string;
/** Current canvas ref — empty string for root. */
canvasRef: string;
Expand Down
Loading
Loading