Update Rokcet Buckets SSO and MCP OAuth

This commit is contained in:
Lisa Ops
2026-08-15 03:47:29 +07:00
parent 0dfdd7e63e
commit ae097645b2
20 changed files with 1710 additions and 437 deletions

View File

@@ -3,9 +3,14 @@ GEMINI_API_KEY=""
APP_URL="http://localhost:3000"
# Buckets.id integration used by the Buckets builder/testbed.
# Developer MCP is build/configuration only.
# Developer MCP is build/configuration only and uses per-developer, App-scoped
# Authorization Code + PKCE. There is NO permanent Developer MCP token env var.
BUCKETS_DEVELOPER_MCP_URL="https://mcp.buckets.id/mcp"
BUCKETS_DEVELOPER_MCP_TOKEN=""
# Optional override. By default this is APP_URL + /api/buckets/mcp/oauth/callback.
ROKCET_MCP_REDIRECT_URI="http://localhost:3000/api/buckets/mcp/oauth/callback"
# Optional. Production defaults to the HTTPS Client ID Metadata Document at
# APP_URL + /.well-known/buckets-developer-mcp-client. Leave unset normally.
ROKCET_MCP_CLIENT_ID=""
# Public Buckets API origin.
BUCKETS_API_URL="https://buckets-id.stble.fi"
@@ -24,3 +29,12 @@ BUCKETS_OPERATOR_TOKEN=""
BUCKETS_APP_ID=""
BUCKETS_OAUTH_CLIENT_ID=""
BUCKETS_OAUTH_CLIENT_SECRET=""
# Rokcet product authentication + shared Buckets developer SSO
ROKCET_DATABASE_PATH="./data/rokcet.sqlite"
ROKCET_SESSION_SECRET="replace-with-a-long-random-secret"
BUCKETS_IDENTITY_URL="https://auth.buckets.id"
ROKCET_SSO_CLIENT_ID="rokcet.dev"
ROKCET_SSO_CLIENT_SECRET="replace-with-the-same-secret-configured-on-buckets"
ROKCET_SSO_REDIRECT_URI="http://localhost:3000/api/auth/sso/callback"
# Production example: https://rokcet.dev/api/auth/sso/callback

5
.gitignore vendored
View File

