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 (
+
+
+
+
+ );
+}
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.
+
+
+
+
+
+ );
+}
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}
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/web/components/layout/sidebar.tsx b/web/components/layout/sidebar.tsx
new file mode 100644
index 0000000..2ee4a79
--- /dev/null
+++ b/web/components/layout/sidebar.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import Link from "next/link";
+import { Settings, PanelLeftClose, PanelLeftOpen, Shield } from "lucide-react";
+import { useChatStore } from "@/lib/store/chat-store";
+import { Button } from "@/components/ui/button";
+
+export function Sidebar() {
+ const { sidebarOpen, setSidebarOpen, messages } = useChatStore();
+
+ return (
+
+ );
+}
diff --git a/web/components/ui/button.tsx b/web/components/ui/button.tsx
new file mode 100644
index 0000000..43a5fbe
--- /dev/null
+++ b/web/components/ui/button.tsx
@@ -0,0 +1,35 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-accent text-white hover:opacity-90",
+ ghost: "bg-transparent hover:bg-white/10",
+ outline: "border border-border bg-panel hover:bg-white/5"
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ icon: "h-10 w-10"
+ }
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default"
+ }
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {}
+
+export const Button = React.forwardRef(
+ ({ className, variant, size, ...props }, ref) => {
+ return ;
+ }
+);
+Button.displayName = "Button";
diff --git a/web/components/ui/input.tsx b/web/components/ui/input.tsx
new file mode 100644
index 0000000..05a2b65
--- /dev/null
+++ b/web/components/ui/input.tsx
@@ -0,0 +1,18 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export const Input = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Input.displayName = "Input";
diff --git a/web/components/ui/switch.tsx b/web/components/ui/switch.tsx
new file mode 100644
index 0000000..5f6157d
--- /dev/null
+++ b/web/components/ui/switch.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import * as React from "react";
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+import { cn } from "@/lib/utils";
+
+export function Switch({ className, ...props }: React.ComponentPropsWithoutRef) {
+ return (
+
+
+
+ );
+}
diff --git a/web/components/ui/textarea.tsx b/web/components/ui/textarea.tsx
new file mode 100644
index 0000000..83f9199
--- /dev/null
+++ b/web/components/ui/textarea.tsx
@@ -0,0 +1,19 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.TextareaHTMLAttributes
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+Textarea.displayName = "Textarea";
diff --git a/web/lib/ariv.ts b/web/lib/ariv.ts
new file mode 100644
index 0000000..d04ad55
--- /dev/null
+++ b/web/lib/ariv.ts
@@ -0,0 +1,22 @@
+import { env, requireArivApiBaseUrl } from "@/lib/env";
+
+export async function forwardToAriv(path: string, init?: RequestInit): Promise {
+ const url = new URL(path, requireArivApiBaseUrl());
+
+ const response = await fetch(url, {
+ ...init,
+ headers: {
+ "Content-Type": "application/json",
+ ...(env.ARIV_API_KEY ? { Authorization: `Bearer ${env.ARIV_API_KEY}` } : {}),
+ ...(init?.headers ?? {})
+ },
+ cache: "no-store"
+ });
+
+ if (!response.ok) {
+ const body = await response.text();
+ throw new Error(`ARIV backend error (${response.status}): ${body}`);
+ }
+
+ return response.json() as Promise;
+}
diff --git a/web/lib/env.ts b/web/lib/env.ts
new file mode 100644
index 0000000..6ec240e
--- /dev/null
+++ b/web/lib/env.ts
@@ -0,0 +1,35 @@
+import { z } from "zod";
+
+const envSchema = z.object({
+ AUTH_SECRET: z.string().min(32).optional(),
+ AUTH_URL: z.string().url().optional(),
+ AUTH_GITHUB_ID: z.string().optional(),
+ AUTH_GITHUB_SECRET: z.string().optional(),
+ AUTH_GOOGLE_ID: z.string().optional(),
+ AUTH_GOOGLE_SECRET: z.string().optional(),
+ ARIV_API_BASE_URL: z.string().url().optional(),
+ ARIV_API_KEY: z.string().optional(),
+ RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60000),
+ RATE_LIMIT_MAX_REQUESTS: z.coerce.number().default(30)
+});
+
+export const env = envSchema.parse({
+ AUTH_SECRET: process.env.AUTH_SECRET,
+ AUTH_URL: process.env.AUTH_URL,
+ AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
+ AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
+ AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
+ AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
+ ARIV_API_BASE_URL: process.env.ARIV_API_BASE_URL,
+ ARIV_API_KEY: process.env.ARIV_API_KEY,
+ RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
+ RATE_LIMIT_MAX_REQUESTS: process.env.RATE_LIMIT_MAX_REQUESTS
+});
+
+export function requireArivApiBaseUrl() {
+ if (!env.ARIV_API_BASE_URL) {
+ throw new Error("Missing ARIV_API_BASE_URL environment variable.");
+ }
+
+ return env.ARIV_API_BASE_URL;
+}
diff --git a/web/lib/rate-limit.ts b/web/lib/rate-limit.ts
new file mode 100644
index 0000000..93d0144
--- /dev/null
+++ b/web/lib/rate-limit.ts
@@ -0,0 +1,20 @@
+type Bucket = { count: number; resetAt: number };
+const buckets = new Map();
+
+export function checkRateLimit(key: string, windowMs: number, maxRequests: number) {
+ const now = Date.now();
+ const bucket = buckets.get(key);
+
+ if (!bucket || bucket.resetAt <= now) {
+ buckets.set(key, { count: 1, resetAt: now + windowMs });
+ return { allowed: true, remaining: maxRequests - 1 };
+ }
+
+ if (bucket.count >= maxRequests) {
+ return { allowed: false, remaining: 0, resetAt: bucket.resetAt };
+ }
+
+ bucket.count += 1;
+ buckets.set(key, bucket);
+ return { allowed: true, remaining: maxRequests - bucket.count };
+}
diff --git a/web/lib/security.ts b/web/lib/security.ts
new file mode 100644
index 0000000..b9b01f8
--- /dev/null
+++ b/web/lib/security.ts
@@ -0,0 +1,22 @@
+import { z } from "zod";
+
+export const commandSchema = z.object({
+ command: z
+ .string()
+ .min(1)
+ .max(1200)
+ .regex(/^[\w\s.,:;!?@#%+\-_=\/()\[\]{}"']+$/, "Invalid characters in command"),
+ sessionId: z.string().uuid().optional()
+});
+
+export const configSchema = z.object({
+ model: z.string().min(1).max(120),
+ temperature: z.number().min(0).max(2),
+ streaming: z.boolean(),
+ safeMode: z.boolean(),
+ telemetry: z.boolean()
+});
+
+export function sanitizeText(input: string) {
+ return input.replace(/[<>]/g, "").trim();
+}
diff --git a/web/lib/store/chat-store.ts b/web/lib/store/chat-store.ts
new file mode 100644
index 0000000..3afff29
--- /dev/null
+++ b/web/lib/store/chat-store.ts
@@ -0,0 +1,26 @@
+import { create } from "zustand";
+import type { ArivConfig, ChatMessage } from "@/types";
+
+type ChatState = {
+ messages: ChatMessage[];
+ config: ArivConfig;
+ sidebarOpen: boolean;
+ setSidebarOpen: (open: boolean) => void;
+ addMessage: (message: ChatMessage) => void;
+ setConfig: (config: ArivConfig) => void;
+};
+
+export const useChatStore = create((set) => ({
+ messages: [],
+ sidebarOpen: true,
+ config: {
+ model: "ariv-default",
+ temperature: 0.7,
+ streaming: true,
+ safeMode: true,
+ telemetry: false
+ },
+ setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
+ addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })),
+ setConfig: (config) => set({ config })
+}));
diff --git a/web/lib/utils.ts b/web/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/web/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/web/middleware.ts b/web/middleware.ts
new file mode 100644
index 0000000..3dcc82d
--- /dev/null
+++ b/web/middleware.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/auth";
+import { env } from "@/lib/env";
+import { checkRateLimit } from "@/lib/rate-limit";
+
+const protectedApiPaths = ["/api/command", "/api/config", "/api/logs"];
+
+export default auth((req) => {
+ const { pathname } = req.nextUrl;
+
+ if (protectedApiPaths.some((path) => pathname.startsWith(path))) {
+ if (!req.auth) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
+ const key = `${ip}:${pathname}`;
+ const result = checkRateLimit(key, env.RATE_LIMIT_WINDOW_MS, env.RATE_LIMIT_MAX_REQUESTS);
+
+ if (!result.allowed) {
+ return NextResponse.json(
+ { error: "Too many requests", retryAfter: result.resetAt },
+ { status: 429 }
+ );
+ }
+ }
+
+ return NextResponse.next();
+});
+
+export const config = {
+ matcher: ["/api/:path*"]
+};
diff --git a/web/next-env.d.ts b/web/next-env.d.ts
new file mode 100644
index 0000000..84ab714
--- /dev/null
+++ b/web/next-env.d.ts
@@ -0,0 +1,4 @@
+///
+///
+
+// NOTE: This file should not be edited
diff --git a/web/next.config.js b/web/next.config.js
new file mode 100644
index 0000000..b9c4558
--- /dev/null
+++ b/web/next.config.js
@@ -0,0 +1,27 @@
+/** @type {import('next').NextConfig} */
+const securityHeaders = [
+ { key: 'X-Frame-Options', value: 'DENY' },
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
+ {
+ key: 'Content-Security-Policy',
+ value:
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' data:; frame-ancestors 'none';"
+ }
+];
+
+const nextConfig = {
+ poweredByHeader: false,
+ reactStrictMode: true,
+ async headers() {
+ return [
+ {
+ source: '/(.*)',
+ headers: securityHeaders
+ }
+ ];
+ }
+};
+
+module.exports = nextConfig;
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..b1ceac8
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "ariv-web",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@hookform/resolvers": "^3.9.0",
+ "@radix-ui/react-dialog": "^1.1.2",
+ "@radix-ui/react-label": "^2.1.0",
+ "@radix-ui/react-scroll-area": "^1.2.0",
+ "@radix-ui/react-slot": "^1.1.0",
+ "@radix-ui/react-switch": "^1.1.1",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.453.0",
+ "next": "^14.2.15",
+ "next-auth": "^5.0.0-beta.25",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-hook-form": "^7.53.0",
+ "tailwind-merge": "^2.5.3",
+ "zod": "^3.23.8",
+ "zustand": "^4.5.5",
+ "uuid": "^11.0.3"
+ },
+ "devDependencies": {
+ "@types/node": "^22.7.4",
+ "@types/react": "^18.3.11",
+ "@types/react-dom": "^18.3.0",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^8.57.1",
+ "eslint-config-next": "^14.2.15",
+ "postcss": "^8.4.47",
+ "tailwindcss": "^3.4.13",
+ "typescript": "^5.6.2"
+ }
+}
diff --git a/web/postcss.config.js b/web/postcss.config.js
new file mode 100644
index 0000000..5cbc2c7
--- /dev/null
+++ b/web/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+};
diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts
new file mode 100644
index 0000000..2c51f11
--- /dev/null
+++ b/web/tailwind.config.ts
@@ -0,0 +1,30 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ darkMode: ["class"],
+ content: [
+ "./app/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ "./lib/**/*.{ts,tsx}"
+ ],
+ theme: {
+ extend: {
+ borderRadius: {
+ lg: "0.75rem",
+ md: "0.5rem",
+ sm: "0.375rem"
+ },
+ colors: {
+ background: "#0f1115",
+ foreground: "#e5e7eb",
+ panel: "#171a21",
+ border: "#2a2f3a",
+ muted: "#9ca3af",
+ accent: "#10a37f"
+ }
+ }
+ },
+ plugins: []
+};
+
+export default config;
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..e0d07bd
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "es2022"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/web/types/index.ts b/web/types/index.ts
new file mode 100644
index 0000000..ce588be
--- /dev/null
+++ b/web/types/index.ts
@@ -0,0 +1,16 @@
+export type ChatRole = "user" | "assistant" | "system";
+
+export type ChatMessage = {
+ id: string;
+ role: ChatRole;
+ content: string;
+ createdAt: string;
+};
+
+export type ArivConfig = {
+ model: string;
+ temperature: number;
+ streaming: boolean;
+ safeMode: boolean;
+ telemetry: boolean;
+};
diff --git a/web/types/next-auth.d.ts b/web/types/next-auth.d.ts
new file mode 100644
index 0000000..0cef3fa
--- /dev/null
+++ b/web/types/next-auth.d.ts
@@ -0,0 +1,12 @@
+import "next-auth";
+
+declare module "next-auth" {
+ interface Session {
+ user?: {
+ id: string;
+ name?: string | null;
+ email?: string | null;
+ image?: string | null;
+ };
+ }
+}