updated stuff

This commit is contained in:
SinachPat
2026-05-13 15:20:54 +01:00
parent 7bf43fc481
commit 65a9051bf5
13 changed files with 1005 additions and 8 deletions
@@ -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 });
}
+75
View File
@@ -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