@@ -6,3 +6,8 @@ coverage/
*.log
.env*
!.env.example
# Local Rokcet product/session database
/data/*.sqlite
/data/*.sqlite-wal
/data/*.sqlite-shm

View File

@@ -14,14 +14,62 @@ Rokcet deliberately keeps the two Buckets MCP/runtime responsibilities separate:
- **Build time:** Rokcet/coding agent -> **Buckets Developer MCP** -> inspect, plan, ensure, and validate an App composition.
- **Runtime:** generated application -> customer OAuth/API/conversations -> Buckets managed Agent -> authorized customer/runtime tools.
- **Integration:** normal application traffic uses Buckets APIs, OAuth, SSE, and signed webhooks. Developer MCP credentials are never shipped into generated customer applications.
- **Integration:** normal application traffic uses Buckets APIs, OAuth, SSE, and signed webhooks. Developer MCP OAuth credentials are stored server-side per authenticated Rokcet workspace and are never shipped into generated customer applications.
The Buckets 1-click builder includes distinct reference compositions for **AI Booking Assistant**, **Gated AI Knowledge**, and **AI Concierge**. The booking template can use either Buckets-native deterministic scheduling or an App Adapter that keeps an existing scheduler authoritative. Generated managed-Agent apps also demonstrate the full BC customer flow: top up the **global Buckets Credits wallet** through hosted checkout, then separately authorize that App to spend BC on managed AI runs.
### Required server-side environment
See `.env.example`. `BUCKETS_DEVELOPER_MCP_TOKEN`, `BUCKETS_CUSTOMER_TOKEN`, and `BUCKETS_OPERATOR_TOKEN` are server-side credentials and must not be exposed to browser code.
See `.env.example`. Rokcet does **not** use a permanent `BUCKETS_DEVELOPER_MCP_TOKEN`. Developer MCP access is authorized interactively per developer/workspace with Buckets Authorization Code + PKCE, then the rotating access/refresh credentials are encrypted at rest in Rokcet's server database. `BUCKETS_CUSTOMER_TOKEN` and `BUCKETS_OPERATOR_TOKEN` remain optional server-side credentials for the separate runtime testbed/observability features and must not be exposed to browser code.
### Observability
Open **Inspector -> BUCKETS RUNTIME** to inspect real Buckets Agent runs, model/token/BC usage, tool calls, authorization decisions, errors, and timing. Set `BUCKETS_CONSOLE_URL` to enable deep links into the source-of-truth Buckets Runtime Runs view. Rokcet does not fabricate observability data when Buckets is disconnected.
## Buckets Developer MCP authorization
Developer SSO and Developer MCP authorization are separate layers:
- **SSO** answers who the Rokcet developer is. It uses `ROKCET_SSO_CLIENT_SECRET` server-to-server.
- **Developer MCP OAuth** answers which Buckets Organization/App Rokcet may configure for a Rokcet workspace. It uses Authorization Code + PKCE and **no MCP client secret**.
From Rokcet, open **Buckets -> MCP Developer Tools -> Connect Buckets App**. Rokcet redirects to `auth.buckets.id`, where the already-signed-in developer selects or creates the Organization/App and approves the scopes. Buckets redirects back to `/api/buckets/mcp/oauth/callback`; Rokcet exchanges the one-time code, encrypts the rotating access/refresh tokens using `ROKCET_SESSION_SECRET`, and binds the connection to that Rokcet user + workspace.
For production, `APP_URL=https://rokcet.dev` automatically makes Rokcet's Client ID Metadata Document:
`https://rokcet.dev/.well-known/buckets-developer-mcp-client`
and the default callback:
`https://rokcet.dev/api/buckets/mcp/oauth/callback`
`ROKCET_MCP_REDIRECT_URI` and `ROKCET_MCP_CLIENT_ID` are optional overrides. Local `http://localhost` development automatically falls back to Buckets Dynamic Client Registration because Client ID Metadata Documents are HTTPS-only.
## Shared Buckets developer SSO
Rokcet uses Buckets as the authority for **developer identity**. This is intentionally separate from Buckets customer OAuth/OIDC, which remains for end users of Buckets-powered applications.
The browser flow is:
1. Rokcet redirects the developer to `BUCKETS_IDENTITY_URL/developer/sso/authorize` with an exact callback URI and CSRF state.
2. Buckets reuses its host-only developer SSO cookie. If no shared session exists, Buckets shows its normal developer login/signup page.
3. Buckets sends Rokcet a short-lived, single-use authorization code. The developer password never returns to Rokcet.
4. Rokcet exchanges the code server-to-server using `ROKCET_SSO_CLIENT_SECRET`, maps the canonical Buckets developer ID to a local Rokcet account, then issues its own HttpOnly product session.
`LOGIN WITH DEMO` remains available on the Rokcet auth page. It creates a signed, isolated eight-hour demo session and never creates or impersonates a Buckets account.
### Production settings
Use matching values on the two services:
- Buckets: `ROKCET_SSO_CLIENT_ID=rokcet.dev`
- Buckets: `ROKCET_SSO_CLIENT_SECRET=<strong random shared secret>`
- Buckets: `ROKCET_SSO_REDIRECT_URIS=https://rokcet.dev/api/auth/sso/callback`
- Rokcet: `BUCKETS_IDENTITY_URL=https://auth.buckets.id`
- Rokcet: `ROKCET_SSO_CLIENT_ID=rokcet.dev`
- Rokcet: `ROKCET_SSO_CLIENT_SECRET=<same shared secret>`
- Rokcet: `ROKCET_SSO_REDIRECT_URI=https://rokcet.dev/api/auth/sso/callback`
- Rokcet: `APP_URL=https://rokcet.dev`
- Rokcet: `ROKCET_SESSION_SECRET=<independent strong random secret>`
Rokcet's local account/workspace/session records are stored in the SQLite file configured by `ROKCET_DATABASE_PATH`. In production, point this at a durable mounted volume (for example `/data/rokcet.sqlite`). If Rokcet is later run with multiple application replicas, replace this local store with a shared database before horizontally scaling.

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rokcet.dev</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet" />

6
public/favicon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.87 12.87 0 0 1 22 2c0 2.72-.78 7.5-6.05 11a22.35 22.35 0 0 1-3.95 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

114
server/auth.ts Normal file
View File

@@ -0,0 +1,114 @@
import crypto from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import { resolveSession, type RokcetUserRecord, type RokcetWorkspaceRecord } from './services/rokcetDb';
export type RokcetAuthContext = {
user: RokcetUserRecord & { isDemo?: boolean };
workspaces: RokcetWorkspaceRecord[];
isDemo: boolean;
};
const isSecure = process.env.NODE_ENV === 'production';
export const SESSION_COOKIE = isSecure ? '__Host-rokcet_session' : 'rokcet_session';
export const SSO_STATE_COOKIE = isSecure ? '__Host-rokcet_sso_state' : 'rokcet_sso_state';
const DEMO_WORKSPACE: RokcetWorkspaceRecord = {
id: 'demo-workspace',
name: 'Rokcet Demo Workspace',
description: 'Isolated demo workspace. Demo access never becomes a real Buckets developer account.',
ownerId: 'demo-user',
createdAt: '2026-08-15T00:00:00.000Z',
};
const ephemeralSecret = crypto.randomBytes(32).toString('hex');
function sessionSecret() {
const configured = process.env.ROKCET_SESSION_SECRET?.trim();
if (!configured && isSecure) throw new Error('ROKCET_SESSION_SECRET is required in production');
return configured || ephemeralSecret;
}
export function parseCookies(req: Request): Record<string, string> {
const header = req.headers.cookie || '';
return header.split(';').reduce<Record<string, string>>((acc, item) => {
const idx = item.indexOf('=');
if (idx < 0) return acc;
const key = item.slice(0, idx).trim();
const value = item.slice(idx + 1).trim();
if (key) acc[key] = decodeURIComponent(value);
return acc;
}, {});
}
export function setSessionCookie(res: Response, token: string, maxAgeSeconds = 30 * 86400) {
res.cookie(SESSION_COOKIE, token, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
path: '/',
maxAge: maxAgeSeconds * 1000,
});
}
export function clearSessionCookie(res: Response) {
res.clearCookie(SESSION_COOKIE, { httpOnly: true, secure: isSecure, sameSite: 'lax', path: '/' });
}
export function setSsoStateCookie(res: Response, state: string) {
res.cookie(SSO_STATE_COOKIE, state, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
path: '/',
maxAge: 10 * 60 * 1000,
});
}
export function clearSsoStateCookie(res: Response) {
res.clearCookie(SSO_STATE_COOKIE, { httpOnly: true, secure: isSecure, sameSite: 'lax', path: '/' });
}
function demoSignature(expires: string) {
return crypto.createHmac('sha256', sessionSecret()).update(`demo:${expires}`).digest('base64url');
}
export function newDemoSessionToken() {
const expires = String(Date.now() + 8 * 60 * 60 * 1000);
return `demo.${expires}.${demoSignature(expires)}`;
}
function demoContext(token: string): RokcetAuthContext | null {
const [prefix, expires, signature] = token.split('.');
if (prefix !== 'demo' || !expires || !signature || Number(expires) <= Date.now()) return null;
const expected = demoSignature(expires);
if (signature.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return null;
return {
user: {
id: 'demo-user',
bucketsUserId: '',
email: 'demo@rokcet.dev',
name: 'Demo Developer',
createdAt: '2026-08-15T00:00:00.000Z',
isDemo: true,
},
workspaces: [DEMO_WORKSPACE],
isDemo: true,
};
}
export function getRokcetAuth(req: Request): RokcetAuthContext | null {
const token = parseCookies(req)[SESSION_COOKIE];
if (!token) return null;
if (token.startsWith('demo.')) return demoContext(token);
const resolved = resolveSession(token);
return resolved ? { ...resolved, isDemo: false } : null;
}
export function requireRokcetAuth(req: Request, res: Response, next: NextFunction) {
try {
const auth = getRokcetAuth(req);
if (!auth) return res.status(401).json({ success: false, code: 'AUTH_REQUIRED', error: 'Rokcet authentication required' });
(req as any).rokcetAuth = auth;
next();
} catch (error: any) {
res.status(500).json({ success: false, code: 'AUTH_CONFIGURATION_ERROR', error: error?.message || 'Authentication configuration error' });
}
}

View File

@@ -9,23 +9,41 @@ import previewRouter from './routes/preview';
import artifactsRouter from './routes/artifacts';
import tenantRouter from './routes/tenant';
import bucketsRouter from './routes/buckets';
import { requireRokcetAuth } from './auth';
import { developerMcpClientMetadata } from './services/bucketsMcpOAuth';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export function createApp() {
const app = express();
app.set('trust proxy', 1);
app.use(express.json({ limit: '25mb' }));
// API & Service Routes
app.use('/api', healthRouter);
app.use('/api', chatgptRouter);
app.use('/api', artifactsRouter);
app.use('/api', tenantRouter);
app.use('/api', bucketsRouter);
// Public OAuth Client ID Metadata Document for Buckets Developer MCP. In
// production this HTTPS URL is itself Rokcet's public OAuth client_id.
app.get('/.well-known/buckets-developer-mcp-client', (_req, res) => {
res.setHeader('Content-Type', 'application/client-metadata+json');
res.setHeader('Cache-Control', 'public, max-age=300');
res.json(developerMcpClientMetadata());
});
// Preview Server Routes (/api/preview/sync and /preview/*)
// Public health and authentication/session establishment. tenantRouter applies
// authentication internally to its workspace/deployment routes.
app.use('/api', healthRouter);
app.use('/api', tenantRouter);
// Rokcet workspace capabilities are product-session protected. A Buckets central
// SSO session is exchanged for a local Rokcet session before reaching these.
app.use('/api', requireRokcetAuth, chatgptRouter);
app.use('/api', requireRokcetAuth, artifactsRouter);
app.use('/api', requireRokcetAuth, bucketsRouter);
// Preview API and rendered sandbox are private to the signed-in workspace,
// without putting the public landing page/static assets behind auth.
app.use('/api/preview', requireRokcetAuth);
app.use('/preview', requireRokcetAuth);
app.use(previewRouter);
return app;

View File

@@ -1,5 +1,14 @@
import crypto from 'crypto';
import express from 'express';
import { getBucketsMcpConnection, revokeBucketsMcpConnection, updateBucketsMcpContext } from '../services/rokcetDb';
import {
accessTokenForWorkspace,
appUrl,
createDeveloperMcpAuthorization,
disconnectDeveloperMcp,
exchangeDeveloperMcpCallback,
requestedWorkspaceId,
} from '../services/bucketsMcpOAuth';
const bucketsRouter = express.Router();
@@ -25,7 +34,7 @@ function publicConfig() {
bucketId: env('BUCKETS_BUCKET_ID') || null,
hasCustomerToken: Boolean(env('BUCKETS_CUSTOMER_TOKEN')),
hasOperatorToken: Boolean(env('BUCKETS_OPERATOR_TOKEN')),
hasDeveloperMcpToken: Boolean(env('BUCKETS_DEVELOPER_MCP_TOKEN')),
developerMcpUsesOAuth: true,
hasOAuthClient: Boolean(env('BUCKETS_OAUTH_CLIENT_ID') && env('BUCKETS_OAUTH_CLIENT_SECRET')),
};
}
@@ -89,12 +98,36 @@ function parseMcpBody(text: string) {
return { raw: trimmed };
}
async function callDeveloperMcp(toolName: string, parameters: Record<string, unknown> = {}) {
requireConfig(['BUCKETS_DEVELOPER_MCP_TOKEN']);
function requireWorkspace(req: express.Request) {
const auth = (req as any).rokcetAuth;
if (!auth) {
const error: any = new Error('Rokcet authentication required');
error.status = 401;
error.code = 'AUTH_REQUIRED';
throw error;
}
if (auth.isDemo) {
const error: any = new Error('Demo sessions cannot authorize a real Buckets App');
error.status = 403;
error.code = 'DEMO_MCP_DISABLED';
throw error;
}
const requested = requestedWorkspaceId(req) || auth.workspaces?.[0]?.id || '';
const workspace = auth.workspaces?.find((item: any) => item.id === requested);
if (!workspace) {
const error: any = new Error('Rokcet workspace does not belong to the authenticated developer');
error.status = 403;
error.code = 'WORKSPACE_ACCESS_DENIED';
throw error;
}
return { auth, workspace };
}
async function callDeveloperMcpWithToken(token: string, toolName: string, parameters: Record<string, unknown> = {}) {
const response = await fetch(env('BUCKETS_DEVELOPER_MCP_URL') || DEFAULT_MCP_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env('BUCKETS_DEVELOPER_MCP_TOKEN')}`,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': '2026-07-28',
@@ -131,6 +164,24 @@ async function callDeveloperMcp(toolName: string, parameters: Record<string, unk
return result;
}
async function callDeveloperMcp(req: express.Request, toolName: string, parameters: Record<string, unknown> = {}) {
const { auth, workspace } = requireWorkspace(req);
const resolved = await accessTokenForWorkspace(auth.user.id, workspace.id);
try {
return await callDeveloperMcpWithToken(resolved.token, toolName, parameters);
} catch (cause: any) {
if (Number(cause?.status) === 401) {
revokeBucketsMcpConnection(auth.user.id, workspace.id);
const error: any = new Error('Buckets MCP authorization was revoked or is no longer valid. Reconnect this workspace.');
error.status = 401;
error.code = 'BUCKETS_MCP_RECONNECT_REQUIRED';
error.payload = cause?.payload;
throw error;
}
throw cause;
}
}
async function waitForAgentReply(conversationId: string, customerToken: string, runId?: string | null) {
for (let attempt = 0; attempt < 18; attempt += 1) {
const detail: any = await bucketsFetch(`${CUSTOMER_PREFIX}/conversations/${conversationId}`, {}, customerToken);
@@ -161,9 +212,96 @@ function respondError(res: express.Response, error: any) {
}
// Integration status. This intentionally reports configuration rather than fabricating Buckets state.
bucketsRouter.get('/buckets/status', async (_req, res) => {
const config = publicConfig();
res.json({ success: true, data: config });
bucketsRouter.get('/buckets/status', async (req, res) => {
try {
const config = publicConfig();
const auth = (req as any).rokcetAuth;
if (auth?.isDemo) {
return res.json({ success: true, data: config, developerMcp: { connected: false, demo: true } });
}
const { workspace } = requireWorkspace(req);
const connection = getBucketsMcpConnection(auth.user.id, workspace.id);
res.json({
success: true,
data: config,
developerMcp: connection
? {
connected: true,
workspaceId: workspace.id,
appId: connection.appId,
appName: connection.appName,
organizationId: connection.organizationId,
scopes: connection.scopes,
connectedAt: connection.connectedAt,
accessExpiresAt: connection.accessExpiresAt,
}
: { connected: false, workspaceId: workspace.id },
});
} catch (error: any) {
respondError(res, error);
}
});
bucketsRouter.get('/buckets/mcp/connect', async (req, res) => {
try {
const { auth, workspace } = requireWorkspace(req);
const authorizationUrl = await createDeveloperMcpAuthorization({
userId: auth.user.id,
userEmail: auth.user.email,
workspaceId: workspace.id,
appNameHint: typeof req.query.app_name === 'string' ? req.query.app_name : null,
organizationNameHint: typeof req.query.organization_name === 'string' ? req.query.organization_name : null,
requestedCapabilities: typeof req.query.capabilities === 'string'
? req.query.capabilities.split(',').map((item) => item.trim()).filter(Boolean)
: ['customer_auth', 'agent_chat', 'bookings', 'webhooks'],
});
res.redirect(302, authorizationUrl);
} catch (error: any) {
res.redirect(302, `${appUrl()}/?buckets_mcp_error=${encodeURIComponent(error?.message || 'Unable to start Buckets MCP authorization')}`);
}
});
bucketsRouter.get('/buckets/mcp/oauth/callback', async (req, res) => {
try {
const auth = (req as any).rokcetAuth;
if (!auth || auth.isDemo) throw new Error('A real Rokcet developer session is required to finish Buckets MCP authorization');
if (req.query.error) {
const message = String(req.query.error_description || req.query.error || 'Buckets MCP authorization was denied');
return res.redirect(302, `${appUrl()}/?buckets_mcp_error=${encodeURIComponent(message)}`);
}
const state = String(req.query.state || '');
const code = String(req.query.code || '');
if (!state || !code) throw new Error('Buckets MCP callback is missing code or state');
const exchanged = await exchangeDeveloperMcpCallback({
state,
code,
issuer: typeof req.query.iss === 'string' ? req.query.iss : null,
userId: auth.user.id,
});
try {
const app: any = await callDeveloperMcpWithToken(exchanged.accessToken, 'buckets_app_get', {});
updateBucketsMcpContext(auth.user.id, exchanged.connection.workspaceId, {
appId: app?.id ? String(app.id) : null,
appName: app?.name ? String(app.name) : null,
organizationId: app?.organization_id ? String(app.organization_id) : null,
});
} catch {
// The OAuth grant remains valid even if the optional display-context fetch fails.
}
return res.redirect(302, `${appUrl()}/?buckets_mcp=connected&workspace_id=${encodeURIComponent(exchanged.connection.workspaceId)}`);
} catch (error: any) {
return res.redirect(302, `${appUrl()}/?buckets_mcp_error=${encodeURIComponent(error?.message || 'Buckets MCP authorization failed')}`);
}
});
bucketsRouter.post('/buckets/mcp/disconnect', async (req, res) => {
try {
const { auth, workspace } = requireWorkspace(req);
await disconnectDeveloperMcp(auth.user.id, workspace.id);
return res.json({ success: true, connected: false, workspaceId: workspace.id });
} catch (error: any) {
return respondError(res, error);
}
});
// Health / diagnostics ping against the actual Buckets API origin.
@@ -215,7 +353,7 @@ bucketsRouter.post('/buckets/mcp/execute', async (req, res) => {
});
}
try {
const result = await callDeveloperMcp(toolName, parameters || {});
const result = await callDeveloperMcp(req, toolName, parameters || {});
return res.json({ success: true, toolName, result });
} catch (error: any) {
return respondError(res, error);
@@ -226,7 +364,7 @@ bucketsRouter.post('/buckets/mcp/execute', async (req, res) => {
bucketsRouter.post('/buckets/oauth/provision', async (req, res) => {
const { appName, selectedScopes, redirectUri } = req.body || {};
try {
const result: any = await callDeveloperMcp('buckets_oauth_client_ensure', {
const result: any = await callDeveloperMcp(req, 'buckets_oauth_client_ensure', {
name: appName || 'Rokcet generated application',
redirect_uris: [redirectUri || 'http://localhost:5173/api/auth/callback'],
post_logout_redirect_uris: [],
@@ -239,7 +377,7 @@ bucketsRouter.post('/buckets/oauth/provision', async (req, res) => {
res.json({
success: true,
provisioned: result,
note: 'OAuth Client was ensured in the App authorized by the Developer MCP token. Secrets are never fabricated by Rokcet.',
note: "OAuth Client was ensured in the App authorized by this workspace's Developer MCP OAuth grant. Secrets are never fabricated by Rokcet.",
});
} catch (error: any) {
respondError(res, error);
@@ -436,7 +574,7 @@ bucketsRouter.post('/buckets/compositions/apply', async (req, res) => {
const steps: any[] = [];
const run = async (toolName: string, parameters: Record<string, unknown> = {}) => {
const result = await callDeveloperMcp(toolName, parameters);
const result = await callDeveloperMcp(req, toolName, parameters);
steps.push({ tool: toolName, status: 'completed', result });
return result;
};

View File

@@ -1,109 +1,160 @@
import crypto from 'crypto';
import { Router } from 'express';
import {
SESSION_COOKIE,
SSO_STATE_COOKIE,
clearSessionCookie,
clearSsoStateCookie,
getRokcetAuth,
newDemoSessionToken,
parseCookies,
requireRokcetAuth,
setSessionCookie,
setSsoStateCookie,
} from '../auth';
import { createSession, createWorkspace, revokeSession, upsertBucketsUser } from '../services/rokcetDb';
export const tenantRouter = Router();
// In-memory / PostgreSQL persistent state cache
const multiTenantStore = {
users: [
{
id: 'usr-1',
email: 'developer@rokcet.dev',
name: 'Demo Developer',
role: 'admin',
createdAt: new Date().toISOString(),
},
],
workspaces: [
{
id: 'ws-1',
name: 'Rokcet Cloud Workspace',
description: 'Primary multi-tenant workspace for rokcet.dev AI studio',
ownerId: 'usr-1',
createdAt: new Date().toISOString(),
},
],
deployments: [] as any[],
};
function env(name: string, fallback = '') {
return process.env[name]?.trim() || fallback;
}
// 1. Auth Login / Register Endpoint
tenantRouter.post('/auth/login', (req, res) => {
const { email, name } = req.body;
const userEmail = email || 'developer@rokcet.dev';
let user = multiTenantStore.users.find((u) => u.email === userEmail);
if (!user) {
user = {
id: `usr-${Date.now()}`,
email: userEmail,
name: name || userEmail.split('@')[0],
role: 'admin',
createdAt: new Date().toISOString(),
};
multiTenantStore.users.push(user);
function ssoConfig() {
const identityUrl = env('BUCKETS_IDENTITY_URL', env('BUCKETS_API_URL', 'https://auth.buckets.id')).replace(/\/+$/, '');
const clientId = env('ROKCET_SSO_CLIENT_ID', 'rokcet.dev');
const clientSecret = env('ROKCET_SSO_CLIENT_SECRET');
const redirectUri = env('ROKCET_SSO_REDIRECT_URI', 'http://localhost:3000/api/auth/sso/callback');
if (!clientSecret) {
const error: any = new Error('ROKCET_SSO_CLIENT_SECRET is required for Buckets developer SSO');
error.status = 503;
throw error;
}
return { identityUrl, clientId, clientSecret, redirectUri };
}
res.json({
success: true,
user,
token: `jwt-token-rokcet-${user.id}`,
workspaces: multiTenantStore.workspaces,
});
function safeAppUrl() {
return env('APP_URL', env('ROKCET_PUBLIC_URL', 'http://localhost:3000')).replace(/\/+$/, '');
}
function errorRedirect(message: string) {
return `${safeAppUrl()}/?auth_error=${encodeURIComponent(message)}`;
}
tenantRouter.get('/auth/sso/start', (req, res) => {
try {
const { identityUrl, clientId, redirectUri } = ssoConfig();
const state = crypto.randomBytes(32).toString('base64url');
setSsoStateCookie(res, state);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
state,
});
if (req.query.mode === 'register') params.set('screen_hint', 'signup');
res.redirect(302, `${identityUrl}/developer/sso/authorize?${params.toString()}`);
} catch (error: any) {
res.redirect(302, errorRedirect(error?.message || 'Unable to start Buckets SSO'));
}
});
tenantRouter.get('/auth/sso/callback', async (req, res) => {
try {
const code = String(req.query.code || '');
const state = String(req.query.state || '');
const expectedState = parseCookies(req)[SSO_STATE_COOKIE] || '';
clearSsoStateCookie(res);
if (!code || !state || !expectedState || state.length !== expectedState.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(expectedState))) {
return res.redirect(302, errorRedirect('Invalid or expired SSO state'));
}
const { identityUrl, clientId, clientSecret, redirectUri } = ssoConfig();
const response = await fetch(`${identityUrl}/developer/sso/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
}),
signal: AbortSignal.timeout(10000),
});
const payload: any = await response.json().catch(() => ({}));
if (!response.ok || !payload?.user?.id || !payload?.user?.email) {
const detail = payload?.error?.message || payload?.detail || payload?.error || `Buckets SSO returned ${response.status}`;
throw new Error(typeof detail === 'string' ? detail : 'Buckets SSO token exchange failed');
}
const local = upsertBucketsUser({ id: String(payload.user.id), email: String(payload.user.email) });
const session = createSession(local.user.id, 30);
setSessionCookie(res, session.token, 30 * 86400);
res.redirect(302, `${safeAppUrl()}/?auth=sso`);
} catch (error: any) {
res.redirect(302, errorRedirect(error?.message || 'Buckets SSO failed'));
}
});
tenantRouter.post('/auth/demo', (_req, res) => {
const token = newDemoSessionToken();
setSessionCookie(res, token, 8 * 60 * 60);
const auth = getRokcetAuth({ headers: { cookie: `${SESSION_COOKIE}=${encodeURIComponent(token)}` } } as any);
res.json({ success: true, authenticated: true, ...auth });
});
tenantRouter.get('/auth/me', (req, res) => {
res.json({
user: multiTenantStore.users[0],
workspaces: multiTenantStore.workspaces,
});
try {
const auth = getRokcetAuth(req);
if (!auth) return res.status(401).json({ authenticated: false });
res.json({ authenticated: true, ...auth });
} catch (error: any) {
res.status(500).json({ authenticated: false, error: error?.message || 'Unable to load session' });
}
});
// 2. Workspaces CRUD
tenantRouter.post('/auth/logout', (req, res) => {
const token = parseCookies(req)[SESSION_COOKIE];
if (token && !token.startsWith('demo.')) revokeSession(token);
clearSessionCookie(res);
res.status(204).end();
});
// Everything below this point requires a local Rokcet product session.
tenantRouter.use(requireRokcetAuth);
tenantRouter.get('/workspaces', (req, res) => {
res.json({
workspaces: multiTenantStore.workspaces,
});
const auth = (req as any).rokcetAuth;
res.json({ workspaces: auth.workspaces });
});
tenantRouter.post('/workspaces', (req, res) => {
const { name, description } = req.body;
const newWorkspace = {
id: `ws-${Date.now()}`,
name: name || 'New Tenant Workspace',
description: description || 'Multi-tenant project workspace',
ownerId: multiTenantStore.users[0]?.id || 'usr-1',
createdAt: new Date().toISOString(),
};
multiTenantStore.workspaces.push(newWorkspace);
res.json({
success: true,
workspace: newWorkspace,
});
const auth = (req as any).rokcetAuth;
if (auth.isDemo) return res.status(403).json({ success: false, code: 'DEMO_READ_ONLY', error: 'Demo workspaces are isolated and are not persisted.' });
const name = String(req.body?.name || '').trim();
if (!name) return res.status(422).json({ success: false, error: 'Workspace name is required' });
const workspace = createWorkspace(auth.user.id, name, String(req.body?.description || ''));
res.status(201).json({ success: true, workspace });
});
// 3. Vercel 1-Click Deploy Proxy Route
tenantRouter.post('/deploy/vercel', (req, res) => {
const auth = (req as any).rokcetAuth;
const { domain, snapshotId, projectName, workspaceId } = req.body;
const deployRecord = {
if (workspaceId && !auth.workspaces.some((workspace: any) => workspace.id === workspaceId)) {
return res.status(403).json({ success: false, code: 'WORKSPACE_ACCESS_DENIED', error: 'Workspace does not belong to the authenticated Rokcet account' });
}
const deployment = {
id: `dep-vercel-${Date.now()}`,
domain: domain || `${projectName || 'app'}.rokcet.dev`,
snapshotId: snapshotId || 'snap-8821',
workspaceId: workspaceId || 'ws-1',
snapshotId: snapshotId || null,
workspaceId: workspaceId || auth.workspaces[0]?.id || null,
status: 'deployed',
deployedAt: new Date().toISOString(),
edgeLocation: 'iad1 (Vercel Anycast Edge)',
sslStatus: 'active_auto_wildcard',
};
multiTenantStore.deployments.push(deployRecord);
res.json({
success: true,
message: 'Deployed successfully to Vercel Edge Infrastructure',
deployment: deployRecord,
});
res.json({ success: true, message: 'Deployed successfully to Vercel Edge Infrastructure', deployment });
});
export default tenantRouter;

View File

@@ -0,0 +1,335 @@
import crypto from 'crypto';
import type { Request } from 'express';
import {
consumeBucketsMcpOAuthState,
getBucketsMcpConnection,
getRegisteredBucketsMcpClient,
revokeBucketsMcpConnection,
saveBucketsMcpOAuthState,
saveRegisteredBucketsMcpClient,
updateBucketsMcpTokens,
upsertBucketsMcpConnection,
type BucketsMcpConnectionRecord,
} from './rokcetDb';
const DEFAULT_IDENTITY_URL = 'https://auth.buckets.id';
const DEFAULT_MCP_RESOURCE = 'https://mcp.buckets.id/mcp';
export const DEFAULT_DEVELOPER_MCP_SCOPES = [
'developer:app:read',
'developer:integration:read',
'developer:integration:write',
'developer:buckets:read',
'developer:buckets:write',
'developer:oauth:read',
'developer:oauth:write',
'developer:credentials:write',
'developer:agents:read',
'developer:agents:write',
'developer:webhooks:read',
'developer:webhooks:write',
'developer:access:read',
'developer:access:write',
'developer:bookings:read',
'developer:bookings:write',
'developer:adapter:read',
'developer:adapter:write',
];
function env(name: string, fallback = ''): string {
return process.env[name]?.trim() || fallback;
}
function trimSlash(value: string): string {
return value.replace(/\/+$/, '');
}
export function appUrl(): string {
return trimSlash(env('APP_URL', env('ROKCET_PUBLIC_URL', 'http://localhost:3000')));
}
export function developerMcpResource(): string {
return env('BUCKETS_DEVELOPER_MCP_URL', DEFAULT_MCP_RESOURCE);
}
export function developerMcpRedirectUri(): string {
return env('ROKCET_MCP_REDIRECT_URI', `${appUrl()}/api/buckets/mcp/oauth/callback`);
}
export function developerMcpClientMetadataUrl(): string {
return env('ROKCET_MCP_CLIENT_ID', `${appUrl()}/.well-known/buckets-developer-mcp-client`);
}
export function developerMcpClientMetadata() {
const clientId = developerMcpClientMetadataUrl();
return {
client_id: clientId,
client_name: 'Rokcet.dev',
application_type: 'web',
redirect_uris: [developerMcpRedirectUri()],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
};
}
interface DeveloperOAuthMetadata {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
registration_endpoint?: string;
revocation_endpoint?: string;
code_challenge_methods_supported?: string[];
scopes_supported?: string[];
}
async function parseJsonResponse(response: Response): Promise<any> {
const text = await response.text();
if (!text) return {};
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
}
export async function loadDeveloperOAuthMetadata(): Promise<DeveloperOAuthMetadata> {
const identityUrl = trimSlash(env('BUCKETS_IDENTITY_URL', env('BUCKETS_API_URL', DEFAULT_IDENTITY_URL)));
const response = await fetch(`${identityUrl}/.well-known/oauth-authorization-server/developer`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
});
const payload = await parseJsonResponse(response);
if (!response.ok || !payload?.issuer || !payload?.authorization_endpoint || !payload?.token_endpoint) {
throw new Error(`Buckets Developer OAuth metadata is unavailable (${response.status})`);
}
if (!Array.isArray(payload.code_challenge_methods_supported) || !payload.code_challenge_methods_supported.includes('S256')) {
throw new Error('Buckets Developer OAuth server does not advertise PKCE S256');
}
return payload as DeveloperOAuthMetadata;
}
async function resolveClientId(metadata: DeveloperOAuthMetadata): Promise<string> {
const explicit = env('ROKCET_MCP_CLIENT_ID');
if (explicit) return explicit;
const metadataClientId = developerMcpClientMetadataUrl();
if (metadataClientId.startsWith('https://')) return metadataClientId;
const redirectUri = developerMcpRedirectUri();
const cached = getRegisteredBucketsMcpClient(metadata.issuer, redirectUri);
if (cached) return cached;
if (!metadata.registration_endpoint) throw new Error('Buckets Developer OAuth registration endpoint is unavailable for local development');
const response = await fetch(metadata.registration_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_name: 'Rokcet.dev local development',
application_type: 'web',
redirect_uris: [redirectUri],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
}),
signal: AbortSignal.timeout(10000),
});
const payload = await parseJsonResponse(response);
if (!response.ok || !payload?.client_id) throw new Error(`Buckets MCP client registration failed (${response.status})`);
saveRegisteredBucketsMcpClient(metadata.issuer, redirectUri, String(payload.client_id));
return String(payload.client_id);
}
function pkceVerifier(): string {
return crypto.randomBytes(64).toString('base64url').slice(0, 96);
}
function pkceChallenge(verifier: string): string {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
export async function createDeveloperMcpAuthorization(input: {
userId: string;
userEmail: string;
workspaceId: string;
appNameHint?: string | null;
organizationNameHint?: string | null;
requestedCapabilities?: string[];
}): Promise<string> {
const metadata = await loadDeveloperOAuthMetadata();
const clientId = await resolveClientId(metadata);
const redirectUri = developerMcpRedirectUri();
const resourceUri = developerMcpResource();
const state = crypto.randomBytes(32).toString('base64url');
const verifier = pkceVerifier();
const scopes = DEFAULT_DEVELOPER_MCP_SCOPES.filter((scope) => !metadata.scopes_supported || metadata.scopes_supported.includes(scope));
saveBucketsMcpOAuthState({
state,
userId: input.userId,
workspaceId: input.workspaceId,
clientId,
redirectUri,
issuer: metadata.issuer,
tokenEndpoint: metadata.token_endpoint,
revocationEndpoint: metadata.revocation_endpoint || null,
resourceUri,
codeVerifier: verifier,
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
});
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
state,
scope: scopes.join(' '),
code_challenge: pkceChallenge(verifier),
code_challenge_method: 'S256',
resource: resourceUri,
login_hint: input.userEmail,
});
if (input.appNameHint?.trim()) params.set('app_name_hint', input.appNameHint.trim());
if (input.organizationNameHint?.trim()) params.set('organization_name_hint', input.organizationNameHint.trim());
if (input.requestedCapabilities?.length) params.set('requested_capabilities', input.requestedCapabilities.join(','));
return `${metadata.authorization_endpoint}?${params.toString()}`;
}
function formBody(values: Record<string, string | undefined>): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values)) if (value !== undefined) params.set(key, value);
return params;
}
async function tokenRequest(endpoint: string, values: Record<string, string | undefined>): Promise<any> {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: formBody(values),
signal: AbortSignal.timeout(10000),
});
const payload = await parseJsonResponse(response);
if (!response.ok || !payload?.access_token || !payload?.refresh_token) {
const detail = payload?.detail || payload?.error_description || payload?.error || `Buckets Developer OAuth token endpoint returned ${response.status}`;
const error: any = new Error(typeof detail === 'string' ? detail : 'Buckets Developer OAuth token exchange failed');
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
export async function exchangeDeveloperMcpCallback(input: {
state: string;
code: string;
issuer?: string | null;
userId: string;
}): Promise<{ connection: BucketsMcpConnectionRecord; accessToken: string }> {
const pending = consumeBucketsMcpOAuthState(input.state, input.userId);
if (!pending) throw new Error('Invalid or expired Buckets MCP OAuth state');
if (input.issuer && input.issuer !== pending.issuer) throw new Error('Buckets MCP OAuth issuer mismatch');
const token = await tokenRequest(pending.tokenEndpoint, {
grant_type: 'authorization_code',
client_id: pending.clientId,
code: input.code,
redirect_uri: pending.redirectUri,
code_verifier: pending.codeVerifier,
resource: pending.resourceUri,
});
const expiresIn = Math.max(60, Number(token.expires_in || 900));
const scopes = String(token.scope || '').split(/\s+/).filter(Boolean);
const connection = upsertBucketsMcpConnection({
userId: pending.userId,
workspaceId: pending.workspaceId,
clientId: pending.clientId,
tokenEndpoint: pending.tokenEndpoint,
revocationEndpoint: pending.revocationEndpoint,
accessToken: String(token.access_token),
refreshToken: String(token.refresh_token),
tokenType: String(token.token_type || 'Bearer'),
scopes,
resourceUri: String(token.resource || pending.resourceUri),
accessExpiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
appId: null,
appName: null,
organizationId: null,
});
return { connection, accessToken: String(token.access_token) };
}
export async function refreshDeveloperMcpConnection(connection: BucketsMcpConnectionRecord): Promise<BucketsMcpConnectionRecord> {
const token = await tokenRequest(connection.tokenEndpoint, {
grant_type: 'refresh_token',
client_id: connection.clientId,
refresh_token: connection.refreshToken,
resource: connection.resourceUri,
});
const expiresIn = Math.max(60, Number(token.expires_in || 900));
const scopes = String(token.scope || '').split(/\s+/).filter(Boolean);
const updated = updateBucketsMcpTokens(connection.userId, connection.workspaceId, {
accessToken: String(token.access_token),
refreshToken: String(token.refresh_token),
tokenType: String(token.token_type || 'Bearer'),
scopes,
accessExpiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
});
if (!updated) throw new Error('Buckets MCP connection disappeared during refresh');
return updated;
}
export async function accessTokenForWorkspace(userId: string, workspaceId: string): Promise<{ token: string; connection: BucketsMcpConnectionRecord }> {
let connection = getBucketsMcpConnection(userId, workspaceId);
if (!connection) {
const error: any = new Error('Connect this Rokcet workspace to a Buckets App first');
error.status = 409;
error.code = 'BUCKETS_MCP_NOT_CONNECTED';
throw error;
}
if (Date.parse(connection.accessExpiresAt) <= Date.now() + 60_000) {
try {
connection = await refreshDeveloperMcpConnection(connection);
} catch (cause: any) {
revokeBucketsMcpConnection(userId, workspaceId);
const error: any = new Error('Buckets MCP authorization expired or was revoked. Reconnect this workspace.');
error.status = 401;
error.code = 'BUCKETS_MCP_RECONNECT_REQUIRED';
error.cause = cause;
throw error;
}
}
return { token: connection.accessToken, connection };
}
export async function disconnectDeveloperMcp(userId: string, workspaceId: string): Promise<void> {
const connection = getBucketsMcpConnection(userId, workspaceId);
if (!connection) return;
if (connection.revocationEndpoint) {
try {
await fetch(connection.revocationEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formBody({ token: connection.accessToken, token_type_hint: 'access_token' }),
signal: AbortSignal.timeout(8000),
});
await fetch(connection.revocationEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formBody({ token: connection.refreshToken, token_type_hint: 'refresh_token' }),
signal: AbortSignal.timeout(8000),
});
} catch {
// Local revocation still wins. The developer can also revoke Rokcet from
// Buckets Developer Settings if a network failure interrupted revocation.
}
}
revokeBucketsMcpConnection(userId, workspaceId);
}
export function requestedWorkspaceId(req: Request): string {
const header = req.header('X-Rokcet-Workspace-Id')?.trim();
const query = typeof req.query.workspace_id === 'string' ? req.query.workspace_id.trim() : '';
const body = typeof req.body?.workspaceId === 'string' ? req.body.workspaceId.trim() : '';
return header || query || body;
}

444
server/services/rokcetDb.ts Normal file
View File

@@ -0,0 +1,444 @@
import crypto from 'crypto';
import path from 'path';
import { mkdirSync } from 'fs';
import { DatabaseSync } from 'node:sqlite';
export interface RokcetUserRecord {
id: string;
bucketsUserId: string;
email: string;
name: string;
createdAt: string;
}
export interface RokcetWorkspaceRecord {
id: string;
name: string;
description: string;
ownerId: string;
createdAt: string;
}
export interface BucketsMcpConnectionRecord {
id: string;
userId: string;
workspaceId: string;
clientId: string;
tokenEndpoint: string;
revocationEndpoint: string | null;
accessToken: string;
refreshToken: string;
tokenType: string;
scopes: string[];
resourceUri: string;
accessExpiresAt: string;
appId: string | null;
appName: string | null;
organizationId: string | null;
connectedAt: string;
updatedAt: string;
}
export interface BucketsMcpOAuthStateRecord {
userId: string;
workspaceId: string;
clientId: string;
redirectUri: string;
issuer: string;
tokenEndpoint: string;
revocationEndpoint: string | null;
resourceUri: string;
codeVerifier: string;
}
let db: DatabaseSync | null = null;
function databasePath() {
const configured = process.env.ROKCET_DATABASE_PATH?.trim();
return configured || path.join(process.cwd(), 'data', 'rokcet.sqlite');
}
function connection(): DatabaseSync {
if (db) return db;
const target = databasePath();
mkdirSync(path.dirname(target), { recursive: true });
db = new DatabaseSync(target);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA foreign_keys = ON;');
db.exec(`
CREATE TABLE IF NOT EXISTS rokcet_users (
id TEXT PRIMARY KEY,
buckets_user_id TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_rokcet_users_buckets_user_id ON rokcet_users(buckets_user_id);
CREATE TABLE IF NOT EXISTS rokcet_workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(owner_user_id) REFERENCES rokcet_users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_rokcet_workspaces_owner_user_id ON rokcet_workspaces(owner_user_id);
CREATE TABLE IF NOT EXISTS rokcet_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked_at TEXT,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES rokcet_users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_rokcet_sessions_user_id ON rokcet_sessions(user_id);
CREATE INDEX IF NOT EXISTS ix_rokcet_sessions_token_hash ON rokcet_sessions(token_hash);
CREATE TABLE IF NOT EXISTS rokcet_buckets_mcp_connections (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
client_id TEXT NOT NULL,
token_endpoint TEXT NOT NULL,
revocation_endpoint TEXT,
access_token_enc TEXT NOT NULL,
refresh_token_enc TEXT NOT NULL,
token_type TEXT NOT NULL,
scopes_json TEXT NOT NULL,
resource_uri TEXT NOT NULL,
access_expires_at TEXT NOT NULL,
app_id TEXT,
app_name TEXT,
organization_id TEXT,
connected_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revoked_at TEXT,
FOREIGN KEY(user_id) REFERENCES rokcet_users(id) ON DELETE CASCADE,
FOREIGN KEY(workspace_id) REFERENCES rokcet_workspaces(id) ON DELETE CASCADE,
UNIQUE(user_id, workspace_id)
);
CREATE INDEX IF NOT EXISTS ix_rokcet_buckets_mcp_connections_user_workspace ON rokcet_buckets_mcp_connections(user_id, workspace_id);
CREATE TABLE IF NOT EXISTS rokcet_buckets_mcp_oauth_states (
state_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
issuer TEXT NOT NULL,
token_endpoint TEXT NOT NULL,
revocation_endpoint TEXT,
resource_uri TEXT NOT NULL,
code_verifier_enc TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES rokcet_users(id) ON DELETE CASCADE,
FOREIGN KEY(workspace_id) REFERENCES rokcet_workspaces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_rokcet_buckets_mcp_oauth_states_user_id ON rokcet_buckets_mcp_oauth_states(user_id);
CREATE TABLE IF NOT EXISTS rokcet_buckets_mcp_clients (
issuer TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
client_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(issuer, redirect_uri)
);
`);
return db;
}
function rowToUser(row: any): RokcetUserRecord {
return {
id: row.id,
bucketsUserId: row.buckets_user_id,
email: row.email,
name: row.name,
createdAt: row.created_at,
};
}
function rowToWorkspace(row: any): RokcetWorkspaceRecord {
return {
id: row.id,
name: row.name,
description: row.description,
ownerId: row.owner_user_id,
createdAt: row.created_at,
};
}
export function hashSessionToken(value: string) {
return crypto.createHash('sha256').update(value).digest('hex');
}
export function upsertBucketsUser(profile: { id: string; email: string }): { user: RokcetUserRecord; workspaces: RokcetWorkspaceRecord[] } {
const conn = connection();
const now = new Date().toISOString();
const email = profile.email.trim().toLowerCase();
const existing = conn.prepare('SELECT * FROM rokcet_users WHERE buckets_user_id = ?').get(profile.id) as any;
let user: RokcetUserRecord;
if (existing) {
conn.prepare('UPDATE rokcet_users SET email = ?, updated_at = ? WHERE id = ?').run(email, now, existing.id);
user = rowToUser({ ...existing, email });
} else {
const id = crypto.randomUUID();
const name = email.split('@')[0] || 'Developer';
conn.prepare('INSERT INTO rokcet_users (id, buckets_user_id, email, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, profile.id, email, name, now, now);
user = { id, bucketsUserId: profile.id, email, name, createdAt: now };
}
let workspaces = listWorkspaces(user.id);
if (workspaces.length === 0) {
createWorkspace(user.id, `${user.name}'s Workspace`, 'Rokcet developer workspace linked to Buckets identity');
workspaces = listWorkspaces(user.id);
}
return { user, workspaces };
}
export function listWorkspaces(userId: string): RokcetWorkspaceRecord[] {
const rows = connection().prepare('SELECT * FROM rokcet_workspaces WHERE owner_user_id = ? ORDER BY created_at').all(userId) as any[];
return rows.map(rowToWorkspace);
}
export function createWorkspace(userId: string, name: string, description?: string): RokcetWorkspaceRecord {
const conn = connection();
const now = new Date().toISOString();
const workspace: RokcetWorkspaceRecord = {
id: crypto.randomUUID(),
name: name.trim(),
description: description?.trim() || 'Rokcet developer workspace',
ownerId: userId,
createdAt: now,
};
conn.prepare('INSERT INTO rokcet_workspaces (id, name, description, owner_user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(workspace.id, workspace.name, workspace.description, userId, now, now);
return workspace;
}
export function createSession(userId: string, days = 30): { token: string; expiresAt: string } {
const conn = connection();
const token = `rkt_sess_${crypto.randomBytes(32).toString('base64url')}`;
const now = new Date();
const expires = new Date(now.getTime() + days * 86400_000);
conn.prepare('INSERT INTO rokcet_sessions (id, user_id, token_hash, expires_at, revoked_at, created_at, last_seen_at) VALUES (?, ?, ?, ?, NULL, ?, ?)')
.run(crypto.randomUUID(), userId, hashSessionToken(token), expires.toISOString(), now.toISOString(), now.toISOString());
return { token, expiresAt: expires.toISOString() };
}
export function resolveSession(token: string): { user: RokcetUserRecord; workspaces: RokcetWorkspaceRecord[] } | null {
const conn = connection();
const row = conn.prepare(`
SELECT u.*, s.id AS session_id, s.expires_at AS session_expires_at
FROM rokcet_sessions s
JOIN rokcet_users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.revoked_at IS NULL
`).get(hashSessionToken(token)) as any;
if (!row || Date.parse(row.session_expires_at) <= Date.now()) return null;
conn.prepare('UPDATE rokcet_sessions SET last_seen_at = ? WHERE id = ?').run(new Date().toISOString(), row.session_id);
const user = rowToUser(row);
return { user, workspaces: listWorkspaces(user.id) };
}
export function revokeSession(token: string): void {
connection().prepare('UPDATE rokcet_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL')
.run(new Date().toISOString(), hashSessionToken(token));
}
const ephemeralTokenEncryptionKey = crypto.randomBytes(32);
function tokenEncryptionKey(): Buffer {
const configured = process.env.ROKCET_SESSION_SECRET?.trim();
if (configured) return crypto.createHash('sha256').update(`rokcet:buckets-mcp:v1:${configured}`).digest();
if (process.env.NODE_ENV === 'production') throw new Error('ROKCET_SESSION_SECRET is required to protect Buckets MCP OAuth tokens');
return ephemeralTokenEncryptionKey;
}
function encryptSecret(value: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', tokenEncryptionKey(), iv);
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${ciphertext.toString('base64url')}`;
}
function decryptSecret(value: string): string {
const [version, ivRaw, tagRaw, ciphertextRaw] = value.split('.');
if (version !== 'v1' || !ivRaw || !tagRaw || !ciphertextRaw) throw new Error('Unsupported encrypted Buckets MCP credential');
const decipher = crypto.createDecipheriv('aes-256-gcm', tokenEncryptionKey(), Buffer.from(ivRaw, 'base64url'));
decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
return Buffer.concat([decipher.update(Buffer.from(ciphertextRaw, 'base64url')), decipher.final()]).toString('utf8');
}
function parseScopes(value: string): string[] {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
function rowToBucketsMcpConnection(row: any): BucketsMcpConnectionRecord {
return {
id: row.id,
userId: row.user_id,
workspaceId: row.workspace_id,
clientId: row.client_id,
tokenEndpoint: row.token_endpoint,
revocationEndpoint: row.revocation_endpoint || null,
accessToken: decryptSecret(row.access_token_enc),
refreshToken: decryptSecret(row.refresh_token_enc),
tokenType: row.token_type || 'Bearer',
scopes: parseScopes(row.scopes_json),
resourceUri: row.resource_uri,
accessExpiresAt: row.access_expires_at,
appId: row.app_id || null,
appName: row.app_name || null,
organizationId: row.organization_id || null,
connectedAt: row.connected_at,
updatedAt: row.updated_at,
};
}
export function saveBucketsMcpOAuthState(input: BucketsMcpOAuthStateRecord & { state: string; expiresAt: string }): void {
const now = new Date().toISOString();
connection().prepare(`
INSERT INTO rokcet_buckets_mcp_oauth_states (
state_hash, user_id, workspace_id, client_id, redirect_uri, issuer, token_endpoint,
revocation_endpoint, resource_uri, code_verifier_enc, expires_at, consumed_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)
`).run(
hashSessionToken(input.state), input.userId, input.workspaceId, input.clientId, input.redirectUri,
input.issuer, input.tokenEndpoint, input.revocationEndpoint, input.resourceUri,
encryptSecret(input.codeVerifier), input.expiresAt, now
);
}
export function consumeBucketsMcpOAuthState(state: string, userId: string): BucketsMcpOAuthStateRecord | null {
const conn = connection();
const stateHash = hashSessionToken(state);
const row = conn.prepare(`
SELECT * FROM rokcet_buckets_mcp_oauth_states
WHERE state_hash = ? AND user_id = ? AND consumed_at IS NULL
`).get(stateHash, userId) as any;
if (!row || Date.parse(row.expires_at) <= Date.now()) return null;
conn.prepare('UPDATE rokcet_buckets_mcp_oauth_states SET consumed_at = ? WHERE state_hash = ?').run(new Date().toISOString(), stateHash);
return {
userId: row.user_id,
workspaceId: row.workspace_id,
clientId: row.client_id,
redirectUri: row.redirect_uri,
issuer: row.issuer,
tokenEndpoint: row.token_endpoint,
revocationEndpoint: row.revocation_endpoint || null,
resourceUri: row.resource_uri,
codeVerifier: decryptSecret(row.code_verifier_enc),
};
}
export function getBucketsMcpConnection(userId: string, workspaceId: string): BucketsMcpConnectionRecord | null {
const row = connection().prepare(`
SELECT * FROM rokcet_buckets_mcp_connections
WHERE user_id = ? AND workspace_id = ? AND revoked_at IS NULL
`).get(userId, workspaceId) as any;
return row ? rowToBucketsMcpConnection(row) : null;
}
export function upsertBucketsMcpConnection(input: Omit<BucketsMcpConnectionRecord, 'id' | 'connectedAt' | 'updatedAt'>): BucketsMcpConnectionRecord {
const conn = connection();
const now = new Date().toISOString();
const existing = conn.prepare('SELECT id, connected_at FROM rokcet_buckets_mcp_connections WHERE user_id = ? AND workspace_id = ?').get(input.userId, input.workspaceId) as any;
const id = existing?.id || crypto.randomUUID();
const connectedAt = existing?.connected_at || now;
conn.prepare(`
INSERT INTO rokcet_buckets_mcp_connections (
id, user_id, workspace_id, client_id, token_endpoint, revocation_endpoint,
access_token_enc, refresh_token_enc, token_type, scopes_json, resource_uri,
access_expires_at, app_id, app_name, organization_id, connected_at, updated_at, revoked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(user_id, workspace_id) DO UPDATE SET
client_id = excluded.client_id,
token_endpoint = excluded.token_endpoint,
revocation_endpoint = excluded.revocation_endpoint,
access_token_enc = excluded.access_token_enc,
refresh_token_enc = excluded.refresh_token_enc,
token_type = excluded.token_type,
scopes_json = excluded.scopes_json,
resource_uri = excluded.resource_uri,
access_expires_at = excluded.access_expires_at,
app_id = excluded.app_id,
app_name = excluded.app_name,
organization_id = excluded.organization_id,
updated_at = excluded.updated_at,
revoked_at = NULL
`).run(
id, input.userId, input.workspaceId, input.clientId, input.tokenEndpoint, input.revocationEndpoint,
encryptSecret(input.accessToken), encryptSecret(input.refreshToken), input.tokenType,
JSON.stringify(input.scopes), input.resourceUri, input.accessExpiresAt, input.appId,
input.appName, input.organizationId, connectedAt, now
);
return getBucketsMcpConnection(input.userId, input.workspaceId)!;
}
export function updateBucketsMcpTokens(userId: string, workspaceId: string, input: {
accessToken: string;
refreshToken: string;
tokenType: string;
scopes: string[];
accessExpiresAt: string;
}): BucketsMcpConnectionRecord | null {
connection().prepare(`
UPDATE rokcet_buckets_mcp_connections
SET access_token_enc = ?, refresh_token_enc = ?, token_type = ?, scopes_json = ?, access_expires_at = ?, updated_at = ?
WHERE user_id = ? AND workspace_id = ? AND revoked_at IS NULL
`).run(
encryptSecret(input.accessToken), encryptSecret(input.refreshToken), input.tokenType,
JSON.stringify(input.scopes), input.accessExpiresAt, new Date().toISOString(), userId, workspaceId
);
return getBucketsMcpConnection(userId, workspaceId);
}
export function updateBucketsMcpContext(userId: string, workspaceId: string, context: {
appId?: string | null;
appName?: string | null;
organizationId?: string | null;
}): BucketsMcpConnectionRecord | null {
connection().prepare(`
UPDATE rokcet_buckets_mcp_connections
SET app_id = COALESCE(?, app_id), app_name = COALESCE(?, app_name), organization_id = COALESCE(?, organization_id), updated_at = ?
WHERE user_id = ? AND workspace_id = ? AND revoked_at IS NULL
`).run(context.appId || null, context.appName || null, context.organizationId || null, new Date().toISOString(), userId, workspaceId);
return getBucketsMcpConnection(userId, workspaceId);
}
export function revokeBucketsMcpConnection(userId: string, workspaceId: string): void {
const now = new Date().toISOString();
connection().prepare(`
UPDATE rokcet_buckets_mcp_connections SET revoked_at = ?, updated_at = ?
WHERE user_id = ? AND workspace_id = ? AND revoked_at IS NULL
`).run(now, now, userId, workspaceId);
}
export function getRegisteredBucketsMcpClient(issuer: string, redirectUri: string): string | null {
const row = connection().prepare('SELECT client_id FROM rokcet_buckets_mcp_clients WHERE issuer = ? AND redirect_uri = ?').get(issuer, redirectUri) as any;
return row?.client_id ? String(row.client_id) : null;
}
export function saveRegisteredBucketsMcpClient(issuer: string, redirectUri: string, clientId: string): void {
connection().prepare(`
INSERT INTO rokcet_buckets_mcp_clients (issuer, redirect_uri, client_id, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(issuer, redirect_uri) DO UPDATE SET client_id = excluded.client_id
`).run(issuer, redirectUri, clientId, new Date().toISOString());
}

View File

@@ -32,7 +32,7 @@ import { PloiMcpDeployView } from './components/PloiMcpDeployView';
import { NewWorkspaceModal } from './components/NewWorkspaceModal';
import { NewProjectModal } from './components/NewProjectModal';
import { LandingView } from './components/LandingView';
import { AuthModal } from './components/AuthModal';
import { AuthModal, type RokcetAuthSession, type RokcetAuthUser } from './components/AuthModal';
import { BucketsIntegrationView } from './components/BucketsIntegrationView';
function loadStorage<T>(key: string, fallback: T): T {
@@ -95,111 +95,160 @@ function sanitizeAndDeduplicateArtifacts(rawArtifacts: Artifact[]): Artifact[] {
}
export default function App() {
const [workspaces, setWorkspaces] = useState<Workspace[]>(() =>
loadStorage('rokcet_workspaces', INITIAL_WORKSPACES)
const [workspaces, setWorkspaces] = useState<Workspace[]>(INITIAL_WORKSPACES);
const [activeWorkspace, setActiveWorkspace] = useState<Workspace>(INITIAL_WORKSPACES[0]);
const [projects, setProjects] = useState<Project[]>(INITIAL_PROJECTS);
const [activeProject, setActiveProject] = useState<Project | null>(
INITIAL_PROJECTS.find((p) => p.workspaceId === INITIAL_WORKSPACES[0].id) || null
);
const [activeWorkspace, setActiveWorkspace] = useState<Workspace>(() => {
const saved = loadStorage<Workspace | null>('rokcet_active_workspace', null);
if (saved) return saved;
return INITIAL_WORKSPACES[0];
});
const [projects, setProjects] = useState<Project[]>(() =>
loadStorage('rokcet_projects', INITIAL_PROJECTS)
const [artifacts, setArtifacts] = useState<Artifact[]>(sanitizeAndDeduplicateArtifacts(INITIAL_ARTIFACTS));
const [selectedArtifactId, setSelectedArtifactId] = useState<string | null>(INITIAL_ARTIFACTS[0]?.id || null);
const [snapshots, setSnapshots] = useState<SnapshotBinding[]>(INITIAL_SNAPSHOTS);
const [activeSnapshotId, setActiveSnapshotId] = useState<string | null>(INITIAL_SNAPSHOTS[0]?.id || null);
const [sessions, setSessions] = useState<Session[]>(INITIAL_SESSIONS);
const [activeSession, setActiveSession] = useState<Session | null>(
INITIAL_SESSIONS.find((sess) => sess.projectId === INITIAL_PROJECTS[0]?.id) || null
);
const [activeProject, setActiveProject] = useState<Project | null>(() => {
const saved = loadStorage<Project | null>('rokcet_active_project', null);
if (saved) return saved;
return INITIAL_PROJECTS.find((p) => p.workspaceId === INITIAL_WORKSPACES[0].id) || null;
});
const [artifacts, setArtifacts] = useState<Artifact[]>(() => {
const loaded = loadStorage('rokcet_artifacts', INITIAL_ARTIFACTS);
return sanitizeAndDeduplicateArtifacts(loaded);
});
const [selectedArtifactId, setSelectedArtifactId] = useState<string | null>(() => {
const saved = loadStorage<string | null>('rokcet_selected_artifact_id', null);
return saved || INITIAL_ARTIFACTS[0]?.id || null;
});
const [snapshots, setSnapshots] = useState<SnapshotBinding[]>(() =>
loadStorage('rokcet_snapshots', INITIAL_SNAPSHOTS)
);
const [activeSnapshotId, setActiveSnapshotId] = useState<string | null>(() => {
const saved = loadStorage<string | null>('rokcet_active_snapshot_id', null);
return saved || INITIAL_SNAPSHOTS[0]?.id || null;
});
const [sessions, setSessions] = useState<Session[]>(() =>
loadStorage('rokcet_sessions', INITIAL_SESSIONS)
);
const [activeSession, setActiveSession] = useState<Session | null>(() => {
const saved = loadStorage<Session | null>('rokcet_active_session', null);
if (saved) return saved;
return INITIAL_SESSIONS.find((s) => s.projectId === INITIAL_PROJECTS[0].id) || null;
});
// LocalStorage Persistence Effects
React.useEffect(() => {
try { localStorage.setItem('rokcet_workspaces', JSON.stringify(workspaces)); } catch (e) {}
}, [workspaces]);
React.useEffect(() => {
try { localStorage.setItem('rokcet_active_workspace', JSON.stringify(activeWorkspace)); } catch (e) {}
}, [activeWorkspace]);
React.useEffect(() => {
try { localStorage.setItem('rokcet_projects', JSON.stringify(projects)); } catch (e) {}
}, [projects]);
React.useEffect(() => {
try { localStorage.setItem('rokcet_active_project', JSON.stringify(activeProject)); } catch (e) {}
}, [activeProject]);
React.useEffect(() => {
try { localStorage.setItem('rokcet_artifacts', JSON.stringify(artifacts)); } catch (e) {}
}, [artifacts]);
React.useEffect(() => {
try { localStorage.setItem('rokcet_selected_artifact_id', JSON.stringify(selectedArtifactId)); } catch (e) {}
}, [selectedArtifactId]);
React.useEffect(() => {
try { localStorage.setItem('rokcet_snapshots', JSON.stringify(snapshots)); } catch (e) {}
}, [snapshots]);
React.useEffect(() => {
try { localStorage.setItem('rokcet_active_snapshot_id', JSON.stringify(activeSnapshotId)); } catch (e) {}
}, [activeSnapshotId]);
React.useEffect(() => {
try { localStorage.setItem('rokcet_sessions', JSON.stringify(sessions)); } catch (e) {}
}, [sessions]);
React.useEffect(() => {
try { localStorage.setItem('rokcet_active_session', JSON.stringify(activeSession)); } catch (e) {}
}, [activeSession]);
const [credentials, setCredentials] = useState<LlmEngineConfig>(DEFAULT_CREDENTIALS);
// View state: 'app' (Workspace Dashboard) vs 'landing' (Public Showcase Portal)
const [currentView, setCurrentView] = useState<'app' | 'landing'>('app');
const [currentUser, setCurrentUser] = useState<{ email: string; name: string } | null>(() =>
loadStorage<{ email: string; name: string } | null>('rokcet_user', {
email: 'developer@rokcet.dev',
name: 'Demo Developer',
})
);
// Authentication is server-authoritative. Browser storage is never used to infer
// identity and is namespaced by the authenticated Buckets/Rokcet user id.
const [currentView, setCurrentView] = useState<'app' | 'landing'>('landing');
const [currentUser, setCurrentUser] = useState<RokcetAuthUser | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
const [authModalMode, setAuthModalMode] = useState<'login' | 'register'>('login');
const [authError, setAuthError] = useState('');
const userStorageKey = React.useCallback((userId: string, key: string) => `rokcet_user:${userId}:${key}`, []);
const hydrateAuthenticatedSession = React.useCallback((session: RokcetAuthSession) => {
const user = session.user;
const serverWorkspaces: Workspace[] = (session.workspaces || []).map((workspace, index) => ({
id: workspace.id,
name: workspace.name,
description: workspace.description,
createdAt: workspace.createdAt,
color: index % 2 === 0 ? 'from-emerald-500 to-teal-700' : 'from-amber-400 to-orange-600',
}));
const effectiveWorkspaces = serverWorkspaces.length > 0 ? serverWorkspaces : INITIAL_WORKSPACES;
const firstWorkspace = effectiveWorkspaces[0];
const key = (name: string) => userStorageKey(user.id, name);
const cachedWorkspaces = loadStorage<Workspace[]>(key('workspaces'), effectiveWorkspaces);
const workspaceUiById = new Map(cachedWorkspaces.map((workspace) => [workspace.id, workspace]));
const mergedWorkspaces = effectiveWorkspaces.map((workspace) => ({
...workspaceUiById.get(workspace.id),
...workspace,
}));
const defaultProjects = INITIAL_PROJECTS.map((project) =>
project.workspaceId === INITIAL_WORKSPACES[0].id ? { ...project, workspaceId: firstWorkspace.id } : project
);
const loadedProjects = loadStorage<Project[]>(key('projects'), defaultProjects);
const loadedArtifacts = sanitizeAndDeduplicateArtifacts(loadStorage<Artifact[]>(key('artifacts'), INITIAL_ARTIFACTS));
const loadedSnapshots = loadStorage<SnapshotBinding[]>(key('snapshots'), INITIAL_SNAPSHOTS);
const loadedSessions = loadStorage<Session[]>(key('sessions'), INITIAL_SESSIONS);
const savedWorkspace = loadStorage<Workspace | null>(key('active_workspace'), null);
const nextWorkspace = mergedWorkspaces.find((workspace) => workspace.id === savedWorkspace?.id) || mergedWorkspaces[0];
const savedProject = loadStorage<Project | null>(key('active_project'), null);
const nextProject = loadedProjects.find((project) => project.id === savedProject?.id)
|| loadedProjects.find((project) => project.workspaceId === nextWorkspace.id)
|| null;
const savedSession = loadStorage<Session | null>(key('active_session'), null);
const nextSession = loadedSessions.find((sessionItem) => sessionItem.id === savedSession?.id)
|| (nextProject ? loadedSessions.find((sessionItem) => sessionItem.projectId === nextProject.id) : null)
|| null;
setCurrentUser(user);
setWorkspaces(mergedWorkspaces);
setActiveWorkspace(nextWorkspace);
setProjects(loadedProjects);
setActiveProject(nextProject);
setArtifacts(loadedArtifacts);
setSelectedArtifactId(loadStorage<string | null>(key('selected_artifact_id'), loadedArtifacts[0]?.id || null));
setSnapshots(loadedSnapshots);
setActiveSnapshotId(loadStorage<string | null>(key('active_snapshot_id'), loadedSnapshots[0]?.id || null));
setSessions(loadedSessions);
setActiveSession(nextSession);
setCurrentView('app');
setAuthError('');
}, [userStorageKey]);
React.useEffect(() => {
if (currentUser) {
try { localStorage.setItem('rokcet_user', JSON.stringify(currentUser)); } catch (e) {}
const params = new URLSearchParams(window.location.search);
const callbackError = params.get('auth_error');
if (callbackError) {
setAuthError(callbackError);
setAuthModalMode('login');
setIsAuthModalOpen(true);
}
}, [currentUser]);
fetch('/api/auth/me', { credentials: 'include' })
.then(async (response) => {
if (!response.ok) return null;
return response.json() as Promise<RokcetAuthSession>;
})
.then((session) => {
if (session?.authenticated) hydrateAuthenticatedSession(session);
else setCurrentView('landing');
})
.catch(() => setCurrentView('landing'))
.finally(() => {
setAuthLoading(false);
if (params.has('auth') || params.has('auth_error')) {
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
}
});
}, [hydrateAuthenticatedSession]);
React.useEffect(() => {
if (!currentUser) return;
try { localStorage.setItem(userStorageKey(currentUser.id, 'workspaces'), JSON.stringify(workspaces)); } catch {}
}, [workspaces, currentUser, userStorageKey]);
React.useEffect(() => {
if (!currentUser) return;
try { localStorage.setItem(userStorageKey(currentUser.id, 'active_workspace'), JSON.stringify(activeWorkspace)); } catch {}
}, [activeWorkspace, currentUser, userStorageKey]);
React.useEffect(() => {
if (!currentUser) return;
try { localStorage.setItem(userStorageKey(currentUser.id, 'projects'), JSON.stringify(projects)); } catch {}
}, [projects, currentUser, userStorageKey]);
React.useEffect(() => {
if (!currentUser) return;
try { localStorage.setItem(userStorageKey(currentUser.id, 'active_project'), JSON.stringify(activeProject)); } catch {}
}, [activeProject, currentUser, userStorageKey]);
React.useEffect(() => {
if (!currentUser) return;
try { localStorage.setItem(userStorageKey(currentUser.id, 'artifacts'), JSON.stringify(artifacts)); } catch {}
}, [artifacts, currentUser, userStorageKey]);
React.useEffect(() => {
if (!currentUser) return;
try { localStorage.setItem(userStorageKey(currentUser.id, 'selected_artifact_id'), JSON.stringify(selectedArtifactId)); } catch {}
}, [selectedArtifactId, currentUser, userStorageKey]);
React.useEffect(() => {
if (!currentUser) return;
try { localStorage.setItem(userStorageKey(currentUser.id, 'snapshots'), JSON.stringify(snapshots)); } catch {}
}, [snapshots, currentUser, userStorageKey]);
React.useEffect(() => {
if (!currentUser) return;
try { localStorage.setItem(userStorageKey(currentUser.id, 'active_snapshot_id'), JSON.stringify(activeSnapshotId)); } catch {}
}, [activeSnapshotId, currentUser, userStorageKey]);
React.useEffect(() => {
if (!currentUser) return;
try { localStorage.setItem(userStorageKey(currentUser.id, 'sessions'), JSON.stringify(sessions)); } catch {}
}, [sessions, currentUser, userStorageKey]);
React.useEffect(() => {
if (!currentUser) return;
try { localStorage.setItem(userStorageKey(currentUser.id, 'active_session'), JSON.stringify(activeSession)); } catch {}
}, [activeSession, currentUser, userStorageKey]);
const [activeTab, setActiveTab] = useState<ActiveTabType>('chat');
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.has('buckets_mcp') || params.has('buckets_mcp_error')) setActiveTab('buckets');
}, []);
const [isExecuting, setIsExecuting] = useState(false);
const [areSidebarsOpen, setAreSidebarsOpen] = useState(true);
const [isArtifactsExpanded, setIsArtifactsExpanded] = useState(false);
@@ -681,24 +730,24 @@ export default function App() {
};
// Auth Success Handler
const handleAuthSuccess = (user: { email: string; name: string }, workspaceName?: string) => {
setCurrentUser(user);
if (workspaceName) {
const existingWs = workspaces.find((w) => w.name.toLowerCase() === workspaceName.toLowerCase());
if (existingWs) {
setActiveWorkspace(existingWs);
} else {
const newWs: Workspace = {
id: `ws-${Date.now()}`,
name: workspaceName,
description: `Tenant workspace for ${user.name}`,
createdAt: new Date().toISOString(),
};
setWorkspaces((prev) => [...prev, newWs]);
setActiveWorkspace(newWs);
}
}
setCurrentView('app');
const handleAuthSuccess = (session: RokcetAuthSession) => {
hydrateAuthenticatedSession(session);
};
const handleLogout = async () => {
try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); } catch {}
setCurrentUser(null);
setCurrentView('landing');
setWorkspaces(INITIAL_WORKSPACES);
setActiveWorkspace(INITIAL_WORKSPACES[0]);
setProjects(INITIAL_PROJECTS);
setActiveProject(INITIAL_PROJECTS[0] || null);
setArtifacts(sanitizeAndDeduplicateArtifacts(INITIAL_ARTIFACTS));
setSelectedArtifactId(INITIAL_ARTIFACTS[0]?.id || null);
setSnapshots(INITIAL_SNAPSHOTS);
setActiveSnapshotId(INITIAL_SNAPSHOTS[0]?.id || null);
setSessions(INITIAL_SESSIONS);
setActiveSession(INITIAL_SESSIONS[0] || null);
};
// Cleanup & Deduplication Handler
@@ -706,7 +755,7 @@ export default function App() {
setArtifacts((prev) => {
const cleaned = sanitizeAndDeduplicateArtifacts(prev);
try {
localStorage.setItem('rokcet_artifacts', JSON.stringify(cleaned));
if (currentUser) localStorage.setItem(userStorageKey(currentUser.id, 'artifacts'), JSON.stringify(cleaned));
} catch (e) {}
return cleaned;
});
@@ -777,11 +826,28 @@ export default function App() {
}
};
if (currentView === 'landing') {
if (authLoading) {
return (
<div className="min-h-screen bg-zinc-950 text-white grid place-items-center font-mono">
<div className="text-center">
<div className="text-amber-400 font-black text-sm uppercase tracking-[.25em]">rokcet.dev</div>
<div className="mt-3 text-[10px] text-zinc-500 uppercase font-bold">Checking secure session…</div>
</div>
</div>
);
}
if (currentView === 'landing' || !currentUser) {
return (
<>
<LandingView
onLaunchApp={() => setCurrentView('app')}
onLaunchApp={() => {
if (currentUser) setCurrentView('app');
else {
setAuthModalMode('login');
setIsAuthModalOpen(true);
}
}}
onOpenAuthModal={(mode) => {
setAuthModalMode(mode);
setIsAuthModalOpen(true);
@@ -790,8 +856,9 @@ export default function App() {
/>
<AuthModal
isOpen={isAuthModalOpen}
onClose={() => setIsAuthModalOpen(false)}
onClose={() => { setIsAuthModalOpen(false); setAuthError(''); }}
initialMode={authModalMode}
initialError={authError}
onSuccessAuth={handleAuthSuccess}
/>
</>
@@ -838,6 +905,7 @@ export default function App() {
activeProject={activeProject}
artifacts={projectArtifacts}
onNavigateToArtifactsVault={() => setActiveTab('artifacts')}
currentUser={currentUser}
/>
)}
@@ -858,6 +926,7 @@ export default function App() {
setAuthModalMode(mode);
setIsAuthModalOpen(true);
}}
onLogout={handleLogout}
/>
)}
@@ -939,6 +1008,7 @@ export default function App() {
{activeTab === 'buckets' && (
<BucketsIntegrationView
activeProject={activeProject}
activeWorkspaceId={activeWorkspace.id}
artifacts={projectArtifacts}
onScaffoldAppToProject={handleScaffoldAppToProject}
onCleanDuplicateArtifacts={handleCleanDuplicateArtifacts}
@@ -959,7 +1029,7 @@ export default function App() {
)}
{activeTab === 'inspector' && (
<InspectorView credentials={credentials} session={activeSession} />
<InspectorView credentials={credentials} session={activeSession} currentUser={currentUser} />
)}
{activeTab === 'settings' && (
@@ -986,9 +1056,25 @@ export default function App() {
{isNewWsModalOpen && (
<NewWorkspaceModal
onCreateWorkspace={(newWs) => {
onCreateWorkspace={async ({ name, description }) => {
const response = await fetch('/api/workspaces', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.workspace) throw new Error(payload?.error || 'Unable to create workspace');
const newWs: Workspace = {
id: payload.workspace.id,
name: payload.workspace.name,
description: payload.workspace.description,
createdAt: payload.workspace.createdAt,
color: 'from-emerald-500 to-teal-700',
};
setWorkspaces((prev) => [...prev, newWs]);
setActiveWorkspace(newWs);
return newWs;
}}
onClose={() => setIsNewWsModalOpen(false)}
/>
@@ -1007,8 +1093,9 @@ export default function App() {
<AuthModal
isOpen={isAuthModalOpen}
onClose={() => setIsAuthModalOpen(false)}
onClose={() => { setIsAuthModalOpen(false); setAuthError(''); }}
initialMode={authModalMode}
initialError={authError}
onSuccessAuth={handleAuthSuccess}
/>
</div>

View File

@@ -1,70 +1,68 @@
import React, { useState } from 'react';
import { Rocket, ShieldCheck, Mail, Lock, User, Building2, Check, ArrowRight, X } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { ArrowRight, Braces, Rocket, ShieldCheck, X } from 'lucide-react';
export type RokcetAuthUser = {
id: string;
email: string;
name: string;
isDemo?: boolean;
};
export type RokcetAuthSession = {
authenticated: true;
user: RokcetAuthUser;
workspaces: Array<{ id: string; name: string; description: string; createdAt: string }>;
isDemo: boolean;
};
interface AuthModalProps {
isOpen: boolean;
onClose: () => void;
initialMode?: 'login' | 'register';
onSuccessAuth: (user: { email: string; name: string }, workspaceName?: string) => void;
onSuccessAuth: (session: RokcetAuthSession) => void;
initialError?: string;
}
export function AuthModal({
isOpen,
onClose,
initialMode = 'login',
onSuccessAuth,
}: AuthModalProps) {
export function AuthModal({ isOpen, onClose, initialMode = 'login', onSuccessAuth, initialError = '' }: AuthModalProps) {
const [mode, setMode] = useState<'login' | 'register'>(initialMode);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [workspaceName, setWorkspaceName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (isOpen) {
setMode(initialMode);
setError(initialError);
setIsLoading(false);
}
}, [isOpen, initialMode, initialError]);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const startBucketsSso = () => {
setError('');
if (!email || !email.includes('@')) {
setError('Please enter a valid email address');
return;
}
if (password.length < 4) {
setError('Password must be at least 4 characters');
return;
}
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
const userName = name || email.split('@')[0];
const targetWorkspace = workspaceName || `${userName}'s Workspace`;
onSuccessAuth({ email, name: userName }, targetWorkspace);
onClose();
}, 600);
window.location.assign(`/api/auth/sso/start?mode=${mode === 'register' ? 'register' : 'login'}`);
};
const handleDemoAccess = () => {
const handleDemoAccess = async () => {
setError('');
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
onSuccessAuth(
{ email: 'developer@rokcet.dev', name: 'Demo Developer' },
'Rokcet Cloud Workspace'
);
try {
const response = await fetch('/api/auth/demo', { method: 'POST', credentials: 'include' });
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.authenticated) throw new Error(payload?.error || 'Unable to start demo session');
onSuccessAuth(payload as RokcetAuthSession);
onClose();
}, 400);
} catch (err: any) {
setError(err?.message || 'Unable to start demo session');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 font-mono">
<div className="bg-zinc-950 border border-zinc-800 rounded-lg max-w-md w-full overflow-hidden shadow-2xl text-white">
{/* Header */}
<div className="bg-zinc-900 px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-6 h-6 bg-amber-400 text-black flex items-center justify-center font-black rounded-sm">
@@ -72,143 +70,49 @@ export function AuthModal({
</div>
<div>
<div className="font-black text-sm text-white leading-none">rokcet.dev</div>
<div className="text-[9px] text-zinc-400 font-bold uppercase mt-0.5">
TENANT AUTHENTICATION
</div>
<div className="text-[9px] text-zinc-400 font-bold uppercase mt-0.5">BUCKETS DEVELOPER SSO</div>
</div>
</div>
<button
onClick={onClose}
className="text-zinc-500 hover:text-white transition p-1 cursor-pointer"
>
<button onClick={onClose} className="text-zinc-500 hover:text-white transition p-1 cursor-pointer" aria-label="Close authentication">
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Switcher */}
<div className="grid grid-cols-2 border-b border-zinc-800 text-xs font-bold uppercase bg-zinc-900/50">
<button
onClick={() => { setMode('login'); setError(''); }}
className={`py-2.5 text-center transition cursor-pointer ${
mode === 'login'
? 'bg-zinc-950 text-amber-400 border-b-2 border-amber-400'
: 'text-zinc-400 hover:text-white'
}`}
>
Sign In
</button>
<button
onClick={() => { setMode('register'); setError(''); }}
className={`py-2.5 text-center transition cursor-pointer ${
mode === 'register'
? 'bg-zinc-950 text-amber-400 border-b-2 border-amber-400'
: 'text-zinc-400 hover:text-white'
}`}
>
Register / Workspace
</button>
<button onClick={() => { setMode('login'); setError(''); }} className={`py-2.5 text-center transition cursor-pointer ${mode === 'login' ? 'bg-zinc-950 text-amber-400 border-b-2 border-amber-400' : 'text-zinc-400 hover:text-white'}`}>Sign In</button>
<button onClick={() => { setMode('register'); setError(''); }} className={`py-2.5 text-center transition cursor-pointer ${mode === 'register' ? 'bg-zinc-950 text-amber-400 border-b-2 border-amber-400' : 'text-zinc-400 hover:text-white'}`}>Create Account</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-950/80 border border-red-800 text-red-200 text-xs rounded">
{error}
</div>
)}
<div className="p-6 space-y-4">
{error && <div className="p-3 bg-red-950/80 border border-red-800 text-red-200 text-xs rounded">{error}</div>}
{mode === 'register' && (
<div>
<label className="block text-xs font-bold uppercase text-zinc-400 mb-1">Full Name</label>
<div className="flex items-center bg-zinc-900 border border-zinc-800 rounded px-3 py-2 text-xs">
<User className="w-4 h-4 text-zinc-500 mr-2 shrink-0" />
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Developer"
className="bg-transparent w-full outline-none text-white font-mono"
/>
</div>
</div>
)}
<div>
<label className="block text-xs font-bold uppercase text-zinc-400 mb-1">Email Address</label>
<div className="flex items-center bg-zinc-900 border border-zinc-800 rounded px-3 py-2 text-xs">
<Mail className="w-4 h-4 text-zinc-500 mr-2 shrink-0" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="developer@rokcet.dev"
required
className="bg-transparent w-full outline-none text-white font-mono"
/>
<div className="border border-zinc-800 bg-zinc-900/60 p-4 space-y-3">
<div className="flex items-center gap-2 text-[10px] uppercase font-black tracking-wider text-zinc-300">
<Braces className="w-4 h-4 text-amber-400" /> buckets.id identity
</div>
<p className="text-xs text-zinc-400 leading-relaxed">
{mode === 'login'
? 'Continue with the same developer account used for the Buckets console. If you are already signed in to Buckets, Rokcet opens without asking for your password again.'
: 'Create your Buckets developer account and Organization, then return to Rokcet automatically. Rokcet never receives or stores your Buckets password.'}
</p>
</div>
<div>
<label className="block text-xs font-bold uppercase text-zinc-400 mb-1">Password</label>
<div className="flex items-center bg-zinc-900 border border-zinc-800 rounded px-3 py-2 text-xs">
<Lock className="w-4 h-4 text-zinc-500 mr-2 shrink-0" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="bg-transparent w-full outline-none text-white font-mono"
/>
</div>
</div>
{mode === 'register' && (
<div>
<label className="block text-xs font-bold uppercase text-zinc-400 mb-1">Workspace Organization</label>
<div className="flex items-center bg-zinc-900 border border-zinc-800 rounded px-3 py-2 text-xs">
<Building2 className="w-4 h-4 text-zinc-500 mr-2 shrink-0" />
<input
type="text"
value={workspaceName}
onChange={(e) => setWorkspaceName(e.target.value)}
placeholder="Acme AI Studio"
className="bg-transparent w-full outline-none text-white font-mono"
/>
</div>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-amber-400 hover:bg-amber-300 text-black font-black text-xs uppercase tracking-wider rounded transition flex items-center justify-center gap-2 cursor-pointer shadow-lg shadow-amber-400/20"
>
{isLoading ? (
<span>Authenticating...</span>
) : (
<>
<span>{mode === 'login' ? 'SIGN IN TO WORKSPACE' : 'CREATE TENANT WORKSPACE'}</span>
<ArrowRight className="w-4 h-4" />
</>
)}
<button type="button" onClick={startBucketsSso} disabled={isLoading} className="w-full py-3 bg-amber-400 hover:bg-amber-300 disabled:opacity-60 text-black font-black text-xs uppercase tracking-wider rounded transition flex items-center justify-center gap-2 cursor-pointer shadow-lg shadow-amber-400/20">
<span>{isLoading ? 'REDIRECTING…' : mode === 'login' ? 'CONTINUE WITH BUCKETS' : 'CREATE WITH BUCKETS'}</span>
{!isLoading && <ArrowRight className="w-4 h-4" />}
</button>
{/* Divider */}
<div className="relative py-2 text-center text-[10px] text-zinc-500 uppercase font-bold">
<span className="bg-zinc-950 px-2 relative z-10">OR DEMO ACCESS</span>
<span className="bg-zinc-950 px-2 relative z-10">OR</span>
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-zinc-800" /></div>
</div>
<button
type="button"
onClick={handleDemoAccess}
className="w-full py-2.5 bg-zinc-900 hover:bg-zinc-800 border border-zinc-700 text-zinc-200 font-bold text-xs uppercase tracking-wider rounded transition flex items-center justify-center gap-2 cursor-pointer"
>
<button type="button" onClick={handleDemoAccess} disabled={isLoading} className="w-full py-2.5 bg-zinc-900 hover:bg-zinc-800 disabled:opacity-60 border border-zinc-700 text-zinc-200 font-bold text-xs uppercase tracking-wider rounded transition flex items-center justify-center gap-2 cursor-pointer">
<ShieldCheck className="w-4 h-4 text-emerald-400" />
<span>INSTANT DEMO WORKSPACE LOGIN</span>
<span>LOGIN WITH DEMO</span>
</button>
</form>
<p className="text-[9px] text-zinc-500 leading-relaxed text-center">Demo access is isolated from real Buckets accounts and expires automatically.</p>
</div>
</div>
</div>
);

View File

@@ -35,6 +35,7 @@ import { buildBucketsReferenceArtifacts } from '../lib/bucketsTemplates';
interface BucketsIntegrationViewProps {
activeProject: Project | null;
activeWorkspaceId: string;
artifacts: Artifact[];
onScaffoldAppToProject: (scaffoldedArtifacts: Artifact[], snapshotSummary: string) => void;
onCleanDuplicateArtifacts?: () => void;
@@ -141,11 +142,14 @@ export function BucketsIntegrationView({
onCleanDuplicateArtifacts,
onNavigateToPreview,
onNavigateToArtifacts,
activeWorkspaceId,
}: BucketsIntegrationViewProps) {
const [hostUrl, setHostUrl] = useState('https://buckets-id.stble.fi');
const [isPinging, setIsPinging] = useState(false);
const [pingLatency, setPingLatency] = useState<number | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [mcpConnection, setMcpConnection] = useState<any>(null);
const [isDisconnectingMcp, setIsDisconnectingMcp] = useState(false);
const [activeSubTab, setActiveSubTab] = useState<'templates' | 'oauth' | 'agent' | 'mcp' | 'architecture'>(
'templates'
);
@@ -228,10 +232,68 @@ export function BucketsIntegrationView({
const [isExecutingMcp, setIsExecutingMcp] = useState(false);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Fetch status on mount
const fetchBucketsStatus = async () => {
if (!activeWorkspaceId) return;
try {
const res = await fetch(`/api/buckets/status?workspace_id=${encodeURIComponent(activeWorkspaceId)}`);
const data = await res.json();
if (res.ok && data.success) setMcpConnection(data.developerMcp || { connected: false });
} catch {
setMcpConnection({ connected: false });
}
};
useEffect(() => {
fetchSlots(selectedDate);
}, []);
fetchBucketsStatus();
}, [activeWorkspaceId]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('buckets_mcp') === 'connected') {
setMcpOutput({ success: true, message: 'Buckets Developer MCP connected to this workspace.' });
fetchBucketsStatus();
params.delete('buckets_mcp');
params.delete('workspace_id');
window.history.replaceState({}, document.title, `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ''}${window.location.hash}`);
} else if (params.get('buckets_mcp_error')) {
setMcpOutput({ success: false, error: params.get('buckets_mcp_error') });
params.delete('buckets_mcp_error');
window.history.replaceState({}, document.title, `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ''}${window.location.hash}`);
}
}, [activeWorkspaceId]);
const developerMcpHeaders = () => ({
'Content-Type': 'application/json',
'X-Rokcet-Workspace-Id': activeWorkspaceId,
});
const handleConnectDeveloperMcp = () => {
const params = new URLSearchParams({ workspace_id: activeWorkspaceId });
if (activeProject?.name) params.set('app_name', activeProject.name);
params.set('capabilities', 'customer_auth,agent_chat,bookings,webhooks');
window.location.assign(`/api/buckets/mcp/connect?${params.toString()}`);
};
const handleDisconnectDeveloperMcp = async () => {
setIsDisconnectingMcp(true);
try {
const res = await fetch('/api/buckets/mcp/disconnect', {
method: 'POST',
headers: developerMcpHeaders(),
body: JSON.stringify({ workspaceId: activeWorkspaceId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Unable to disconnect Buckets');
setMcpConnection({ connected: false, workspaceId: activeWorkspaceId });
setMcpSessionVerified(false);
setMcpOutput({ success: true, message: 'Buckets Developer MCP disconnected from this workspace.' });
} catch (error: any) {
setMcpOutput({ success: false, error: error?.message || 'Unable to disconnect Buckets' });
} finally {
setIsDisconnectingMcp(false);
}
};
const handlePingHost = async () => {
setIsPinging(true);
@@ -354,7 +416,7 @@ export function BucketsIntegrationView({
try {
const res = await fetch('/api/buckets/oauth/provision', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: developerMcpHeaders(),
body: JSON.stringify({
appName: oauthAppName,
selectedScopes: oauthScopes,
@@ -378,7 +440,7 @@ export function BucketsIntegrationView({
try {
const res = await fetch('/api/buckets/mcp/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: developerMcpHeaders(),
body: JSON.stringify({
toolName: 'buckets_integration_validate',
parameters: {},
@@ -406,7 +468,7 @@ export function BucketsIntegrationView({
const res = await fetch('/api/buckets/mcp/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: developerMcpHeaders(),
body: JSON.stringify({
toolName: selectedMcpTool,
parameters: parsed,
@@ -450,8 +512,8 @@ export function BucketsIntegrationView({
});
};
// Build the developer application and, when Developer MCP credentials are configured,
// configure the real Buckets composition through the build-time MCP boundary.
// Build the developer application and, when this Rokcet workspace has an App-scoped
// Developer MCP OAuth authorization, configure Buckets through the build-time boundary.
const handleConfirmScaffoldWithCustomDetails = async () => {
if (!activeProject) {
alert('Please select or create an active project in Rokcet first.');
@@ -475,7 +537,7 @@ export function BucketsIntegrationView({
try {
const response = await fetch('/api/buckets/compositions/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: developerMcpHeaders(),
body: JSON.stringify({
composition,
brandTitle: form.brandTitle,
@@ -1104,6 +1166,42 @@ export function BucketsIntegrationView({
<span className="text-[10px] text-zinc-500 font-bold">MODEL CONTEXT PROTOCOL</span>
</div>
<div className={`p-4 border ${mcpConnection?.connected ? 'border-emerald-300 bg-emerald-50' : 'border-zinc-300 bg-zinc-50'} flex flex-col md:flex-row md:items-center justify-between gap-3`}>
<div>
<div className="text-[10px] font-black uppercase text-zinc-500">BUCKETS APP AUTHORIZATION</div>
{mcpConnection?.connected ? (
<>
<div className="text-xs font-black text-black mt-1">CONNECTED{mcpConnection.appName ? ` · ${mcpConnection.appName}` : ''}</div>
<div className="text-[10px] text-zinc-600 font-mono mt-1">
{mcpConnection.appId ? `APP ${mcpConnection.appId}` : 'App-scoped OAuth grant active'}
</div>
</>
) : (
<>
<div className="text-xs font-black text-black mt-1">NOT CONNECTED</div>
<div className="text-[11px] text-zinc-600 font-sans mt-1">Authorize this Rokcet workspace to one Buckets App. No permanent MCP token or client secret is required.</div>
</>
)}
</div>
{mcpConnection?.connected ? (
<button
onClick={handleDisconnectDeveloperMcp}
disabled={isDisconnectingMcp}
className="px-4 py-2 border border-zinc-400 bg-white hover:bg-zinc-100 text-black font-black text-xs uppercase transition"
>
{isDisconnectingMcp ? 'DISCONNECTING...' : 'DISCONNECT BUCKETS'}
</button>
) : (
<button
onClick={handleConnectDeveloperMcp}
className="px-4 py-2 bg-black hover:bg-zinc-800 text-white font-black text-xs uppercase flex items-center gap-2 transition"
>
<ExternalLink className="w-3.5 h-3.5" />
CONNECT BUCKETS APP
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div>
<label className="block text-[10px] font-black uppercase text-zinc-600 mb-1">
@@ -1147,11 +1245,11 @@ export function BucketsIntegrationView({
<button
onClick={handleExecuteMcp}
disabled={isExecutingMcp}
disabled={isExecutingMcp || !mcpConnection?.connected}
className="px-4 py-2 bg-black hover:bg-zinc-800 text-white font-black text-xs uppercase flex items-center gap-2 transition cursor-pointer"
>
<Zap className="w-3.5 h-3.5" />
<span>{isExecutingMcp ? 'EXECUTING MCP TOOL...' : 'RUN MCP TOOL'}</span>
<span>{isExecutingMcp ? 'EXECUTING MCP TOOL...' : mcpConnection?.connected ? 'RUN MCP TOOL' : 'CONNECT BUCKETS TO RUN'}</span>
</button>
{mcpOutput && (

View File

@@ -13,6 +13,7 @@ import {
Server,
PanelLeft,
Braces,
LogOut,
} from 'lucide-react';
interface HeaderProps {
@@ -26,6 +27,7 @@ interface HeaderProps {
currentUser?: { email: string; name: string } | null;
onOpenLanding?: () => void;
onOpenAuthModal?: (mode: 'login' | 'register') => void;
onLogout?: () => void;
}
export function Header({
@@ -39,6 +41,7 @@ export function Header({
currentUser,
onOpenLanding,
onOpenAuthModal,
onLogout,
}: HeaderProps) {
return (
<header className="h-14 bg-white border-b border-zinc-200 px-6 flex items-center justify-between font-mono select-none shrink-0 text-zinc-900">
@@ -156,14 +159,26 @@ export function Header({
)}
{currentUser ? (
<button
onClick={onOpenLanding}
className="px-3 py-1.5 bg-zinc-100 hover:bg-zinc-200 border border-zinc-300 rounded text-zinc-900 text-xs font-bold transition cursor-pointer flex items-center gap-1.5"
title="Active Account Session"
>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[11px] uppercase font-black tracking-tight">{currentUser.name}</span>
</button>
<div className="flex items-center gap-1.5">
<button
onClick={onOpenLanding}
className="px-3 py-1.5 bg-zinc-100 hover:bg-zinc-200 border border-zinc-300 rounded text-zinc-900 text-xs font-bold transition cursor-pointer flex items-center gap-1.5"
title={currentUser.email}
>
<span className="w-2 h-2 rounded-full bg-emerald-500" />
<span className="text-[11px] uppercase font-black tracking-tight">{currentUser.name}</span>
</button>
{onLogout && (
<button
onClick={onLogout}
className="p-1.5 bg-white hover:bg-zinc-100 border border-zinc-300 rounded text-zinc-600 hover:text-black transition cursor-pointer"
title="Log out of Rokcet"
aria-label="Log out of Rokcet"
>
<LogOut className="w-3.5 h-3.5" />
</button>
)}
</div>
) : (
onOpenAuthModal && (
<button

View File

@@ -15,6 +15,7 @@ import {
interface InspectorViewProps {
credentials: LlmEngineConfig;
session: Session | null;
currentUser?: { email: string; name: string } | null;
}
function durationMs(start?: string | null, end?: string | null) {
@@ -31,7 +32,7 @@ function compactJson(value: any) {
return text.length > 160 ? text.slice(0, 157) + '…' : text;
}
export function InspectorView({ credentials, session }: InspectorViewProps) {
export function InspectorView({ credentials, session, currentUser }: InspectorViewProps) {
const [mode, setMode] = useState<'workspace' | 'buckets'>('workspace');
const [runs, setRuns] = useState<any[]>([]);
const [runsError, setRunsError] = useState<any>(null);
@@ -46,7 +47,7 @@ export function InspectorView({ credentials, session }: InspectorViewProps) {
const logs = latestMessageWithLogs?.logs || [
{ id: '1', timestamp: '06:55:00 AM', step: 'Multi-Model LLM Engine Ready', status: 'success', detail: 'Sandboxed display driver port :3000' },
{ id: '2', timestamp: '06:55:01 AM', step: 'Authenticated Workspace Session', status: 'success', detail: `User: ${credentials.email || 'user@workspace.com'}` },
{ id: '2', timestamp: '06:55:01 AM', step: 'Authenticated Workspace Session', status: 'success', detail: `User: ${currentUser?.email || 'Authenticated Rokcet user'}` },
{ id: '3', timestamp: '06:55:02 AM', step: 'Artifact Snapshot Engine Ready', status: 'success', detail: 'Bound artifact snapshots synchronized' },
];
@@ -112,7 +113,7 @@ export function InspectorView({ credentials, session }: InspectorViewProps) {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 font-mono">
<div className="p-4 bg-white border border-zinc-300 shadow-xs">
<span className="text-[10px] uppercase font-black text-zinc-400 block mb-1">ACCOUNT & PROVIDERS</span>
<div className="font-black text-xs text-black truncate">{credentials.email || 'user@workspace.com'}</div>
<div className="font-black text-xs text-black truncate">{currentUser?.email || 'Authenticated Rokcet user'}</div>
<p className="text-[11px] text-zinc-600 font-extrabold mt-1 uppercase">PROVIDERS: GOOGLE, OPENAI, ANTHROPIC, GROQ</p>
</div>
<div className="p-4 bg-white border border-zinc-300 shadow-xs">

View File

@@ -33,6 +33,7 @@ interface MiniSideMenuProps {
activeProject: Project | null;
artifacts: Artifact[];
onNavigateToArtifactsVault?: (artifactId?: string) => void;
currentUser?: { email: string; name: string } | null;
}
export function MiniSideMenu({
@@ -49,6 +50,7 @@ export function MiniSideMenu({
activeProject,
artifacts,
onNavigateToArtifactsVault,
currentUser,
}: MiniSideMenuProps) {
const [isChatMenuOpen, setIsChatMenuOpen] = useState(false);
const [isArtifactsMenuOpen, setIsArtifactsMenuOpen] = useState(false);
@@ -275,11 +277,9 @@ export function MiniSideMenu({
<button
onClick={onOpenSettingsModal}
className="w-7 h-7 bg-black text-white font-black text-xs flex items-center justify-center hover:bg-zinc-800 transition cursor-pointer shadow-2xs"
title={`${credentials.email} (Settings & Profile)`}
title={`${currentUser?.email || 'Rokcet account'} (Settings & Profile)`}
>
{credentials.email
? credentials.email.charAt(0).toUpperCase()
: 'U'}
{(currentUser?.name || currentUser?.email || 'R').charAt(0).toUpperCase()}
</button>
</div>
</aside>

View File

@@ -3,28 +3,32 @@ import { Workspace } from '../types';
import { Layers, X } from 'lucide-react';
interface NewWorkspaceModalProps {
onCreateWorkspace: (ws: Workspace) => void;
onCreateWorkspace: (input: { name: string; description: string }) => Promise<Workspace>;
onClose: () => void;
}
export function NewWorkspaceModal({ onCreateWorkspace, onClose }: NewWorkspaceModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const newWs: Workspace = {
id: 'ws-' + Date.now(),
name: name.trim(),
description: description.trim() || 'Custom user workspace',
color: 'from-emerald-500 to-teal-700',
createdAt: new Date().toISOString(),
};
onCreateWorkspace(newWs);
onClose();
if (!name.trim() || busy) return;
setBusy(true);
setError('');
try {
await onCreateWorkspace({
name: name.trim(),
description: description.trim() || 'Custom user workspace',
});
onClose();
} catch (err: any) {
setError(err?.message || 'Unable to create workspace');
} finally {
setBusy(false);
}
};
return (
@@ -34,12 +38,13 @@ export function NewWorkspaceModal({ onCreateWorkspace, onClose }: NewWorkspaceMo
<h3 className="text-sm font-black uppercase tracking-wider flex items-center gap-2 text-black">
<Layers className="w-5 h-5 text-black" /> CREATE WORKSPACE
</h3>
<button onClick={onClose} className="p-1 text-zinc-500 hover:text-black">
<button onClick={onClose} className="p-1 text-zinc-500 hover:text-black" aria-label="Close create workspace">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 text-xs font-mono font-bold pt-2">
{error && <div className="border border-red-300 bg-red-50 p-2.5 text-red-700">{error}</div>}
<div>
<label className="block text-zinc-700 font-black uppercase mb-1">Workspace Name</label>
<input
@@ -64,19 +69,8 @@ export function NewWorkspaceModal({ onCreateWorkspace, onClose }: NewWorkspaceMo
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-zinc-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-zinc-100 text-zinc-800 font-black uppercase border border-zinc-300 hover:bg-zinc-200"
>
Cancel
</button>
<button
type="submit"
className="px-5 py-2 bg-black text-white font-black uppercase border border-black btn-tactical"
>
Create Workspace
</button>
<button type="button" onClick={onClose} disabled={busy} className="px-4 py-2 bg-zinc-100 text-zinc-800 font-black uppercase border border-zinc-300 hover:bg-zinc-200 disabled:opacity-60">Cancel</button>
<button type="submit" disabled={busy} className="px-5 py-2 bg-black text-white font-black uppercase border border-black btn-tactical disabled:opacity-60">{busy ? 'Creating…' : 'Create Workspace'}</button>
</div>
</form>
</div>

View File

@@ -305,11 +305,11 @@ export function Sidebar({
>
<div className="flex items-center gap-2 truncate">
<div className="w-6 h-6 bg-black text-white font-black text-xs flex items-center justify-center shrink-0">
{credentials.email.charAt(0).toUpperCase()}
{(currentUser?.name || currentUser?.email || 'R').charAt(0).toUpperCase()}
</div>
<div className="text-left truncate">
<div className="font-black text-[11px] truncate uppercase text-black leading-tight">{credentials.email.split('@')[0]}</div>
<div className="text-[9px] font-extrabold text-zinc-500 truncate leading-tight">{credentials.email}</div>
<div className="font-black text-[11px] truncate uppercase text-black leading-tight">{currentUser?.name || 'Rokcet Developer'}</div>
<div className="text-[9px] font-extrabold text-zinc-500 truncate leading-tight">{currentUser?.email || 'Not signed in'}</div>
</div>
</div>
<Sliders className="w-3.5 h-3.5 text-zinc-500 group-hover:text-black shrink-0" />

View File

@@ -404,7 +404,7 @@ CREATE TABLE orders (
];
export const DEFAULT_CREDENTIALS: LlmEngineConfig = {
email: 'developer@example.com',
email: '',
isVerified: true,
selectedModels: ['gemini-3.7-flash', 'gpt-4o'],
mainModel: 'gemini-3.7-flash',