Add OpenPayd marketplace admin integration
Some checks failed
API CI / API Lint (push) Successful in 38s
API CI / API Type Check (push) Failing after 43s
API CI / API Test (push) Successful in 56s
API CI / API Build (push) Failing after 45s
API CI / Build Docker Image (push) Has been skipped
CD Pipeline / Deploy to Staging (push) Failing after 23s
CI Pipeline / Lint and Type Check (push) Failing after 33s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 1m21s
CI Pipeline / Test Frontend (push) Failing after 32s
CI Pipeline / Security Scan (push) Failing after 1m19s
Deploy to Staging / Deploy to Staging (push) Failing after 27s
Portal CI / Portal Lint (push) Failing after 23s
Portal CI / Portal Type Check (push) Failing after 20s
Portal CI / Portal Test (push) Failing after 23s
Portal CI / Portal Build (push) Failing after 23s
Test Suite / frontend-tests (push) Failing after 31s
Test Suite / api-tests (push) Failing after 45s
Test Suite / blockchain-tests (push) Failing after 31s
Type Check / type-check (map[directory:. name:root]) (push) Failing after 19s
Type Check / type-check (map[directory:api name:api]) (push) Failing after 19s
Type Check / type-check (map[directory:portal name:portal]) (push) Failing after 21s
CD Pipeline / Deploy to Production (push) Has been skipped

This commit is contained in:
defiQUG
2026-07-20 19:05:43 -07:00
parent 7e915e8c10
commit d2a5d7684e
9 changed files with 590 additions and 14 deletions

View File

@@ -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<typeof tenantFromRequest>): 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<string, unknown>) {
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<string, unknown>
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)
})
}

View File

@@ -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()

View File

@@ -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 (
<RoleGate
allowedRoles={['admin', 'tenant-admin', 'ADMIN', 'TENANT_ADMIN']}
callbackUrl="/admin/openpayd"
badge="OpenPayd Admin"
title="OpenPayd operator access required"
subtitle="Sign in with a tenant-admin or internal-admin account to manage the OpenPayd connector."
>
<OpenPaydAdminWorkbench />
</RoleGate>
);
}
function OpenPaydAdminWorkbench() {
const [environment, setEnvironment] = useState<Environment>('sandbox');
const [status, setStatus] = useState<Record<string, unknown> | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<string, unknown> = {}) {
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 (
<main className="mx-auto max-w-6xl px-4 py-10 text-white">
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-sm font-medium uppercase tracking-wide text-orange-400">Operator Console</p>
<h1 className="mt-1 text-3xl font-bold">OpenPayd Connector Admin</h1>
<p className="mt-2 max-w-3xl text-gray-400">
Manage environment probes, lifecycle state, credential rotation workflow, evidence exports, and
production gates without exposing raw OpenPayd credentials.
</p>
</div>
<div className="flex gap-2">
<Button variant={environment === 'sandbox' ? 'primary' : 'outline'} onClick={() => setEnvironment('sandbox')}>
Sandbox
</Button>
<Button variant={environment === 'production' ? 'primary' : 'outline'} onClick={() => setEnvironment('production')}>
Production
</Button>
<Button variant="secondary" onClick={load} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" /> Refresh
</Button>
</div>
</div>
{message ? <div className="mb-4 rounded-md border border-emerald-800 bg-emerald-950/40 p-3 text-sm text-emerald-200">{message}</div> : null}
{error ? <div className="mb-4 rounded-md border border-red-800 bg-red-950/40 p-3 text-sm text-red-200">{error}</div> : null}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<ActionCard icon={PlayCircle} title="Run Probe" text="Token and read-only account probe." action={() => command('probeEnvironment')} disabled={loading} />
<ActionCard icon={KeyRound} title="Rotate Credential" text="Record a credential rotation through secret adapter." action={() => command('rotateCredential')} disabled={loading} />
<ActionCard icon={ShieldCheck} title="Production Gate" text="Enable production mutating traffic for approved tenants." action={() => command('setProductionMutatingGate', { enabled: true })} disabled={loading || environment !== 'production'} />
<ActionCard icon={PauseCircle} title="Suspend Tenant" text="Block new mutating actions and keep read-only reconciliation." action={() => command('suspendTenant')} disabled={loading} />
<ActionCard icon={Download} title="Export Evidence" text="Generate redacted compliance evidence pack." action={() => command('exportComplianceEvidencePack')} disabled={loading} />
</div>
<Card className="mt-6 border-gray-800 bg-gray-900">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<ShieldCheck className="h-5 w-5 text-orange-400" /> Connector State
</CardTitle>
</CardHeader>
<CardContent>
<pre className="max-h-[36rem] overflow-auto rounded-md bg-black/30 p-3 text-xs text-gray-300">
{JSON.stringify(status || {}, null, 2)}
</pre>
</CardContent>
</Card>
</main>
);
}
function ActionCard({
icon: Icon,
title,
text,
action,
disabled,
}: {
icon: typeof PlayCircle;
title: string;
text: string;
action: () => void;
disabled: boolean;
}) {
return (
<Card className="border-gray-800 bg-gray-900">
<CardHeader>
<Icon className="h-6 w-6 text-orange-400" />
<CardTitle className="text-white">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4 text-sm text-gray-400">{text}</p>
<Button variant="secondary" onClick={action} disabled={disabled}>Execute</Button>
</CardContent>
</Card>
);
}

