updated stuff
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// ── SDK Bridge — canvas commands channel ──────────────────────────────────────
|
||||
//
|
||||
// GET /api/sdk/[projectId]/commands — SDK subscribes (SSE). Receives canvas
|
||||
// commands: PATCH_ELEMENT_STYLE,
|
||||
// REQUEST_ELEMENT_STYLES, SELECT_COMPONENT,
|
||||
// SET_DESIGN_TOKENS, etc.
|
||||
// Auth: Bearer <sdk-token>.
|
||||
//
|
||||
// POST /api/sdk/[projectId]/commands — Canvas pushes a command to the bridge.
|
||||
// Auth: Supabase session cookie.
|
||||
// Body: HostMessage JSON.
|
||||
// The bridge fans the command out to all
|
||||
// connected SDK instances for this project.
|
||||
//
|
||||
// This route handles the Canvas → SDK direction of the bridge.
|
||||
// For SDK → Canvas events see: /api/sdk/[projectId]/route.ts
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { extractSdkToken } from '@/lib/sdk-auth';
|
||||
import {
|
||||
registerSdkSink,
|
||||
pushToSdk,
|
||||
canvasSubscriberCount,
|
||||
} from '@/lib/sdk-bridge-registry';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
|
||||
// ── GET — SDK subscribes to canvas commands ───────────────────────────────────
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const { projectId } = await params;
|
||||
|
||||
// Authenticate the SDK via Bearer token.
|
||||
const sdkToken = extractSdkToken(req.headers.get('authorization'));
|
||||
if (!sdkToken) {
|
||||
return new NextResponse('Invalid or missing SDK token', { status: 401 });
|
||||
}
|
||||
if (sdkToken.projectId !== projectId) {
|
||||
return new NextResponse('Token project mismatch', { status: 403 });
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(enc.encode(': sdk-commands connected\n\n'));
|
||||
// Tell the SDK how many canvas tabs are actively subscribed.
|
||||
controller.enqueue(enc.encode(
|
||||
`data: ${JSON.stringify({ type: '__bridge_status__', canvasCount: canvasSubscriberCount(projectId) })}\n\n`,
|
||||
));
|
||||
|
||||
// Register this SDK instance as a sink for canvas commands.
|
||||
const unsubscribe = registerSdkSink(projectId, (chunk) => {
|
||||
try { controller.enqueue(enc.encode(chunk)); } catch { /* disconnected */ }
|
||||
});
|
||||
|
||||
const keepAlive = setInterval(() => {
|
||||
try { controller.enqueue(enc.encode(': keep-alive\n\n')); } catch {
|
||||
clearInterval(keepAlive);
|
||||
}
|
||||
}, 25_000);
|
||||
|
||||
req.signal.addEventListener('abort', () => {
|
||||
clearInterval(keepAlive);
|
||||
unsubscribe();
|
||||
try { controller.close(); } catch { /* already closed */ }
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── POST — Canvas pushes a command to the SDK ─────────────────────────────────
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const { projectId } = await params;
|
||||
|
||||
// Verify the canvas user has a valid session.
|
||||
const authCookie = req.cookies.get('sb-access-token')?.value
|
||||
?? req.headers.get('x-supabase-auth')
|
||||
?? null;
|
||||
|
||||
if (!authCookie) {
|
||||
return NextResponse.json({ error: 'Unauthorized — missing session' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Lightweight project access check.
|
||||
const db = serverClient();
|
||||
const { error: projectError } = await db
|
||||
.from('artboards')
|
||||
.select('id')
|
||||
.eq('id', projectId)
|
||||
.single();
|
||||
|
||||
if (projectError) {
|
||||
return NextResponse.json({ error: 'Project not found or access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Parse the command body.
|
||||
let command: Record<string, unknown>;
|
||||
try {
|
||||
command = (await req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof command.type !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing command.type' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fan out to all SDK SSE subscribers for this project.
|
||||
pushToSdk(projectId, command);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// ── SDK Bridge — fiber events channel ─────────────────────────────────────────
|
||||
//
|
||||
// GET /api/sdk/[projectId] — Canvas subscribes (SSE). Receives SDK events:
|
||||
// READY, FIBER_TREE_UPDATE, ELEMENT_STYLES, etc.
|
||||
// Auth: Supabase session cookie.
|
||||
//
|
||||
// POST /api/sdk/[projectId] — SDK pushes an event to the bridge.
|
||||
// Auth: Bearer <sdk-token>.
|
||||
// Body: RendererMessage JSON.
|
||||
// The bridge fans the event out to all canvas SSE
|
||||
// subscribers for this project.
|
||||
//
|
||||
// This route handles the SDK → Canvas direction of the bridge.
|
||||
// For Canvas → SDK commands see: /api/sdk/[projectId]/commands/route.ts
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { extractSdkToken } from '@/lib/sdk-auth';
|
||||
import {
|
||||
registerCanvasSink,
|
||||
pushToCanvas,
|
||||
sdkSubscriberCount,
|
||||
} from '@/lib/sdk-bridge-registry';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
|
||||
// ── GET — Canvas subscribes to SDK events ─────────────────────────────────────
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const { projectId } = await params;
|
||||
|
||||
// Verify the canvas user has access to this project via Supabase session.
|
||||
// We read the session from the cookie — canvas is a browser client.
|
||||
const db = serverClient();
|
||||
const authToken = req.cookies.get('sb-access-token')?.value
|
||||
?? req.headers.get('x-supabase-auth')
|
||||
?? null;
|
||||
|
||||
if (!authToken) {
|
||||
return new NextResponse('Unauthorized — missing session', { status: 401 });
|
||||
}
|
||||
|
||||
// Verify the project exists and belongs to a workspace the user can access.
|
||||
// For now we do a lightweight existence check. Full RLS is enforced by the
|
||||
// service-role client below — swap for auth-user client for full RLS.
|
||||
const { error: projectError } = await db
|
||||
.from('artboards')
|
||||
.select('id')
|
||||
.eq('id', projectId)
|
||||
.single();
|
||||
|
||||
if (projectError) {
|
||||
return new NextResponse('Project not found or access denied', { status: 403 });
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// SSE preamble: tells the browser the connection is alive.
|
||||
controller.enqueue(enc.encode(': sdk-bridge connected\n\n'));
|
||||
// Inform the canvas how many SDK instances are currently connected.
|
||||
controller.enqueue(enc.encode(
|
||||
`data: ${JSON.stringify({ type: '__bridge_status__', sdkCount: sdkSubscriberCount(projectId) })}\n\n`,
|
||||
));
|
||||
|
||||
// Register this canvas tab as a sink for SDK events.
|
||||
const unsubscribe = registerCanvasSink(projectId, (chunk) => {
|
||||
try { controller.enqueue(enc.encode(chunk)); } catch { /* disconnected */ }
|
||||
});
|
||||
|
||||
// Keep-alive every 25 s to prevent proxy idle timeouts.
|
||||
const keepAlive = setInterval(() => {
|
||||
try { controller.enqueue(enc.encode(': keep-alive\n\n')); } catch {
|
||||
clearInterval(keepAlive);
|
||||
}
|
||||
}, 25_000);
|
||||
|
||||
req.signal.addEventListener('abort', () => {
|
||||
clearInterval(keepAlive);
|
||||
unsubscribe();
|
||||
try { controller.close(); } catch { /* already closed */ }
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── POST — SDK pushes a fiber event ──────────────────────────────────────────
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const { projectId } = await params;
|
||||
|
||||
// Authenticate the SDK via Bearer token.
|
||||
const sdkToken = extractSdkToken(req.headers.get('authorization'));
|
||||
if (!sdkToken) {
|
||||
return NextResponse.json({ error: 'Invalid or missing SDK token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// The token is scoped to a specific project — verify it matches the URL.
|
||||
if (sdkToken.projectId !== projectId) {
|
||||
return NextResponse.json({ error: 'Token project mismatch' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Parse the SDK event body.
|
||||
let message: Record<string, unknown>;
|
||||
try {
|
||||
message = (await req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof message.type !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing message.type' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fan out to all canvas SSE subscribers for this project.
|
||||
pushToCanvas(projectId, message);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// ── SDK token issuance ────────────────────────────────────────────────────────
|
||||
//
|
||||
// POST /api/sdk/token
|
||||
// Body: { projectId: string, workspaceId: string }
|
||||
// Returns: { token: string }
|
||||
//
|
||||
// Issues a signed SDK token scoped to a project. The token is used by
|
||||
// @originmain/dev to authenticate with the bridge:
|
||||
// - POST /api/sdk/[projectId] (SDK → canvas, fiber events)
|
||||
// - GET /api/sdk/[projectId]/commands (SDK ← canvas, edit commands)
|
||||
//
|
||||
// Auth: Supabase session cookie (canvas user must own the project/workspace).
|
||||
//
|
||||
// The token is HMAC-signed (SHA-256) and expires in 90 days. It is NOT stored
|
||||
// in the database — it is self-contained and verifiable without a DB lookup.
|
||||
// To revoke: rotate AGENT_BRIDGE_SECRET (invalidates all outstanding tokens).
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { issueSdkToken } from '@/lib/sdk-auth';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Require a canvas session.
|
||||
const authCookie = req.cookies.get('sb-access-token')?.value
|
||||
?? req.headers.get('x-supabase-auth')
|
||||
?? null;
|
||||
|
||||
if (!authCookie) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Parse request body.
|
||||
let body: { projectId?: string; workspaceId?: string };
|
||||
try {
|
||||
body = (await req.json()) as typeof body;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { projectId, workspaceId } = body;
|
||||
if (!projectId || !workspaceId) {
|
||||
return NextResponse.json({ error: 'projectId and workspaceId are required' }, { status: 422 });
|
||||
}
|
||||
|
||||
// Verify the project exists and belongs to the specified workspace.
|
||||
// The service-role client bypasses RLS — so we manually check workspace membership.
|
||||
const db = serverClient();
|
||||
|
||||
const { error: projectError } = await db
|
||||
.from('artboards')
|
||||
.select('id, workspace_id')
|
||||
.eq('id', projectId)
|
||||
.eq('workspace_id', workspaceId)
|
||||
.single();
|
||||
|
||||
if (projectError) {
|
||||
return NextResponse.json({ error: 'Project not found in workspace' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Issue the token.
|
||||
let token: string;
|
||||
try {
|
||||
token = issueSdkToken(projectId, workspaceId);
|
||||
} catch (err) {
|
||||
// AGENT_BRIDGE_SECRET not configured on this server.
|
||||
const message = err instanceof Error ? err.message : 'Token signing failed';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ token });
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// ── SDK token auth ────────────────────────────────────────────────────────────
|
||||
// Format: base64url(projectId:workspaceId:issuedAt:hmac)
|
||||
// HMAC-SHA256 signed with AGENT_BRIDGE_SECRET (same key as workspace tokens).
|
||||
// Tokens expire after 90 days (SDK tokens are long-lived; rotate via UI).
|
||||
//
|
||||
// Separate from @originmain/agent-bridge WorkspaceToken because SDK tokens are
|
||||
// scoped to a project (not just a workspace) and use a different agent type.
|
||||
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
|
||||
|
||||
export interface SdkToken {
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
issuedAt: number;
|
||||
}
|
||||
|
||||
function getSecret(): string {
|
||||
const secret = process.env.AGENT_BRIDGE_SECRET;
|
||||
if (!secret) throw new Error('AGENT_BRIDGE_SECRET is not set');
|
||||
return secret;
|
||||
}
|
||||
|
||||
function sign(payload: string, secret: string): string {
|
||||
return createHmac('sha256', secret).update(payload).digest('hex');
|
||||
}
|
||||
|
||||
/** Issue an SDK token scoped to a specific project. Called from the canvas
|
||||
* when the user generates a token in project settings. */
|
||||
export function issueSdkToken(projectId: string, workspaceId: string): string {
|
||||
const issuedAt = Date.now();
|
||||
const payload = `sdk:${projectId}:${workspaceId}:${issuedAt}`;
|
||||
const hmac = sign(payload, getSecret());
|
||||
return Buffer.from(`${payload}:${hmac}`).toString('base64url');
|
||||
}
|
||||
|
||||
/** Verify and decode an SDK token from a Bearer header.
|
||||
* Returns null if invalid, expired, or the secret is mismatched. */
|
||||
export function verifySdkToken(token: string): SdkToken | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(token, 'base64url').toString('utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format: "sdk:<projectId>:<workspaceId>:<issuedAt>:<hmac>"
|
||||
// Split by ':' but only the first 5 segments — projectId/workspaceId can't
|
||||
// contain ':' (they're UUIDs), so 5 parts is always correct.
|
||||
const parts = decoded.split(':');
|
||||
if (parts.length !== 5 || parts[0] !== 'sdk') return null;
|
||||
|
||||
const [, projectId, workspaceId, issuedAtStr, providedHmac] = parts;
|
||||
if (!projectId || !workspaceId || !issuedAtStr || !providedHmac) return null;
|
||||
|
||||
const issuedAt = parseInt(issuedAtStr, 10);
|
||||
if (isNaN(issuedAt) || Date.now() - issuedAt > TOKEN_TTL_MS) return null;
|
||||
|
||||
const payload = `sdk:${projectId}:${workspaceId}:${issuedAtStr}`;
|
||||
const expectedHmac = sign(payload, getSecret());
|
||||
|
||||
const expected = Buffer.from(expectedHmac, 'hex');
|
||||
const provided = Buffer.from(providedHmac, 'hex');
|
||||
if (expected.length !== provided.length) return null;
|
||||
if (!timingSafeEqual(expected, provided)) return null;
|
||||
|
||||
return { projectId, workspaceId, issuedAt };
|
||||
}
|
||||
|
||||
/** Extract and verify the Bearer token from an Authorization header string. */
|
||||
export function extractSdkToken(authHeader: string | null): SdkToken | null {
|
||||
if (!authHeader?.startsWith('Bearer ')) return null;
|
||||
return verifySdkToken(authHeader.slice(7));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// ── SDK bridge registry ───────────────────────────────────────────────────────
|
||||
// In-process pub/sub for the bidirectional SDK ↔ Canvas WebSocket bridge.
|
||||
//
|
||||
// Two registries per project:
|
||||
// canvasSinks — canvas browser tabs subscribed to SDK events
|
||||
// (fiber tree updates, style responses, etc.)
|
||||
// sdkSinks — SDK instances subscribed to canvas commands
|
||||
// (patch style, request styles, select component, etc.)
|
||||
//
|
||||
// ⚠️ Serverless limitation: this module uses in-process Maps and therefore
|
||||
// only works when all requests for a project hit the same Node.js process.
|
||||
// In Vercel serverless deployments, POST and GET requests may hit different
|
||||
// function instances, breaking pub/sub.
|
||||
//
|
||||
// To make this production-grade on Vercel:
|
||||
// - Replace these Maps with Supabase Realtime channels (already in stack)
|
||||
// - Or add an Upstash Redis publisher/subscriber
|
||||
// - Or run on Vercel Fluid Compute (persistent Node.js process)
|
||||
//
|
||||
// For local development (`next dev`) and self-hosted Node.js deployments,
|
||||
// the in-process Maps work correctly.
|
||||
|
||||
/** A single SSE sink: a function that pushes raw SSE data chunks to a client. */
|
||||
type Sink = (chunk: string) => void;
|
||||
|
||||
const canvasSinks = new Map<string, Set<Sink>>();
|
||||
const sdkSinks = new Map<string, Set<Sink>>();
|
||||
|
||||
// ── Canvas sinks (SDK → Canvas direction) ─────────────────────────────────────
|
||||
|
||||
export function registerCanvasSink(projectId: string, sink: Sink): () => void {
|
||||
let sinks = canvasSinks.get(projectId);
|
||||
if (!sinks) { sinks = new Set(); canvasSinks.set(projectId, sinks); }
|
||||
sinks.add(sink);
|
||||
return () => {
|
||||
sinks?.delete(sink);
|
||||
if (sinks?.size === 0) canvasSinks.delete(projectId);
|
||||
};
|
||||
}
|
||||
|
||||
/** Push a JSON-encoded message to all canvas tabs for a project. */
|
||||
export function pushToCanvas(projectId: string, message: object): void {
|
||||
const sinks = canvasSinks.get(projectId);
|
||||
if (!sinks || sinks.size === 0) return;
|
||||
const chunk = `data: ${JSON.stringify(message)}\n\n`;
|
||||
for (const sink of sinks) {
|
||||
try { sink(chunk); } catch { /* client disconnected between iteration */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of canvas tabs connected to a project. */
|
||||
export function canvasSubscriberCount(projectId: string): number {
|
||||
return canvasSinks.get(projectId)?.size ?? 0;
|
||||
}
|
||||
|
||||
// ── SDK sinks (Canvas → SDK direction) ────────────────────────────────────────
|
||||
|
||||
export function registerSdkSink(projectId: string, sink: Sink): () => void {
|
||||
let sinks = sdkSinks.get(projectId);
|
||||
if (!sinks) { sinks = new Set(); sdkSinks.set(projectId, sinks); }
|
||||
sinks.add(sink);
|
||||
return () => {
|
||||
sinks?.delete(sink);
|
||||
if (sinks?.size === 0) sdkSinks.delete(projectId);
|
||||
};
|
||||
}
|
||||
|
||||
/** Push a JSON-encoded command to all SDK instances for a project. */
|
||||
export function pushToSdk(projectId: string, command: object): void {
|
||||
const sinks = sdkSinks.get(projectId);
|
||||
if (!sinks || sinks.size === 0) return;
|
||||
const chunk = `data: ${JSON.stringify(command)}\n\n`;
|
||||
for (const sink of sinks) {
|
||||
try { sink(chunk); } catch { /* SDK disconnected */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of SDK instances connected to a project. */
|
||||
export function sdkSubscriberCount(projectId: string): number {
|
||||
return sdkSinks.get(projectId)?.size ?? 0;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user