Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions docs/gateway-foundation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Gateway Foundation

This document captures the consolidated Channel Gateway foundation from PRs
#441, #446, #449, #447, #448, #442, and #443.

## Scope

The gateway layer provides a provider-neutral ingress path from external chat
channels into Huf Agents and Flows. It adds:

- Gateway administration DocTypes for channel configuration, admission control,
route bindings, and event evidence.
- A provider-neutral adapter SDK for verified inbound events and outbound text
replies.
- Runtime bridging for installed SDK adapters.
- Initial provider coverage for VK, WeCom, Microsoft Teams outgoing webhooks,
and Discord interactions.

## Architecture

Provider adapters own their native verification contracts. Only verified,
normalized events enter `huf.ai.gateway_service.ingest_gateway_event`.

The core flow is:

1. A provider-specific endpoint or SDK adapter receives the native webhook.
2. The adapter validates native authentication before persistence.
3. The adapter normalizes the event into sender, conversation, thread, message,
room, mention, and provider event metadata.
4. `Gateway Event` persists redacted evidence and an idempotency hash.
5. Gateway admission policy decides whether the sender or room may execute work.
6. Gateway bindings choose the first matching Agent or Flow route by priority.
7. Accepted events are queued for asynchronous Agent or Flow execution.
8. Agent responses can be delivered through the same runtime adapter when the
provider supports outbound replies.

Key modules:

- `huf.ai.gateway_service`: provider-neutral persistence, idempotency,
admission, routing, queueing, and execution.
- `huf.ai.gateway_webhook`: runtime bridge for SDK-based adapters and outbound
replies.
- `huf.ai.gateway_adapters`: SDK contracts, registry, conformance checks, VK,
and WeCom adapters.
- `huf.ai.tools.teams_webhook`: Microsoft Teams outgoing webhook ingress.
- `huf.ai.gateways.discord`: Discord Interaction ingress.

## Security Boundaries

Gateway ingress is fail-closed:

- No unverified payload is routed to an Agent or Flow.
- Provider credentials are read from linked `Integration Settings` password
rows and are not stored on `Gateway Event`.
- Raw event evidence is redacted for common secret-bearing keys before
persistence.
- Gateway execution runs as the configured non-administrator `execution_user`.
- Direct-message pairing creates a pending access request but never executes the
triggering message.
- Room admission and sender admission are separate. Room access does not grant
sender access.
- Room messages can require a mention before they route.
- Route preview requires read permission on `Gateway`.

The consolidated `Gateway` DocType keeps the richer admission model from the
foundation stack:

- `direct_policy`: `Disabled`, `Pairing`, `Allow list`, or `Open`.
- `room_policy`: `Disabled`, `Allow list`, or `Open`.
- `room_sender_policy`: `Allow list` or `Open`.
- `mention_required` and `pairing_ttl_minutes`.

## Adapter Contract

Every SDK adapter implements `GatewayAdapter`:

- `verify_inbound(request)`: validate the native webhook authentication.
- `normalize_inbound(request)`: convert a verified payload to
`NormalizedGatewayEvent`.
- `send_reply(reply)`: deliver a `GatewayReply` and return `OutboundDelivery`.

Adapters also declare:

- `provider_id`
- `credential_schema`
- `capabilities`

Conformance checks enforce that required fields and capability declarations are
present before adapters are registered.

## Provider Coverage

VK and WeCom use the provider-neutral adapter SDK and runtime bridge. The
runtime bridge currently maps these channels to their installed adapter classes.

Microsoft Teams uses a dedicated outgoing-webhook handler because Teams expects
a short synchronous acknowledgement after HMAC validation. Huf verifies the
`Authorization: HMAC ...` header, queues approved events, and returns a fixed
acknowledgement without running a model inline.

Discord uses Interaction ingress. It verifies Ed25519 signatures over the exact
timestamp and raw request body, responds to ping checks, defers command
interactions, and queues approved events through the gateway service.

## Evidence And Tests

The umbrella branch includes focused tests for:

- Gateway service admission, routing, idempotency, redaction, and execution
queueing.
- Adapter SDK value objects, registry, and conformance.
- Runtime webhook verification and outbound reply delivery.
- VK adapter verification, normalization, and reply behavior.
- WeCom adapter verification, URL challenge, normalization, and reply behavior.
- Microsoft Teams HMAC validation and webhook acknowledgement behavior.
- Discord Ed25519 verification, ping handling, command deferral, and rejection
paths.

Run these locally with the repository's available backend/static checks before
review. The umbrella PR body should include the exact command results from the
branch.

