diff --git a/.env.example b/.env.example index 4ec48da..029eabb 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # Neta self-host environment +# Use app-only when Neta connects to an external Supabase-compatible backend. +NETA_INSTALL_MODE=app-only + # Public URL where users open Neta. NEXT_PUBLIC_SITE_URL=http://localhost:3000 @@ -18,6 +21,10 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= # Keep this secret. It is only used server-side. SUPABASE_SERVICE_ROLE_KEY= +# Optional internal Supabase URL for Docker networks. +# In bundled mode this is set to http://neta-supabase-proxy:8000 by docker-compose.full.yml. +# SUPABASE_INTERNAL_URL= + # Optional: direct Postgres connection string used only by bash ./scripts/apply-migrations.sh. # Do not expose this to browsers. It is not required by the web container. # DATABASE_URL=postgresql://postgres:password@host:5432/postgres diff --git a/.env.full.example b/.env.full.example new file mode 100644 index 0000000..759e378 --- /dev/null +++ b/.env.full.example @@ -0,0 +1,23 @@ +# Neta full-stack self-host environment + +NETA_INSTALL_MODE=full-stack +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +NETA_PORT=3000 + +# Public Supabase API URL reachable from the browser. +# For local Docker installs this is usually http://localhost:8000. +NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000 +SUPABASE_API_PORT=8000 + +# Generated by install.sh in full-stack mode. +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= +JWT_SECRET= +POSTGRES_PASSWORD= + +# Exposes bundled Postgres on the host for backups/manual access. +POSTGRES_PORT=54322 + +# Optional auth/mail settings. +JWT_EXPIRY=3600 +SMTP_ADMIN_EMAIL=admin@neta.local diff --git a/.gitignore b/.gitignore index ea7a3af..6bfa344 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist build .env* !.env.example +!.env.full.example npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/README.md b/README.md index a32ee1a..a93895c 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,14 @@ Neta is engineered using modern, high-performance web technologies: ## Installation and Deployment -Neta is designed for self-hosting. The current Docker Compose file runs the Neta web application and connects it to a Supabase-compatible backend. A bundled Supabase Compose profile is planned for the full-stack self-host mode. +Neta is designed for self-hosting. It supports two Docker deployment modes: + +- **Full-stack:** Neta + bundled Postgres/Auth/PostgREST/Storage. +- **App-only:** Neta connected to an existing Supabase-compatible backend. ### Prerequisites - Docker and Docker Compose -- A Supabase project or self-hosted Supabase backend -- Supabase API URL, anon key, and service role key -- A direct Postgres `DATABASE_URL` if you want the installer to apply migrations automatically +- For app-only mode: Supabase API URL, anon key, service role key, and a direct Postgres `DATABASE_URL` for migrations ### 1-Click Installation (Recommended) @@ -66,22 +67,31 @@ You can install Neta using the interactive setup script: curl -sL https://raw.githubusercontent.com/poyrazavsever/neta/main/install.sh | bash ``` -The installer asks for the required Supabase values, writes a `.env` file, optionally applies database migrations, validates Docker Compose configuration, and starts the application. +The installer asks for the deployment mode, writes a `.env` file, validates Docker Compose configuration, and starts the application. In full-stack mode it generates Supabase JWT secrets and applies Neta migrations automatically through the `neta-migrations` service. ### Manual Installation If you prefer to set up Neta manually: 1. Clone the repository: `git clone https://github.com/poyrazavsever/neta.git` -2. Navigate to the directory and copy the `.env.example` file to `.env`. -3. Fill every required value in `.env`. -4. Apply database migrations: +2. Navigate to the directory. +3. For full-stack mode, generate a `.env` file and run: ```bash -DATABASE_URL='postgresql://postgres:password@host:5432/postgres' bash ./scripts/apply-migrations.sh +node scripts/generate-full-stack-env.mjs > .env ``` -5. Build and start the Docker container: +```bash +docker compose -f docker-compose.full.yml up -d --build +``` + +4. For app-only mode, copy `.env.example` to `.env`, fill every required value, and apply database migrations: + +```bash +DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh +``` + +5. Start the app-only Docker container: ```bash docker compose up -d --build @@ -89,6 +99,8 @@ docker compose up -d --build Docker Compose intentionally fails fast when required Supabase environment values are missing. +Coolify and Dokploy users should use `docker-compose.full.yml` for the no-external-service setup, or `docker-compose.yml` when connecting to an existing Supabase backend. See `docs/deployment/self-hosting.md` for the deployment checklist. + ### First Administrator Account To ensure data security, Neta is locked to a single administrator. Upon launching the application for the first time, navigate to the `/register` route to create the initial admin account. Once this account is created, public registration is permanently disabled. diff --git a/app/api/create-client-user/route.ts b/app/api/create-client-user/route.ts index 7804c2f..a7edd8e 100644 --- a/app/api/create-client-user/route.ts +++ b/app/api/create-client-user/route.ts @@ -18,7 +18,8 @@ export async function POST(request: Request) { ); } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseUrl = + process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!supabaseUrl || !serviceRoleKey) { diff --git a/deploy/supabase/db/init.sh b/deploy/supabase/db/init.sh new file mode 100644 index 0000000..08aa0a3 --- /dev/null +++ b/deploy/supabase/db/init.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env sh +set -eu + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < .env +``` + +Then adjust these values before starting: + +- `NEXT_PUBLIC_SITE_URL`: public Neta URL +- `NEXT_PUBLIC_SUPABASE_URL`: public bundled Supabase API URL +- `NETA_PORT`: host port for Neta when deploying directly with Docker +- `SUPABASE_API_PORT`: host port for the bundled Supabase API +- `POSTGRES_PORT`: host port for backup/manual DB access + +Start: + +```bash +docker compose -f docker-compose.full.yml up -d --build +``` + +## App-Only Mode + +Use this when Neta connects to an existing Supabase or Supabase-compatible backend. + +Compose file: + +```bash +docker-compose.yml +``` + +Required env values: + +- `NEXT_PUBLIC_SITE_URL` +- `NEXT_PUBLIC_SUPABASE_URL` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` +- `SUPABASE_SERVICE_ROLE_KEY` +- `DATABASE_URL` only when applying migrations manually + +Apply migrations: + +```bash +DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh +``` + +Start: + +```bash +docker compose up -d --build +``` + +## Coolify + +For a no-external-service install, choose Docker Compose deployment and set the compose file to `docker-compose.full.yml`. + +Set the generated full-stack env values in Coolify's environment variables. Route the app domain to `neta-web:3000`. If you expose Supabase through a second domain, route it to `neta-supabase-proxy:8000` and set `NEXT_PUBLIC_SUPABASE_URL` to that public URL. + +## Dokploy + +Create a Compose app from this repository. Use `docker-compose.full.yml` for full-stack deployments and paste the generated env values into the environment panel. + +Route the Neta domain to `neta-web` port `3000`. Route the bundled Supabase API domain, if used, to `neta-supabase-proxy` port `8000`. + +## First Admin + +Open `/register` after the stack starts. The first registered user becomes the admin, and public registration is locked after that. diff --git a/install.sh b/install.sh index fb7e38f..2265c88 100644 --- a/install.sh +++ b/install.sh @@ -6,6 +6,7 @@ set -euo pipefail REPO_URL="${NETA_REPO_URL:-https://github.com/poyrazavsever/neta.git}" TARGET_DIR="${NETA_TARGET_DIR:-neta-os}" +INSTALL_MODE="${NETA_INSTALL_MODE:-}" info() { printf "\n%s\n" "$1" @@ -89,17 +90,135 @@ prompt_secret_required() { done } +choose_install_mode() { + if [ -n "$INSTALL_MODE" ]; then + case "$INSTALL_MODE" in + full|full-stack|bundled) + INSTALL_MODE="full-stack" + ;; + app|app-only|external) + INSTALL_MODE="app-only" + ;; + *) + fail "Invalid NETA_INSTALL_MODE. Use full-stack or app-only." + ;; + esac + export INSTALL_MODE + return + fi + + echo "Choose install mode:" + echo " 1) full-stack Neta + bundled Supabase/Postgres/Auth/Storage" + echo " 2) app-only Neta app connected to an existing Supabase project" + + while true; do + read -r -p "Install mode [full-stack]: " answer + case "${answer:-full-stack}" in + 1|full|full-stack|bundled) + INSTALL_MODE="full-stack" + export INSTALL_MODE + return + ;; + 2|app|app-only|external) + INSTALL_MODE="app-only" + export INSTALL_MODE + return + ;; + *) + echo "Please choose full-stack or app-only." + ;; + esac + done +} + +run_node_script() { + local script="$1" + + if command -v node >/dev/null 2>&1; then + node -e "$script" + else + docker run --rm \ + -e ROLE="${ROLE:-}" \ + -e JWT_SECRET="${JWT_SECRET:-}" \ + node:22-alpine node -e "$script" + fi +} + +random_secret() { + run_node_script "console.log(require('crypto').randomBytes(32).toString('hex'))" +} + +generate_supabase_jwt() { + local role="$1" + local secret="$2" + ROLE="$role" JWT_SECRET="$secret" run_node_script "const crypto=require('crypto'); const b64=(v)=>Buffer.from(v).toString('base64url'); const header=b64(JSON.stringify({alg:'HS256',typ:'JWT'})); const payload=b64(JSON.stringify({iss:'supabase',ref:'neta',role:process.env.ROLE,iat:1700000000,exp:4102444800})); const unsigned=header+'.'+payload; const sig=crypto.createHmac('sha256', process.env.JWT_SECRET).update(unsigned).digest('base64url'); console.log(unsigned+'.'+sig);" +} + write_env_file() { cat > .env <> .env </dev/null + + info "Building and starting bundled Neta stack" + $compose -f docker-compose.full.yml up -d --build + else + info "Validating Docker Compose configuration" + $compose config >/dev/null + + info "Building and starting Neta" + $compose up -d --build + fi +} + main() { info "Neta self-host installer" @@ -115,36 +234,42 @@ main() { git clone "$REPO_URL" "$TARGET_DIR" cd "$TARGET_DIR" - prompt_optional NEXT_PUBLIC_SITE_URL "Public Neta URL" "http://localhost:3000" - prompt_optional NETA_PORT "Host port for Neta" "3000" - prompt_required NEXT_PUBLIC_SUPABASE_URL "Supabase API URL" - prompt_secret_required NEXT_PUBLIC_SUPABASE_ANON_KEY "Supabase anon key" - prompt_secret_required SUPABASE_SERVICE_ROLE_KEY "Supabase service role key" + choose_install_mode + + if [ "$INSTALL_MODE" = "full-stack" ]; then + configure_full_stack + else + configure_app_only + fi write_env_file info "Wrote .env" - read -r -p "Apply Neta database migrations now? Requires a direct Postgres DATABASE_URL. [y/N]: " apply_migrations - if [ "$apply_migrations" = "y" ] || [ "$apply_migrations" = "Y" ]; then - prompt_secret_required DATABASE_URL "Postgres DATABASE_URL" - DATABASE_URL="$DATABASE_URL" bash ./scripts/apply-migrations.sh + if [ "$INSTALL_MODE" = "app-only" ]; then + read -r -p "Apply Neta database migrations now? Requires a direct Postgres DATABASE_URL. [y/N]: " apply_migrations + if [ "$apply_migrations" = "y" ] || [ "$apply_migrations" = "Y" ]; then + prompt_secret_required DATABASE_URL "Postgres DATABASE_URL" + DATABASE_URL="$DATABASE_URL" sh ./scripts/apply-migrations.sh + else + echo "Skipping migrations. Run them later with:" + echo " DATABASE_URL='postgresql://...' sh ./scripts/apply-migrations.sh" + fi else - echo "Skipping migrations. Run them later with:" - echo " DATABASE_URL='postgresql://...' bash ./scripts/apply-migrations.sh" + echo "Bundled mode applies migrations automatically through the neta-migrations service." fi local compose compose="$(compose_cmd)" - info "Validating Docker Compose configuration" - $compose config >/dev/null - - info "Building and starting Neta" - $compose up -d --build + run_compose "$compose" info "Neta is starting" echo "Open: $NEXT_PUBLIC_SITE_URL" echo "Create the first admin account at: $NEXT_PUBLIC_SITE_URL/register" + if [ "$INSTALL_MODE" = "full-stack" ]; then + echo "Bundled Supabase API: $NEXT_PUBLIC_SUPABASE_URL" + echo "Bundled Postgres host port: $POSTGRES_PORT" + fi } main "$@" diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index a887656..2830370 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -5,9 +5,11 @@ export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ request, }) + const supabaseUrl = + process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL! const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, + supabaseUrl, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts index c398d47..34a4676 100644 --- a/lib/supabase/server.ts +++ b/lib/supabase/server.ts @@ -3,9 +3,11 @@ import { cookies } from 'next/headers' export async function createClient() { const cookieStore = await cookies() + const supabaseUrl = + process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL! return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, + supabaseUrl, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { diff --git a/package.json b/package.json index fc41121..2f8c31e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "selfhost:env": "node scripts/generate-full-stack-env.mjs" }, "dependencies": { "@ai-sdk/google": "^3.0.80", diff --git a/scripts/apply-migrations.sh b/scripts/apply-migrations.sh index 78d479f..3d9cb3a 100644 --- a/scripts/apply-migrations.sh +++ b/scripts/apply-migrations.sh @@ -1,31 +1,19 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh -set -euo pipefail +set -eu -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" if [ -z "${DATABASE_URL:-}" ]; then echo "DATABASE_URL is required." >&2 echo "Example:" >&2 - echo " DATABASE_URL='postgresql://postgres:password@host:5432/postgres' bash ./scripts/apply-migrations.sh" >&2 + echo " DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh" >&2 exit 1 fi -SQL_FILES=( - "supabase/schema.sql" - "supabase/migrations/0002_add_freelancer_os_core_tables.sql" - "supabase/migrations/0003_add_project_planning_assets.sql" - "supabase/migrations/0004_add_business_os_tables.sql" - "supabase/migrations/0005_add_advanced_crm_tables.sql" - "supabase/migrations/0006_add_pgvector_and_embeddings.sql" - "supabase/migrations/0007_add_client_portal_tables.sql" - "supabase/migrations/0008_add_project_progress_and_quota.sql" - "supabase/migrations/0009_lock_registration_after_first_admin.sql" -) - run_sql_file() { - local file_path="$1" - local absolute_path="$ROOT_DIR/$file_path" + file_path="$1" + absolute_path="$ROOT_DIR/$file_path" if [ ! -f "$absolute_path" ]; then echo "Missing SQL file: $file_path" >&2 @@ -45,8 +33,19 @@ run_sql_file() { fi } -for sql_file in "${SQL_FILES[@]}"; do +while IFS= read -r sql_file; do + [ -n "$sql_file" ] || continue run_sql_file "$sql_file" -done +done <<'SQL_FILES' +supabase/schema.sql +supabase/migrations/0002_add_freelancer_os_core_tables.sql +supabase/migrations/0003_add_project_planning_assets.sql +supabase/migrations/0004_add_business_os_tables.sql +supabase/migrations/0005_add_advanced_crm_tables.sql +supabase/migrations/0006_add_pgvector_and_embeddings.sql +supabase/migrations/0007_add_client_portal_tables.sql +supabase/migrations/0008_add_project_progress_and_quota.sql +supabase/migrations/0009_lock_registration_after_first_admin.sql +SQL_FILES echo "All Neta migrations were applied." diff --git a/scripts/generate-full-stack-env.mjs b/scripts/generate-full-stack-env.mjs new file mode 100644 index 0000000..398f706 --- /dev/null +++ b/scripts/generate-full-stack-env.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; + +const jwtSecret = process.env.JWT_SECRET || randomSecret(); + +const values = { + NETA_INSTALL_MODE: "full-stack", + NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000", + NETA_PORT: process.env.NETA_PORT || "3000", + NEXT_PUBLIC_SUPABASE_URL: + process.env.NEXT_PUBLIC_SUPABASE_URL || "http://localhost:8000", + SUPABASE_API_PORT: process.env.SUPABASE_API_PORT || "8000", + NEXT_PUBLIC_SUPABASE_ANON_KEY: + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || generateSupabaseJwt("anon", jwtSecret), + SUPABASE_SERVICE_ROLE_KEY: + process.env.SUPABASE_SERVICE_ROLE_KEY || + generateSupabaseJwt("service_role", jwtSecret), + JWT_SECRET: jwtSecret, + POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD || randomSecret(), + POSTGRES_PORT: process.env.POSTGRES_PORT || "54322", + JWT_EXPIRY: process.env.JWT_EXPIRY || "3600", + SMTP_ADMIN_EMAIL: process.env.SMTP_ADMIN_EMAIL || "admin@neta.local", +}; + +for (const [key, value] of Object.entries(values)) { + console.log(`${key}=${value}`); +} + +function randomSecret() { + return crypto.randomBytes(32).toString("hex"); +} + +function generateSupabaseJwt(role, secret) { + const header = base64UrlJson({ alg: "HS256", typ: "JWT" }); + const payload = base64UrlJson({ + iss: "supabase", + ref: "neta", + role, + iat: 1700000000, + exp: 4102444800, + }); + const unsigned = `${header}.${payload}`; + const signature = crypto + .createHmac("sha256", secret) + .update(unsigned) + .digest("base64url"); + + return `${unsigned}.${signature}`; +} + +function base64UrlJson(value) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +}