diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..fb1f124 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,16 @@ +AUTH_SECRET=replace-with-openssl-rand-32 +AUTH_URL=http://localhost:3000 + +# OAuth providers +AUTH_GITHUB_ID= +AUTH_GITHUB_SECRET= +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= + +# ARIV backend endpoint (Python service / API) +ARIV_API_BASE_URL=http://localhost:8000 +ARIV_API_KEY= + +# Rate limiting +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX_REQUESTS=30 diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..7f021fa --- /dev/null +++ b/web/README.md @@ -0,0 +1,92 @@ +# ARIV Web Console (Next.js + Vercel) + +Production-ready ChatGPT-inspired control plane for ARIV with authentication, API proxying, and secure defaults. + +## Project structure + +```text +web/ +├── app/ +│ ├── api/ +│ │ ├── auth/[...nextauth]/route.ts +│ │ ├── command/route.ts +│ │ ├── config/route.ts +│ │ └── logs/route.ts +│ ├── settings/page.tsx +│ ├── globals.css +│ ├── layout.tsx +│ └── page.tsx +├── components/ +│ ├── chat/chat-window.tsx +│ ├── layout/sidebar.tsx +│ └── ui/{button,input,switch,textarea}.tsx +├── lib/ +│ ├── store/chat-store.ts +│ ├── ariv.ts +│ ├── env.ts +│ ├── rate-limit.ts +│ ├── security.ts +│ └── utils.ts +├── types/{index,next-auth}.d.ts +├── auth.ts +├── middleware.ts +├── .env.example +├── next.config.js +├── package.json +├── tailwind.config.ts +└── tsconfig.json +``` + +## Security controls included + +- Auth.js (NextAuth v5) with GitHub/Google OAuth providers. +- JWT-backed sessions with protected API routes in middleware. +- Request throttling using a per-IP + per-route rate limiter. +- Command/config input validation using Zod schemas. +- Output/input sanitization for potentially unsafe content. +- Hardened response headers (CSP, X-Frame-Options, nosniff, etc.). +- Secrets only from environment variables. +- Build-safe auth configuration: deployment builds no longer fail if OAuth env vars are missing; sign-in remains disabled until a provider is configured. + +## Local development + +1. Install dependencies: + ```bash + cd web + npm install + ``` +2. Configure env vars: + ```bash + cp .env.example .env.local + ``` +3. Run: + ```bash + npm run dev + ``` + +## ARIV backend contract + +The Next.js APIs are secure proxies and expect an ARIV backend service exposing: + +- `POST /api/command` → `{ output: string }` +- `GET /api/config` / `PUT /api/config` +- `GET /api/logs` → `{ entries: string[] }` + +Point `ARIV_API_BASE_URL` to this backend URL. + +## Deploy to Vercel + +1. Push repository. +2. Import the project in Vercel and set **Root Directory** to `web`. +3. Add env vars from `.env.example`: + - `AUTH_SECRET` (required, min 32 chars) + - OAuth provider keys + - `ARIV_API_BASE_URL` (+ optional `ARIV_API_KEY`) + - Rate limiting values +4. Deploy. + +### Recommended production settings + +- Restrict OAuth redirect domains to your production origin. +- Use managed Redis/Upstash for distributed rate limiting (replace in-memory limiter). +- Monitor logs and enable Vercel WAF + Bot Protection. diff --git a/web/app/api/auth/[...nextauth]/route.ts b/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..86c9f3d --- /dev/null +++ b/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/web/app/api/command/route.ts b/web/app/api/command/route.ts new file mode 100644 index 0000000..78f213d --- /dev/null +++ b/web/app/api/command/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { forwardToAriv } from "@/lib/ariv"; +import { commandSchema, sanitizeText } from "@/lib/security"; + +export async function POST(request: Request) { + try { + const payload = commandSchema.parse(await request.json()); + + const result = await forwardToAriv<{ output: string }>("/api/command", { + method: "POST", + body: JSON.stringify({ + command: sanitizeText(payload.command), + sessionId: payload.sessionId + }) + }); + + return NextResponse.json(result); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Invalid request" }, + { status: 400 } + ); + } +} diff --git a/web/app/api/config/route.ts b/web/app/api/config/route.ts new file mode 100644 index 0000000..c290bc8 --- /dev/null +++ b/web/app/api/config/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { forwardToAriv } from "@/lib/ariv"; +import { configSchema } from "@/lib/security"; + +export async function GET() { + try { + const config = await forwardToAriv("/api/config", { method: "GET" }); + return NextResponse.json(config); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch config" }, + { status: 500 } + ); + } +} + +export async function PUT(request: Request) { + try { + const payload = configSchema.parse(await request.json()); + const updated = await forwardToAriv("/api/config", { + method: "PUT", + body: JSON.stringify(payload) + }); + return NextResponse.json(updated); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to update config" }, + { status: 400 } + ); + } +} diff --git a/web/app/api/logs/route.ts b/web/app/api/logs/route.ts new file mode 100644 index 0000000..21c5174 --- /dev/null +++ b/web/app/api/logs/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { forwardToAriv } from "@/lib/ariv"; + +export async function GET() { + try { + const logs = await forwardToAriv<{ entries: string[] }>("/api/logs", { method: "GET" }); + return NextResponse.json(logs); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch logs" }, + { status: 500 } + ); + } +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..20b4b14 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,18 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; +} + +body { + @apply bg-background text-foreground antialiased; + font-family: 'Inter', system-ui, sans-serif; +} + +* { + @apply border-border; +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..72e8ad9 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "ARIV Console", + description: "Secure web control plane for ARIV" +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..9b45448 --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,19 @@ +import { Sidebar } from "@/components/layout/sidebar"; +import { ChatWindow } from "@/components/chat/chat-window"; + +export default function HomePage() { + return ( +
+ +
+
+
+

