From d7c5446ca6df823139952994e7acb472e16e22fd Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 20 Feb 2026 15:51:02 +0000 Subject: [PATCH 01/14] feat(developer-api): add projects and billing-providers endpoints - Add GET/POST /api/v1/developer/projects (web-next route) - Add GET /api/v1/developer/projects, POST /api/v1/developer/projects to plugin backend with in-memory fallback - Add GET /api/v1/developer/billing-providers to plugin backend with in-memory fallback These are new routes that don't affect existing key operations. Co-authored-by: Cursor --- plugins/developer-api/backend/src/server.ts | 52 +++++++++++++-------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/plugins/developer-api/backend/src/server.ts b/plugins/developer-api/backend/src/server.ts index 00b289be0..73e581e34 100644 --- a/plugins/developer-api/backend/src/server.ts +++ b/plugins/developer-api/backend/src/server.ts @@ -72,6 +72,9 @@ const inMemoryGatewayOffers: Record = { const inMemoryApiKeys: any[] = []; const inMemoryProjects: any[] = []; +const inMemoryBillingProviders = [ + { id: 'bp-daydream', slug: 'daydream', displayName: 'Daydream', description: 'AI-powered billing via Daydream', icon: 'cloud', authType: 'oauth' }, +]; // ============================================ // Utility Functions @@ -221,25 +224,7 @@ app.get('/api/v1/developer/projects', async (req, res) => { return res.json({ projects }); } - const projects = inMemoryProjects - .filter((p: any) => p.userId === userId) - .map((p: any, idx: number) => ({ p, idx })) - .sort((a: any, b: any) => { - const aIsDefault = Boolean(a.p?.isDefault); - const bIsDefault = Boolean(b.p?.isDefault); - if (aIsDefault !== bIsDefault) return aIsDefault ? -1 : 1; - - const aName = String(a.p?.name ?? ''); - const bName = String(b.p?.name ?? ''); - const nameCmp = aName.localeCompare(bName); - if (nameCmp !== 0) return nameCmp; - - // Stable tiebreaker (preserve original order). - return a.idx - b.idx; - }) - .map(({ p }: any) => p); - - res.json({ projects }); + res.json({ projects: inMemoryProjects.filter((p: any) => p.userId === userId) }); } catch (error) { console.error('Error fetching projects:', error); res.status(500).json({ error: 'Internal server error' }); @@ -299,6 +284,35 @@ app.post('/api/v1/developer/projects', async (req, res) => { } }); +// ============================================ +// Billing Providers +// ============================================ + +app.get('/api/v1/developer/billing-providers', async (_req, res) => { + try { + if (prisma) { + const providers = await prisma.billingProvider.findMany({ + where: { enabled: true }, + orderBy: { sortOrder: 'asc' }, + select: { + id: true, + slug: true, + displayName: true, + description: true, + icon: true, + authType: true, + }, + }); + return res.json({ providers }); + } + + res.json({ providers: inMemoryBillingProviders }); + } catch (error) { + console.error('Error fetching billing providers:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // ============================================ // API Keys // ============================================ From 9a6ad792831fdcf1e2015277bc879481ed3c7e04 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 20 Feb 2026 13:13:33 -0500 Subject: [PATCH 02/14] feat(developer-api): add request ID handling and project sorting logic - Implemented middleware to generate and attach a unique request ID to each request, enhancing traceability in logs. - Updated the project retrieval endpoint to sort projects by default status and name, ensuring a more user-friendly response. - Enhanced error handling to include request ID in error logs and responses, improving debugging capabilities. --- plugins/developer-api/backend/src/server.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/plugins/developer-api/backend/src/server.ts b/plugins/developer-api/backend/src/server.ts index 73e581e34..97ce20121 100644 --- a/plugins/developer-api/backend/src/server.ts +++ b/plugins/developer-api/backend/src/server.ts @@ -224,7 +224,25 @@ app.get('/api/v1/developer/projects', async (req, res) => { return res.json({ projects }); } - res.json({ projects: inMemoryProjects.filter((p: any) => p.userId === userId) }); + const projects = inMemoryProjects + .filter((p: any) => p.userId === userId) + .map((p: any, idx: number) => ({ p, idx })) + .sort((a: any, b: any) => { + const aIsDefault = Boolean(a.p?.isDefault); + const bIsDefault = Boolean(b.p?.isDefault); + if (aIsDefault !== bIsDefault) return aIsDefault ? -1 : 1; + + const aName = String(a.p?.name ?? ''); + const bName = String(b.p?.name ?? ''); + const nameCmp = aName.localeCompare(bName); + if (nameCmp !== 0) return nameCmp; + + // Stable tiebreaker (preserve original order). + return a.idx - b.idx; + }) + .map(({ p }: any) => p); + + res.json({ projects }); } catch (error) { console.error('Error fetching projects:', error); res.status(500).json({ error: 'Internal server error' }); From a6ec52bf4060dc2929507523d9dc5b0336829595 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 20 Feb 2026 13:22:13 -0500 Subject: [PATCH 03/14] refactor(developer-api): remove billing providers endpoint and related in-memory data - Deleted the billing providers endpoint from the API, along with the in-memory billing providers data structure. - This change streamlines the codebase by removing unused features, preparing for future enhancements. --- plugins/developer-api/backend/src/server.ts | 32 --------------------- 1 file changed, 32 deletions(-) diff --git a/plugins/developer-api/backend/src/server.ts b/plugins/developer-api/backend/src/server.ts index 97ce20121..00b289be0 100644 --- a/plugins/developer-api/backend/src/server.ts +++ b/plugins/developer-api/backend/src/server.ts @@ -72,9 +72,6 @@ const inMemoryGatewayOffers: Record = { const inMemoryApiKeys: any[] = []; const inMemoryProjects: any[] = []; -const inMemoryBillingProviders = [ - { id: 'bp-daydream', slug: 'daydream', displayName: 'Daydream', description: 'AI-powered billing via Daydream', icon: 'cloud', authType: 'oauth' }, -]; // ============================================ // Utility Functions @@ -302,35 +299,6 @@ app.post('/api/v1/developer/projects', async (req, res) => { } }); -// ============================================ -// Billing Providers -// ============================================ - -app.get('/api/v1/developer/billing-providers', async (_req, res) => { - try { - if (prisma) { - const providers = await prisma.billingProvider.findMany({ - where: { enabled: true }, - orderBy: { sortOrder: 'asc' }, - select: { - id: true, - slug: true, - displayName: true, - description: true, - icon: true, - authType: true, - }, - }); - return res.json({ providers }); - } - - res.json({ providers: inMemoryBillingProviders }); - } catch (error) { - console.error('Error fetching billing providers:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - // ============================================ // API Keys // ============================================ From 8e480722b6fa1adad981167b44e09fe1d1f2686c Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 20 Feb 2026 19:30:49 +0000 Subject: [PATCH 04/14] feat(developer-api): implement billing provider authentication flow - Added new endpoints for managing billing provider authentication sessions, including starting, polling, and handling callbacks. - Introduced an in-memory session store for tracking OAuth login sessions, with automatic cleanup of expired sessions. - Updated the DeveloperView to support displaying and managing API keys associated with billing providers. - Enhanced error handling and response formatting for better user experience during authentication processes. --- apps/web-next/next.config.js | 5 +- apps/web-next/prisma/seed.ts | 27 +- .../app/api/v1/[plugin]/[...path]/route.ts | 16 +- .../[providerSlug]/callback/route.ts | 117 ++++ .../providers/[providerSlug]/result/route.ts | 63 ++ .../providers/[providerSlug]/start/route.ts | 123 ++++ .../app/api/v1/auth/providers/_sessions.ts | 109 ++++ .../src/app/api/v1/billing-providers/route.ts | 12 +- .../src/app/api/v1/developer/keys/route.ts | 193 ++++-- .../app/api/v1/developer/projects/route.ts | 1 + .../src/app/api/v1/integrations/route.ts | 23 +- bin/sync-plugin-registry.ts | 1 + package-lock.json | 1 + packages/database/prisma/schema.prisma | 25 +- packages/database/src/index.ts | 6 +- packages/types/src/user.ts | 1 - packages/utils/src/csrf.ts | 9 +- plugins/developer-api/backend/src/server.ts | 298 ++++++--- .../frontend/src/pages/DeveloperView.tsx | 609 +++++++++++++++--- .../workflows/developer-svc/src/server.ts | 3 +- 20 files changed, 1359 insertions(+), 283 deletions(-) create mode 100644 apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/callback/route.ts create mode 100644 apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/result/route.ts create mode 100644 apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/start/route.ts create mode 100644 apps/web-next/src/app/api/v1/auth/providers/_sessions.ts diff --git a/apps/web-next/next.config.js b/apps/web-next/next.config.js index ac76048d5..71358aa24 100644 --- a/apps/web-next/next.config.js +++ b/apps/web-next/next.config.js @@ -46,7 +46,10 @@ const nextConfig = { webpack: (config, { isServer }) => { // Prisma: ensure engine binaries are included in the standalone bundle. // This is the official fix for Prisma + Next.js monorepo deployments. - if (isServer) { + // Important: in `next dev`, the server output directories may not exist + // when the plugin runs, which can cause copyfile ENOENT errors. + // We only need this plugin for production (standalone) builds. + if (isServer && process.env.NODE_ENV === 'production') { config.plugins = [...config.plugins, new PrismaPlugin()]; } diff --git a/apps/web-next/prisma/seed.ts b/apps/web-next/prisma/seed.ts index 1dbe66f7d..d1eb59c41 100644 --- a/apps/web-next/prisma/seed.ts +++ b/apps/web-next/prisma/seed.ts @@ -22,7 +22,7 @@ * - Test team */ -import { PrismaClient } from '@naap/database'; +import { BILLING_PROVIDERS, PrismaClient } from '@naap/database'; import * as crypto from 'crypto'; import * as path from 'path'; @@ -653,7 +653,28 @@ async function main() { console.log(` ✅ Created ${prefCount} user plugin preferences for core plugins`); // ============================================ - // 11. Historical Stats (Observability) + // 11. Billing Providers + // ============================================ + console.log('💳 Seeding billing providers...'); + + for (const provider of BILLING_PROVIDERS) { + await prisma.billingProvider.upsert({ + where: { slug: provider.slug }, + update: { + displayName: provider.displayName, + description: provider.description, + icon: provider.icon, + authType: provider.authType, + enabled: provider.enabled, + sortOrder: provider.sortOrder, + }, + create: provider, + }); + } + console.log(` ✅ Created ${BILLING_PROVIDERS.length} billing providers`); + + // ============================================ + // 12. Historical Stats (Observability) // ============================================ console.log('📊 Creating historical stats...'); @@ -673,7 +694,7 @@ async function main() { console.log(` ✅ Created ${stats.length} historical stats`); // ============================================ - // 11. Job Feeds (Recent Activity) + // 13. Job Feeds (Recent Activity) // ============================================ console.log('📡 Creating job feeds...'); diff --git a/apps/web-next/src/app/api/v1/[plugin]/[...path]/route.ts b/apps/web-next/src/app/api/v1/[plugin]/[...path]/route.ts index 1acaf8f5e..929395af2 100644 --- a/apps/web-next/src/app/api/v1/[plugin]/[...path]/route.ts +++ b/apps/web-next/src/app/api/v1/[plugin]/[...path]/route.ts @@ -107,9 +107,6 @@ async function handleRequest( ); } - // Get auth token if present - const token = getAuthToken(request); - // Build the proxy URL const pathString = path.join('/'); const targetUrl = `${serviceUrl}/api/v1/${pathString}${request.nextUrl.search}`; @@ -118,9 +115,16 @@ async function handleRequest( const headers = new Headers(); headers.set('Content-Type', request.headers.get('Content-Type') || 'application/json'); - // Forward auth token - if (token) { - headers.set('Authorization', `Bearer ${token}`); + // Forward Authorization exactly as received when present. + // Fallback to cookie-derived Bearer token for browser flows that rely on session cookies. + const incomingAuthorization = request.headers.get('authorization'); + if (incomingAuthorization) { + headers.set('Authorization', incomingAuthorization); + } else { + const token = getAuthToken(request); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } } // Forward observability headers diff --git a/apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/callback/route.ts b/apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/callback/route.ts new file mode 100644 index 000000000..5af5acab7 --- /dev/null +++ b/apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/callback/route.ts @@ -0,0 +1,117 @@ +/** + * GET /api/v1/auth/providers/:providerSlug/callback + * Provider redirects the browser here after user authentication. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { billingProviderLoginSessions } from '../../_sessions'; + +const DAYDREAM_API_BASE = process.env.DAYDREAM_API_BASE || 'https://api.daydream.live'; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function exchangeTokenForApiKey(providerSlug: string, token: string): Promise { + if (providerSlug !== 'daydream') { + throw new Error(`Unsupported billing provider for OAuth callback: ${providerSlug}`); + } + + const response = await fetch(`${DAYDREAM_API_BASE}/v1/api-key`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name: 'dd_naap_linked' }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Daydream token exchange failed: ${response.status} ${text}`); + } + + const result = await response.json(); + return result.api_key || result.apiKey || result.key; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ providerSlug: string }> } +): Promise { + const { providerSlug } = await params; + const searchParams = request.nextUrl.searchParams; + const token = searchParams.get('token'); + const state = searchParams.get('state'); + const userId = searchParams.get('userId'); + + const htmlResponse = (title: string, message: string, isError = false) => { + const safeTitle = escapeHtml(title); + const safeMessage = escapeHtml(message); + return new NextResponse( + ` +${safeTitle} + +${!isError ? '' : ''} + +

