diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml index 510500e3..431031b7 100644 --- a/docker-compose.demo.yml +++ b/docker-compose.demo.yml @@ -39,6 +39,7 @@ services: WEB_PORT: 3000 restart: "no" network_mode: "host" + networks: !reset [] volumes: - type: tmpfs target: /app/data diff --git a/user-docs/OPERATIONS.md b/user-docs/OPERATIONS.md index 61bec721..e6438a8a 100644 --- a/user-docs/OPERATIONS.md +++ b/user-docs/OPERATIONS.md @@ -107,6 +107,23 @@ Release Candidate Runbook deployment is not required to launch. The Python-to-Rust steps below are compatibility rehearsals using synthetic fixtures, not production migration prerequisites. V2 backup, restore, and deployment recovery still require proof. +- Behavior changes from the Python gateway (review before reusing a v1 configuration): + - Tier and rate-limit-rule management now require the `manage_tiers` / + `manage_rate_limits` permissions (Python left them unauthenticated). Grant + these to any automation that calls those APIs. + - Gateway subscription bypass applies only to platform-admin roles, not to + every role with `manage_gateway`. + - JWT `iss`/`aud` claims are always required and expiry has no leeway. + - gRPC-Web requests run the full authentication and policy chain. + - `MEM_AUTO_SAVE_ENABLED` is honored; memory-mode deployments that relied on + Python's unconditional periodic dumps must set it to `true`. + - An invalid `JWT_KEYS` value refuses startup instead of falling back to + `JWT_SECRET_KEY`. + - `GATEWAY_TIMEOUT`, `RETRY_ENABLED`, and `RETRY_MAX_ATTEMPTS` are applied. + - MongoDB: on first start the gateway replaces the `apis.api_id_1` unique + index with a partial unique index (`api_id_present_1`) so APIs without an + `api_id` no longer collide. No manual step is needed; the account needs + index-management privileges on the database. - Scope: Run this against the exact immutable image digest proposed for release, a production-like MongoDB replica set, and a separate Redis instance. Keep the generated artifacts under `release-evidence/` and do not overwrite a diff --git a/web-client/public/android-chrome-192x192.svg b/web-client/public/android-chrome-192x192.svg index 34380f57..d8a66384 100644 --- a/web-client/public/android-chrome-192x192.svg +++ b/web-client/public/android-chrome-192x192.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/web-client/public/android-chrome-512x512.svg b/web-client/public/android-chrome-512x512.svg index 34380f57..d8a66384 100644 --- a/web-client/public/android-chrome-512x512.svg +++ b/web-client/public/android-chrome-512x512.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/web-client/public/apple-touch-icon.svg b/web-client/public/apple-touch-icon.svg index 34380f57..d8a66384 100644 --- a/web-client/public/apple-touch-icon.svg +++ b/web-client/public/apple-touch-icon.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/web-client/public/doorman-logo.svg b/web-client/public/doorman-logo.svg index de9f8612..e238ff1d 100644 --- a/web-client/public/doorman-logo.svg +++ b/web-client/public/doorman-logo.svg @@ -1,8 +1,8 @@ - - - - - - Doorman + + + + + + Doorman diff --git a/web-client/public/doorman-mark.svg b/web-client/public/doorman-mark.svg index 34380f57..d8a66384 100644 --- a/web-client/public/doorman-mark.svg +++ b/web-client/public/doorman-mark.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/web-client/public/favicon-16x16.svg b/web-client/public/favicon-16x16.svg index 34380f57..d8a66384 100644 --- a/web-client/public/favicon-16x16.svg +++ b/web-client/public/favicon-16x16.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/web-client/public/favicon-32x32.svg b/web-client/public/favicon-32x32.svg index 34380f57..d8a66384 100644 --- a/web-client/public/favicon-32x32.svg +++ b/web-client/public/favicon-32x32.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/web-client/public/favicon.svg b/web-client/public/favicon.svg index 34380f57..d8a66384 100644 --- a/web-client/public/favicon.svg +++ b/web-client/public/favicon.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/web-client/public/safari-pinned-tab.svg b/web-client/public/safari-pinned-tab.svg index 34380f57..d8a66384 100644 --- a/web-client/public/safari-pinned-tab.svg +++ b/web-client/public/safari-pinned-tab.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx b/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx index f0fb3c51..32c9819c 100644 --- a/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx +++ b/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx @@ -1,187 +1,12 @@ 'use client' -import React, { useEffect, useState } from 'react' -import Link from 'next/link' +import { useEffect } from 'react' import { useParams, useRouter } from 'next/navigation' -import Layout from '@/components/Layout' -import InfoTooltip from '@/components/InfoTooltip' -import FormHelp from '@/components/FormHelp' -import { SERVER_URL } from '@/utils/config' -import { postJson } from '@/utils/api' -export default function AddEndpointPage() { +// Endpoints are added inline from the endpoints table; keep this route so old links still work. +export default function AddEndpointRedirect() { const params = useParams() const router = useRouter() - const apiId = params.apiId as string - const [apiName, setApiName] = useState('') - const [apiVersion, setApiVersion] = useState('') - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [success, setSuccess] = useState(null) - - const [method, setMethod] = useState('GET') - const [uri, setUri] = useState('') - const [clientUri, setClientUri] = useState('') - const [description, setDescription] = useState('') - const [useOverride, setUseOverride] = useState(false) - const [servers, setServers] = useState([]) - const [newServer, setNewServer] = useState('') - - useEffect(() => { - try { - const apiData = sessionStorage.getItem('selectedApi') - if (apiData) { - const parsed = JSON.parse(apiData) - setApiName(parsed.api_name || '') - setApiVersion(parsed.api_version || '') - } - } catch { } - }, []) - - const addServer = () => { - const v = newServer.trim() - if (!v) return - if (servers.includes(v)) return - setServers(prev => [...prev, v]) - setNewServer('') - } - - const removeServer = (idx: number) => { - setServers(prev => prev.filter((_, i) => i !== idx)) - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!uri.trim()) return - setLoading(true) - setError(null) - try { - const body: any = { - api_name: apiName, - api_version: apiVersion, - endpoint_method: method, - endpoint_uri: uri.startsWith('/') ? uri : '/' + uri, - endpoint_description: description || `${method} ${uri}` - } - if (clientUri.trim()) { - body.client_uri = clientUri.trim() - } - if (useOverride && servers.length > 0) { - body.endpoint_servers = servers - } - await postJson(`${SERVER_URL}/platform/endpoint`, body) - setSuccess('Endpoint created') - setTimeout(() => setSuccess(null), 1500) - router.push(`/apis/${encodeURIComponent(apiId)}/endpoints`) - } catch (e: any) { - setError(e?.message || 'Failed to create endpoint') - } finally { - setLoading(false) - } - } - - return ( - -
-
-
-

