From d2a5d7684e33c06110e441f0064aa791b5c74ef9 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Mon, 20 Jul 2026 19:05:43 -0700 Subject: [PATCH] Add OpenPayd marketplace admin integration --- api/src/routes/openpayd-connector.ts | 114 ++++++++++++++ api/src/server.ts | 14 +- portal/src/app/admin/openpayd/page.tsx | 149 ++++++++++++++++++ portal/src/app/admin/page.tsx | 9 +- .../api/openpayd/commands/[command]/route.ts | 37 +++++ portal/src/app/api/openpayd/status/route.ts | 35 ++++ .../dashboard/page.tsx | 138 ++++++++++++++++ portal/src/app/marketplace/openpayd/page.tsx | 84 ++++++++++ portal/src/lib/corporate-site-data.ts | 24 ++- 9 files changed, 590 insertions(+), 14 deletions(-) create mode 100644 api/src/routes/openpayd-connector.ts create mode 100644 portal/src/app/admin/openpayd/page.tsx create mode 100644 portal/src/app/api/openpayd/commands/[command]/route.ts create mode 100644 portal/src/app/api/openpayd/status/route.ts create mode 100644 portal/src/app/marketplace/entitlements/phoenix-openpayd-connector/dashboard/page.tsx create mode 100644 portal/src/app/marketplace/openpayd/page.tsx diff --git a/api/src/routes/openpayd-connector.ts b/api/src/routes/openpayd-connector.ts new file mode 100644 index 0000000..f4b9f87 --- /dev/null +++ b/api/src/routes/openpayd-connector.ts @@ -0,0 +1,114 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import { requireTenant } from '../middleware/tenant-auth.js' +import { marketplaceSubscriptionService } from '../services/marketplace-subscription.js' + +const PRODUCT_SLUG = 'phoenix-openpayd-connector' +const ENTITLEMENT_KEY = 'OPENPAYD_CONNECTOR_ENTITLED' +const CONNECTOR_BASE = + process.env.OPENPAYD_CONNECTOR_INTERNAL_URL || 'https://openpayd-connector.sankofa.nexus' + +const OPERATOR_COMMANDS = new Set([ + 'probeEnvironment', + 'rotateCredential', + 'setProductionMutatingGate', + 'suspendTenant', + 'offboardTenant', + 'exportComplianceEvidencePack' +]) + +const TENANT_COMMANDS = new Set([ + 'getStatus', + 'syncPaymentAccounts', + 'getBeneficiaryRequiredDetails', + 'validateBeneficiary', + 'createLinkedClient', + 'createAccount', + 'createBankBeneficiary', + 'createPayout', + 'initiateOpenBankingPayment' +]) + +function tenantFromRequest(request: FastifyRequest, reply: FastifyReply) { + const headerTenant = (request.headers['x-tenant-id'] as string | undefined)?.trim() + if (headerTenant && request.tenantContext) { + request.tenantContext = { ...request.tenantContext, tenantId: headerTenant } + } + return requireTenant(request, reply) +} + +function isOperator(context: ReturnType): boolean { + const role = `${context.role || ''}`.toLowerCase() + const tenantRole = `${context.tenantRole || ''}`.toLowerCase() + return context.isSystemAdmin || role === 'admin' || tenantRole === 'tenant-admin' || tenantRole === 'admin' +} + +async function entitlementState(tenantId: string) { + const entitlements = await marketplaceSubscriptionService.getTenantEntitlements(tenantId) + const row = entitlements.find((entitlement) => entitlement.entitlementKey === ENTITLEMENT_KEY) + return { + entitlement: row, + entitled: Boolean(row && ['ACTIVE', 'PENDING', 'REQUEST_ONLY'].includes(row.status)), + active: row?.status === 'ACTIVE' + } +} + +async function proxyConnector(command: string, body: Record) { + const res = await fetch(`${CONNECTOR_BASE.replace(/\/$/, '')}/v1/commands/${command}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + const data = await res.json().catch(() => ({})) + return { status: res.status, data } +} + +export async function registerOpenPaydConnectorRoutes(fastify: FastifyInstance) { + fastify.get('/api/v1/openpayd/status', async (request, reply) => { + const context = tenantFromRequest(request, reply) + const tenantId = context.tenantId! + const entitlements = await entitlementState(tenantId) + if (!entitlements.entitled && !isOperator(context)) { + return reply.code(403).send({ error: 'OpenPayd connector entitlement required', productSlug: PRODUCT_SLUG }) + } + + const environment = ((request.query as { environment?: string }).environment || 'sandbox') as string + const proxied = await proxyConnector('getStatus', { tenantId, environment }) + return reply.code(proxied.status).send({ + productSlug: PRODUCT_SLUG, + entitlementKey: ENTITLEMENT_KEY, + entitlementStatus: entitlements.entitlement?.status || 'NONE', + connector: proxied.data + }) + }) + + fastify.post('/api/v1/openpayd/commands/:command', async (request, reply) => { + const context = tenantFromRequest(request, reply) + const tenantId = context.tenantId! + const command = (request.params as { command: string }).command + const body = (request.body || {}) as Record + const entitlements = await entitlementState(tenantId) + const operator = isOperator(context) + + if (!entitlements.entitled && !operator) { + return reply.code(403).send({ error: 'OpenPayd connector entitlement required', productSlug: PRODUCT_SLUG }) + } + if (OPERATOR_COMMANDS.has(command) && !operator) { + return reply.code(403).send({ error: 'Operator role required for OpenPayd command', command }) + } + if (!OPERATOR_COMMANDS.has(command) && !TENANT_COMMANDS.has(command)) { + return reply.code(404).send({ error: 'Unsupported OpenPayd command', command }) + } + if (!operator && !entitlements.active && command !== 'getStatus') { + return reply.code(403).send({ error: 'Active OpenPayd entitlement required', command }) + } + + const proxied = await proxyConnector(command, { + tenantId, + environment: body.environment || 'sandbox', + payload: body.payload || {}, + idempotencyKey: body.idempotencyKey, + approvalRef: body.approvalRef + }) + return reply.code(proxied.status).send(proxied.data) + }) +} diff --git a/api/src/server.ts b/api/src/server.ts index 3d0a946..1ad3af6 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -17,6 +17,7 @@ import { validateAllSecrets } from './lib/secret-validation' import { initializeFIPS } from './lib/crypto' import { getFastifyTLSOptions } from './lib/tls-config' import { registerPhoenixRailingRoutes } from './routes/phoenix-railing.js' +import { registerOpenPaydConnectorRoutes } from './routes/openpayd-connector.js' import { printSchema } from 'graphql' // Get TLS configuration (empty if certificates not available) @@ -98,11 +99,12 @@ async function startServer() { await apolloServer.start() // Register GraphQL route - fastify.post('/graphql', async (request, reply) => { - return fastifyApolloHandler(apolloServer, { - context: async () => createContext(request), - })(request, reply) - }) + fastify.post( + '/graphql', + fastifyApolloHandler(apolloServer, { + context: async (request) => createContext(request), + }) + ) // Health check endpoint fastify.get('/health', async () => { @@ -141,6 +143,7 @@ async function startServer() { // Phoenix API Railing: /api/v1/infra/*, /api/v1/ve/*, /api/v1/health/* proxy + /api/v1/tenants/me/* await registerPhoenixRailingRoutes(fastify) + await registerOpenPaydConnectorRoutes(fastify) // Start Fastify server const port = parseInt(process.env.PORT || '4000', 10) @@ -161,4 +164,3 @@ async function startServer() { } startServer() - diff --git a/portal/src/app/admin/openpayd/page.tsx b/portal/src/app/admin/openpayd/page.tsx new file mode 100644 index 0000000..87c5bf3 --- /dev/null +++ b/portal/src/app/admin/openpayd/page.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { Download, KeyRound, PauseCircle, PlayCircle, RefreshCw, ShieldCheck } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { RoleGate } from '@/components/auth/RoleGate'; +import { Button } from '@/components/ui/Button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; + +type Environment = 'sandbox' | 'production'; + +export default function OpenPaydAdminPage() { + return ( + + + + ); +} + +function OpenPaydAdminWorkbench() { + const [environment, setEnvironment] = useState('sandbox'); + const [status, setStatus] = useState | null>(null); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function load() { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/openpayd/status?environment=${environment}`, { cache: 'no-store' }); + const payload = await res.json(); + if (!res.ok) throw new Error(payload.error || payload.message || 'Status load failed'); + setStatus(payload); + } catch (e) { + setError(e instanceof Error ? e.message : 'Status load failed'); + } finally { + setLoading(false); + } + } + + async function command(name: string, payload: Record = {}) { + setLoading(true); + setError(null); + setMessage(null); + try { + const res = await fetch(`/api/openpayd/commands/${name}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ environment, payload }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.status || data.error || data.message || `${name} failed`); + setMessage(`${name}: ${data.status || 'ok'}`); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : `${name} failed`); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + }, [environment]); + + return ( +
+
+
+

Operator Console

+

OpenPayd Connector Admin

+

+ Manage environment probes, lifecycle state, credential rotation workflow, evidence exports, and + production gates without exposing raw OpenPayd credentials. +

+
+
+ + + +
+
+ + {message ?
{message}
: null} + {error ?
{error}
: null} + +
+ command('probeEnvironment')} disabled={loading} /> + command('rotateCredential')} disabled={loading} /> + command('setProductionMutatingGate', { enabled: true })} disabled={loading || environment !== 'production'} /> + command('suspendTenant')} disabled={loading} /> + command('exportComplianceEvidencePack')} disabled={loading} /> +
+ + + + + Connector State + + + +
+            {JSON.stringify(status || {}, null, 2)}
+          
+
+
+
+ ); +} + +function ActionCard({ + icon: Icon, + title, + text, + action, + disabled, +}: { + icon: typeof PlayCircle; + title: string; + text: string; + action: () => void; + disabled: boolean; +}) { + return ( + + + + {title} + + +

{text}

+ +
+
+ ); +} diff --git a/portal/src/app/admin/page.tsx b/portal/src/app/admin/page.tsx index 7ff98ab..f834923 100644 --- a/portal/src/app/admin/page.tsx +++ b/portal/src/app/admin/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Building2, Users, CreditCard, Shield, ArrowRight } from 'lucide-react'; +import { Building2, Users, CreditCard, Shield, ArrowRight, Landmark } from 'lucide-react'; import Link from 'next/link'; import { useSession } from 'next-auth/react'; @@ -39,6 +39,13 @@ export default function AdminPortalPage() { href: '/admin/compliance', features: ['Compliance dashboard', 'Audit logs', 'Export reports'], }, + { + title: 'OpenPayd Connector', + description: 'Manage OpenPayd credentials, probes, lifecycle gates, evidence packs, and incidents', + icon: Landmark, + href: '/admin/openpayd', + features: ['Sandbox probes', 'Production gates', 'Evidence export'], + }, ]; return ( diff --git a/portal/src/app/api/openpayd/commands/[command]/route.ts b/portal/src/app/api/openpayd/commands/[command]/route.ts new file mode 100644 index 0000000..458447b --- /dev/null +++ b/portal/src/app/api/openpayd/commands/[command]/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; + +import { authOptions } from '@/lib/auth'; + +const API_BASE = + process.env.PHOENIX_API_INTERNAL_URL || + process.env.NEXT_PUBLIC_PHOENIX_API_URL || + 'https://api.sankofa.nexus'; + +type SessionClaims = { + accessToken?: string; + tenantId?: string; +} | null; + +export async function POST(request: Request, { params }: { params: { command: string } }) { + const session = (await getServerSession(authOptions)) as SessionClaims; + if (!session?.accessToken) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const headers: Record = { + Authorization: `Bearer ${session.accessToken}`, + 'Content-Type': 'application/json', + }; + if (session.tenantId) headers['X-Tenant-Id'] = session.tenantId; + + const res = await fetch(`${API_BASE}/api/v1/openpayd/commands/${params.command}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + cache: 'no-store', + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/portal/src/app/api/openpayd/status/route.ts b/portal/src/app/api/openpayd/status/route.ts new file mode 100644 index 0000000..4899cf8 --- /dev/null +++ b/portal/src/app/api/openpayd/status/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; + +import { authOptions } from '@/lib/auth'; + +const API_BASE = + process.env.PHOENIX_API_INTERNAL_URL || + process.env.NEXT_PUBLIC_PHOENIX_API_URL || + 'https://api.sankofa.nexus'; + +type SessionClaims = { + accessToken?: string; + tenantId?: string; +} | null; + +export async function GET(request: Request) { + const session = (await getServerSession(authOptions)) as SessionClaims; + if (!session?.accessToken) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const environment = url.searchParams.get('environment') || 'sandbox'; + const headers: Record = { + Authorization: `Bearer ${session.accessToken}`, + }; + if (session.tenantId) headers['X-Tenant-Id'] = session.tenantId; + + const res = await fetch(`${API_BASE}/api/v1/openpayd/status?environment=${encodeURIComponent(environment)}`, { + headers, + cache: 'no-store', + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/portal/src/app/marketplace/entitlements/phoenix-openpayd-connector/dashboard/page.tsx b/portal/src/app/marketplace/entitlements/phoenix-openpayd-connector/dashboard/page.tsx new file mode 100644 index 0000000..2d9f022 --- /dev/null +++ b/portal/src/app/marketplace/entitlements/phoenix-openpayd-connector/dashboard/page.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { RefreshCw, ShieldAlert } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; + +type ConnectorStatus = { + entitlementStatus?: string; + connector?: { + ok?: boolean; + status?: string; + data?: { + tenantEnvironment?: Record; + recentCommands?: Record[]; + recentWebhookEvents?: Record[]; + reconciliationExceptions?: Record[]; + }; + }; +}; + +export default function OpenPaydTenantDashboardPage() { + const [environment, setEnvironment] = useState<'sandbox' | 'production'>('sandbox'); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function load() { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/openpayd/status?environment=${environment}`, { cache: 'no-store' }); + const payload = await res.json(); + if (!res.ok) throw new Error(payload.error || payload.message || 'OpenPayd status failed'); + setData(payload); + } catch (e) { + setError(e instanceof Error ? e.message : 'OpenPayd status failed'); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + }, [environment]); + + const status = data?.connector?.data?.tenantEnvironment || {}; + const commands = data?.connector?.data?.recentCommands || []; + const events = data?.connector?.data?.recentWebhookEvents || []; + const exceptions = data?.connector?.data?.reconciliationExceptions || []; + + return ( +
+
+
+

OpenPayd Connector

+

Tenant Dashboard

+

+ Read-only operational view for entitlement, lifecycle, environment health, commands, webhook facts, + and reconciliation exceptions. Sensitive account and credential values are not rendered here. +

+
+
+ + + +
+
+ + {error ? ( + + {error} + + ) : null} + +
+ + Entitlement + {data?.entitlementStatus || 'Loading'} + + + Lifecycle + {String(status.state || 'unknown')} + + + Mutating Gate + {String(status.mutatingActionsEnabled || false)} + +
+ +
+ + +
+ + + + + Reconciliation Exceptions + + + + {exceptions.length === 0 ? ( +

No reconciliation exceptions reported.

+ ) : ( +
+              {JSON.stringify(exceptions, null, 2)}
+            
+ )} +
+
+
+ ); +} + +function RecordList({ title, rows }: { title: string; rows: Record[] }) { + return ( + + {title} + + {rows.length === 0 ? ( +

No records yet.

+ ) : ( +
+            {JSON.stringify(rows, null, 2)}
+          
+ )} +
+
+ ); +} diff --git a/portal/src/app/marketplace/openpayd/page.tsx b/portal/src/app/marketplace/openpayd/page.tsx new file mode 100644 index 0000000..a562489 --- /dev/null +++ b/portal/src/app/marketplace/openpayd/page.tsx @@ -0,0 +1,84 @@ +import { ArrowRight, Landmark, LockKeyhole, ShieldCheck } from 'lucide-react'; +import Link from 'next/link'; + +import { CorporateFooter } from '@/components/corporate/CorporateFooter'; +import { CorporateHeader } from '@/components/corporate/CorporateHeader'; + +export const metadata = { + title: 'OpenPayd Financial Services Connector — Sankofa Marketplace', + description: 'Operator-provisioned OpenPayd connector for Phoenix Financial Cloud subscribers.', +}; + +const skus = [ + 'openpayd-connector-sandbox', + 'openpayd-connector-production-readonly', + 'openpayd-connector-production-payouts', + 'openpayd-connector-digital-assets-gated', +]; + +export default function OpenPaydMarketplacePage() { + return ( +
+ +
+

+ Phoenix Financial Cloud +

+

+ OpenPayd Financial Services Connector +

+

+ Operator-provisioned access to OpenPayd-backed accounts, virtual IBANs, beneficiary validation, + payouts, Open Banking, and gated digital-asset capabilities through Phoenix controls. +

+ +
+ {[ + { icon: Landmark, title: 'Financial rails', text: 'Accounts, payment accounts, beneficiaries, payouts, and Open Banking behind tenant policy.' }, + { icon: ShieldCheck, title: 'Phoenix gated', text: 'Entitlements, approval gates, audit records, and reconciliation boundaries stay in Phoenix.' }, + { icon: LockKeyhole, title: 'Operator provisioned', text: 'Credentials and production mutating traffic are controlled by authorized operators only.' }, + ].map((item) => { + const Icon = item.icon; + return ( +
+ +

{item.title}

+

{item.text}

+
+ ); + })} +
+ +
+

Catalog SKUs

+
+ {skus.map((sku) => ( +
+ {sku} +
+ ))} +
+

+ Production URLs, production credentials, webhook keys, and digital-asset contracts remain gated + OpenPayd onboarding inputs. Production mutating actions are disabled by default. +

+
+ + Request subscription + + + Open dashboard + +
+
+
+ +
+ ); +} diff --git a/portal/src/lib/corporate-site-data.ts b/portal/src/lib/corporate-site-data.ts index 3d2444d..7c43665 100644 --- a/portal/src/lib/corporate-site-data.ts +++ b/portal/src/lib/corporate-site-data.ts @@ -140,13 +140,23 @@ export const productDivisions: ProductDivision[] = [ ]; /** Marketplace partner / product stack entries derived from product divisions. */ -export const partnerStackServices: PartnerStackService[] = productDivisions.map((division) => ({ - name: division.name, - href: division.href, - description: division.description, - category: division.tagline, - external: division.external, -})); +export const partnerStackServices: PartnerStackService[] = [ + ...productDivisions.map((division) => ({ + name: division.name, + href: division.href, + description: division.description, + category: division.tagline, + external: division.external, + })), + { + name: 'OpenPayd Financial Services Connector', + href: '/marketplace/openpayd', + description: + 'Operator-provisioned OpenPayd accounts, virtual IBANs, payouts, Open Banking, and gated digital-asset rails for Phoenix Financial Cloud subscribers.', + serviceUrl: 'https://openpayd-connector.sankofa.nexus', + category: 'Financial messaging', + }, +]; export const platformServices: ServiceOffering[] = [ {