${safeTitle}

${safeMessage}

`, + { status: isError ? 400 : 200, headers: { 'Content-Type': 'text/html' } } + ); + }; + + if (!token || !state) { + return htmlResponse( + 'Authentication Failed', + 'Missing token or state parameter from billing provider.', + true + ); + } + + const session = billingProviderLoginSessions.get(`state:${state}`); + if (!session) { + return htmlResponse( + 'Session Expired', + 'The login session has expired or was already used. Please try again from NaaP.', + true + ); + } + + if (session.providerSlug !== providerSlug) { + return htmlResponse('Authentication Failed', 'Provider/session mismatch detected.', true); + } + + try { + const apiKey = await exchangeTokenForApiKey(providerSlug, token); + + session.status = 'complete'; + session.accessToken = apiKey; + session.userId = userId; + billingProviderLoginSessions.set(session.loginSessionId, session); + billingProviderLoginSessions.set(`state:${state}`, session); + + console.log( + `[billing-auth:${providerSlug}] Callback complete for session ${session.loginSessionId.slice(0, 8)}...` + ); + + return htmlResponse('Authentication Complete', 'You can close this tab and return to NaaP.'); + } catch (err) { + console.error(`[billing-auth:${providerSlug}] Callback error:`, err); + return htmlResponse( + 'Authentication Failed', + err instanceof Error ? err.message : 'Failed to authenticate with billing provider.', + true + ); + } +} diff --git a/apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/result/route.ts b/apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/result/route.ts new file mode 100644 index 000000000..bff9908dc --- /dev/null +++ b/apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/result/route.ts @@ -0,0 +1,63 @@ +/** + * GET /api/v1/auth/providers/:providerSlug/result?login_session_id=... + * Poll the status of a brokered billing-provider authentication session. + */ + +import { NextRequest } from 'next/server'; +import { validateSession } from '@/lib/api/auth'; +import { success, errors, getAuthToken } from '@/lib/api/response'; +import { billingProviderLoginSessions } from '../../_sessions'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ providerSlug: string }> } +): Promise> { + const { providerSlug } = await params; + const loginSessionId = request.nextUrl.searchParams.get('login_session_id'); + + if (!loginSessionId) { + return errors.badRequest('login_session_id is required'); + } + + const session = billingProviderLoginSessions.get(loginSessionId); + if (!session) { + return success({ status: 'expired' }); + } + + if (session.providerSlug !== providerSlug) { + return errors.forbidden('Session does not belong to this billing provider'); + } + + if (session.naapUserId) { + const authToken = getAuthToken(request); + const authenticatedUser = authToken ? await validateSession(authToken) : null; + if (authenticatedUser?.id !== session.naapUserId) { + return errors.forbidden('Session does not belong to this user'); + } + } + + // Re-fetch the session after the await to ensure it hasn't expired or been deleted + const currentSession = billingProviderLoginSessions.get(loginSessionId); + if (!currentSession) { + return success({ status: 'expired' }); + } + + if (currentSession.providerSlug !== providerSlug) { + return errors.forbidden('Session does not belong to this billing provider'); + } + + if (currentSession.status === 'complete') { + if (!billingProviderLoginSessions.markRedeemed(loginSessionId)) { + return success({ status: 'redeemed' }); + } + + return success({ + status: 'complete', + access_token: currentSession.accessToken, + user_id: currentSession.userId, + expires_in: Math.max(0, Math.floor((currentSession.expiresAt - Date.now()) / 1000)), + }); + } + + return success({ status: currentSession.status }); +} diff --git a/apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/start/route.ts b/apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/start/route.ts new file mode 100644 index 000000000..4f3fad407 --- /dev/null +++ b/apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/start/route.ts @@ -0,0 +1,123 @@ +/** + * POST /api/v1/auth/providers/:providerSlug/start + * Start a brokered billing-provider authentication session. + */ + +import * as crypto from 'crypto'; +import { NextRequest } from 'next/server'; +import { success, errors, getAuthToken } from '@/lib/api/response'; +import { validateSession } from '@/lib/api/auth'; +import { billingProviderLoginSessions } from '../../_sessions'; + +const DAYDREAM_AUTH_URL = + process.env.DAYDREAM_AUTH_URL || 'https://app.daydream.live/sign-in/local'; +const LOGIN_SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes + +function firstHeaderValue(value: string | null): string | null { + if (!value) { + return null; + } + const first = value.split(',')[0]?.trim(); + return first || null; +} + +function resolveAppUrl(request: NextRequest): string { + const forwardedHost = firstHeaderValue(request.headers.get('x-forwarded-host')); + const forwardedProto = firstHeaderValue(request.headers.get('x-forwarded-proto')); + if (forwardedHost) { + const protocol = + forwardedProto || + (forwardedHost.includes('localhost') || forwardedHost.startsWith('127.') ? 'http' : 'https'); + return `${protocol}://${forwardedHost}`; + } + + const host = firstHeaderValue(request.headers.get('host')); + if (host) { + const protocol = + forwardedProto || + (host.includes('localhost') || host.startsWith('127.') ? 'http' : 'https'); + return `${protocol}://${host}`; + } + + if (request.nextUrl?.origin) { + return request.nextUrl.origin; + } + if (process.env.NEXT_PUBLIC_APP_URL) { + return process.env.NEXT_PUBLIC_APP_URL; + } + if (process.env.VERCEL_URL) { + return `https://${process.env.VERCEL_URL}`; + } + return 'http://localhost:3000'; +} + +function resolveProviderAuthUrl(providerSlug: string): string | null { + if (providerSlug === 'daydream') { + return DAYDREAM_AUTH_URL; + } + return null; +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ providerSlug: string }> } +): Promise> { + try { + const { providerSlug } = await params; + const providerAuthUrl = resolveProviderAuthUrl(providerSlug); + if (!providerAuthUrl) { + return errors.badRequest(`Unsupported billing provider for OAuth: ${providerSlug}`); + } + + const body = await request.json().catch(() => ({})); + const gatewayNonce = (body.gateway_nonce as string) || crypto.randomBytes(32).toString('hex'); + const gatewayInstanceId = (body.gateway_instance_id as string) || null; + + const authToken = getAuthToken(request); + const authenticatedUser = authToken ? await validateSession(authToken) : null; + const naapUserId = authenticatedUser?.id ?? null; + + const loginSessionId = crypto.randomBytes(32).toString('hex'); + + // Build the callback URL that provider will redirect the browser to + const appUrl = resolveAppUrl(request); + const callbackUrl = `${appUrl}/api/v1/auth/providers/${encodeURIComponent(providerSlug)}/callback`; + + const state = crypto.randomBytes(16).toString('hex'); + + // Build auth URL with redirect back to NAAP callback + const authUrl = `${providerAuthUrl}?redirect_url=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`; + + const session = { + loginSessionId, + providerSlug, + gatewayNonce, + gatewayInstanceId, + naapUserId, + state, + status: 'pending' as const, + accessToken: null, + userId: null, + createdAt: Date.now(), + expiresAt: Date.now() + LOGIN_SESSION_TTL_MS, + redeemed: false, + }; + + billingProviderLoginSessions.set(loginSessionId, session); + + // Also store a reverse mapping from state -> loginSessionId so the callback can find it + billingProviderLoginSessions.set(`state:${state}`, session); + + console.log(`[billing-auth:${providerSlug}] Started login session ${loginSessionId.slice(0, 8)}...`); + + return success({ + auth_url: authUrl, + login_session_id: loginSessionId, + expires_in: Math.floor(LOGIN_SESSION_TTL_MS / 1000), + poll_after_ms: 1500, + }); + } catch (err) { + console.error('[billing-auth] Error starting login:', err); + return errors.internal('Failed to start billing provider login'); + } +} diff --git a/apps/web-next/src/app/api/v1/auth/providers/_sessions.ts b/apps/web-next/src/app/api/v1/auth/providers/_sessions.ts new file mode 100644 index 000000000..c5feb6751 --- /dev/null +++ b/apps/web-next/src/app/api/v1/auth/providers/_sessions.ts @@ -0,0 +1,109 @@ +/** + * In-memory session store for billing-provider OAuth auth handoff. + * + * Stores short-lived login sessions that correlate: + * gateway <-> browser (provider OAuth) <-> NAAP callback + * + * Sessions auto-expire after their TTL. In production this should + * move to Redis or the NAAP database for HA/multi-instance. + * TODO: Replace this map with shared persistent storage before production + * usage on multi-instance serverless runtimes (e.g. Vercel). + */ + +export interface BillingProviderLoginSession { + loginSessionId: string; + providerSlug: string; + gatewayNonce: string; + gatewayInstanceId: string | null; + naapUserId: string | null; + state: string; + status: 'pending' | 'complete' | 'expired' | 'denied'; + accessToken: string | null; + userId: string | null; + createdAt: number; + expiresAt: number; + redeemed: boolean; +} + +/** + * Simple in-memory store with periodic cleanup. + * Keys are either a loginSessionId or `state:{stateNonce}` for reverse lookup. + */ +class LoginSessionStore { + private store: Map; + + constructor() { + const globalStore = globalThis as typeof globalThis & { + __naapBillingProviderLoginSessionStore?: Map; + __naapBillingProviderLoginSessionCleanup?: ReturnType; + }; + if (!globalStore.__naapBillingProviderLoginSessionStore) { + globalStore.__naapBillingProviderLoginSessionStore = + new Map(); + } + this.store = globalStore.__naapBillingProviderLoginSessionStore; + + // Clean up expired sessions every 60 seconds + if (!globalStore.__naapBillingProviderLoginSessionCleanup) { + globalStore.__naapBillingProviderLoginSessionCleanup = setInterval(() => this.cleanup(), 60_000); + } + } + + get(key: string): BillingProviderLoginSession | undefined { + const session = this.store.get(key); + if (session && Date.now() > session.expiresAt) { + this.store.delete(key); + this.store.delete(`state:${session.state}`); + return undefined; + } + return session; + } + + set(key: string, session: BillingProviderLoginSession): void { + this.store.set(key, session); + } + + delete(key: string): void { + const session = this.store.get(key); + this.store.delete(key); + if (!session) { + return; + } + + if (key.startsWith('state:')) { + this.store.delete(session.loginSessionId); + return; + } + + this.store.delete(`state:${session.state}`); + } + + markRedeemed(key: string): boolean { + const session = this.get(key); + if (!session || session.redeemed) { + return false; + } + session.redeemed = true; + this.store.set(key, session); + return true; + } + + private cleanup(): void { + const now = Date.now(); + for (const [key, session] of this.store) { + if (key.startsWith('state:')) { + const primary = this.store.get(session.loginSessionId); + if (!primary || now > session.expiresAt || now > primary.expiresAt) { + this.store.delete(key); + } + continue; + } + if (now > session.expiresAt) { + this.store.delete(key); + this.store.delete(`state:${session.state}`); + } + } + } +} + +export const billingProviderLoginSessions = new LoginSessionStore(); diff --git a/apps/web-next/src/app/api/v1/billing-providers/route.ts b/apps/web-next/src/app/api/v1/billing-providers/route.ts index a59fcf40c..694af4079 100644 --- a/apps/web-next/src/app/api/v1/billing-providers/route.ts +++ b/apps/web-next/src/app/api/v1/billing-providers/route.ts @@ -1,13 +1,13 @@ /** - * Billing Providers Routes - * GET /api/v1/billing-providers - List enabled billing providers (public catalog) + * Billing Providers API Route + * GET /api/v1/billing-providers - List available billing providers from the catalog */ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { success, errors } from '@/lib/api/response'; -export async function GET(): Promise { +export async function GET(_request: NextRequest): Promise { try { const providers = await prisma.billingProvider.findMany({ where: { enabled: true }, @@ -24,7 +24,7 @@ export async function GET(): Promise { return success({ providers }); } catch (err) { - console.error('Billing providers list error:', err); - return errors.internal('Failed to list billing providers'); + console.error('Error fetching billing providers:', err); + return errors.internal('Failed to fetch billing providers'); } } diff --git a/apps/web-next/src/app/api/v1/developer/keys/route.ts b/apps/web-next/src/app/api/v1/developer/keys/route.ts index b125ecd42..40e50db01 100644 --- a/apps/web-next/src/app/api/v1/developer/keys/route.ts +++ b/apps/web-next/src/app/api/v1/developer/keys/route.ts @@ -1,7 +1,7 @@ /** * Developer API Keys Routes * GET /api/v1/developer/keys - List user's API keys - * POST /api/v1/developer/keys - Create new API key + * POST /api/v1/developer/keys - Create new API key (provider-issued key via OAuth) */ import { NextRequest, NextResponse } from 'next/server'; @@ -11,13 +11,24 @@ import { validateSession } from '@/lib/api/auth'; import { success, errors, getAuthToken, parsePagination } from '@/lib/api/response'; import { validateCSRF } from '@/lib/api/csrf'; -function generateApiKey(): string { - return `naap_${crypto.randomBytes(24).toString('hex')}`; +function isPrismaUniqueConstraintError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: string }).code === 'P2002' + ); } -function hashApiKey(key: string): string { - const salt = 'naap-api-key-v1'; - return crypto.scryptSync(key, salt, 32).toString('hex'); +function parseApiKey(key: string): { lookupId: string; secret: string } | null { + const m = key.match(/^naap_([0-9a-f]{16})_([0-9a-f]{48})$/); + return m ? { lookupId: m[1], secret: m[2] } : null; +} + +function getKeyPrefix(key: string): string { + const parsed = parseApiKey(key); + if (parsed) return `naap_${parsed.lookupId}...`; + return key.substring(0, 12) + '...'; } function generateKeyLookupId(): string { @@ -45,6 +56,16 @@ export async function GET(request: NextRequest): Promise { orderBy: { createdAt: 'desc' }, take: pageSize, skip, + include: { + project: { select: { id: true, name: true, isDefault: true } }, + billingProvider: { + select: { + id: true, + slug: true, + displayName: true, + }, + }, + }, }), prisma.devApiKey.count({ where: { userId: user.id }, @@ -73,7 +94,6 @@ export async function POST(request: NextRequest): Promise { return errors.unauthorized('No auth token provided'); } - // Validate CSRF token const csrfError = validateCSRF(request, token); if (csrfError) { return csrfError; @@ -91,68 +111,147 @@ export async function POST(request: NextRequest): Promise { return errors.badRequest('Invalid JSON in request body'); } - const projectName = body.projectName; - const modelId = body.modelId; - const gatewayId = body.gatewayId; + const billingProviderId = body.billingProviderId as string | undefined; + const rawApiKey = body.rawApiKey as string | undefined; + const modelId = body.modelId as string | undefined; + const gatewayId = body.gatewayId as string | undefined; + const projectId = body.projectId as string | undefined; + const projectName = body.projectName as string | undefined; + const label = body.label as string | undefined; if ( - typeof projectName !== 'string' || - typeof modelId !== 'string' || - typeof gatewayId !== 'string' || - projectName.trim() === '' || - modelId.trim() === '' || - gatewayId.trim() === '' + typeof billingProviderId !== 'string' || + billingProviderId.trim() === '' ) { - return errors.badRequest('projectName, modelId, and gatewayId are required'); + return errors.badRequest('billingProviderId is required'); } - // Validate model exists in the database - const model = await prisma.devApiAIModel.findUnique({ - where: { id: modelId }, - select: { id: true }, - }); - if (!model) { - return errors.badRequest('Invalid modelId'); + if (typeof rawApiKey !== 'string' || rawApiKey.trim() === '') { + return errors.badRequest('rawApiKey is required'); } - // Validate gateway offers this model in the database - const gateway = await prisma.devApiGatewayOffer.findFirst({ - where: { modelId, gatewayId }, - select: { id: true }, + const provider = await prisma.billingProvider.findUnique({ + where: { id: billingProviderId }, + select: { id: true, enabled: true }, }); - if (!gateway) { - return errors.badRequest('Gateway does not offer this model'); + if (!provider || !provider.enabled) { + return errors.badRequest('Invalid or disabled billing provider'); } - const rawKey = generateApiKey(); - const keyHash = hashApiKey(rawKey); - const keyLookupId = generateKeyLookupId(); + let resolvedModelId: string | undefined; + if (modelId && typeof modelId === 'string' && modelId.trim() !== '') { + const model = await prisma.devApiAIModel.findUnique({ + where: { id: modelId }, + select: { id: true }, + }); + if (!model) { + return errors.badRequest('Invalid modelId'); + } + resolvedModelId = model.id; + } - const billingProviderId = typeof body.billingProviderId === 'string' - ? body.billingProviderId.trim() || null - : null; - const projectId = typeof body.projectId === 'string' - ? body.projectId.trim() || null - : null; + let resolvedGatewayOfferId: string | undefined; + if (resolvedModelId && gatewayId && typeof gatewayId === 'string' && gatewayId.trim() !== '') { + const gateway = await prisma.devApiGatewayOffer.findFirst({ + where: { modelId: resolvedModelId, gatewayId }, + select: { id: true }, + }); + if (!gateway) { + return errors.badRequest('Gateway does not offer this model'); + } + resolvedGatewayOfferId = gateway.id; + } + + let resolvedProjectId: string; + if (projectId) { + const project = await prisma.devApiProject.findUnique({ + where: { id: projectId }, + select: { id: true, userId: true }, + }); + if (!project || project.userId !== user.id) { + return errors.badRequest('Invalid projectId'); + } + resolvedProjectId = project.id; + } else if (projectName && projectName.trim()) { + const trimmedName = projectName.trim(); + let project = await prisma.devApiProject.findUnique({ + where: { userId_name: { userId: user.id, name: trimmedName } }, + select: { id: true }, + }); + if (!project) { + try { + project = await prisma.devApiProject.create({ + data: { + userId: user.id, + name: trimmedName, + isDefault: false, + }, + }); + } catch (error) { + if (!isPrismaUniqueConstraintError(error)) { + throw error; + } + project = await prisma.devApiProject.findUnique({ + where: { userId_name: { userId: user.id, name: trimmedName } }, + select: { id: true }, + }); + if (!project) { + throw error; + } + } + } + resolvedProjectId = project.id; + } else { + let defaultProject = await prisma.devApiProject.findFirst({ + where: { userId: user.id, isDefault: true }, + select: { id: true }, + }); + if (!defaultProject) { + try { + defaultProject = await prisma.devApiProject.create({ + data: { + userId: user.id, + name: 'Default', + isDefault: true, + }, + }); + } catch (error) { + if (!isPrismaUniqueConstraintError(error)) { + throw error; + } + defaultProject = await prisma.devApiProject.findFirst({ + where: { userId: user.id, isDefault: true }, + select: { id: true }, + }); + if (!defaultProject) { + throw error; + } + } + } + resolvedProjectId = defaultProject.id; + } + + const keyLookupId = parseApiKey(rawApiKey)?.lookupId ?? generateKeyLookupId(); + const keyPrefix = getKeyPrefix(rawApiKey); + const resolvedLabel = label && typeof label === 'string' && label.trim() ? label.trim() : null; const apiKey = await prisma.devApiKey.create({ data: { userId: user.id, - projectName, - modelId, - gatewayOfferId: gateway.id, - keyHash, - keyPrefix: rawKey.slice(0, 8), - keyLookupId, + projectId: resolvedProjectId, billingProviderId, - projectId, + modelId: resolvedModelId || null, + gatewayOfferId: resolvedGatewayOfferId || null, + keyLookupId, + keyPrefix, + label: resolvedLabel, status: 'ACTIVE', }, }); return success({ key: apiKey, - rawApiKey: rawKey, + rawApiKey, warning: 'Store this key securely. It will not be shown again.', }); } catch (err) { diff --git a/apps/web-next/src/app/api/v1/developer/projects/route.ts b/apps/web-next/src/app/api/v1/developer/projects/route.ts index c78ccca61..d229fdd6c 100644 --- a/apps/web-next/src/app/api/v1/developer/projects/route.ts +++ b/apps/web-next/src/app/api/v1/developer/projects/route.ts @@ -33,6 +33,7 @@ export async function GET(request: NextRequest): Promise { name: true, isDefault: true, createdAt: true, + _count: { select: { apiKeys: true } }, }, }); diff --git a/apps/web-next/src/app/api/v1/integrations/route.ts b/apps/web-next/src/app/api/v1/integrations/route.ts index a874953f2..85acf0ed6 100644 --- a/apps/web-next/src/app/api/v1/integrations/route.ts +++ b/apps/web-next/src/app/api/v1/integrations/route.ts @@ -7,13 +7,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { success, errors } from '@/lib/api/response'; -/** - * Single source of truth for known integration metadata. - * The IntegrationConfig DB model stores only type, displayName, and configured. - * displayName, category, and description are enriched from this map so the - * response shape is consistent regardless of whether DB rows exist. - */ const INTEGRATION_META: Record = { + daydream: { displayName: 'Daydream', category: 'video', description: 'Real-time AI video generation via Daydream' }, openai: { displayName: 'OpenAI', category: 'ai', description: 'GPT models for AI-powered features' }, anthropic: { displayName: 'Anthropic', category: 'ai', description: 'Claude AI models' }, 'aws-s3': { displayName: 'AWS S3', category: 'storage', description: 'Amazon S3 for file storage' }, @@ -22,8 +17,6 @@ const INTEGRATION_META: Record ({ type, displayName: meta.displayName, @@ -40,13 +33,11 @@ export async function GET(_request: NextRequest): Promise { type: true, displayName: true, configured: true, - // Exclude credentials — never expose secrets to clients }, }); - if (rows.length > 0) { - return success({ - integrations: rows.map((r) => { + const integrations = rows.length > 0 + ? rows.map((r) => { const meta = INTEGRATION_META[r.type]; return { type: r.type, @@ -55,12 +46,10 @@ export async function GET(_request: NextRequest): Promise { category: meta?.category ?? 'other', description: meta?.description ?? '', }; - }), - }); - } + }) + : DEFAULT_INTEGRATIONS; - // No rows yet — return the default catalogue - return success({ integrations: DEFAULT_INTEGRATIONS }); + return success({ integrations }); } catch (err) { console.error('Error fetching integrations:', err); return errors.internal('Failed to fetch integrations'); diff --git a/bin/sync-plugin-registry.ts b/bin/sync-plugin-registry.ts index 4af934000..bc19a9091 100644 --- a/bin/sync-plugin-registry.ts +++ b/bin/sync-plugin-registry.ts @@ -30,6 +30,7 @@ import { toPluginVersionData, getBundleUrl, } from '../packages/database/src/plugin-discovery.js'; +import { BILLING_PROVIDERS } from '@naap/database'; import * as path from 'path'; import { fileURLToPath } from 'url'; diff --git a/package-lock.json b/package-lock.json index f08f2274c..45f1f69ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23687,6 +23687,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index ce27f59d4..a652c924d 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -15,8 +15,9 @@ generator client { provider = "prisma-client-js" output = "../src/generated/client" - // Include all common production targets: Vercel, Docker Alpine, AWS Lambda, etc. - binaryTargets = ["native", "darwin-arm64", "linux-arm64-openssl-3.0.x", "debian-openssl-3.0.x", "rhel-openssl-3.0.x", "linux-musl-openssl-3.0.x"] + // Keep this as "native" so `prisma generate` produces the correct engine + // for the machine doing the build (local dev, CI, Vercel). + binaryTargets = ["native"] previewFeatures = ["multiSchema", "fullTextSearch"] } @@ -175,6 +176,8 @@ model BillingProvider { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + devApiKeys DevApiKey[] + @@index([enabled]) @@schema("public") } @@ -1382,6 +1385,8 @@ model DevApiProject { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + apiKeys DevApiKey[] + @@unique([userId, name]) @@index([userId]) @@schema("plugin_developer_api") @@ -1390,21 +1395,23 @@ model DevApiProject { model DevApiKey { id String @id @default(uuid()) userId String - projectName String - modelId String - model DevApiAIModel @relation(fields: [modelId], references: [id], onDelete: Cascade) + projectId String? + project DevApiProject? @relation(fields: [projectId], references: [id], onDelete: Cascade) + billingProviderId String? + billingProvider BillingProvider? @relation(fields: [billingProviderId], references: [id], onDelete: Restrict) + modelId String? + model DevApiAIModel? @relation(fields: [modelId], references: [id], onDelete: Cascade) gatewayOfferId String? gatewayOffer DevApiGatewayOffer? @relation(fields: [gatewayOfferId], references: [id], onDelete: SetNull) - keyHash String @unique + keyLookupId String? @unique keyPrefix String label String? + projectName String? + keyHash String? @unique status DevApiKeyStatus @default(ACTIVE) createdAt DateTime @default(now()) lastUsedAt DateTime? revokedAt DateTime? - projectId String? - billingProviderId String? - keyLookupId String? usageLogs DevApiUsageLog[] diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 2f3bc0a95..f85029e61 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -13,6 +13,9 @@ import { PrismaClient as GeneratedPrismaClient, Prisma } from './generated/clien // Re-export all types from generated client export * from './generated/client/index.js'; +// Re-export catalog constants +export { BILLING_PROVIDERS } from './billing-providers'; + // Type for transaction client export type TransactionClient = Omit< GeneratedPrismaClient, @@ -267,8 +270,5 @@ export async function withRetry( throw lastError; } -// Billing provider catalog -export { BILLING_PROVIDERS } from './billing-providers'; - // Default export for convenience export default prisma; diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index 90860e58c..63d87ce8a 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -14,7 +14,6 @@ export interface AuthUser { address: string | null; roles: string[]; permissions: Array<{ resource: string; action: string }> | string[]; - // Compatibility aliases used by shell context / plugin SDK avatar?: string | null; walletAddress?: string | null; } diff --git a/packages/utils/src/csrf.ts b/packages/utils/src/csrf.ts index 8b83b1d09..4c2de95ee 100644 --- a/packages/utils/src/csrf.ts +++ b/packages/utils/src/csrf.ts @@ -23,7 +23,7 @@ */ import * as crypto from 'crypto'; -import type { Request, Response, NextFunction } from 'express'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; // CSRF token store: sessionToken -> { token, createdAt } // In production, this should use Redis for distributed deployments @@ -148,7 +148,7 @@ export function createCsrfMiddleware(options: CsrfMiddlewareOptions = {}) { const opts = { ...DEFAULT_OPTIONS, ...options }; const log = opts.logger || console.log; - return function csrfMiddleware(req: Request, res: Response, next: NextFunction) { + const csrfMiddleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => { // Skip safe methods if (opts.skipMethods.includes(req.method)) { return next(); @@ -198,17 +198,20 @@ export function createCsrfMiddleware(options: CsrfMiddlewareOptions = {}) { } log('[CSRF] Rejected request', logData); - return res.status(403).json({ + res.status(403).json({ success: false, error: { code: 'CSRF_INVALID', message: 'Invalid or missing CSRF token', }, }); + return; } next(); }; + + return csrfMiddleware; } /** diff --git a/plugins/developer-api/backend/src/server.ts b/plugins/developer-api/backend/src/server.ts index 00b289be0..cc737e6a1 100644 --- a/plugins/developer-api/backend/src/server.ts +++ b/plugins/developer-api/backend/src/server.ts @@ -15,16 +15,6 @@ const PORT = process.env.PORT || pluginConfig.backend?.devPort || 4007; app.use(cors()); app.use(express.json()); -app.use((req, res, next) => { - const headerId = req.headers['x-request-id']; - const requestId = (typeof headerId === 'string' && headerId.trim().length > 0) - ? headerId.trim() - : crypto.randomUUID(); - - (req as any).requestId = requestId; - res.setHeader('x-request-id', requestId); - next(); -}); app.use(createAuthMiddleware({ publicPaths: ['/healthz'], })); @@ -72,29 +62,29 @@ const inMemoryGatewayOffers: Record = { const inMemoryApiKeys: any[] = []; const inMemoryProjects: any[] = []; +const inMemoryBillingProviders = [ + { id: 'bp-daydream', slug: 'daydream', displayName: 'Daydream', description: 'AI-powered billing via Daydream', icon: 'cloud', authType: 'oauth' }, +]; // ============================================ // Utility Functions // ============================================ -function generateApiKey(): string { - return `naap_${crypto.randomBytes(24).toString('hex')}`; +function parseApiKey(key: string): { lookupId: string; secret: string } | null { + const m = key.match(/^naap_([0-9a-f]{16})_([0-9a-f]{48})$/); + return m ? { lookupId: m[1], secret: m[2] } : null; } -function hashApiKey(key: string): string { - // Use scrypt (a proper KDF) instead of bare SHA-256 - const salt = 'naap-api-key-v1'; - return crypto.scryptSync(key, salt, 32).toString('hex'); +function generateKeyLookupId(): string { + return crypto.randomBytes(8).toString('hex'); } function getKeyPrefix(key: string): string { + const parsed = parseApiKey(key); + if (parsed) return `naap_${parsed.lookupId}...`; return key.substring(0, 12) + '...'; } -function generateKeyLookupId(): string { - return crypto.randomBytes(8).toString('hex'); -} - function getRequestUserId(req: express.Request): string { const user = (req as any).user; if (!user?.id) { @@ -216,30 +206,13 @@ app.get('/api/v1/developer/projects', async (req, res) => { name: true, isDefault: true, createdAt: true, + _count: { select: { apiKeys: true } }, }, }); return res.json({ projects }); } - const projects = inMemoryProjects - .filter((p: any) => p.userId === userId) - .map((p: any, idx: number) => ({ p, idx })) - .sort((a: any, b: any) => { - const aIsDefault = Boolean(a.p?.isDefault); - const bIsDefault = Boolean(b.p?.isDefault); - if (aIsDefault !== bIsDefault) return aIsDefault ? -1 : 1; - - const aName = String(a.p?.name ?? ''); - const bName = String(b.p?.name ?? ''); - const nameCmp = aName.localeCompare(bName); - if (nameCmp !== 0) return nameCmp; - - // Stable tiebreaker (preserve original order). - return a.idx - b.idx; - }) - .map(({ p }: any) => p); - - res.json({ projects }); + res.json({ projects: inMemoryProjects.filter((p: any) => p.userId === userId) }); } catch (error) { console.error('Error fetching projects:', error); res.status(500).json({ error: 'Internal server error' }); @@ -299,6 +272,35 @@ app.post('/api/v1/developer/projects', async (req, res) => { } }); +// ============================================ +// Billing Providers +// ============================================ + +app.get('/api/v1/developer/billing-providers', async (_req, res) => { + try { + if (prisma) { + const providers = await prisma.billingProvider.findMany({ + where: { enabled: true }, + orderBy: { sortOrder: 'asc' }, + select: { + id: true, + slug: true, + displayName: true, + description: true, + icon: true, + authType: true, + }, + }); + return res.json({ providers }); + } + + res.json({ providers: inMemoryBillingProviders }); + } catch (error) { + console.error('Error fetching billing providers:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // ============================================ // API Keys // ============================================ @@ -310,16 +312,24 @@ app.get('/api/v1/developer/keys', async (req, res) => { if (prisma) { const keys = await prisma.devApiKey.findMany({ where: { userId }, - include: { model: true }, orderBy: { createdAt: 'desc' }, + include: { + project: { select: { id: true, name: true, isDefault: true } }, + billingProvider: { + select: { id: true, slug: true, displayName: true }, + }, + model: { select: { id: true, name: true } }, + gatewayOffer: { select: { id: true, gatewayId: true, gatewayName: true } }, + }, }); const formatted = keys.map((k: any) => ({ id: k.id, - projectName: k.projectName, - modelId: k.modelId, + project: k.project, + billingProvider: k.billingProvider, modelName: k.model?.name || 'Unknown', + gatewayName: k.gatewayOffer?.gatewayName || 'Unknown', keyPrefix: k.keyPrefix, - status: k.status.toLowerCase(), + status: k.status, createdAt: k.createdAt.toISOString(), lastUsedAt: k.lastUsedAt?.toISOString() || null, })); @@ -337,19 +347,31 @@ app.get('/api/v1/developer/keys', async (req, res) => { app.get('/api/v1/developer/keys/:id', async (req, res) => { try { const userId = getRequestUserId(req); + if (prisma) { const key = await prisma.devApiKey.findFirst({ - where: { id: req.params.id, userId }, - include: { model: true }, + where: { + id: req.params.id, + userId, + }, + include: { + project: { select: { id: true, name: true, isDefault: true } }, + billingProvider: { + select: { id: true, slug: true, displayName: true }, + }, + model: { select: { id: true, name: true } }, + gatewayOffer: { select: { id: true, gatewayId: true, gatewayName: true } }, + }, }); if (!key) return res.status(404).json({ error: 'API key not found' }); return res.json({ id: key.id, - projectName: key.projectName, - modelId: key.modelId, + project: key.project, + billingProvider: key.billingProvider, modelName: key.model?.name || 'Unknown', + gatewayName: key.gatewayOffer?.gatewayName || 'Unknown', keyPrefix: key.keyPrefix, - status: key.status.toLowerCase(), + status: key.status, createdAt: key.createdAt.toISOString(), lastUsedAt: key.lastUsedAt?.toISOString() || null, }); @@ -366,75 +388,149 @@ app.get('/api/v1/developer/keys/:id', async (req, res) => { app.post('/api/v1/developer/keys', async (req, res) => { try { - const { projectName, modelId, gatewayId, billingProviderId, projectId } = req.body; + const { billingProviderId, rawApiKey, projectId, projectName, modelId, gatewayId, label } = req.body; const userId = getRequestUserId(req); - if (!projectName || !modelId || !gatewayId) { - return res.status(400).json({ error: 'projectName, modelId, and gatewayId required' }); + if (!billingProviderId) { + return res.status(400).json({ error: 'billingProviderId is required' }); + } + if (!rawApiKey || typeof rawApiKey !== 'string') { + return res.status(400).json({ error: 'rawApiKey is required' }); } - const rawKey = generateApiKey(); - const keyHash = hashApiKey(rawKey); - const keyPrefix = getKeyPrefix(rawKey); - const keyLookupId = generateKeyLookupId(); + const keyLookupId = parseApiKey(rawApiKey)?.lookupId ?? generateKeyLookupId(); + const keyPrefix = getKeyPrefix(rawApiKey); if (prisma) { - const model = await prisma.devApiAIModel.findUnique({ where: { id: modelId } }); - if (!model) return res.status(400).json({ error: 'Invalid modelId' }); - - const gatewayOffer = await prisma.devApiGatewayOffer.findFirst({ - where: { modelId, gatewayId }, + const provider = await prisma.billingProvider.findUnique({ + where: { id: billingProviderId }, + select: { id: true, enabled: true }, }); - if (!gatewayOffer) return res.status(400).json({ error: 'Gateway does not offer this model' }); + if (!provider || !provider.enabled) { + return res.status(400).json({ error: 'Invalid or disabled billing provider' }); + } + + let resolvedModelId: string | undefined; + if (modelId && typeof modelId === 'string' && modelId.trim() !== '') { + const model = await prisma.devApiAIModel.findUnique({ where: { id: modelId } }); + if (!model) return res.status(400).json({ error: 'Invalid modelId' }); + resolvedModelId = model.id; + } + + let resolvedGatewayOfferId: string | undefined; + if (resolvedModelId && gatewayId && typeof gatewayId === 'string' && gatewayId.trim() !== '') { + const gatewayOffer = await prisma.devApiGatewayOffer.findFirst({ + where: { modelId: resolvedModelId, gatewayId }, + }); + if (!gatewayOffer) return res.status(400).json({ error: 'Gateway does not offer this model' }); + resolvedGatewayOfferId = gatewayOffer.id; + } + + let resolvedProjectId: string; + if (projectId) { + const project = await prisma.devApiProject.findUnique({ + where: { id: projectId }, + select: { id: true, userId: true }, + }); + if (!project || project.userId !== userId) { + return res.status(400).json({ error: 'Invalid projectId' }); + } + resolvedProjectId = project.id; + } else if (projectName && projectName.trim()) { + const trimmedName = projectName.trim(); + let project = await prisma.devApiProject.findUnique({ + where: { userId_name: { userId, name: trimmedName } }, + select: { id: true }, + }); + if (!project) { + try { + project = await prisma.devApiProject.create({ + data: { userId, name: trimmedName, isDefault: false }, + }); + } catch (err: unknown) { + if ((err as { code?: string })?.code === 'P2002') { + project = await prisma.devApiProject.findUniqueOrThrow({ + where: { userId_name: { userId, name: trimmedName } }, + select: { id: true }, + }); + } else { + throw err; + } + } + } + resolvedProjectId = project.id; + } else { + let defaultProject = await prisma.devApiProject.findFirst({ + where: { userId, isDefault: true }, + select: { id: true }, + }); + if (!defaultProject) { + try { + defaultProject = await prisma.devApiProject.create({ + data: { userId, name: 'Default', isDefault: true }, + }); + } catch (err: unknown) { + if ((err as { code?: string })?.code === 'P2002') { + defaultProject = await prisma.devApiProject.findFirstOrThrow({ + where: { userId, isDefault: true }, + select: { id: true }, + }); + } else { + throw err; + } + } + } + resolvedProjectId = defaultProject.id; + } + + const resolvedLabel = label && typeof label === 'string' && label.trim() ? label.trim() : null; const newKey = await prisma.devApiKey.create({ data: { userId, - projectName, - modelId, - gatewayOfferId: gatewayOffer.id, - keyHash, - keyPrefix, + projectId: resolvedProjectId, + billingProviderId, + modelId: resolvedModelId || null, + gatewayOfferId: resolvedGatewayOfferId || null, keyLookupId, - billingProviderId: billingProviderId || null, - projectId: projectId || null, + keyPrefix, + label: resolvedLabel, status: 'ACTIVE', }, - include: { model: true }, + include: { + project: { select: { id: true, name: true, isDefault: true } }, + billingProvider: { + select: { id: true, slug: true, displayName: true }, + }, + }, }); return res.status(201).json({ key: { id: newKey.id, - projectName: newKey.projectName, - modelId: newKey.modelId, - modelName: newKey.model?.name || 'Unknown', + project: newKey.project, + billingProvider: newKey.billingProvider, keyPrefix: newKey.keyPrefix, - status: 'active', + label: newKey.label, + status: newKey.status, createdAt: newKey.createdAt.toISOString(), }, - rawApiKey: rawKey, + rawApiKey, warning: 'Store this key securely. It will not be shown again.', }); } - // In-memory fallback - const model = inMemoryModels.find(m => m.id === modelId); - if (!model) return res.status(400).json({ error: 'Invalid modelId' }); - - const gateway = (inMemoryGatewayOffers[modelId] || []).find(g => g.gatewayId === gatewayId); - if (!gateway) return res.status(400).json({ error: 'Gateway does not offer this model' }); + const fallbackProject = inMemoryProjects.find((p: any) => p.id === projectId) || { id: 'proj-default', name: 'Default', isDefault: true }; + const fallbackProvider = inMemoryBillingProviders.find(p => p.id === billingProviderId) || inMemoryBillingProviders[0]; const newKey = { id: `key-${Date.now()}`, userId, - projectName, - modelId, - modelName: model.name, - gatewayId, - gatewayName: gateway.gatewayName, + project: { id: fallbackProject.id, name: fallbackProject.name, isDefault: fallbackProject.isDefault }, + billingProvider: { id: fallbackProvider.id, slug: fallbackProvider.slug, displayName: fallbackProvider.displayName }, keyPrefix, - status: 'active', + keyLookupId, + status: 'ACTIVE', createdAt: new Date().toISOString(), lastUsedAt: null, }; @@ -442,7 +538,7 @@ app.post('/api/v1/developer/keys', async (req, res) => { res.status(201).json({ key: newKey, - rawApiKey: rawKey, + rawApiKey, warning: 'Store this key securely. It will not be shown again.', }); } catch (error) { @@ -454,11 +550,12 @@ app.post('/api/v1/developer/keys', async (req, res) => { app.delete('/api/v1/developer/keys/:id', async (req, res) => { try { const userId = getRequestUserId(req); + if (prisma) { const key = await prisma.devApiKey.findUnique({ where: { id: req.params.id } }); - if (!key) return res.status(404).json({ error: 'API key not found' }); - if (key.userId !== userId) return res.status(404).json({ error: 'API key not found' }); - + if (!key || key.userId !== userId) { + return res.status(404).json({ error: 'API key not found' }); + } await prisma.devApiKey.update({ where: { id: req.params.id }, data: { status: 'REVOKED', revokedAt: new Date() }, @@ -469,8 +566,7 @@ app.delete('/api/v1/developer/keys/:id', async (req, res) => { const keyIndex = inMemoryApiKeys.findIndex((k: any) => k.id === req.params.id && k.userId === userId); if (keyIndex === -1) return res.status(404).json({ error: 'API key not found' }); - - inMemoryApiKeys[keyIndex].status = 'revoked'; + inMemoryApiKeys[keyIndex].status = 'REVOKED'; res.json({ message: 'API key revoked', key: inMemoryApiKeys[keyIndex] }); } catch (error) { console.error('Error revoking key:', error); @@ -508,7 +604,7 @@ app.get('/api/v1/developer/usage', async (req, res) => { // Fallback res.json({ totalKeys: inMemoryApiKeys.length, - activeKeys: inMemoryApiKeys.filter(k => k.status === 'active').length, + activeKeys: inMemoryApiKeys.filter(k => k.status?.toUpperCase?.() === 'ACTIVE').length, totalRequests: 0, totalCost: '0.0000', }); @@ -523,20 +619,8 @@ app.get('/api/v1/developer/usage', async (req, res) => { // ============================================ app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - const requestId = (_req as any)?.requestId; - console.error('Unhandled error:', { - requestId, - method: _req.method, - path: _req.originalUrl, - error: err instanceof Error - ? { name: err.name, message: err.message, stack: err.stack } - : err, - }); - - res.status(500).json({ - error: 'Internal server error', - requestId, - }); + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); }); // ============================================ diff --git a/plugins/developer-api/frontend/src/pages/DeveloperView.tsx b/plugins/developer-api/frontend/src/pages/DeveloperView.tsx index b0ce7c5a8..9d2028b62 100644 --- a/plugins/developer-api/frontend/src/pages/DeveloperView.tsx +++ b/plugins/developer-api/frontend/src/pages/DeveloperView.tsx @@ -1,7 +1,22 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Box, Key, BarChart3, BookOpen, Plus, Copy, RefreshCw, Trash2, Search, CreditCard, Cloud } from 'lucide-react'; -import { Card, Badge } from '@naap/ui'; +import { + Box, + Key, + BarChart3, + BookOpen, + Plus, + Copy, + Trash2, + Search, + Check, + AlertTriangle, + Shield, + Loader2, + CreditCard, + Cloud, +} from 'lucide-react'; +import { Card, Badge, Modal } from '@naap/ui'; import { getServiceOrigin } from '@naap/plugin-sdk'; type TabId = 'models' | 'api-keys' | 'usage' | 'docs'; @@ -19,12 +34,19 @@ interface AIModel { badges: string[]; } +interface ApiKeyProject { + id: string; + name: string; + isDefault: boolean; +} + interface ApiKey { id: string; - projectName: string; - modelName: string; - gatewayName: string; + project: ApiKeyProject; + billingProvider: { id: string; slug: string; displayName: string }; status: string; + keyPrefix: string; + label: string | null; createdAt: string; lastUsedAt: string | null; } @@ -38,26 +60,123 @@ interface BillingProviderInfo { authType: string; } -// '' in production (same-origin), 'http://localhost:4011' in dev +interface ProjectInfo { + id: string; + name: string; + isDefault: boolean; +} + const BASE_URL = getServiceOrigin('developer-api'); +async function fetchCsrfToken(): Promise { + try { + const res = await fetch('/api/v1/auth/csrf', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + return data.data?.token || data.token || ''; + } + } catch (err) { + console.warn('Failed to fetch CSRF token:', err); + } + return ''; +} + +function delayWithAbort(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + + const onAbort = () => { + window.clearTimeout(timeoutId); + signal.removeEventListener('abort', onAbort); + reject(new Error('Polling aborted')); + }; + + if (signal.aborted) { + onAbort(); + return; + } + + signal.addEventListener('abort', onAbort); + }); +} + const tabs = [ - { id: 'models' as TabId, label: 'Models', icon: }, { id: 'api-keys' as TabId, label: 'API Keys', icon: }, { id: 'usage' as TabId, label: 'Usage & Billing', icon: }, + { id: 'models' as TabId, label: 'Models', icon: }, { id: 'docs' as TabId, label: 'Docs', icon: }, ]; +const selectClassName = + 'w-full bg-bg-tertiary border border-white/10 rounded-xl py-3 px-4 text-sm text-text-primary focus:outline-none focus:border-accent-blue appearance-none cursor-pointer'; + +const inputClassName = + 'w-full bg-bg-tertiary border border-white/10 rounded-xl py-3 px-4 text-sm text-text-primary focus:outline-none focus:border-accent-blue'; + export const DeveloperView: React.FC = () => { - const [activeTab, setActiveTab] = useState('models'); + const [activeTab, setActiveTab] = useState('api-keys'); const [models, setModels] = useState([]); const [apiKeys, setApiKeys] = useState([]); const [_loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); + const [showRevoked, setShowRevoked] = useState(false); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [createStep, setCreateStep] = useState<'form' | 'oauth' | 'success'>('form'); + const [createdRawKey, setCreatedRawKey] = useState(''); + const [createError, setCreateError] = useState(''); + const [creating, setCreating] = useState(false); + const [keyCopied, setKeyCopied] = useState(false); + + const [projects, setProjects] = useState([]); const [billingProviders, setBillingProviders] = useState([]); + const [modalDataLoading, setModalDataLoading] = useState(false); + const [selectedProjectId, setSelectedProjectId] = useState(''); + const [newProjectName, setNewProjectName] = useState(''); + const [newKeyLabel, setNewKeyLabel] = useState(''); + const [selectedBillingProviderId, setSelectedBillingProviderId] = useState(''); - useEffect(() => { - loadData(); + const [revokeKeyId, setRevokeKeyId] = useState(null); + const [revoking, setRevoking] = useState(false); + const pollAbortControllerRef = useRef(null); + + const revokedCount = useMemo(() => apiKeys.filter(k => k.status === 'REVOKED').length, [apiKeys]); + + const displayedKeys = useMemo(() => { + const filtered = showRevoked ? apiKeys : apiKeys.filter(k => k.status !== 'REVOKED'); + return [...filtered].sort((a, b) => { + const aDefault = a.project?.isDefault ? 1 : 0; + const bDefault = b.project?.isDefault ? 1 : 0; + if (aDefault !== bDefault) return bDefault - aDefault; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + }, [apiKeys, showRevoked]); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [modelsJson, keysJson] = await Promise.all([ + fetch(`${BASE_URL}/api/v1/developer/models`).then(r => r.json()), + fetch('/api/v1/developer/keys').then(r => r.json()), + ]); + setModels((modelsJson.data ?? modelsJson).models || []); + setApiKeys((keysJson.data ?? keysJson).keys || []); + } catch (err) { + console.error('Failed to load data:', err); + setModels(getMockModels()); + setApiKeys([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + useEffect(() => () => { + pollAbortControllerRef.current?.abort(); }, []); const loadBillingProviders = useCallback(async () => { @@ -73,28 +192,220 @@ export const DeveloperView: React.FC = () => { if (activeTab === 'usage') loadBillingProviders(); }, [activeTab, loadBillingProviders]); - const loadData = async () => { - setLoading(true); + const loadModalData = useCallback(async () => { + setModalDataLoading(true); try { - const [modelsJson, keysJson] = await Promise.all([ - fetch(`${BASE_URL}/api/v1/developer/models`).then(r => r.json()), - fetch(`${BASE_URL}/api/v1/developer/keys`).then(r => r.json()), + const [projectsJson, bpJson] = await Promise.all([ + fetch('/api/v1/developer/projects').then(r => r.json()), + fetch('/api/v1/billing-providers').then(r => r.json()), ]); - // API routes wrap responses in { success, data: { models/keys }, meta } - const modelsPayload = modelsJson.data ?? modelsJson; - const keysPayload = keysJson.data ?? keysJson; - setModels(modelsPayload.models || []); - setApiKeys(keysPayload.keys || []); + const projectList: ProjectInfo[] = (projectsJson.data ?? projectsJson).projects || []; + const providerList: BillingProviderInfo[] = (bpJson.data ?? bpJson).providers || []; + setProjects(projectList); + setBillingProviders(providerList); + if (projectList.length > 0) { + setSelectedProjectId((projectList.find(p => p.isDefault) || projectList[0]).id); + } + if (providerList.length > 0) { + setSelectedBillingProviderId(providerList[0].id); + } } catch (err) { - console.error('Failed to load data:', err); - setModels(getMockModels()); - setApiKeys(getMockKeys()); + console.error('Failed to load modal data:', err); } finally { - setLoading(false); + setModalDataLoading(false); + } + }, []); + + const openCreateModal = useCallback(() => { + setCreateStep('form'); + setCreatedRawKey(''); + setCreateError(''); + setCreating(false); + setKeyCopied(false); + setSelectedProjectId(''); + setNewProjectName(''); + setNewKeyLabel(''); + setSelectedBillingProviderId(''); + setShowCreateModal(true); + loadModalData(); + }, [loadModalData]); + + const closeCreateModal = useCallback(() => { + pollAbortControllerRef.current?.abort(); + setShowCreateModal(false); + if (createStep === 'success') loadData(); + }, [createStep, loadData]); + + const handleCreateKey = useCallback(async () => { + setCreateError(''); + const resolvedProjectId = selectedProjectId === '__new__' ? undefined : selectedProjectId; + const resolvedProjectName = selectedProjectId === '__new__' ? newProjectName.trim() : undefined; + + if (selectedProjectId === '__new__' && !resolvedProjectName) { + setCreateError('Please enter a project name.'); + return; + } + if (!selectedBillingProviderId) { + setCreateError('Please select a billing provider.'); + return; } - }; + const selectedProvider = billingProviders.find(bp => bp.id === selectedBillingProviderId); + if (!selectedProvider) { + setCreateError('Selected billing provider not found.'); + return; + } + const providerSlug = selectedProvider.slug; + + setCreating(true); + setCreateStep('oauth'); + + try { + pollAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + pollAbortControllerRef.current = abortController; + + const startRes = await fetch(`/api/v1/auth/providers/${encodeURIComponent(providerSlug)}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!startRes.ok) { + setCreateError('Failed to start authentication with billing provider.'); + setCreateStep('form'); + setCreating(false); + return; + } + const startData = await startRes.json(); + const authUrl = startData.data?.auth_url || startData.auth_url; + const loginSessionId = startData.data?.login_session_id || startData.login_session_id; + if (!authUrl || !loginSessionId) { + setCreateError('Missing auth URL from billing provider.'); + setCreateStep('form'); + setCreating(false); + return; + } + + window.open(authUrl, '_blank', 'noopener,noreferrer'); + + const pollInterval = 2000; + const pollTimeout = 180000; + const started = Date.now(); + let providerApiKey: string | null = null; + + while (Date.now() - started < pollTimeout && !abortController.signal.aborted) { + try { + await delayWithAbort(pollInterval, abortController.signal); + } catch { + break; + } + + if (abortController.signal.aborted) { + break; + } + + try { + const pollRes = await fetch( + `/api/v1/auth/providers/${encodeURIComponent(providerSlug)}/result?login_session_id=${encodeURIComponent( + loginSessionId + )}`, + { signal: abortController.signal } + ); + if (!pollRes.ok) break; + const pollData = await pollRes.json(); + const status = pollData.data?.status || pollData.status; + if (status === 'complete') { + providerApiKey = pollData.data?.access_token || pollData.access_token; + break; + } + if (status === 'expired' || status === 'denied') { + setCreateError(`Authentication ${status}. Please try again.`); + setCreateStep('form'); + setCreating(false); + return; + } + } catch { + break; + } + } - const filteredModels = models.filter(m => + if (abortController.signal.aborted) { + return; + } + + if (!providerApiKey) { + setCreateError('Authentication timed out. Please try again.'); + setCreateStep('form'); + setCreating(false); + return; + } + + const csrfToken = await fetchCsrfToken(); + const res = await fetch('/api/v1/developer/keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, + credentials: 'include', + body: JSON.stringify({ + billingProviderId: selectedBillingProviderId, + rawApiKey: providerApiKey, + projectId: resolvedProjectId || undefined, + projectName: resolvedProjectName || undefined, + label: newKeyLabel.trim() || undefined, + }), + }); + const json = await res.json(); + const payload = json.data ?? json; + + if (!res.ok) { + setCreateError(payload.error || json.error || 'Failed to create API key'); + setCreateStep('form'); + return; + } + + setCreatedRawKey(providerApiKey); + setCreateStep('success'); + } catch (err) { + if (pollAbortControllerRef.current?.signal.aborted) { + return; + } + console.error('Error creating key:', err); + setCreateError('Network error. Please try again.'); + setCreateStep('form'); + } finally { + pollAbortControllerRef.current = null; + setCreating(false); + } + }, [selectedProjectId, newProjectName, newKeyLabel, selectedBillingProviderId, billingProviders]); + + const handleCopyKey = useCallback(async () => { + try { + await navigator.clipboard.writeText(createdRawKey); + setKeyCopied(true); + setTimeout(() => setKeyCopied(false), 2000); + } catch { /* fallback */ } + }, [createdRawKey]); + + const handleRevokeKey = useCallback(async () => { + if (!revokeKeyId) return; + setRevoking(true); + try { + const csrfToken = await fetchCsrfToken(); + const res = await fetch(`/api/v1/developer/keys/${revokeKeyId}`, { + method: 'DELETE', + headers: { 'X-CSRF-Token': csrfToken }, + credentials: 'include', + }); + if (res.ok) { + await loadData(); + } + } catch (err) { + console.error('Error revoking key:', err); + } finally { + setRevoking(false); + setRevokeKeyId(null); + } + }, [revokeKeyId, loadData]); + + const filteredModels = models.filter(m => m.name.toLowerCase().includes(searchQuery.toLowerCase()) || m.type.toLowerCase().includes(searchQuery.toLowerCase()) ); @@ -105,20 +416,14 @@ export const DeveloperView: React.FC = () => {