Add Endpoint

-

For API {apiName}/{apiVersion}

-
-
- Back to Endpoints -
-
- - {success && ( -
-

{success}

-
- )} - {error && ( -
-

{error}

-
- )} - -
-
-
- Define method and URI; optional upstream override per endpoint. -
-
- - -
-
- - setUri(e.target.value)} placeholder="/backend/path/{id}" /> -
-
- - setClientUri(e.target.value)} placeholder="/public/path/{id}" /> -
-
- - setDescription(e.target.value)} placeholder="Describe endpoint" /> -
- -
-
- setUseOverride(e.target.checked)} /> - -
-
- setNewServer(e.target.value)} - placeholder="e.g., http://localhost:8082" - onKeyPress={(e) => useOverride && e.key === 'Enter' && addServer()} - disabled={!useOverride} - /> - -
-
- {servers.map((srv, idx) => ( -
- {srv} - -
- ))} - {!useOverride && ( -

Disabled — API servers will be used unless enabled.

- )} - {useOverride && servers.length === 0 && ( -

No endpoint-specific servers. API servers will be used.

- )} -
-
- -
- - Cancel -
-
-
-
-
- ) + useEffect(() => { router.replace(`/apis/${encodeURIComponent(params.apiId as string)}/endpoints?add=1`) }, [params.apiId, router]) + return null } diff --git a/web-client/src/app/apis/[apiId]/endpoints/page.tsx b/web-client/src/app/apis/[apiId]/endpoints/page.tsx index 10b115b1..f4b04c61 100644 --- a/web-client/src/app/apis/[apiId]/endpoints/page.tsx +++ b/web-client/src/app/apis/[apiId]/endpoints/page.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { useParams, useRouter } from 'next/navigation' import Layout from '@/components/Layout' import { SERVER_URL } from '@/utils/config' -import { getJson } from '@/utils/api' +import { getJson, postJson } from '@/utils/api' import ConfirmModal from '@/components/ConfirmModal' import { SignalBreadcrumbs, @@ -14,7 +14,6 @@ import { SignalMethodFilter, SignalPageHeader, SignalPanel, - SignalPrimaryLink, SignalSearchInput, SignalTable } from '@/components/signal/Signal' @@ -260,6 +259,46 @@ export default function ApiEndpointsPage() { const [deleteConfirmation, setDeleteConfirmation] = useState('') const [endpointToDelete, setEndpointToDelete] = useState(null) const [methodFilter, setMethodFilter] = useState('ALL') + const [adding, setAdding] = useState(false) + const [draft, setDraft] = useState({ method: 'GET', uri: '', clientUri: '', description: '' }) + const [draftBusy, setDraftBusy] = useState(false) + const [draftError, setDraftError] = useState(null) + const draftUriRef = React.useRef(null) + + useEffect(() => { + try { if (new URLSearchParams(window.location.search).get('add') === '1') setAdding(true) } catch {} + }, []) + useEffect(() => { if (adding) draftUriRef.current?.focus() }, [adding]) + + const submitDraft = async (keepOpen: boolean) => { + const uri = draft.uri.trim() + if (!uri) { setDraftError('Enter a URI for the endpoint.'); draftUriRef.current?.focus(); return } + setDraftBusy(true); setDraftError(null) + try { + const body: any = { + api_name: apiName, + api_version: apiVersion, + endpoint_method: draft.method, + endpoint_uri: uri.startsWith('/') ? uri : '/' + uri, + endpoint_description: draft.description.trim() || `${draft.method} ${uri}` + } + if (draft.clientUri.trim()) body.client_uri = draft.clientUri.trim() + await postJson(`${SERVER_URL}/platform/endpoint`, body) + setSuccess(`Added ${draft.method} ${body.endpoint_uri}`) + setTimeout(() => setSuccess(null), 2000) + setDraft(d => ({ ...d, uri: '', clientUri: '', description: '' })) + await loadEndpoints() + if (keepOpen) draftUriRef.current?.focus(); else setAdding(false) + } catch (e: any) { + setDraftError(e?.message || 'Failed to add endpoint') + } finally { + setDraftBusy(false) + } + } + const draftKey = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); submitDraft(true) } + if (e.key === 'Escape') { setAdding(false); setDraftError(null) } + } const methodCounts = useMemo(() => { const counts: Record = { ALL: allEndpoints.length } @@ -443,9 +482,9 @@ export default function ApiEndpointsPage() { description="Create, edit, and delete endpoints. Precedence: Routing (client-key) → Endpoint servers → API servers." actions={
- + Back to API @@ -514,17 +553,37 @@ export default function ApiEndpointsPage() { + {adding && (<> + + + + + + setDraft(d => ({ ...d, uri: e.target.value }))} onKeyDown={draftKey} disabled={draftBusy} /> + setDraft(d => ({ ...d, clientUri: e.target.value }))} onKeyDown={draftKey} disabled={draftBusy} /> + setDraft(d => ({ ...d, description: e.target.value }))} onKeyDown={draftKey} disabled={draftBusy} /> + Default + API servers + +
+ + +
+ + + {draftError ? {draftError} : 'Press Enter to add and keep going; Esc or ✕ to close. Use {param} for path variables. Upstream servers can be overridden per endpoint after adding.'} + )} {loading ? ( Loading endpoints... - ) : filtered.length === 0 ? ( + ) : filtered.length === 0 && adding ? null : filtered.length === 0 ? ( - Add Endpoint -
+ } > {searchTerm || methodFilter !== 'ALL' ? 'Try adjusting your search terms or method filter.' : 'Get started by creating your first endpoint for this API.'} @@ -548,7 +607,7 @@ export default function ApiEndpointsPage() { - {ep.endpoint_method} + {ep.endpoint_method} {ep.endpoint_uri} diff --git a/web-client/src/app/apis/add/page.tsx b/web-client/src/app/apis/add/page.tsx index d9c801d4..75711b56 100644 --- a/web-client/src/app/apis/add/page.tsx +++ b/web-client/src/app/apis/add/page.tsx @@ -12,6 +12,40 @@ import ConfirmModal from '@/components/ConfirmModal' import { getJson } from '@/utils/api' import SearchableSelect from '@/components/SearchableSelect' +const STEPS = [ + { id: 'basics', title: 'Basics', hint: 'Name and protocol' }, + { id: 'upstream', title: 'Upstream', hint: 'Servers and routing' }, + { id: 'access', title: 'Access', hint: 'Who can call it' }, + { id: 'policies', title: 'Policies', hint: 'IP rules, headers, credits' }, + { id: 'review', title: 'Review', hint: 'Confirm and create' }, +] + +function Field({ label, htmlFor, hint, tip, required, children }: { label: string; htmlFor?: string; hint?: React.ReactNode; tip?: string; required?: boolean; children: React.ReactNode }) { + return
+ + {children} + {hint &&

{hint}

} +
+} + +function Toggle({ id, name, checked, onChange, disabled, title, description, tip }: { id: string; name: string; checked: boolean; onChange: (e: React.ChangeEvent) => void; disabled?: boolean; title: string; description?: React.ReactNode; tip?: string }) { + return
+ + +
+} + +function Chip({ text, onRemove }: { text: string; onRemove: () => void }) { + return {text} +} + +function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { + return

{title}

{description &&

{description}

}
{children}
+} + const AddApiPage = () => { const router = useRouter() const [loading, setLoading] = useState(false) @@ -52,6 +86,7 @@ const AddApiPage = () => { const [clientIpXff, setClientIpXff] = useState('') const [protoFile, setProtoFile] = useState(null) const [uploadProto, setUploadProto] = useState(false) + const [step, setStep] = useState(0) React.useEffect(() => { (async () => { @@ -93,8 +128,8 @@ const AddApiPage = () => { setIpWhitelistText(prev => (prev && prev.trim().length > 0) ? `${prev.trim()}\n${effectiveIp}` : effectiveIp) } - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault() setLoading(true) setError(null) @@ -115,7 +150,8 @@ const AddApiPage = () => { if (!Array.isArray(payload.api_allowed_groups) || payload.api_allowed_groups.length === 0) { payload.api_allowed_groups = ['ALL'] } - await postJson(`${SERVER_URL}/platform/api`, payload) + const created = await postJson(`${SERVER_URL}/platform/api`, payload) + const createdApi = created?.api || created?.response?.api || created // Upload proto file if provided if (uploadProto && protoFile) { @@ -135,9 +171,15 @@ const AddApiPage = () => { } } - router.push('/apis') + const newId = createdApi?.api_id + if (newId) { + try { sessionStorage.setItem('selectedApi', JSON.stringify(createdApi)) } catch {} + router.push(`/apis/${encodeURIComponent(String(newId))}/endpoints?add=1`) + } else { + router.push('/apis') + } } catch (err) { - setError('Network error. Please try again.') + setError(err instanceof Error && err.message ? err.message : 'Unable to create the API. Please try again.') } finally { setLoading(false) } @@ -226,642 +268,242 @@ const AddApiPage = () => { setFormData(prev => ({ ...prev, api_allowed_headers: prev.api_allowed_headers.filter((_, i) => i !== index) })) } + const parseList = (t: string) => t.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean) + const ipWarning = (() => { + const effectiveIp = (((formData as any).api_trust_x_forwarded_for && clientIpXff) ? clientIpXff : clientIp) + const wl = parseList(ipWhitelistText) + const bl = parseList(ipBlacklistText) + const isIPv6 = (s: string) => s.includes(':') + const toIPv4 = (s: string) => { const parts = s.split('.'); if (parts.length !== 4) return null as any; return parts.reduce((a, p) => (a << 8n) + (BigInt(parseInt(p, 10) & 255)), 0n) } + const expandIPv6 = (ip: string) => { if (ip.indexOf('::') !== -1) { const [h, t] = ip.split('::'); const hp = h ? h.split(':') : []; const tp = t ? t.split(':') : []; const missing = 8 - (hp.length + tp.length); return [...hp, ...Array(Math.max(0, missing)).fill('0'), ...tp].map(x => x || '0') } return ip.split(':') } + const toIPv6 = (s: string) => { const parts = expandIPv6(s); if (parts.length !== 8) return null as any; try { return parts.reduce((acc, h) => (acc << 16n) + BigInt(parseInt(h || '0', 16)), 0n) } catch { return null as any } } + const matches = (ip: string, patterns: string[]) => { + if (!ip) return false + const v6 = isIPv6(ip) + const ipVal = v6 ? toIPv6(ip) : toIPv4(ip) + return patterns.some(raw => { + const p = raw.trim(); if (!p) return false + if (p.includes('/')) { + const [net, maskStr] = p.split('/'); const m = parseInt(maskStr, 10) + const n6 = isIPv6(net); if (n6 !== v6) return false + const netVal = n6 ? toIPv6(net) : toIPv4(net); if (netVal === null || ipVal === null || isNaN(m as any)) return false + const bits = n6 ? 128 : 32 + const shift = BigInt(bits - Math.min(Math.max(m, 0), bits)) + const mask = ((1n << BigInt(bits)) - 1n) ^ ((1n << shift) - 1n) + return ((ipVal & mask) === (netVal & mask)) + } + return p.toLowerCase() === ip.toLowerCase() + }) + } + const warnWL = formData.api_ip_mode === 'whitelist' && wl.length > 0 && !matches(effectiveIp, wl) + const warnBL = matches(effectiveIp, bl) + if (!(warnWL || warnBL)) return null + return { text: warnBL ? 'Your current IP is in the blocked list. You may lose access after saving.' : 'Your current IP is not in the allowed list. You may lose access after saving.', ip: effectiveIp || 'unknown', xff: !!formData.api_trust_x_forwarded_for } + })() + + const fd = formData as any + const nameOk = formData.api_name.trim().length > 0 && formData.api_version.trim().length > 0 + const stepError = step === 0 && !nameOk ? 'Enter an API name and version to continue.' : null + const isLast = step === STEPS.length - 1 + const goNext = () => { if (stepError) { setError(stepError); return } setError(null); setStep(s => Math.min(s + 1, STEPS.length - 1)) } + const goBack = () => { setError(null); setStep(s => Math.max(s - 1, 0)) } + const jump = (i: number) => { if (i > 0 && !nameOk) { setError('Enter an API name and version to continue.'); setStep(0); return } setError(null); setStep(i) } + + const yesNo = (v: boolean) => (v ? 'Yes' : 'No') + const reviewRows: [string, React.ReactNode][] = [ + ['Name / version', `${formData.api_name || '—'} / ${formData.api_version || '—'}`], + ['Protocol', formData.api_type], + ['Enabled on creation', yesNo(!!formData.active)], + ['Upstream servers', formData.api_servers.length ? formData.api_servers.join(', ') : 'None (add later)'], + ['Custom hostname', formData.api_hostname || '—'], + ['Retry attempts', String(formData.api_allowed_retry_count)], + ['Authentication required', yesNo(!!formData.api_auth_required)], + ['Public access', yesNo(!!fd.api_public)], + ['Allowed groups', formData.api_allowed_groups.join(', ') || 'ALL'], + ['Allowed roles', formData.api_allowed_roles.join(', ') || 'Any'], + ['IP policy', formData.api_ip_mode === 'whitelist' ? `Allow list only (${parseList(ipWhitelistText).length} entries)` : 'Allow all'], + ['Blocked IPs', String(parseList(ipBlacklistText).length)], + ['Credits', formData.api_credits_enabled ? `On (${formData.api_credit_group || 'no group'})` : 'Off'], + ['gRPC proto file', uploadProto && protoFile ? protoFile.name : '—'], + ] + return ( -
+

Add API

-

- Define a new API and its default upstream servers -

+

Step {step + 1} of {STEPS.length}: {STEPS[step].hint}

- - - - - Back to APIs - + Cancel
- {error && ( -
-
- - - -
-

{error}

-
-
-
- )} - -
-
-
-

Basic Information

- Fill API name/version; these form the base path clients call. -
-
- {((formData as any).api_public && (formData as any).api_credits_enabled) && ( -
- Public + Credits: Anyone can call this API and the group API key will be injected. Per-user deductions/keys are skipped. -
- )} -
- -
- - -
- {((formData as any).api_credits_enabled === true) && ( -

Disable Credits to change Public status.

- )} -

Use with care. Authentication, subscriptions, and group checks are skipped.

-
-
- -
- - -
-
-
-
- - -

- A unique identifier for your API -

-
- -
- - -

- API version (e.g., v1, v2) -

-
+
    + {STEPS.map((s, i) => ( +
  1. + +
  2. + ))} +
+ + {error &&
{error}
} + + { e.preventDefault(); if (isLast) handleSubmit(); else goNext() }} className="card space-y-6 !p-6"> + {step === 0 && ( +
+
+ + + + + +
-
- - + -

The protocol type for this API

-
-
-
- - -
-
- -