From b219fb4519d48fa2181ffa61e330dc3c00f6144b Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Fri, 4 Sep 2026 20:54:58 +0100 Subject: [PATCH] initial NextChair Manager. Key additions: * Separate platform Manager stack: [stacks/manager/compose.yaml] * Dedicated manager authentication with signed sessions. * Business overview dashboard. * Add Business workflow with slug suggestions and deployment progress. * Business detail page with start, stop, restart, update, logs, Dockge, and conservative removal actions. * Explicit management API endpoints under [src/app/api/manager] * Shared provisioning service layer in [provisioning.service.ts] * CLI and UI now use the same [create-business.sh] provisioning logic. * Registry metadata, database status, container health, and media usage reporting. * Manager-only entrypoint mode that does not require a business database. * Updated deployment documentation with Manager setup and Caddy routing. Validation passed: * Infra, Caddy, Manager, and generated business Compose configurations. * Shell syntax checks. * Registry generation with registry image tags. * git diff --check. --- .gitignore | 1 + README.md | 21 ++++ docker/entrypoint.sh | 17 +-- scripts/create-business.sh | 25 +++- src/app/api/manager/auth/route.ts | 16 +++ .../businesses/[slug]/actions/route.ts | 19 +++ .../manager/businesses/[slug]/logs/route.ts | 12 ++ .../api/manager/businesses/[slug]/route.ts | 12 ++ src/app/api/manager/businesses/route.ts | 25 ++++ src/app/manager/add/page.tsx | 8 ++ src/app/manager/businesses/[slug]/page.tsx | 14 +++ src/app/manager/layout.tsx | 5 + src/app/manager/page.tsx | 9 ++ src/app/manager/sign-in/page.tsx | 19 +++ .../components/manager-add-business.tsx | 41 +++++++ .../components/manager-business-detail.tsx | 34 +++++ .../components/manager-dashboard.tsx | 60 +++++++++ .../components/manager-sign-in-form.tsx | 39 ++++++ src/proxy.ts | 6 +- src/services/management/management-auth.ts | 51 ++++++++ src/services/management/manager-config.ts | 11 ++ .../management/provisioning.service.ts | 116 ++++++++++++++++++ src/services/management/types.ts | 22 ++++ stacks/manager/compose.yaml | 24 ++++ 24 files changed, 596 insertions(+), 11 deletions(-) create mode 100644 src/app/api/manager/auth/route.ts create mode 100644 src/app/api/manager/businesses/[slug]/actions/route.ts create mode 100644 src/app/api/manager/businesses/[slug]/logs/route.ts create mode 100644 src/app/api/manager/businesses/[slug]/route.ts create mode 100644 src/app/api/manager/businesses/route.ts create mode 100644 src/app/manager/add/page.tsx create mode 100644 src/app/manager/businesses/[slug]/page.tsx create mode 100644 src/app/manager/layout.tsx create mode 100644 src/app/manager/page.tsx create mode 100644 src/app/manager/sign-in/page.tsx create mode 100644 src/features/management/components/manager-add-business.tsx create mode 100644 src/features/management/components/manager-business-detail.tsx create mode 100644 src/features/management/components/manager-dashboard.tsx create mode 100644 src/features/management/components/manager-sign-in-form.tsx create mode 100644 src/services/management/management-auth.ts create mode 100644 src/services/management/manager-config.ts create mode 100644 src/services/management/provisioning.service.ts create mode 100644 src/services/management/types.ts create mode 100644 stacks/manager/compose.yaml diff --git a/.gitignore b/.gitignore index fd281bd..d7c7a0c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ dist/ !.env.business.example .env.caddy stacks/*/.env +stacks/registry.json stacks/caddy/sites/*.caddy # prisma diff --git a/README.md b/README.md index af1d5c7..00e30a5 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,27 @@ docker compose -f stacks/infra/compose.yaml up -d docker compose -f stacks/caddy/compose.yaml up -d ``` +Start the Manager stack as well, after copying its environment template and +setting a strong platform-admin password and auth secret: + +```bash +cp stacks/manager/.env.example stacks/manager/.env +docker compose -f stacks/manager/compose.yaml up -d +``` + +Add a Manager-only Caddy route in `stacks/caddy/sites/manager.caddy`, replacing +the example hostname with the private/admin hostname you control: + +```caddy +manager.example.com { + reverse_proxy nextchair-manager:3000 +} +``` + +Reload Caddy after adding the route, then open `/manager`. The Manager has its +own administrator session and does not use any business customer, staff, or +owner account. + The MySQL port is loopback-only by default. Use an SSH tunnel for database administration rather than opening port 3306 publicly. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index abc6fc9..477925e 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -10,14 +10,17 @@ # fresh container is left empty and shows the installation wizard. set -e -echo "[entrypoint] Applying database migrations..." -npx prisma migrate deploy +if [ "${MANAGER_MODE:-false}" = "true" ]; then + echo "[entrypoint] Starting in NextChair Manager mode; skipping business database bootstrap." +else + echo "[entrypoint] Applying database migrations..." + npx prisma migrate deploy -# Optional — only provisions/syncs a Super Administrator when -# SUPER_ADMIN_EMAIL is set (see scripts/ensure-super-admin.ts). Runs after -# migrations so the User.isSuperAdmin column always exists by this point. -echo "[entrypoint] Syncing Super Administrator..." -npx tsx scripts/ensure-super-admin.ts + # Optional — only provisions/syncs a Super Administrator when + # SUPER_ADMIN_EMAIL is set (see scripts/ensure-super-admin.ts). + echo "[entrypoint] Syncing Super Administrator..." + npx tsx scripts/ensure-super-admin.ts +fi echo "[entrypoint] Starting application..." # `exec` + calling the `next` binary directly (not `npm run start`) so diff --git a/scripts/create-business.sh b/scripts/create-business.sh index 35541f8..bf038e8 100755 --- a/scripts/create-business.sh +++ b/scripts/create-business.sh @@ -2,15 +2,16 @@ set -Eeuo pipefail usage() { - echo "Usage: $0 [image-tag]" >&2 - echo "Example: $0 blake-heritage blakeheritage.com nextchair:2026-09-04" >&2 + echo "Usage: $0 [image-tag] [business-name]" >&2 + echo "Example: $0 blake-heritage blakeheritage.com nextchair:2026-09-04 \"Blake Heritage\"" >&2 exit 2 } -[[ $# -ge 2 && $# -le 3 ]] || usage +[[ $# -ge 2 && $# -le 4 ]] || usage business_slug="$1" domain="$2" image_tag="${3:-nextchair:latest}" +business_name="${4:-$business_slug}" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" stack_root="${NEXTCHAIR_STACK_ROOT:-${repo_root}/stacks}" infra_env="${NEXTCHAIR_INFRA_ENV:-${repo_root}/.env.infra}" @@ -20,6 +21,7 @@ compose_template="${repo_root}/deploy/business-compose.yaml.template" [[ "$business_slug" =~ ^[a-z0-9]+([a-z0-9-]*[a-z0-9])?$ ]] || { echo "Invalid slug: use lowercase letters, numbers, and hyphens." >&2; exit 2; } [[ ${#business_slug} -le 22 ]] || { echo "Invalid slug: maximum length is 22 characters." >&2; exit 2; } [[ "$domain" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "Invalid domain." >&2; exit 2; } +[[ "$image_tag" =~ ^[A-Za-z0-9._/@:-]+$ ]] || { echo "Invalid image tag." >&2; exit 2; } [[ -f "$infra_env" ]] || { echo "Missing $infra_env. Copy .env.infra.example first." >&2; exit 1; } [[ -f "$compose_template" ]] || { echo "Missing $compose_template." >&2; exit 1; } [[ ! -e "$stack_dir" ]] || { echo "Stack already exists: $stack_dir" >&2; exit 1; } @@ -74,6 +76,23 @@ $domain { } EOF +registry_file="$stack_root/registry.json" +REGISTRY_FILE="$registry_file" BUSINESS_NAME="$business_name" BUSINESS_SLUG="$business_slug" BUSINESS_DOMAIN="$domain" BUSINESS_IMAGE="$image_tag" node <<'NODE' +const fs = require("node:fs"); +const file = process.env.REGISTRY_FILE; +const items = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : []; +const now = new Date().toISOString(); +items.push({ + name: process.env.BUSINESS_NAME, + slug: process.env.BUSINESS_SLUG, + domain: process.env.BUSINESS_DOMAIN, + image: process.env.BUSINESS_IMAGE, + createdAt: now, + updatedAt: now, +}); +fs.writeFileSync(file, `${JSON.stringify(items, null, 2)}\n`, { mode: 0o600 }); +NODE + chmod 600 "$stack_dir/.env" printf '\nCreated Dockge stack: %s\n' "$stack_dir" printf 'Created Caddy route: %s/%s.caddy\n' "$caddy_sites_dir" "$business_slug" diff --git a/src/app/api/manager/auth/route.ts b/src/app/api/manager/auth/route.ts new file mode 100644 index 0000000..3eb522b --- /dev/null +++ b/src/app/api/manager/auth/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { authenticateManager, clearManagerSession, createManagerSession } from "@/services/management/management-auth"; + +export async function POST(request: Request) { + const body = (await request.json().catch(() => null)) as { email?: string; password?: string } | null; + if (!body?.email || !body.password || !authenticateManager(body.email, body.password)) { + return NextResponse.json({ error: "Incorrect manager credentials." }, { status: 401 }); + } + await createManagerSession(); + return NextResponse.json({ ok: true }); +} + +export async function DELETE() { + await clearManagerSession(); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/manager/businesses/[slug]/actions/route.ts b/src/app/api/manager/businesses/[slug]/actions/route.ts new file mode 100644 index 0000000..82becdc --- /dev/null +++ b/src/app/api/manager/businesses/[slug]/actions/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { isManagerAuthenticated } from "@/services/management/management-auth"; +import { removeBusiness, restartBusiness, startBusiness, stopBusiness, updateBusiness } from "@/services/management/provisioning.service"; + +const operations = { start: startBusiness, stop: stopBusiness, restart: restartBusiness, update: updateBusiness, remove: removeBusiness } as const; + +export async function POST(request: Request, context: { params: Promise<{ slug: string }> }) { + if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + const { slug } = await context.params; + const body = (await request.json().catch(() => null)) as { action?: string } | null; + const operation = body?.action && body.action in operations ? operations[body.action as keyof typeof operations] : undefined; + if (!operation) return NextResponse.json({ error: "Unsupported management action." }, { status: 400 }); + try { + const result = await operation(slug); + return NextResponse.json(result ?? { ok: true }); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : "Management action failed." }, { status: 500 }); + } +} diff --git a/src/app/api/manager/businesses/[slug]/logs/route.ts b/src/app/api/manager/businesses/[slug]/logs/route.ts new file mode 100644 index 0000000..589ba0c --- /dev/null +++ b/src/app/api/manager/businesses/[slug]/logs/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { isManagerAuthenticated } from "@/services/management/management-auth"; +import { getBusinessLogs } from "@/services/management/provisioning.service"; + +export async function GET(_: Request, context: { params: Promise<{ slug: string }> }) { + if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + try { + return new NextResponse(await getBusinessLogs((await context.params).slug), { headers: { "Content-Type": "text/plain; charset=utf-8" } }); + } catch { + return NextResponse.json({ error: "Unable to read business logs." }, { status: 500 }); + } +} diff --git a/src/app/api/manager/businesses/[slug]/route.ts b/src/app/api/manager/businesses/[slug]/route.ts new file mode 100644 index 0000000..b638555 --- /dev/null +++ b/src/app/api/manager/businesses/[slug]/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { isManagerAuthenticated } from "@/services/management/management-auth"; +import { getBusiness } from "@/services/management/provisioning.service"; + +export async function GET(_: Request, context: { params: Promise<{ slug: string }> }) { + if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + try { + return NextResponse.json(await getBusiness((await context.params).slug)); + } catch { + return NextResponse.json({ error: "Business deployment not found." }, { status: 404 }); + } +} diff --git a/src/app/api/manager/businesses/route.ts b/src/app/api/manager/businesses/route.ts new file mode 100644 index 0000000..12179f7 --- /dev/null +++ b/src/app/api/manager/businesses/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { isManagerAuthenticated } from "@/services/management/management-auth"; +import { createBusiness, listBusinesses } from "@/services/management/provisioning.service"; + +export async function GET() { + if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + return NextResponse.json(await listBusinesses()); +} + +export async function POST(request: Request) { + if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + const body = (await request.json().catch(() => null)) as Record | null; + const name = typeof body?.name === "string" ? body.name.trim() : ""; + const slug = typeof body?.slug === "string" ? body.slug.trim().toLowerCase() : ""; + const domain = typeof body?.domain === "string" ? body.domain.trim().toLowerCase() : ""; + const image = typeof body?.image === "string" ? body.image.trim() : ""; + if (!name || name.length > 120 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length > 22 || !/^[a-z0-9.-]+$/.test(domain) || !/^[A-Za-z0-9._/@:-]+$/.test(image)) { + return NextResponse.json({ error: "Enter a valid business name, slug, domain, and image version." }, { status: 400 }); + } + try { + return NextResponse.json(await createBusiness({ name, slug, domain, image }), { status: 201 }); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : "Business deployment failed." }, { status: 500 }); + } +} diff --git a/src/app/manager/add/page.tsx b/src/app/manager/add/page.tsx new file mode 100644 index 0000000..b634675 --- /dev/null +++ b/src/app/manager/add/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; +import { ManagerAddBusiness } from "@/features/management/components/manager-add-business"; +import { isManagerAuthenticated } from "@/services/management/management-auth"; + +export default async function ManagerAddPage() { + if (!(await isManagerAuthenticated())) redirect("/manager/sign-in"); + return ; +} diff --git a/src/app/manager/businesses/[slug]/page.tsx b/src/app/manager/businesses/[slug]/page.tsx new file mode 100644 index 0000000..8cab71b --- /dev/null +++ b/src/app/manager/businesses/[slug]/page.tsx @@ -0,0 +1,14 @@ +import { notFound, redirect } from "next/navigation"; +import { ManagerBusinessDetail } from "@/features/management/components/manager-business-detail"; +import { isManagerAuthenticated } from "@/services/management/management-auth"; +import { getManagerConfig } from "@/services/management/manager-config"; +import { getBusiness } from "@/services/management/provisioning.service"; + +export default async function ManagerBusinessPage({ params }: { params: Promise<{ slug: string }> }) { + if (!(await isManagerAuthenticated())) redirect("/manager/sign-in"); + try { + return ; + } catch { + notFound(); + } +} diff --git a/src/app/manager/layout.tsx b/src/app/manager/layout.tsx new file mode 100644 index 0000000..d453a48 --- /dev/null +++ b/src/app/manager/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export default function ManagerLayout({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/src/app/manager/page.tsx b/src/app/manager/page.tsx new file mode 100644 index 0000000..f8b7b4b --- /dev/null +++ b/src/app/manager/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from "next/navigation"; +import { ManagerDashboard } from "@/features/management/components/manager-dashboard"; +import { isManagerAuthenticated } from "@/services/management/management-auth"; +import { listBusinesses } from "@/services/management/provisioning.service"; + +export default async function ManagerPage() { + if (!(await isManagerAuthenticated())) redirect("/manager/sign-in"); + return ; +} diff --git a/src/app/manager/sign-in/page.tsx b/src/app/manager/sign-in/page.tsx new file mode 100644 index 0000000..4673554 --- /dev/null +++ b/src/app/manager/sign-in/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { ManagerSignInForm } from "@/features/management/components/manager-sign-in-form"; + +export const metadata: Metadata = { title: "NextChair Manager" }; + +export default function ManagerSignInPage() { + return ( +
+
+
+

NextChair Manager

+

Platform operations

+

Manage business deployments without opening up Docker administration.

+
+ +
+
+ ); +} diff --git a/src/features/management/components/manager-add-business.tsx b/src/features/management/components/manager-add-business.tsx new file mode 100644 index 0000000..23772b5 --- /dev/null +++ b/src/features/management/components/manager-add-business.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ArrowLeft, Check, Globe, Rocket } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +const steps = ["Creating database", "Generating configuration", "Creating Dockge stack", "Starting application", "Configuring Caddy", "Checking application"]; + +export function ManagerAddBusiness() { + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [slugTouched, setSlugTouched] = useState(false); + const [domain, setDomain] = useState(""); + const [image, setImage] = useState("nextchair:latest"); + const [pending, setPending] = useState(false); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + const [deployed, setDeployed] = useState(null); + + function changeName(value: string) { + setName(value); + if (!slugTouched) setSlug(value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 22)); + } + + async function deploy(event: React.FormEvent) { + event.preventDefault(); + setPending(true); setError(null); setProgress(1); + const timer = window.setInterval(() => setProgress((value) => Math.min(value + 1, steps.length)), 900); + const response = await fetch("/api/manager/businesses", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, slug, domain, image }) }); + window.clearInterval(timer); + if (!response.ok) { setError((await response.json()).error ?? "Deployment failed."); setPending(false); return; } + setProgress(steps.length); setPending(false); setDeployed(slug); + } + + if (deployed) return

Business deployed successfully

The application stack is ready. Complete the business setup before inviting customers.

; + + return
Businesses

New deployment

Add Business

Create an isolated NextChair application with its own data and media storage.

changeName(event.target.value)} placeholder="Blake Heritage" /> { setSlugTouched(true); setSlug(event.target.value.toLowerCase()); }} hint="Lowercase letters, numbers, and hyphens. Maximum 22 characters." placeholder="blake-heritage" /> setDomain(event.target.value.toLowerCase())} icon={} placeholder="blakeheritage.com" /> setImage(event.target.value)} hint="Use nextchair:latest locally or a pinned registry image in production." placeholder="nextchair:2026-09-04" />{error &&

{error}

}{pending &&

Deployment progress

{steps.map((step, index) =>

{index < progress ? : }{step}{index === progress - 1 && Working}

)}
}
; +} diff --git a/src/features/management/components/manager-business-detail.tsx b/src/features/management/components/manager-business-detail.tsx new file mode 100644 index 0000000..ddb26fa --- /dev/null +++ b/src/features/management/components/manager-business-detail.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { ArrowLeft, ExternalLink, Play, RotateCw, Square, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import type { BusinessStatus } from "@/services/management/types"; + +function formatBytes(value: number | null) { return value === null ? "Not available" : value < 1024 * 1024 ? `${Math.round(value / 1024)} KB` : `${(value / 1024 / 1024).toFixed(1)} MB`; } + +export function ManagerBusinessDetail({ business: initialBusiness, dockgeUrl }: { business: BusinessStatus; dockgeUrl: string }) { + const [business, setBusiness] = useState(initialBusiness); + const [pending, setPending] = useState(null); + const [logs, setLogs] = useState(null); + const [error, setError] = useState(null); + + async function action(actionName: string) { + if (actionName === "remove" && !window.confirm("Remove the application deployment? The database and media volume will be kept.")) return; + setPending(actionName); setError(null); + const response = await fetch(`/api/manager/businesses/${business.slug}/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: actionName }) }); + const result = await response.json(); + setPending(null); + if (!response.ok) { setError(result.error ?? "Action failed."); return; } + if (actionName === "remove") { window.location.assign("/manager"); return; } + setBusiness(result); + } + + async function showLogs() { + setPending("logs"); const response = await fetch(`/api/manager/businesses/${business.slug}/logs`); setLogs(await response.text()); setPending(null); + } + + return
Businesses

Business deployment

{business.name}

{business.domain} · {business.slug}

Runtime

Application version
{business.image}
Container
{business.container}
Health
{business.health}
Database
{business.database}
Media storage
{formatBytes(business.storageBytes)}
Last updated
{new Date(business.updatedAt).toLocaleString()}

Manage deployment

{dockgeUrl && }

Update is explicit and uses the image pinned to this business. Removing the application does not delete its database or media volume.

{error &&

{error}

}{logs !== null &&

Recent logs

{logs}
}
; +} diff --git a/src/features/management/components/manager-dashboard.tsx b/src/features/management/components/manager-dashboard.tsx new file mode 100644 index 0000000..37f3169 --- /dev/null +++ b/src/features/management/components/manager-dashboard.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { ExternalLink, LogOut, Plus, RefreshCw, Server, Store, Activity } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import type { BusinessStatus } from "@/services/management/types"; + +function formatBytes(value: number | null) { + if (value === null) return "Not available"; + if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +function stateTone(value: string) { + return value === "running" || value === "healthy" || value === "online" ? "text-accent-foreground" : value === "unknown" ? "text-muted" : "text-danger"; +} + +export function ManagerDashboard({ initialBusinesses }: { initialBusinesses: BusinessStatus[] }) { + const [businesses, setBusinesses] = useState(initialBusinesses); + const [loading, setLoading] = useState(false); + + async function refresh() { + setLoading(true); + const response = await fetch("/api/manager/businesses", { cache: "no-store" }); + if (response.ok) setBusinesses(await response.json()); + setLoading(false); + } + + async function signOut() { + await fetch("/api/manager/auth", { method: "DELETE" }); + window.location.assign("/manager/sign-in"); + } + + return ( +
+
+
+

NextChair Manager

+

Businesses

+

Product-level deployment control. Detailed Docker operations remain in Dockge.

+
+
+ + +
+
+ +
+

{businesses.length}

Deployed businesses

+

{businesses.filter((business) => business.container === "running").length}

Running applications

+

{businesses.filter((business) => business.database === "online").length}

Database connections

+
+ +

All deployments

+ {businesses.length === 0 ?

No businesses deployed yet.

Create the first business to start its isolated application stack.

:
{businesses.map((business) =>

{business.name}

{business.domain} · {business.slug}

{business.container}

Version

{business.image}

Health

{business.health}

Database

{business.database}

Media

{formatBytes(business.storageBytes)}

)}
} +
+ ); +} diff --git a/src/features/management/components/manager-sign-in-form.tsx b/src/features/management/components/manager-sign-in-form.tsx new file mode 100644 index 0000000..ac81cfe --- /dev/null +++ b/src/features/management/components/manager-sign-in-form.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Lock, Mail } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +export function ManagerSignInForm() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setPending(true); + setError(null); + const response = await fetch("/api/manager/auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) }); + setPending(false); + if (!response.ok) { + setError("Incorrect manager credentials."); + return; + } + router.push("/manager"); + router.refresh(); + } + + return ( +
+ setEmail(event.target.value)} icon={} /> + setPassword(event.target.value)} icon={} /> + {error &&

{error}

} + + + ); +} diff --git a/src/proxy.ts b/src/proxy.ts index ad21066..b47e45b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -21,6 +21,7 @@ const PROTECTED_PREFIXES = ["/staff", "/owner"]; // business data guard themselves (see /api/health and // /api/cron/process-notifications). const INSTALL_EXEMPT_PREFIXES = [ + "/manager", "/install", "/offline", "/manifest.webmanifest", @@ -90,7 +91,10 @@ export default auth(async (req) => { const isInstallExempt = INSTALL_EXEMPT_PREFIXES.some( (prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`), ); - const installed = await isInstalledForRequest(); + // The platform Manager is a separate deployment and deliberately has no + // business database. Its own session guard runs inside /manager pages/API. + const isManagerPath = pathname === "/manager" || pathname.startsWith("/manager/"); + const installed = isManagerPath ? true : await isInstalledForRequest(); if (!installed && !isInstallExempt) { const response = NextResponse.redirect(new URL("/install", req.nextUrl.origin)); diff --git a/src/services/management/management-auth.ts b/src/services/management/management-auth.ts new file mode 100644 index 0000000..9f55d20 --- /dev/null +++ b/src/services/management/management-auth.ts @@ -0,0 +1,51 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { cookies } from "next/headers"; +import { getManagerConfig } from "./manager-config"; + +const COOKIE_NAME = "nextchair_manager_session"; + +function signature(value: string, secret: string) { + return createHmac("sha256", secret).update(value).digest("hex"); +} + +export function isManagerConfigured() { + const config = getManagerConfig(); + return Boolean(config.adminEmail && config.adminPassword && config.authSecret); +} + +export function authenticateManager(email: string, password: string) { + const config = getManagerConfig(); + if (!isManagerConfigured() || email.trim().toLowerCase() !== config.adminEmail.trim().toLowerCase()) return false; + const expected = Buffer.from(config.adminPassword); + const received = Buffer.from(password); + return expected.length === received.length && timingSafeEqual(expected, received); +} + +export async function createManagerSession() { + const config = getManagerConfig(); + const value = `${config.adminEmail.trim().toLowerCase()}.${Date.now()}`; + (await cookies()).set(COOKIE_NAME, `${value}.${signature(value, config.authSecret)}`, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + path: "/manager", + maxAge: 60 * 60 * 8, + }); +} + +export async function clearManagerSession() { + (await cookies()).delete(COOKIE_NAME); +} + +export async function isManagerAuthenticated() { + const config = getManagerConfig(); + if (!config.authSecret) return false; + const value = (await cookies()).get(COOKIE_NAME)?.value; + if (!value) return false; + const separator = value.lastIndexOf("."); + if (separator < 1) return false; + const payload = value.slice(0, separator); + const received = Buffer.from(value.slice(separator + 1)); + const expected = Buffer.from(signature(payload, config.authSecret)); + return received.length === expected.length && timingSafeEqual(received, expected); +} diff --git a/src/services/management/manager-config.ts b/src/services/management/manager-config.ts new file mode 100644 index 0000000..2931d3b --- /dev/null +++ b/src/services/management/manager-config.ts @@ -0,0 +1,11 @@ +export function getManagerConfig() { + const stackRoot = process.env.NEXTCHAIR_STACK_ROOT || `${process.cwd()}/stacks`; + return { + stackRoot, + caddySitesDir: process.env.NEXTCHAIR_CADDY_SITES_DIR || `${stackRoot}/caddy/sites`, + dockgeUrl: process.env.NEXTCHAIR_DOCKGE_URL || "", + adminEmail: process.env.MANAGER_ADMIN_EMAIL || "", + adminPassword: process.env.MANAGER_ADMIN_PASSWORD || "", + authSecret: process.env.MANAGER_AUTH_SECRET || process.env.AUTH_SECRET || "", + }; +} diff --git a/src/services/management/provisioning.service.ts b/src/services/management/provisioning.service.ts new file mode 100644 index 0000000..3f8db2c --- /dev/null +++ b/src/services/management/provisioning.service.ts @@ -0,0 +1,116 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { BusinessDeployment, BusinessStatus, ProvisionBusinessInput } from "./types"; +import { getManagerConfig } from "./manager-config"; + +const run = promisify(execFile); + +function registryPath() { + return path.join(getManagerConfig().stackRoot, "registry.json"); +} + +function containerName(slug: string) { + return `nextchair-${slug}`; +} + +async function readRegistry(): Promise { + try { + return JSON.parse(await fs.readFile(registryPath(), "utf8")) as BusinessDeployment[]; + } catch { + return []; + } +} + +async function writeRegistry(items: BusinessDeployment[]) { + await fs.mkdir(path.dirname(registryPath()), { recursive: true }); + await fs.writeFile(registryPath(), `${JSON.stringify(items, null, 2)}\n`, { mode: 0o600 }); +} + +async function docker(args: string[]) { + return run("docker", args, { maxBuffer: 1024 * 1024 * 4 }); +} + +async function inspectContainer(slug: string) { + try { + const result = await docker(["inspect", "-f", "{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}", containerName(slug)]); + const [container, health] = result.stdout.trim().split("|"); + return { container: container === "running" ? "running" : container ? "stopped" : "unknown", health: health || "unknown" } as const; + } catch { + return { container: "unknown", health: "unknown" } as const; + } +} + +async function databaseStatus() { + try { + const result = await docker(["inspect", "-f", "{{.State.Status}}", "nextchair-mysql"]); + return result.stdout.trim() === "running" ? "online" : "offline"; + } catch { + return "unknown"; + } +} + +async function storageBytes(slug: string) { + try { + const volume = await docker(["volume", "inspect", "-f", "{{.Mountpoint}}", `nextchair-media-${slug}`]); + const usage = await run("du", ["-sk", volume.stdout.trim()], { maxBuffer: 1024 * 64 }); + return Number.parseInt(usage.stdout.trim().split(/\s+/)[0], 10) * 1024; + } catch { + return null; + } +} + +export async function listBusinesses(): Promise { + const [items, database] = await Promise.all([readRegistry(), databaseStatus()]); + return Promise.all(items.map(async (item) => { + const [runtime, storage] = await Promise.all([inspectContainer(item.slug), storageBytes(item.slug)]); + return { ...item, ...runtime, database, storageBytes: storage }; + })); +} + +export async function getBusiness(slug: string) { + const business = (await listBusinesses()).find((item) => item.slug === slug); + if (!business) throw new Error("Business deployment not found."); + return business; +} + +export async function createBusiness(input: ProvisionBusinessInput) { + const existing = (await readRegistry()).some((item) => item.slug === input.slug); + if (existing) throw new Error("A business with this slug already exists."); + const script = path.join(process.cwd(), "scripts", "create-business.sh"); + await run(script, [input.slug, input.domain, input.image, input.name], { maxBuffer: 1024 * 1024 * 4 }); + return getBusiness(input.slug); +} + +async function compose(slug: string, args: string[]) { + const composeFile = path.join(getManagerConfig().stackRoot, slug, "compose.yaml"); + await run("docker", ["compose", "-f", composeFile, ...args], { maxBuffer: 1024 * 1024 * 4 }); +} + +export async function startBusiness(slug: string) { await compose(slug, ["up", "-d"]); return getBusiness(slug); } +export async function stopBusiness(slug: string) { await compose(slug, ["stop"]); return getBusiness(slug); } +export async function restartBusiness(slug: string) { await compose(slug, ["restart"]); return getBusiness(slug); } +export async function updateBusiness(slug: string) { + // A local tag may not exist in a registry, so a failed pull is allowed to + // fall through to Compose using the already-built local image. + try { await compose(slug, ["pull"]); } catch { /* local/self-hosted image */ } + await compose(slug, ["up", "-d"]); + return getBusiness(slug); +} + +export async function getBusinessLogs(slug: string) { + const composeFile = path.join(getManagerConfig().stackRoot, slug, "compose.yaml"); + const result = await run("docker", ["compose", "-f", composeFile, "logs", "--tail", "200", `nextchair-${slug}`], { maxBuffer: 1024 * 1024 * 4 }); + return result.stdout; +} + +export async function removeBusiness(slug: string) { + const items = await readRegistry(); + const target = items.find((item) => item.slug === slug); + if (!target) throw new Error("Business deployment not found."); + await compose(slug, ["down"]); + await fs.rm(path.join(getManagerConfig().stackRoot, slug), { recursive: true, force: false }); + await fs.rm(path.join(getManagerConfig().caddySitesDir, `${slug}.caddy`), { force: false }); + await writeRegistry(items.filter((item) => item.slug !== slug)); +} diff --git a/src/services/management/types.ts b/src/services/management/types.ts new file mode 100644 index 0000000..4dde169 --- /dev/null +++ b/src/services/management/types.ts @@ -0,0 +1,22 @@ +export type BusinessDeployment = { + name: string; + slug: string; + domain: string; + image: string; + createdAt: string; + updatedAt: string; +}; + +export type BusinessStatus = BusinessDeployment & { + container: "running" | "stopped" | "unknown"; + health: "healthy" | "unhealthy" | "starting" | "unknown"; + database: "online" | "offline" | "unknown"; + storageBytes: number | null; +}; + +export type ProvisionBusinessInput = { + name: string; + slug: string; + domain: string; + image: string; +}; diff --git a/stacks/manager/compose.yaml b/stacks/manager/compose.yaml new file mode 100644 index 0000000..7bb72ef --- /dev/null +++ b/stacks/manager/compose.yaml @@ -0,0 +1,24 @@ +name: nextchair-manager + +# Platform administration is its own application stack. It shares only the +# private proxy network with Caddy and uses no business database. + +services: + nextchair-manager: + image: ${NEXTCHAIR_IMAGE:-nextchair:latest} + container_name: nextchair-manager + restart: unless-stopped + env_file: + - .env + environment: + MANAGER_MODE: "true" + volumes: + - ../:/app/stacks + - ../../.env.infra:/app/.env.infra:ro + networks: + - nextchair-proxy + +networks: + nextchair-proxy: + name: nextchair-proxy + external: true