## Follow-Ups

These are intentionally not blockers for this consolidation PR:

- Add a provider-neutral completion callback for non-SDK endpoints that defer
immediate responses, especially Discord.
- Extend the runtime bridge mapping as more SDK adapters graduate.
- Add bench migration and browser smoke evidence for the Gateway UI page.
- Add provider setup guides for each production channel after credentials and
deployment URLs are finalized.
13 changes: 13 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const IntegrationServiceFormPageWrapper = lazy(
() => import('./pages/IntegrationServiceFormPageWrapper'),
);
const HubSimplePage = lazy(() => import('./pages/HubSimplePage'));
const GatewaysPage = lazy(() => import('./pages/GatewaysPage'));

import { useEffect } from 'react';
import { SocketProvider } from './contexts/SocketContext';
Expand Down Expand Up @@ -460,6 +461,18 @@ function AppShell() {
</ProtectedRoute>
}
/>
<Route
path="/gateways"
element={
<ProtectedRoute>
<UnifiedLayout>
<Suspense fallback={<PageLoader />}>
<GatewaysPage />
</Suspense>
</UnifiedLayout>
</ProtectedRoute>
}
/>
<Route
path="/integration-services"
element={
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ const settingsNavItems = [
icon: Terminal,
capability: "agent.use",
},
{
title: "Gateways",
url: "/gateways",
icon: MessageSquare,
capability: "system.integrations.manage",
},
{
title: "Integrations",
url: "/integrations",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/data/doctypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export const doctype = {
"Agent Settings": "Agent Settings",
"MCP Server": "MCP Server",
"Integration Settings": "Integration Settings",
Gateway: "Gateway",
"Gateway Binding": "Gateway Binding",
"Gateway Event": "Gateway Event",
"Integration Service": "Integration Service",
"Elevenlabs Settings": "Elevenlabs Settings",
"Groq Settings": "Groq Settings",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/layouts/UnifiedHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function UnifiedHeader({ actions, breadcrumbs }: UnifiedHeaderProps) {
if (path.startsWith('/providers')) return 'AI Providers';
if (path.startsWith('/integration-services')) return 'Integration Services';
if (path.startsWith('/integrations')) return 'Integrations';
if (path.startsWith('/gateways')) return 'Gateways';
if (path.startsWith('/settings')) return 'Settings';
if (path.startsWith('/console')) return 'Console';
if (path.startsWith('/help')) return 'Help';
Expand Down
168 changes: 168 additions & 0 deletions frontend/src/pages/GatewaysPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { FormEvent, useEffect, useState } from 'react';
import { Link2, Mail, MessageCircle, Send, Settings, ShieldCheck, Slack } from 'lucide-react';
import { PageLayout, GridView, ItemCard } from '@/components/dashboard';
import { getGateways, type GatewayDoc } from '@/services/gatewayApi';
import { db } from '@/lib/frappe-sdk';
import { doctype } from '@/data/doctypes';
import { Button } from '@/components/ui/button';

const providerIcons = {
Telegram: Send,
Slack,
Email: Mail,
WhatsApp: MessageCircle,
} as const;

const providerNames = {
Telegram: 'Telegram bot',
Slack: 'Slack workspace',
Email: 'Shared inbox',
WhatsApp: 'WhatsApp business number',
} as const;

export default function GatewaysPage() {
const [gateways, setGateways] = useState<GatewayDoc[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showSetup, setShowSetup] = useState(false);
const [creating, setCreating] = useState(false);
const [gatewayName, setGatewayName] = useState('');
const [provider, setProvider] = useState<GatewayDoc['provider']>('Telegram');

useEffect(() => {
getGateways()
.then(setGateways)
.catch(() => setError('Could not load gateways.'))
.finally(() => setLoading(false));
}, []);

return (
<PageLayout subtitle="Let people reach Huf from the channels they already use — safely and with clear routing.">
<div className="mb-6 rounded-lg border border-line bg-panel p-5">
<div className="flex gap-3">
<div className="rounded-md bg-primary/10 p-2 h-fit"><Link2 className="h-5 w-5 text-primary" /></div>
<div className="space-y-2">
<h1 className="text-lg font-semibold">What is a gateway?</h1>
<p className="max-w-3xl text-sm text-steel">
A gateway is a safe front door from Telegram, Slack, email, or WhatsApp into Huf.
It decides who can ask for help and which Agent or Flow should respond. It is different
from an integration tool: tools let an Agent send messages; gateways let people start work.
</p>
<div className="flex items-center gap-2 text-sm text-steel">
<ShieldCheck className="h-4 w-4 text-primary" />
New gateways deny unknown senders until you choose an access policy and route.
</div>
</div>
</div>
</div>

<div className="mb-5 flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold">Your gateways</h2>
<p className="text-sm text-steel">Create a safe draft first. It will not receive messages until you finish routing and enable it.</p>
</div>
<Button size="sm" onClick={() => setShowSetup((value) => !value)}>
{showSetup ? 'Cancel' : 'Add gateway'}
</Button>
</div>

{showSetup && (
<form
className="mb-6 grid gap-4 rounded-lg border border-line bg-panel p-5 md:grid-cols-[1fr_220px_auto] md:items-end"
onSubmit={async (event: FormEvent) => {
event.preventDefault();
if (!gatewayName.trim()) return;
setCreating(true);
setError('');
try {
const created = await db.createDoc(doctype.Gateway, {
gateway_name: gatewayName.trim(),
provider,
is_enabled: 0,
access_policy: 'Deny by default',
}) as GatewayDoc;
setGateways((current) => [created, ...current]);
setGatewayName('');
setShowSetup(false);
} catch {
setError('Could not create the gateway. Choose a different name and try again.');
} finally {
setCreating(false);
}
}}
>
<label className="grid gap-1 text-sm font-medium">
Give it a clear name
<input
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={gatewayName}
onChange={(event) => setGatewayName(event.target.value)}
placeholder="Customer support on Telegram"
required
/>
</label>
<label className="grid gap-1 text-sm font-medium">
Where people will message you
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={provider}
onChange={(event) => setProvider(event.target.value as GatewayDoc['provider'])}
>
<option value="Telegram">Telegram</option>
<option value="Slack">Slack</option>
<option value="Email">Email</option>
<option value="WhatsApp">WhatsApp</option>
</select>
</label>
<Button type="submit" disabled={creating}>{creating ? 'Creating…' : 'Create safe draft'}</Button>
</form>
)}

{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
<GridView
items={gateways}
loading={loading}
columns={{ sm: 1, md: 2, lg: 3 }}
emptyState={
<div className="max-w-xl py-10 text-center">
<p className="mb-2 font-medium">No gateways yet.</p>
<p className="text-sm text-steel">
Start with Telegram or Slack for a conversational assistant, email for an inbox workflow,
or WhatsApp when your customers already use it. Add a safe draft above; it will stay off
until you connect the provider and choose where messages should go.
</p>
</div>
}
renderItem={(gateway) => {
const Icon = providerIcons[gateway.provider];
const target = gateway.default_target_type === 'Agent'
? gateway.default_agent
: gateway.default_target_type === 'Flow' ? gateway.default_flow : 'No default route';
return (
<ItemCard
title={gateway.gateway_name}
description={gateway.description || providerNames[gateway.provider]}
status={{
label: gateway.is_enabled ? 'ready' : 'not receiving messages',
variant: gateway.is_enabled ? 'default' : 'secondary',
}}
metadata={[
{ label: 'Channel', value: gateway.provider, icon: Icon },
{ label: 'Access', value: gateway.access_policy },
{ label: 'Default route', value: target || 'No default route' },
]}
actions={[
{
icon: Settings,
label: 'Finish setup in Desk',
onClick: () => { window.location.href = `/app/gateway/${encodeURIComponent(gateway.name)}`; },
},
]}
/>
);
}}
keyExtractor={(gateway) => gateway.name}
/>
</PageLayout>
);
}
29 changes: 29 additions & 0 deletions frontend/src/services/gatewayApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { db } from '@/lib/frappe-sdk';
import { doctype } from '@/data/doctypes';

export type GatewayProvider = 'Telegram' | 'Slack' | 'Email' | 'WhatsApp';

export interface GatewayDoc {
name: string;
gateway_name: string;
provider: GatewayProvider;
is_enabled: 0 | 1;
access_policy: 'Deny by default' | 'Allow list' | 'Pairing';
description?: string;
default_target_type?: '' | 'Agent' | 'Flow';
default_agent?: string;
default_flow?: string;
last_event_at?: string;
last_error?: string;
}

export async function getGateways(): Promise<GatewayDoc[]> {
return (await db.getDocList(doctype.Gateway, {
fields: [
'name', 'gateway_name', 'provider', 'is_enabled', 'access_policy', 'description',
'default_target_type', 'default_agent', 'default_flow', 'last_event_at', 'last_error',
],
orderBy: { field: 'modified', order: 'desc' },
limit: 100,
})) as GatewayDoc[];
}
Loading