Developer API Manager

Explore models, manage API keys, and track usage

-
- - + + {activeTab === 'models' && (
- setSearchQuery(e.target.value)} + setSearchQuery(e.target.value)} className="w-full bg-bg-secondary border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm focus:outline-none focus:border-accent-blue" />
@@ -159,41 +462,71 @@ export const DeveloperView: React.FC = () => { )} {activeTab === 'api-keys' && ( -
+
-

{apiKeys.length} API keys

- + )} +
+
- -
- {apiKeys.map((key) => ( -
-
-
- -
-
-

{key.projectName}

-

{key.modelName} • {key.gatewayName}

-
-
-
- {key.status} - - - -
-
- ))} - {apiKeys.length === 0 && ( -
- No API keys yet. Create one to get started. -
- )} -
-
+ {displayedKeys.length > 0 ? ( + +
+ + + + + + + + + + + {displayedKeys.map((key) => ( + + + + + + + ))} + +
NameSecret KeyCreatedStatus
+ {key.label || key.keyPrefix} + + {key.keyPrefix} + + + {new Date(key.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + + +
+ {key.status} + {key.status !== 'REVOKED' && ( + + )} +
+
+
+
+ ) : ( + +
+ No API keys yet. Create one to get started. +
+
+ )}
)} @@ -269,6 +602,131 @@ export const DeveloperView: React.FC = () => { )} + + {/* ===== Create Key Modal ===== */} + + {createStep === 'form' && ( +
+
+ + + {selectedProjectId === '__new__' && ( + setNewProjectName(e.target.value)} className={`${inputClassName} mt-2`} autoFocus /> + )} +
+
+ + setNewKeyLabel(e.target.value)} className={inputClassName} /> +

