Update Rokcet Buckets SSO and MCP OAuth
This commit is contained in:
18
.env.example
18
.env.example
@@ -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
5
.gitignore
vendored
@@ -6,3 +6,8 @@ coverage/
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Local Rokcet product/session database
|
||||
/data/*.sqlite
|
||||
/data/*.sqlite-wal
|
||||
/data/*.sqlite-shm
|
||||
|
||||
52
README.md
52
README.md
@@ -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.
|
||||
|
||||
@@ -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
6
public/favicon.svg
Normal 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
114
server/auth.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
335
server/services/bucketsMcpOAuth.ts
Normal file
335
server/services/bucketsMcpOAuth.ts
Normal 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
444
server/services/rokcetDb.ts
Normal 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());
|
||||
}
|
||||
|
||||
327
src/App.tsx
327
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user