ARIV Command Center

+

ChatGPT-inspired interface for secure control and configuration.

+
+ +
+
+
+ ); +} diff --git a/web/app/settings/page.tsx b/web/app/settings/page.tsx new file mode 100644 index 0000000..1383cce --- /dev/null +++ b/web/app/settings/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Sidebar } from "@/components/layout/sidebar"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { useChatStore } from "@/lib/store/chat-store"; + +const formSchema = z.object({ + model: z.string().min(1), + temperature: z.coerce.number().min(0).max(2), + streaming: z.boolean(), + safeMode: z.boolean(), + telemetry: z.boolean() +}); + +type FormData = z.infer; + +export default function SettingsPage() { + const { config, setConfig } = useChatStore(); + const [status, setStatus] = useState(""); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: config + }); + + useEffect(() => { + form.reset(config); + }, [config, form]); + + const onSubmit = form.handleSubmit(async (values) => { + const res = await fetch("/api/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values) + }); + + if (!res.ok) { + setStatus("Failed to save settings."); + return; + } + + setConfig(values); + setStatus("Configuration saved securely."); + }); + + return ( +
+ +
+
+

ARIV Configuration

+

Manage model behavior and safety settings.

+ +
+
+ + +
+ +
+ + +
+ +
+ Enable Streaming Responses + form.setValue("streaming", v)} /> +
+ +
+ Safe Mode (recommended) + form.setValue("safeMode", v)} /> +
+ +
+ Telemetry + form.setValue("telemetry", v)} /> +
+ + + {status ?

{status}

: null} +
+
+
+
+ ); +} diff --git a/web/auth.ts b/web/auth.ts new file mode 100644 index 0000000..af02903 --- /dev/null +++ b/web/auth.ts @@ -0,0 +1,52 @@ +import NextAuth from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import GitHub from "next-auth/providers/github"; +import Google from "next-auth/providers/google"; +import { env } from "@/lib/env"; + +const providers = []; + +if (env.AUTH_GITHUB_ID && env.AUTH_GITHUB_SECRET) { + providers.push( + GitHub({ + clientId: env.AUTH_GITHUB_ID, + clientSecret: env.AUTH_GITHUB_SECRET + }) + ); +} + +if (env.AUTH_GOOGLE_ID && env.AUTH_GOOGLE_SECRET) { + providers.push( + Google({ + clientId: env.AUTH_GOOGLE_ID, + clientSecret: env.AUTH_GOOGLE_SECRET + }) + ); +} + +if (providers.length === 0) { + providers.push( + Credentials({ + name: "Disabled", + credentials: {}, + authorize: async () => null + }) + ); +} + +export const { handlers, signIn, signOut, auth } = NextAuth({ + providers, + secret: env.AUTH_SECRET, + session: { strategy: "jwt" }, + pages: { + signIn: "/" + }, + callbacks: { + async session({ session, token }) { + if (session.user) { + session.user.id = token.sub ?? ""; + } + return session; + } + } +}); diff --git a/web/components/chat/chat-window.tsx b/web/components/chat/chat-window.tsx new file mode 100644 index 0000000..ed18e5e --- /dev/null +++ b/web/components/chat/chat-window.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState } from "react"; +import { v4 as uuid } from "uuid"; +import { SendHorizontal } from "lucide-react"; +import { useChatStore } from "@/lib/store/chat-store"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +export function ChatWindow() { + const { messages, addMessage } = useChatStore(); + const [prompt, setPrompt] = useState(""); + const [loading, setLoading] = useState(false); + + const submitCommand = async () => { + if (!prompt.trim() || loading) return; + + const userMessage = { + id: uuid(), + role: "user" as const, + content: prompt, + createdAt: new Date().toISOString() + }; + + addMessage(userMessage); + setLoading(true); + + try { + const response = await fetch("/api/command", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ command: prompt }) + }); + const data = await response.json(); + addMessage({ + id: uuid(), + role: "assistant", + content: data.output ?? data.error ?? "No response", + createdAt: new Date().toISOString() + }); + } catch (error) { + addMessage({ + id: uuid(), + role: "assistant", + content: `Request failed: ${(error as Error).message}`, + createdAt: new Date().toISOString() + }); + } finally { + setPrompt(""); + setLoading(false); + } + }; + + return ( +
+
+ {messages.length === 0 ? ( +

Ask ARIV anything, run automation commands, or inspect system status.

+ ) : ( + messages.map((msg) => ( +
+ {msg.content} +
+ )) + )} +
+ +
+
+