View File

@@ -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 (

View File

@@ -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<string, string> = {
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 });
}

View File

@@ -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<string, string> = {
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 });
}

View File

@@ -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<string, unknown>;
recentCommands?: Record<string, unknown>[];
recentWebhookEvents?: Record<string, unknown>[];
reconciliationExceptions?: Record<string, unknown>[];
};
};
};
export default function OpenPaydTenantDashboardPage() {
const [environment, setEnvironment] = useState<'sandbox' | 'production'>('sandbox');
const [data, setData] = useState<ConnectorStatus | null>(null);
const [error, setError] = useState<string | null>(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 (
<main className="mx-auto max-w-6xl px-4 py-10 text-white">
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-sm font-medium uppercase tracking-wide text-orange-400">OpenPayd Connector</p>
<h1 className="mt-1 text-3xl font-bold">Tenant Dashboard</h1>
<p className="mt-2 max-w-3xl text-gray-400">
Read-only operational view for entitlement, lifecycle, environment health, commands, webhook facts,
and reconciliation exceptions. Sensitive account and credential values are not rendered here.
</p>
</div>
<div className="flex gap-2">
<Button variant={environment === 'sandbox' ? 'primary' : 'outline'} onClick={() => setEnvironment('sandbox')}>
Sandbox
</Button>
<Button variant={environment === 'production' ? 'primary' : 'outline'} onClick={() => setEnvironment('production')}>
Production
</Button>
<Button variant="secondary" onClick={load} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" /> Refresh
</Button>
</div>
</div>
{error ? (
<Card className="mb-6 border-red-800 bg-red-950/40">
<CardContent className="pt-6 text-red-200">{error}</CardContent>
</Card>
) : null}
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-gray-800 bg-gray-900">
<CardHeader><CardTitle className="text-white">Entitlement</CardTitle></CardHeader>
<CardContent className="text-sm text-gray-300">{data?.entitlementStatus || 'Loading'}</CardContent>
</Card>
<Card className="border-gray-800 bg-gray-900">
<CardHeader><CardTitle className="text-white">Lifecycle</CardTitle></CardHeader>
<CardContent className="text-sm text-gray-300">{String(status.state || 'unknown')}</CardContent>
</Card>
<Card className="border-gray-800 bg-gray-900">
<CardHeader><CardTitle className="text-white">Mutating Gate</CardTitle></CardHeader>
<CardContent className="text-sm text-gray-300">{String(status.mutatingActionsEnabled || false)}</CardContent>
</Card>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<RecordList title="Recent Commands" rows={commands} />
<RecordList title="Webhook Facts" rows={events} />
</div>
<Card className="mt-6 border-gray-800 bg-gray-900">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<ShieldAlert className="h-5 w-5 text-orange-400" /> Reconciliation Exceptions
</CardTitle>
</CardHeader>
<CardContent>
{exceptions.length === 0 ? (
<p className="text-sm text-gray-400">No reconciliation exceptions reported.</p>
) : (
<pre className="overflow-x-auto rounded-md bg-black/30 p-3 text-xs text-gray-300">
{JSON.stringify(exceptions, null, 2)}
</pre>
)}
</CardContent>
</Card>
</main>
);
}
function RecordList({ title, rows }: { title: string; rows: Record<string, unknown>[] }) {
return (
<Card className="border-gray-800 bg-gray-900">
<CardHeader><CardTitle className="text-white">{title}</CardTitle></CardHeader>
<CardContent>
{rows.length === 0 ? (
<p className="text-sm text-gray-400">No records yet.</p>
) : (
<pre className="max-h-80 overflow-auto rounded-md bg-black/30 p-3 text-xs text-gray-300">
{JSON.stringify(rows, null, 2)}
</pre>
)}
</CardContent>
</Card>
);
}

View File

@@ -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 (
<div className="flex min-h-screen flex-col bg-sovereign-obsidian text-sovereign-ivory">
<CorporateHeader />
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-16 sm:px-6 lg:px-8">
<p className="text-xs font-semibold uppercase tracking-wider text-sovereign-gold">
Phoenix Financial Cloud
</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">
OpenPayd Financial Services Connector
</h1>
<p className="mt-4 max-w-3xl text-lg text-sovereign-ivory/70">
Operator-provisioned access to OpenPayd-backed accounts, virtual IBANs, beneficiary validation,
payouts, Open Banking, and gated digital-asset capabilities through Phoenix controls.
</p>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{[
{ 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 (
<article key={item.title} className="rounded-lg border border-sovereign-bronze/25 bg-sovereign-midnight/40 p-5">
<Icon className="h-6 w-6 text-sovereign-gold" />
<h2 className="mt-3 text-lg font-semibold">{item.title}</h2>
<p className="mt-2 text-sm text-sovereign-ivory/60">{item.text}</p>
</article>
);
})}
</div>
<section className="mt-10 rounded-lg border border-sovereign-bronze/25 bg-sovereign-midnight/30 p-6">
<h2 className="text-xl font-semibold">Catalog SKUs</h2>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{skus.map((sku) => (
<div key={sku} className="rounded-md border border-white/10 bg-black/20 p-3 font-mono text-sm text-sovereign-gold">
{sku}
</div>
))}
</div>
<p className="mt-4 text-sm text-sovereign-ivory/65">
Production URLs, production credentials, webhook keys, and digital-asset contracts remain gated
OpenPayd onboarding inputs. Production mutating actions are disabled by default.
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<Link
href="/marketplace/entitlements/phoenix-openpayd-connector"
className="inline-flex items-center gap-2 rounded-lg bg-sovereign-gold px-5 py-2.5 text-sm font-semibold text-sovereign-obsidian no-underline hover:bg-sovereign-ivory"
>
Request subscription <ArrowRight className="h-4 w-4" aria-hidden />
</Link>
<Link
href="/marketplace/entitlements/phoenix-openpayd-connector/dashboard"
className="inline-flex items-center gap-2 rounded-lg border border-sovereign-bronze/40 px-5 py-2.5 text-sm font-semibold text-sovereign-ivory no-underline hover:border-sovereign-gold"
>
Open dashboard
</Link>
</div>
</section>
</main>
<CorporateFooter />
</div>
);
}

View File

@@ -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[] = [
{