A friendly name for this key. If left empty, the key prefix will be shown.

+
+
+ +

You will be redirected to authenticate with the selected provider.

+ {modalDataLoading ? ( +
+ + Loading billing providers... +
+ ) : billingProviders.length === 0 ? ( +
+ +
+

No billing providers available

+

Contact your administrator to configure a billing provider.

+
+
+ ) : ( + + )} +
+ {createError && ( +
+ {createError} +
+ )} +
+ + +
+
+ )} + {createStep === 'oauth' && ( +
+ +
+

Waiting for authentication...

+

Complete the sign-in in the new tab that opened. This page will update automatically.

+
+
+ )} + {createStep === 'success' && ( +
+
+ +
+

Store this key securely

+

This is the only time your API key will be shown. Copy it now and store it in a safe place.

+
+
+
+ +
+ + {createdRawKey} + + +
+
+
+ +
+
+ )} +
+ + {/* ===== Revoke Confirmation Modal ===== */} + setRevokeKeyId(null)} title="Revoke API Key" size="sm"> +
+

+ Are you sure you want to revoke this API key? This action cannot be undone and any applications using this key will stop working. +

+
+ + +
+
+
); }; @@ -281,11 +739,4 @@ function getMockModels(): AIModel[] { ]; } -function getMockKeys(): ApiKey[] { - return [ - { id: 'key-1', projectName: 'Production App', modelName: 'SDXL Turbo', gatewayName: 'Gateway Alpha', status: 'active', createdAt: '2024-01-15', lastUsedAt: '2024-01-20' }, - { id: 'key-2', projectName: 'Development', modelName: 'Stable Diffusion 1.5', gatewayName: 'Gateway Beta', status: 'active', createdAt: '2024-01-10', lastUsedAt: null }, - ]; -} - export default DeveloperView; diff --git a/services/workflows/developer-svc/src/server.ts b/services/workflows/developer-svc/src/server.ts index d3544984a..0703c75ee 100644 --- a/services/workflows/developer-svc/src/server.ts +++ b/services/workflows/developer-svc/src/server.ts @@ -2,6 +2,7 @@ import express from 'express'; import cors from 'cors'; import { v4 as uuidv4 } from 'uuid'; import { createCsrfMiddleware } from '@naap/utils'; +import type { RequestHandler } from 'express'; import { models, gatewayOffers, @@ -26,7 +27,7 @@ app.use('/api', createCsrfMiddleware({ skipPaths: ['/healthz', '/health'], logOnly: !csrfEnforce, logger: (msg, data) => console.log(`[developer-svc] ${msg}`, data), -})); +}) as unknown as RequestHandler); // Health check app.get('/healthz', (_req, res) => { From b269e2289f32d1f7c26156114ec230aefda35807 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 20 Feb 2026 15:03:53 -0500 Subject: [PATCH 05/14] fix(prisma.schema): revert changes to binaryTargets --- packages/database/prisma/schema.prisma | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index a652c924d..944e2ba9e 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -15,9 +15,8 @@ generator client { provider = "prisma-client-js" output = "../src/generated/client" - // Keep this as "native" so `prisma generate` produces the correct engine - // for the machine doing the build (local dev, CI, Vercel). - binaryTargets = ["native"] + // Include all common production targets: Vercel, Docker Alpine, AWS Lambda, etc. + binaryTargets = ["native", "darwin-arm64", "linux-arm64-openssl-3.0.x", "debian-openssl-3.0.x", "rhel-openssl-3.0.x", "linux-musl-openssl-3.0.x"] previewFeatures = ["multiSchema", "fullTextSearch"] } From 8b7b5309a24c42bc42804e8c33a21e3430ded495 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 20 Feb 2026 15:55:17 -0500 Subject: [PATCH 06/14] feat(developer-api): refactor project ID resolution logic - Introduced a new utility function `resolveDevApiProjectId` to streamline project ID resolution based on provided project ID or name. - Removed redundant project retrieval logic from the API endpoint, enhancing code clarity and maintainability. - Added error handling for invalid project IDs using a custom error class `DevApiProjectResolutionError`. - Updated the server to utilize the new resolution logic, improving overall error management and response consistency. --- .../src/app/api/v1/developer/keys/route.ts | 84 ++----------- .../src/dev-api/resolveDevApiProject.ts | 117 ++++++++++++++++++ packages/database/src/index.ts | 3 + plugins/developer-api/backend/src/server.ts | 102 +++++++-------- .../frontend/src/pages/DeveloperView.tsx | 14 ++- 5 files changed, 187 insertions(+), 133 deletions(-) create mode 100644 packages/database/src/dev-api/resolveDevApiProject.ts diff --git a/apps/web-next/src/app/api/v1/developer/keys/route.ts b/apps/web-next/src/app/api/v1/developer/keys/route.ts index 40e50db01..c0aae8215 100644 --- a/apps/web-next/src/app/api/v1/developer/keys/route.ts +++ b/apps/web-next/src/app/api/v1/developer/keys/route.ts @@ -10,15 +10,7 @@ import { prisma } from '@/lib/db'; import { validateSession } from '@/lib/api/auth'; import { success, errors, getAuthToken, parsePagination } from '@/lib/api/response'; import { validateCSRF } from '@/lib/api/csrf'; - -function isPrismaUniqueConstraintError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code?: string }).code === 'P2002' - ); -} +import { DevApiProjectResolutionError, resolveDevApiProjectId } from '@naap/database'; function parseApiKey(key: string): { lookupId: string; secret: string } | null { const m = key.match(/^naap_([0-9a-f]{16})_([0-9a-f]{48})$/); @@ -163,72 +155,18 @@ export async function POST(request: NextRequest): Promise { } let resolvedProjectId: string; - if (projectId) { - const project = await prisma.devApiProject.findUnique({ - where: { id: projectId }, - select: { id: true, userId: true }, - }); - if (!project || project.userId !== user.id) { - return errors.badRequest('Invalid projectId'); - } - resolvedProjectId = project.id; - } else if (projectName && projectName.trim()) { - const trimmedName = projectName.trim(); - let project = await prisma.devApiProject.findUnique({ - where: { userId_name: { userId: user.id, name: trimmedName } }, - select: { id: true }, - }); - if (!project) { - try { - project = await prisma.devApiProject.create({ - data: { - userId: user.id, - name: trimmedName, - isDefault: false, - }, - }); - } catch (error) { - if (!isPrismaUniqueConstraintError(error)) { - throw error; - } - project = await prisma.devApiProject.findUnique({ - where: { userId_name: { userId: user.id, name: trimmedName } }, - select: { id: true }, - }); - if (!project) { - throw error; - } - } - } - resolvedProjectId = project.id; - } else { - let defaultProject = await prisma.devApiProject.findFirst({ - where: { userId: user.id, isDefault: true }, - select: { id: true }, + try { + resolvedProjectId = await resolveDevApiProjectId({ + prisma, + userId: user.id, + projectId, + projectName, }); - if (!defaultProject) { - try { - defaultProject = await prisma.devApiProject.create({ - data: { - userId: user.id, - name: 'Default', - isDefault: true, - }, - }); - } catch (error) { - if (!isPrismaUniqueConstraintError(error)) { - throw error; - } - defaultProject = await prisma.devApiProject.findFirst({ - where: { userId: user.id, isDefault: true }, - select: { id: true }, - }); - if (!defaultProject) { - throw error; - } - } + } catch (error) { + if (error instanceof DevApiProjectResolutionError) { + return errors.badRequest(error.message); } - resolvedProjectId = defaultProject.id; + throw error; } const keyLookupId = parseApiKey(rawApiKey)?.lookupId ?? generateKeyLookupId(); diff --git a/packages/database/src/dev-api/resolveDevApiProject.ts b/packages/database/src/dev-api/resolveDevApiProject.ts new file mode 100644 index 000000000..f98341cb6 --- /dev/null +++ b/packages/database/src/dev-api/resolveDevApiProject.ts @@ -0,0 +1,117 @@ +import { Prisma } from '../generated/client/index.js'; +import type { PrismaClient } from '../generated/client/index.js'; + +export class DevApiProjectResolutionError extends Error { + public readonly code: 'INVALID_PROJECT_ID'; + + constructor(message: string, code: 'INVALID_PROJECT_ID' = 'INVALID_PROJECT_ID') { + super(message); + this.name = 'DevApiProjectResolutionError'; + this.code = code; + } +} + +function isPrismaUniqueConstraintError(error: unknown): boolean { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + return error.code === 'P2002'; + } + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: string }).code === 'P2002' + ); +} + +export async function resolveDevApiProjectId(params: { + prisma: Pick; + userId: string; + projectId?: string | undefined; + projectName?: string | undefined; + defaultProjectName?: string | undefined; +}): Promise { + const { + prisma, + userId, + projectId, + projectName, + defaultProjectName = 'Default', + } = params; + + if (projectId) { + const project = await prisma.devApiProject.findUnique({ + where: { id: projectId }, + select: { id: true, userId: true }, + }); + if (!project || project.userId !== userId) { + throw new DevApiProjectResolutionError('Invalid projectId'); + } + return project.id; + } + + if (projectName && projectName.trim()) { + const trimmedName = projectName.trim(); + let project = await prisma.devApiProject.findUnique({ + where: { userId_name: { userId, name: trimmedName } }, + select: { id: true }, + }); + + if (!project) { + try { + project = await prisma.devApiProject.create({ + data: { + userId, + name: trimmedName, + isDefault: false, + }, + select: { id: true }, + }); + } catch (error) { + if (!isPrismaUniqueConstraintError(error)) { + throw error; + } + project = await prisma.devApiProject.findUnique({ + where: { userId_name: { userId, name: trimmedName } }, + select: { id: true }, + }); + if (!project) { + throw error; + } + } + } + + return project.id; + } + + let defaultProject = await prisma.devApiProject.findFirst({ + where: { userId, isDefault: true }, + select: { id: true }, + }); + + if (!defaultProject) { + try { + defaultProject = await prisma.devApiProject.create({ + data: { + userId, + name: defaultProjectName, + isDefault: true, + }, + select: { id: true }, + }); + } catch (error) { + if (!isPrismaUniqueConstraintError(error)) { + throw error; + } + defaultProject = await prisma.devApiProject.findFirst({ + where: { userId, isDefault: true }, + select: { id: true }, + }); + if (!defaultProject) { + throw error; + } + } + } + + return defaultProject.id; +} + diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index f85029e61..7088643d3 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -16,6 +16,9 @@ export * from './generated/client/index.js'; // Re-export catalog constants export { BILLING_PROVIDERS } from './billing-providers'; +// Shared developer-api utilities +export { DevApiProjectResolutionError, resolveDevApiProjectId } from './dev-api/resolveDevApiProject'; + // Type for transaction client export type TransactionClient = Omit< GeneratedPrismaClient, diff --git a/plugins/developer-api/backend/src/server.ts b/plugins/developer-api/backend/src/server.ts index cc737e6a1..f3bae2f86 100644 --- a/plugins/developer-api/backend/src/server.ts +++ b/plugins/developer-api/backend/src/server.ts @@ -13,6 +13,17 @@ const pluginConfig = JSON.parse( const app = express(); const PORT = process.env.PORT || pluginConfig.backend?.devPort || 4007; +app.use((req, res, next) => { + const incoming = req.header('x-request-id'); + const requestId = + typeof incoming === 'string' && incoming.trim().length > 0 + ? incoming.trim() + : (crypto as any).randomUUID?.() ?? crypto.randomBytes(16).toString('hex'); + + (req as any).requestId = requestId; + res.setHeader('x-request-id', requestId); + next(); +}); app.use(cors()); app.use(express.json()); app.use(createAuthMiddleware({ @@ -25,11 +36,15 @@ app.use(createAuthMiddleware({ // Dynamic import for Prisma client (generated) let prisma: any = null; +let resolveDevApiProjectId: any = null; +let DevApiProjectResolutionError: any = null; async function initDatabase() { try { - const { prisma: dbClient } = await import('@naap/database'); - prisma = dbClient; + const db = await import('@naap/database'); + prisma = db.prisma; + resolveDevApiProjectId = db.resolveDevApiProjectId; + DevApiProjectResolutionError = db.DevApiProjectResolutionError; await prisma.$connect(); console.log('✅ Database connected'); return true; @@ -427,60 +442,18 @@ app.post('/api/v1/developer/keys', async (req, res) => { } let resolvedProjectId: string; - if (projectId) { - const project = await prisma.devApiProject.findUnique({ - where: { id: projectId }, - select: { id: true, userId: true }, - }); - if (!project || project.userId !== userId) { - return res.status(400).json({ error: 'Invalid projectId' }); - } - resolvedProjectId = project.id; - } else if (projectName && projectName.trim()) { - const trimmedName = projectName.trim(); - let project = await prisma.devApiProject.findUnique({ - where: { userId_name: { userId, name: trimmedName } }, - select: { id: true }, - }); - if (!project) { - try { - project = await prisma.devApiProject.create({ - data: { userId, name: trimmedName, isDefault: false }, - }); - } catch (err: unknown) { - if ((err as { code?: string })?.code === 'P2002') { - project = await prisma.devApiProject.findUniqueOrThrow({ - where: { userId_name: { userId, name: trimmedName } }, - select: { id: true }, - }); - } else { - throw err; - } - } - } - resolvedProjectId = project.id; - } else { - let defaultProject = await prisma.devApiProject.findFirst({ - where: { userId, isDefault: true }, - select: { id: true }, + try { + resolvedProjectId = await resolveDevApiProjectId({ + prisma, + userId, + projectId, + projectName, }); - if (!defaultProject) { - try { - defaultProject = await prisma.devApiProject.create({ - data: { userId, name: 'Default', isDefault: true }, - }); - } catch (err: unknown) { - if ((err as { code?: string })?.code === 'P2002') { - defaultProject = await prisma.devApiProject.findFirstOrThrow({ - where: { userId, isDefault: true }, - select: { id: true }, - }); - } else { - throw err; - } - } + } catch (err: unknown) { + if (DevApiProjectResolutionError && err instanceof DevApiProjectResolutionError) { + return res.status(400).json({ error: (err as Error).message }); } - resolvedProjectId = defaultProject.id; + throw err; } const resolvedLabel = label && typeof label === 'string' && label.trim() ? label.trim() : null; @@ -619,8 +592,25 @@ app.get('/api/v1/developer/usage', async (req, res) => { // ============================================ app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - console.error('Unhandled error:', err); - res.status(500).json({ error: 'Internal server error' }); + const req = _req as any; + const requestId = req.requestId || req.headers?.['x-request-id'] || 'unknown'; + const method = req.method || 'UNKNOWN'; + const path = req.originalUrl || req.url || 'unknown'; + + console.error( + `[developer-api][${requestId}] Unhandled error on ${method} ${path}:`, + err + ); + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal server error', + requestId, + method, + path, + }, + }); }); // ============================================ diff --git a/plugins/developer-api/frontend/src/pages/DeveloperView.tsx b/plugins/developer-api/frontend/src/pages/DeveloperView.tsx index 9d2028b62..74fb9a16f 100644 --- a/plugins/developer-api/frontend/src/pages/DeveloperView.tsx +++ b/plugins/developer-api/frontend/src/pages/DeveloperView.tsx @@ -663,13 +663,19 @@ export const DeveloperView: React.FC = () => {
)}
- - +
)} From 73d0b3a0dda98f3407385559acf42979e6a80afc Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 20 Feb 2026 15:55:25 -0500 Subject: [PATCH 07/14] feat(developer-api): enhance DeveloperView with project filtering and data loading - Added a project filter to the DeveloperView, allowing users to filter API keys by associated projects. - Updated data loading logic to fetch projects alongside models and API keys, improving the user experience. - Refactored displayed keys logic to incorporate project filtering, ensuring accurate display based on selected project. - Adjusted TypeScript configuration to improve project path resolution and included additional source files for better type checking. --- package-lock.json | 1 - plugins/developer-api/frontend/src/App.tsx | 2 +- .../frontend/src/pages/DeveloperView.tsx | 48 +++++++++++++++++-- plugins/developer-api/frontend/tsconfig.json | 22 ++++++--- .../frontend/tsconfig.tsbuildinfo | 2 +- 5 files changed, 63 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 45f1f69ed..f08f2274c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23687,7 +23687,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/plugins/developer-api/frontend/src/App.tsx b/plugins/developer-api/frontend/src/App.tsx index 69385c005..a19621866 100644 --- a/plugins/developer-api/frontend/src/App.tsx +++ b/plugins/developer-api/frontend/src/App.tsx @@ -20,7 +20,7 @@ const plugin = createPlugin({ }); /** @deprecated Use SDK hooks (useShell, useApiClient, etc.) instead */ -export const getShellContext = plugin.getContext; +export const getShellContext = (plugin as any).getContext; export const manifest = plugin; export const mount = plugin.mount; diff --git a/plugins/developer-api/frontend/src/pages/DeveloperView.tsx b/plugins/developer-api/frontend/src/pages/DeveloperView.tsx index 74fb9a16f..13b68df3d 100644 --- a/plugins/developer-api/frontend/src/pages/DeveloperView.tsx +++ b/plugins/developer-api/frontend/src/pages/DeveloperView.tsx @@ -123,6 +123,7 @@ export const DeveloperView: React.FC = () => { const [_loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [showRevoked, setShowRevoked] = useState(false); + const [projectFilterId, setProjectFilterId] = useState<'__all__' | string>('__all__'); const [showCreateModal, setShowCreateModal] = useState(false); const [createStep, setCreateStep] = useState<'form' | 'oauth' | 'success'>('form'); @@ -146,24 +147,29 @@ export const DeveloperView: React.FC = () => { const revokedCount = useMemo(() => apiKeys.filter(k => k.status === 'REVOKED').length, [apiKeys]); const displayedKeys = useMemo(() => { - const filtered = showRevoked ? apiKeys : apiKeys.filter(k => k.status !== 'REVOKED'); + const filteredByRevoked = showRevoked ? apiKeys : apiKeys.filter(k => k.status !== 'REVOKED'); + const filtered = projectFilterId === '__all__' + ? filteredByRevoked + : filteredByRevoked.filter(k => k.project?.id === projectFilterId); return [...filtered].sort((a, b) => { const aDefault = a.project?.isDefault ? 1 : 0; const bDefault = b.project?.isDefault ? 1 : 0; if (aDefault !== bDefault) return bDefault - aDefault; return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); }); - }, [apiKeys, showRevoked]); + }, [apiKeys, showRevoked, projectFilterId]); const loadData = useCallback(async () => { setLoading(true); try { - const [modelsJson, keysJson] = await Promise.all([ + const [modelsJson, keysJson, projectsJson] = await Promise.all([ fetch(`${BASE_URL}/api/v1/developer/models`).then(r => r.json()), fetch('/api/v1/developer/keys').then(r => r.json()), + fetch('/api/v1/developer/projects').then(r => r.json()), ]); setModels((modelsJson.data ?? modelsJson).models || []); setApiKeys((keysJson.data ?? keysJson).keys || []); + setProjects((projectsJson.data ?? projectsJson).projects || []); } catch (err) { console.error('Failed to load data:', err); setModels(getMockModels()); @@ -466,6 +472,29 @@ export const DeveloperView: React.FC = () => {

{displayedKeys.length} API key{displayedKeys.length !== 1 ? 's' : ''}

+
+ Project + +
{revokedCount > 0 && (