Attempt 2 of a management ui.
This commit is contained in:
54 files changed
+8804
-267
No files matched your search
@@ -3,6 +3,13 @@ node_modules
|
||||
generated
|
||||
.git
|
||||
.gitea
|
||||
# A fully separate app (own package.json, own Dockerfile) — has no
|
||||
# business being copied into the main app's build context at all, and
|
||||
# its own @/* import alias resolving against the main app's tsconfig
|
||||
# (via the include glob's **/*.ts) broke a plain `docker build` here
|
||||
# before this line existed.
|
||||
manager
|
||||
stacks
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
+14
-15
@@ -1,10 +1,11 @@
|
||||
# One business's config on the shared multi-business VPS setup — see
|
||||
# docker-compose.infra.yaml for the one-time setup this depends on
|
||||
# stacks/infra/compose.yaml for the one-time setup this depends on
|
||||
# (creating this business's database on the shared MySQL server) and
|
||||
# docker-compose.<business>.yaml for how this file is used. Copy this to
|
||||
# `.env.business.<slug>` (e.g. `.env.business.blake-heritage` — kept out
|
||||
# of git, see .gitignore) per business and fill in real values. Nothing
|
||||
# here is a real credential.
|
||||
# stacks/<slug>/compose.yaml for how this file is used. Copy this to
|
||||
# `stacks/<slug>/.env` (e.g. `stacks/blake-heritage/.env` — kept out of
|
||||
# git, see .gitignore) per business and fill in real values. Nothing here
|
||||
# is a real credential. Normally the management UI (manager/) generates
|
||||
# this file for you — this template is for the by-hand setup path.
|
||||
#
|
||||
# The app starts and runs a complete first-install flow with ONLY the
|
||||
# "Required for basic startup" section below filled in — every other
|
||||
@@ -12,17 +13,16 @@
|
||||
# disabled state rather than crashing when its variables are left blank.
|
||||
|
||||
# ── Required for basic startup ─────────────────────────────────────────
|
||||
# The public domain this business is served on — used by Traefik's
|
||||
# routing label (see docker-compose.<business>.yaml) to decide which
|
||||
# container handles requests for it, so this must be set and must match
|
||||
# the domain's real DNS (pointed at the VPS) before Traefik can issue a
|
||||
# The public domain this business is served on — must match the .caddy
|
||||
# site file's own domain (see stacks/infra/sites/.gitkeep) and that
|
||||
# domain's real DNS (pointed at the VPS) before Caddy can issue a
|
||||
# certificate for it.
|
||||
DOMAIN="blake-heritage.example.com"
|
||||
|
||||
# The app reaches MySQL via the shared infra's service hostname
|
||||
# `next-chair-mysql`, never `localhost` — see docker-compose.infra.yaml.
|
||||
# `next-chair-mysql`, never `localhost` — see stacks/infra/compose.yaml.
|
||||
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for
|
||||
# this business on the shared server (see docker-compose.infra.yaml's
|
||||
# this business on the shared server (see stacks/infra/compose.yaml's
|
||||
# "CREATE DATABASE ..." step) — this file doesn't create them, it only
|
||||
# tells the app what to connect as.
|
||||
DATABASE_HOST="next-chair-mysql"
|
||||
@@ -44,10 +44,9 @@ INSTALLATION_TOKEN="replace-me"
|
||||
CRON_SECRET="replace-me"
|
||||
|
||||
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
||||
# Platform-level account with every Owner permission. Not run
|
||||
# automatically by this compose file (that's a Docker-entrypoint-only
|
||||
# behaviour, see docker/entrypoint.sh) — run it manually, once, the same
|
||||
# way migrations were run for the Vercel/Hostinger deployment earlier:
|
||||
# Platform-level account with every Owner permission for THIS business.
|
||||
# Not run automatically by this compose file (that's a Docker-entrypoint-
|
||||
# only behaviour, see docker/entrypoint.sh) — run it manually, once:
|
||||
# docker exec -it next-chair-blake-heritage npx tsx scripts/ensure-super-admin.ts
|
||||
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install
|
||||
# wizard's own administrator account instead.
|
||||
|
||||
+22
-6
@@ -1,12 +1,12 @@
|
||||
# Shared infrastructure config — see docker-compose.infra.yaml for the
|
||||
# full setup sequence. Copy this to `.env.infra` (kept out of git — see
|
||||
# .gitignore) and fill in real values.
|
||||
# Shared infrastructure config — see stacks/infra/compose.yaml for the
|
||||
# full setup sequence. Copy this to `stacks/infra/.env` (kept out of git
|
||||
# — see .gitignore) and fill in real values.
|
||||
#
|
||||
# docker network create next-chair-edge
|
||||
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d
|
||||
# docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
|
||||
|
||||
# ── MySQL (shared server — one database per business, created manually
|
||||
# per business, see docker-compose.infra.yaml's own header) ────────────
|
||||
# per business, see stacks/infra/compose.yaml's own header) ────────────
|
||||
MYSQL_ROOT_PASSWORD="replace-me"
|
||||
# Host-machine port MySQL is reachable on, e.g. for a GUI client over an
|
||||
# SSH tunnel — every business's app container always reaches it via the
|
||||
@@ -14,7 +14,23 @@ MYSQL_ROOT_PASSWORD="replace-me"
|
||||
# this.
|
||||
MYSQL_HOST_PORT=3306
|
||||
|
||||
# ── Traefik (reverse proxy + automatic TLS via Let's Encrypt) ──────────
|
||||
# ── Caddy (reverse proxy + automatic TLS via Let's Encrypt) ────────────
|
||||
# Let's Encrypt sends renewal/expiry notices here — use a real address
|
||||
# you actually check, not a placeholder.
|
||||
ACME_EMAIL="you@example.com"
|
||||
# Leave these at 80/443 on a real deployment — only override for local
|
||||
# testing on a machine already using those ports for something else.
|
||||
CADDY_HTTP_PORT=80
|
||||
CADDY_HTTPS_PORT=443
|
||||
|
||||
# ── Dockge (stack management UI — http://<vps>:5001) ───────────────────
|
||||
# This repo's absolute path on the VPS, e.g. "/opt/nextchair/stacks" — NOT
|
||||
# a relative path. Dockge (and the management UI in manager/) shell
|
||||
# `docker compose` out to the HOST's dockerd via the mounted Docker
|
||||
# socket, so a compose-file path only resolves to the same file on both
|
||||
# sides if the bind-mount doesn't change it — meaning this must be
|
||||
# mounted at the identical path inside the container too (see
|
||||
# stacks/infra/compose.yaml's dockge service). Get this wrong and Dockge
|
||||
# will either fail to find your stacks or manage the wrong path entirely.
|
||||
STACKS_DIR="/opt/nextchair/stacks"
|
||||
DOCKGE_HOST_PORT=5001
|
||||
+13
-12
@@ -26,14 +26,19 @@ dist/
|
||||
.env.production
|
||||
|
||||
# per-deployment production env files (multi-business VPS setup — see
|
||||
# docker-compose.infra.yaml / docker-compose.<business>.yaml). Named
|
||||
# .env.business.* (not .env.<business> directly) specifically so this one
|
||||
# wildcard rule keeps covering every future business without another
|
||||
# .gitignore edit. The template itself (.env.business.example) stays
|
||||
# tracked, same as .env.local.example above.
|
||||
.env.infra
|
||||
.env.business.*
|
||||
!.env.business.example
|
||||
# stacks/infra/compose.yaml / stacks/<business>/compose.yaml). Every real
|
||||
# stack's .env lives at stacks/<name>/.env (Dockge's own directory
|
||||
# convention) — this one wildcard rule covers every current and future
|
||||
# stack without another .gitignore edit. .env.infra.example and
|
||||
# .env.business.example (the templates you copy into each stacks/<name>/
|
||||
# directory) stay tracked, same as .env.local.example above.
|
||||
stacks/*/.env
|
||||
|
||||
# Real per-business Caddy site files (deployment-specific domains, not
|
||||
# something to hand-author into the repo) — only the placeholder stays
|
||||
# tracked.
|
||||
stacks/infra/sites/*.caddy
|
||||
!stacks/infra/sites/.gitkeep
|
||||
|
||||
# prisma
|
||||
prisma/dev.db
|
||||
@@ -57,10 +62,6 @@ yarn-error.log*
|
||||
.idea/
|
||||
|
||||
/generated/prisma
|
||||
.env.example
|
||||
.env.local.example
|
||||
.env.example
|
||||
.env.example
|
||||
|
||||
.agents/
|
||||
.claude/
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
# NextChair
|
||||
|
||||
Booking app as an alternative to Booksy
|
||||
Booking app as an alternative to Booksy
|
||||
|
||||
## VPS deployment (multi-business)
|
||||
|
||||
One-time setup on a fresh VPS with Docker and the Compose plugin already
|
||||
installed. Run everything from the repo's checkout directory.
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo at the path you intend to keep it at — STACKS_DIR
|
||||
# below must be this exact absolute path, since containers shell
|
||||
# `docker compose` back out to the HOST's dockerd via a mounted
|
||||
# socket (see stacks/infra/compose.yaml and manager/README.md for why).
|
||||
git clone <your-repo-url> /opt/nextchair
|
||||
cd /opt/nextchair
|
||||
|
||||
# 2. Shared Docker network every stack attaches to.
|
||||
docker network create next-chair-edge
|
||||
|
||||
# 3. Shared infrastructure: MySQL, Caddy (reverse proxy + automatic TLS),
|
||||
# Dockge (stack management UI, http://<vps-ip>:5001).
|
||||
cp .env.infra.example stacks/infra/.env
|
||||
# edit stacks/infra/.env — MYSQL_ROOT_PASSWORD, ACME_EMAIL, and set
|
||||
# STACKS_DIR to this exact clone path, e.g. /opt/nextchair/stacks
|
||||
docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
|
||||
|
||||
# 4. Build the shared business image (re-run this after every code
|
||||
# update — see stacks/blake-heritage/compose.yaml's own note).
|
||||
docker build -t nextchair-app:latest .
|
||||
|
||||
# 5. The management UI (manager/) — this is what you'll use to add
|
||||
# businesses from here on, not the by-hand steps in stacks/infra/
|
||||
# compose.yaml's header (those exist only as a fallback/reference).
|
||||
cp manager/.env.example stacks/manager/.env
|
||||
# edit stacks/manager/.env — MANAGER_EMAIL/PASSWORD_HASH, MANAGER_SESSION_SECRET,
|
||||
# MYSQL_ROOT_PASSWORD (same value as step 3), and STACKS_DIR (same as step 3)
|
||||
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
|
||||
```
|
||||
|
||||
Then, per business, point its domain's DNS at the VPS and use the
|
||||
management UI (`https://<your-manager-domain>` — add a Caddy site for it
|
||||
the same way as any business, see `stacks/infra/sites/.gitkeep`) to add
|
||||
it: name, slug, domain — it creates the database, generates secrets,
|
||||
starts the container, and runs migrations for you. Finish setup by
|
||||
visiting `https://<business-domain>/install`.
|
||||
|
||||
To ship a code update to every business: `git pull`, rebuild the image
|
||||
(step 4), then use each business's **Update** button in the management UI
|
||||
(or `docker compose up -d --force-recreate` in its `stacks/<slug>/`
|
||||
directory) to recreate it against the new image.
|
||||
@@ -1,63 +0,0 @@
|
||||
name: next-chair-blake-heritage
|
||||
|
||||
# One business's app container on the shared multi-business VPS setup —
|
||||
# see docker-compose.infra.yaml for the shared MySQL/Traefik this attaches
|
||||
# to, and that file's header for the exact one-time setup steps (create
|
||||
# this business's database, copy .env.business.example) needed before
|
||||
# this file can start.
|
||||
#
|
||||
# docker compose -f docker-compose.blake-heritage.yaml --env-file .env.business.blake-heritage up -d --build
|
||||
#
|
||||
# `docker-compose.marios.yaml` is the identical shape for the second
|
||||
# business — copy this file's pattern again for any future business.
|
||||
|
||||
services:
|
||||
next-chair-blake-heritage:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# Builds the image fresh here. Once there are more than a couple of
|
||||
# businesses, it's worth building once (`docker build -t
|
||||
# nextchair-app:latest .`) and swapping this `build:` block for
|
||||
# `image: nextchair-app:latest` in every business's file instead, so
|
||||
# a code update doesn't mean rebuilding the identical image N times —
|
||||
# not necessary to start with.
|
||||
container_name: next-chair-blake-heritage
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.business.blake-heritage
|
||||
volumes:
|
||||
- next-chair-media-blake-heritage:/app/storage/media
|
||||
healthcheck:
|
||||
# No curl/wget in the node:22-alpine base image — a plain Node
|
||||
# request avoids adding a package just for this. See
|
||||
# src/app/api/health/route.ts: healthy as soon as the database is
|
||||
# reachable, even before installation completes.
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.blake-heritage.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.blake-heritage.entrypoints=websecure"
|
||||
- "traefik.http.routers.blake-heritage.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.blake-heritage.loadbalancer.server.port=3000"
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
volumes:
|
||||
next-chair-media-blake-heritage:
|
||||
name: next-chair-media-blake-heritage
|
||||
|
||||
networks:
|
||||
next-chair-edge:
|
||||
name: next-chair-edge
|
||||
external: true
|
||||
@@ -1,103 +0,0 @@
|
||||
name: next-chair-infra
|
||||
|
||||
# Shared infrastructure for a multi-business VPS deployment: one Traefik
|
||||
# reverse proxy (routes each business's domain to its own app container,
|
||||
# handles TLS via Let's Encrypt) and one MySQL server shared by every
|
||||
# business — a separate database + user per business, NOT one shared
|
||||
# schema (see step 1 below).
|
||||
#
|
||||
# Deliberately separate from docker-compose.local.yaml, which is the
|
||||
# single-business local/Docker-Desktop setup and stays untouched by this.
|
||||
#
|
||||
# ── One-time setup ───────────────────────────────────────────────────────
|
||||
# docker network create next-chair-edge
|
||||
# cp .env.infra.example .env.infra (then fill in real values)
|
||||
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d
|
||||
#
|
||||
# ── Per business, after infra is running (see docker-compose.<business>
|
||||
# .yaml — this repo ships one per business, e.g. docker-compose.blake-
|
||||
# heritage.yaml) ────────────────────────────────────────────────────────
|
||||
# 1. Create that business's database + user on the shared MySQL server.
|
||||
# There's no automatic way to do this per business via env vars —
|
||||
# the mysql:8.0 image's MYSQL_DATABASE/USER/PASSWORD vars only
|
||||
# provision ONE database at first boot, and /docker-entrypoint-
|
||||
# initdb.d scripts only ever run once against an empty data
|
||||
# directory — so this one step is manual, run once per business:
|
||||
# docker exec -it next-chair-mysql mysql -uroot -p
|
||||
# CREATE DATABASE blake_heritage CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
# CREATE USER 'blake_heritage'@'%' IDENTIFIED BY 'choose-a-strong-password';
|
||||
# GRANT ALL PRIVILEGES ON blake_heritage.* TO 'blake_heritage'@'%';
|
||||
# FLUSH PRIVILEGES;
|
||||
# 2. Copy .env.business.example to .env.business.<slug>, fill it in
|
||||
# with the credentials just created and that business's domain.
|
||||
# 3. docker compose -f docker-compose.<business>.yaml --env-file .env.business.<slug> up -d --build
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.2
|
||||
container_name: next-chair-traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--providers.docker=true"
|
||||
# Only containers with an explicit traefik.enable=true label are
|
||||
# ever routed to — nothing becomes reachable by accident just for
|
||||
# sitting on the shared network.
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
# Plain HTTP always redirects to HTTPS — no business is ever served
|
||||
# unencrypted, even before you've thought about it per-business.
|
||||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
||||
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
# Read-only — Traefik only needs to observe container labels/state
|
||||
# to discover routes, never control the daemon.
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- next-chair-traefik-certs:/letsencrypt
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
next-chair-mysql:
|
||||
image: mysql:8.0
|
||||
container_name: next-chair-mysql
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.infra
|
||||
# No MYSQL_DATABASE/MYSQL_USER/MYSQL_PASSWORD here deliberately — this
|
||||
# server hosts one database per business (see step 1 above), not one
|
||||
# shared schema. Only MYSQL_ROOT_PASSWORD (in .env.infra) is required
|
||||
# for the image to boot.
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
|
||||
ports:
|
||||
# Host-machine access (e.g. a GUI client over an SSH tunnel) — every
|
||||
# business's app container reaches this over the shared Docker
|
||||
# network via the service name `next-chair-mysql`, never this.
|
||||
- "${MYSQL_HOST_PORT:-3306}:3306"
|
||||
volumes:
|
||||
- next-chair-mysql-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-change-me-too}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
volumes:
|
||||
next-chair-mysql-data:
|
||||
name: next-chair-mysql-data
|
||||
next-chair-traefik-certs:
|
||||
name: next-chair-traefik-certs
|
||||
|
||||
networks:
|
||||
next-chair-edge:
|
||||
name: next-chair-edge
|
||||
external: true
|
||||
@@ -1,63 +0,0 @@
|
||||
name: next-chair-marios
|
||||
|
||||
# One business's app container on the shared multi-business VPS setup —
|
||||
# see docker-compose.infra.yaml for the shared MySQL/Traefik this attaches
|
||||
# to, and that file's header for the exact one-time setup steps (create
|
||||
# this business's database, copy .env.business.example) needed before
|
||||
# this file can start.
|
||||
#
|
||||
# docker compose -f docker-compose.marios.yaml --env-file .env.business.marios up -d --build
|
||||
#
|
||||
# `docker-compose.blake-heritage.yaml` is the identical shape for the
|
||||
# first business — copy this file's pattern again for any future business.
|
||||
|
||||
services:
|
||||
next-chair-marios:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# Builds the image fresh here. Once there are more than a couple of
|
||||
# businesses, it's worth building once (`docker build -t
|
||||
# nextchair-app:latest .`) and swapping this `build:` block for
|
||||
# `image: nextchair-app:latest` in every business's file instead, so
|
||||
# a code update doesn't mean rebuilding the identical image N times —
|
||||
# not necessary to start with.
|
||||
container_name: next-chair-marios
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.business.marios
|
||||
volumes:
|
||||
- next-chair-media-marios:/app/storage/media
|
||||
healthcheck:
|
||||
# No curl/wget in the node:22-alpine base image — a plain Node
|
||||
# request avoids adding a package just for this. See
|
||||
# src/app/api/health/route.ts: healthy as soon as the database is
|
||||
# reachable, even before installation completes.
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.marios.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.marios.entrypoints=websecure"
|
||||
- "traefik.http.routers.marios.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.marios.loadbalancer.server.port=3000"
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
volumes:
|
||||
next-chair-media-marios:
|
||||
name: next-chair-media-marios
|
||||
|
||||
networks:
|
||||
next-chair-edge:
|
||||
name: next-chair-edge
|
||||
external: true
|
||||
+1
-1
@@ -3,7 +3,7 @@ import nextTypescript from "eslint-config-next/typescript";
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
const eslintConfig = [
|
||||
{ ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**"] },
|
||||
{ ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**", "manager/**"] },
|
||||
...nextCoreWebVitals,
|
||||
...nextTypescript,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,38 @@
|
||||
# Copy to stacks/manager/.env (kept out of git — see the repo root's
|
||||
# .gitignore) and fill in real values.
|
||||
|
||||
# ── Platform-operator login ─────────────────────────────────────────────
|
||||
MANAGER_EMAIL="you@example.com"
|
||||
# bcrypt hash (cost 12) of your chosen password — generate one with:
|
||||
# node -e "console.log(require('bcryptjs').hashSync('your-password', 12))"
|
||||
# (run from the manager/ directory so it resolves the bcryptjs dependency)
|
||||
MANAGER_PASSWORD_HASH="replace-me"
|
||||
# Signs the session cookie — generate with: openssl rand -hex 32
|
||||
MANAGER_SESSION_SECRET="replace-me"
|
||||
# The URL this app is actually served at (used to decide whether the
|
||||
# session cookie needs its Secure attribute) — e.g. "https://manager.example.com".
|
||||
MANAGER_PUBLIC_URL="https://manager.example.com"
|
||||
|
||||
# ── Shared MySQL (see stacks/infra/compose.yaml) ────────────────────────
|
||||
MYSQL_HOST="next-chair-mysql"
|
||||
MYSQL_PORT="3306"
|
||||
# Same value as stacks/infra/.env's MYSQL_ROOT_PASSWORD — needed here too
|
||||
# to create each new business's database/user. Two copies of the same
|
||||
# value, deliberately not derived from one another — see stacks/infra/
|
||||
# compose.yaml's own note on why MYSQL_*/DATABASE_* aren't cross-wired.
|
||||
MYSQL_ROOT_PASSWORD="replace-me"
|
||||
|
||||
# ── Docker / stacks ──────────────────────────────────────────────────────
|
||||
# Must be the identical absolute path as stacks/infra/.env's STACKS_DIR —
|
||||
# see that file's comment for why.
|
||||
STACKS_DIR="/opt/nextchair/stacks"
|
||||
# Every business's compose.yaml (see src/lib/compose-template.ts) uses
|
||||
# this image tag — rebuild with `docker build -t nextchair-app:latest .`
|
||||
# from the repo root after a code update, then use each business's
|
||||
# "Update" button (or `docker compose up -d --force-recreate`) to pick it
|
||||
# up.
|
||||
NEXTCHAIR_IMAGE="nextchair-app:latest"
|
||||
|
||||
# ── Optional ─────────────────────────────────────────────────────────────
|
||||
# Shown as a link in the dashboard header if set.
|
||||
DOCKGE_URL="http://your-vps-ip:5001"
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,52 @@
|
||||
# Standalone image for the management UI — separate from the main app's
|
||||
# Dockerfile (see ../Dockerfile) since this needs the Docker CLI itself
|
||||
# baked in (it shells out to `docker compose`/`docker exec` against the
|
||||
# host's dockerd via the mounted socket — see src/lib/docker.ts), which
|
||||
# the main business app never needs.
|
||||
#
|
||||
# Only one instance of this ever runs (unlike the business image, which
|
||||
# is built once and reused across every business — see
|
||||
# stacks/blake-heritage/compose.yaml's own note) so a per-deploy `build:`
|
||||
# in stacks/manager/compose.yaml, rather than a separately tagged image,
|
||||
# is the simpler choice here.
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# The Docker CLI + compose plugin — this container never runs its own
|
||||
# Docker daemon, it only talks to the HOST's via the socket mounted in
|
||||
# stacks/infra/compose.yaml's manager service (docker.sock).
|
||||
RUN apk add --no-cache docker-cli docker-cli-compose
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 manager
|
||||
|
||||
COPY --from=builder --chown=manager:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=manager:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=manager:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=manager:nodejs /app/package.json ./package.json
|
||||
COPY --from=builder --chown=manager:nodejs /app/next.config.ts ./next.config.ts
|
||||
|
||||
# Deliberately still root at runtime, not the `manager` user created
|
||||
# above — this container's whole job is calling the Docker CLI against
|
||||
# the host's socket, and on most VPS setups only root (or a user in the
|
||||
# `docker` group, which would need to be created with a matching host
|
||||
# GID) can use that socket. Worth being explicit about: root inside this
|
||||
# container plus the mounted socket effectively means root on the host —
|
||||
# this app must stay behind its own login (see src/lib/auth.ts) and never
|
||||
# be exposed without that gate.
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
ENTRYPOINT ["node_modules/.bin/next", "start", "-p", "3000"]
|
||||
@@ -0,0 +1,89 @@
|
||||
# NextChair Manager
|
||||
|
||||
Standalone platform-operator tool for provisioning and managing NextChair
|
||||
business deployments on the shared multi-business VPS setup (see
|
||||
`../stacks/`). Not part of the main app — a genuinely separate Next.js
|
||||
app, own `package.json`, own build, own container.
|
||||
|
||||
## Why it's a separate app
|
||||
|
||||
An earlier attempt at this exact feature was built as routes inside the
|
||||
main app (`src/app/manager/...`). It was reverted after repeated,
|
||||
never-fully-resolved bugs: the main app's `src/proxy.ts`, root layout,
|
||||
root page, and global `SessionProvider` all statically import Prisma /
|
||||
Auth.js / business branding — code a Manager (which has no business
|
||||
database of its own, by definition) kept transitively pulling in, causing
|
||||
crashes in "Manager mode."
|
||||
|
||||
This app avoids that class of bug structurally rather than by careful
|
||||
avoidance: it never imports anything from the main app's `src/` at all.
|
||||
A few small pieces (bcrypt convention, the signed-cookie primitive) were
|
||||
copied and adapted, not imported — see `src/lib/auth.ts` and
|
||||
`src/lib/session.ts`.
|
||||
|
||||
## What it does
|
||||
|
||||
- Lists provisioned businesses and their running/stopped state.
|
||||
- **Add Business**: collects a name/slug/domain, then generates secrets,
|
||||
creates that business's database + user on the shared MySQL server,
|
||||
writes its `stacks/<slug>/compose.yaml` + `.env` + Caddy site file,
|
||||
reloads Caddy, starts the container, and runs its migrations. See
|
||||
`src/lib/provisioning.ts` for the exact sequence.
|
||||
- Per business: start / stop / restart / update (recreate the container
|
||||
against the current shared image tag — not a source rebuild) / view
|
||||
recent logs.
|
||||
- Does **not** duplicate the `/install` wizard — after provisioning, the
|
||||
operator is linked to `https://<domain>/install` to finish that
|
||||
business's own setup.
|
||||
|
||||
## How auth works
|
||||
|
||||
A single dedicated platform-operator credential (`MANAGER_EMAIL` /
|
||||
`MANAGER_PASSWORD_HASH`, bcrypt cost 12) — not tied to any one business's
|
||||
own Super Administrator, since this tool spans every business's separate
|
||||
database and has no "primary" one of its own. A signed (HMAC, not
|
||||
encrypted) cookie holds the session; see `src/lib/session.ts`.
|
||||
|
||||
## The Docker-socket trust boundary
|
||||
|
||||
This container has the host's Docker socket mounted (`docker.sock`) so it
|
||||
can run `docker compose`/`docker exec` against the host's dockerd — see
|
||||
`src/lib/docker.ts`. Root inside this container plus that socket
|
||||
effectively means root on the host. Consequences:
|
||||
|
||||
- It must stay behind the login above at all times — never expose it
|
||||
without that gate, and don't reuse `MANAGER_PASSWORD_HASH` anywhere
|
||||
else.
|
||||
- `STACKS_DIR` must be bind-mounted at the *identical absolute path* on
|
||||
both host and container (see `.env.example`) — a `docker compose -f
|
||||
<path>` command run from inside this container is executed by the
|
||||
*host's* dockerd, so the path only means the same file on both sides if
|
||||
the mount doesn't change it. This is the same constraint Dockge itself
|
||||
has.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cp manager/.env.example stacks/manager/.env # fill in real values
|
||||
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
|
||||
```
|
||||
|
||||
Requires `stacks/infra/compose.yaml` already running (shared MySQL,
|
||||
Caddy, the `next-chair-edge` network). Add a Caddy site for this app the
|
||||
same way as any business (see `stacks/infra/sites/.gitkeep`), pointed at
|
||||
`next-chair-manager:3000`.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
cd manager
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Requires network access to a real MySQL server and `docker`/`docker
|
||||
compose` on the machine you run it from for the provisioning/stack
|
||||
actions to actually work — the dashboard's read-only parts (listing
|
||||
stacks) will also just show nothing if `STACKS_DIR` doesn't point at a
|
||||
real directory yet.
|
||||
@@ -0,0 +1,16 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTypescript from "eslint-config-next/typescript";
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
const eslintConfig = [
|
||||
{ ignores: [".next/**", "node_modules/**"] },
|
||||
...nextCoreWebVitals,
|
||||
...nextTypescript,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,19 @@
|
||||
import path from "node:path";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Server Actions here run `docker`/`docker compose`/`mariadb` calls, not
|
||||
// page rendering — nothing about this app benefits from static export or
|
||||
// the standalone output trimming the main app also opts out of (see
|
||||
// ../Dockerfile's own header for the same reasoning).
|
||||
|
||||
// This directory has its own package-lock.json alongside the main
|
||||
// app's at the repo root — without this, Next infers the wrong
|
||||
// workspace root from the nearest lockfile and warns about it on every
|
||||
// build.
|
||||
turbopack: {
|
||||
root: path.join(__dirname),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6975
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "nextchair-manager",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Standalone platform-operator tool for provisioning and managing NextChair business deployments. Deliberately separate from the main app (own package.json, own build, own container) — an earlier in-app attempt at this repeatedly broke from Prisma/Auth.js/business-data imports leaking into a mode with no business database.",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"mariadb": "^3.5.3",
|
||||
"next": "^16.2.12",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.2.12",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Whitespace-only changes.
@@ -0,0 +1,42 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
|
||||
import { isValidSlug, stackDir, stackExists } from "@/lib/stacks";
|
||||
|
||||
async function assertStack(slug: string): Promise<string> {
|
||||
if (!isValidSlug(slug) || !(await stackExists(slug))) {
|
||||
throw new Error(`No such stack: ${slug}`);
|
||||
}
|
||||
return stackDir(slug);
|
||||
}
|
||||
|
||||
export async function startStackAction(slug: string): Promise<void> {
|
||||
const dir = await assertStack(slug);
|
||||
await composeUp(dir);
|
||||
revalidatePath(`/dashboard/${slug}`);
|
||||
}
|
||||
|
||||
export async function stopStackAction(slug: string): Promise<void> {
|
||||
const dir = await assertStack(slug);
|
||||
await composeDown(dir);
|
||||
revalidatePath(`/dashboard/${slug}`);
|
||||
}
|
||||
|
||||
export async function restartStackAction(slug: string): Promise<void> {
|
||||
const dir = await assertStack(slug);
|
||||
await composeRestart(dir);
|
||||
revalidatePath(`/dashboard/${slug}`);
|
||||
}
|
||||
|
||||
/** Recreates the container against whatever the shared image tag currently points to (--force-recreate) — not a source rebuild. See manager/.env.example's NEXTCHAIR_IMAGE comment for the actual rebuild step. */
|
||||
export async function updateStackAction(slug: string): Promise<void> {
|
||||
const dir = await assertStack(slug);
|
||||
await composeUp(dir, { forceRecreate: true });
|
||||
revalidatePath(`/dashboard/${slug}`);
|
||||
}
|
||||
|
||||
export async function fetchLogsAction(slug: string): Promise<string> {
|
||||
const dir = await assertStack(slug);
|
||||
return composeLogs(dir, 200);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
fetchLogsAction,
|
||||
restartStackAction,
|
||||
startStackAction,
|
||||
stopStackAction,
|
||||
updateStackAction,
|
||||
} from "./actions";
|
||||
|
||||
export function BusinessActions({ slug }: { slug: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [logs, setLogs] = useState<string | null>(null);
|
||||
|
||||
const run = (action: (slug: string) => Promise<void>) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await action(slug);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Action failed.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadLogs = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setLogs(await fetchLogsAction(slug));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load logs.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(startStackAction)}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(stopStackAction)}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(restartStackAction)}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(updateStackAction)}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||
title="Recreates the container against the current shared image tag — not a source rebuild."
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={loadLogs}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
View logs
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{logs !== null && (
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { listBusinessStacks } from "@/lib/stacks";
|
||||
import { BusinessActions } from "./business-actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BusinessDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ justCreated?: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { justCreated } = await searchParams;
|
||||
|
||||
const stacks = await listBusinessStacks();
|
||||
const stack = stacks.find((s) => s.slug === slug);
|
||||
if (!stack) notFound();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{justCreated && (
|
||||
<div className="rounded-md border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
|
||||
Business created and started. Visit{" "}
|
||||
<a href={`https://${stack.domain}/install`} target="_blank" rel="noreferrer" className="font-semibold underline">
|
||||
https://{stack.domain}/install
|
||||
</a>{" "}
|
||||
to finish setting it up.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{stack.slug}</h1>
|
||||
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
|
||||
</div>
|
||||
|
||||
<BusinessActions slug={stack.slug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { logoutAction } from "./logout-action";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const dockgeUrl = process.env.DOCKGE_URL;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="flex items-center justify-between border-b border-line bg-surface px-6 py-4">
|
||||
<Link href="/dashboard" className="text-sm font-bold">
|
||||
NextChair Manager
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{dockgeUrl && (
|
||||
<a href={dockgeUrl} target="_blank" rel="noreferrer" className="text-muted hover:text-ink">
|
||||
Dockge ↗
|
||||
</a>
|
||||
)}
|
||||
<form action={logoutAction}>
|
||||
<button type="submit" className="text-muted hover:text-ink">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-4xl px-6 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/session";
|
||||
|
||||
export async function logoutAction(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { provisionBusiness } from "@/lib/provisioning";
|
||||
|
||||
export interface AddBusinessState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function addBusinessAction(_prevState: AddBusinessState, formData: FormData): Promise<AddBusinessState> {
|
||||
const slug = String(formData.get("slug") ?? "");
|
||||
const domain = String(formData.get("domain") ?? "");
|
||||
|
||||
let result: { slug: string };
|
||||
try {
|
||||
result = await provisionBusiness({ slug, domain });
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to provision this business." };
|
||||
}
|
||||
|
||||
redirect(`/dashboard/${result.slug}?justCreated=1`);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { suggestSlug } from "@/lib/slug";
|
||||
import { addBusinessAction, type AddBusinessState } from "./actions";
|
||||
|
||||
const initialState: AddBusinessState = { error: null };
|
||||
|
||||
export function AddBusinessForm() {
|
||||
const [state, formAction, isPending] = useActionState(addBusinessAction, initialState);
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-muted">
|
||||
Business name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
onChange={(e) => {
|
||||
if (!slugTouched) setSlug(suggestSlug(e.target.value));
|
||||
}}
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-muted">
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
name="slug"
|
||||
type="text"
|
||||
required
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlugTouched(true);
|
||||
setSlug(e.target.value);
|
||||
}}
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Used for the container name, database name, and stack directory — lowercase letters, numbers, and hyphens
|
||||
only.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="domain" className="block text-sm font-medium text-muted">
|
||||
Domain
|
||||
</label>
|
||||
<input
|
||||
id="domain"
|
||||
name="domain"
|
||||
type="text"
|
||||
required
|
||||
placeholder="business.example.com"
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Must already point at this server’s IP before Caddy can issue a certificate for it.
|
||||
</p>
|
||||
</div>
|
||||
{state.error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{isPending ? "Provisioning…" : "Create Business"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AddBusinessForm } from "./add-business-form";
|
||||
|
||||
export default function NewBusinessPage() {
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Add Business</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Creates the database, generates secrets, writes the compose stack and Caddy site, and starts the
|
||||
container. Takes a few seconds — the button stays disabled until it’s done.
|
||||
</p>
|
||||
</div>
|
||||
<AddBusinessForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
import { listBusinessStacks } from "@/lib/stacks";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) {
|
||||
const styles = {
|
||||
running: "bg-accent/10 text-accent",
|
||||
stopped: "bg-danger/10 text-danger",
|
||||
unknown: "bg-line text-muted",
|
||||
} as const;
|
||||
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>{status}</span>;
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const stacks = await listBusinessStacks();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Businesses</h1>
|
||||
<Link
|
||||
href="/dashboard/new"
|
||||
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Add Business
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{stacks.length === 0 ? (
|
||||
<p className="text-sm text-muted">No businesses provisioned yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line rounded-lg border border-line bg-surface">
|
||||
{stacks.map((stack) => (
|
||||
<li key={stack.slug}>
|
||||
<Link
|
||||
href={`/dashboard/${stack.slug}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-stone"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{stack.slug}</p>
|
||||
<p className="text-xs text-muted">{stack.domain ?? "no domain set"}</p>
|
||||
</div>
|
||||
<StatusPill status={stack.status} />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ink: #111b15;
|
||||
--color-stone: #f4f6f4;
|
||||
--color-surface: #ffffff;
|
||||
--color-muted: #5b665f;
|
||||
--color-line: #dfe3df;
|
||||
--color-accent: #1f7a4d;
|
||||
--color-danger: #b3261e;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-stone);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "NextChair Manager",
|
||||
description: "Platform-operator tool for provisioning NextChair business deployments.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifyManagerCredentials } from "@/lib/auth";
|
||||
import { createSessionToken, isSecureDeployment, SESSION_COOKIE_NAME } from "@/lib/session";
|
||||
|
||||
export interface LoginState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function loginAction(_prevState: LoginState, formData: FormData): Promise<LoginState> {
|
||||
const email = String(formData.get("email") ?? "");
|
||||
const password = String(formData.get("password") ?? "");
|
||||
|
||||
if (!email || !password) {
|
||||
return { error: "Email and password are required." };
|
||||
}
|
||||
|
||||
const valid = await verifyManagerCredentials(email, password);
|
||||
if (!valid) {
|
||||
return { error: "Incorrect email or password." };
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE_NAME, createSessionToken(email), {
|
||||
httpOnly: true,
|
||||
secure: isSecureDeployment(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { loginAction, type LoginState } from "./actions";
|
||||
|
||||
const initialState: LoginState = { error: null };
|
||||
|
||||
export function LoginForm() {
|
||||
const [state, formAction, isPending] = useActionState(loginAction, initialState);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-muted">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-muted">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{state.error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{isPending ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-6 rounded-lg border border-line bg-surface p-8 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">NextChair Manager</h1>
|
||||
<p className="text-sm text-muted">Platform-operator sign in.</p>
|
||||
</div>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
/**
|
||||
* A single, dedicated platform-operator credential via env vars — not
|
||||
* tied to any one business's own Super Administrator (each business has
|
||||
* its own separate database; this tool has no "primary" one of its own,
|
||||
* so there's no single business account it could sensibly check against).
|
||||
* Bcrypt cost 12 and the dummy-hash timing-safe comparison mirror the
|
||||
* main app's own convention (see scripts/ensure-super-admin.ts and
|
||||
* src/auth.ts) — copied, not imported: this app never imports from the
|
||||
* main app's src/ at all (see session.ts's header for why).
|
||||
*/
|
||||
|
||||
// A real bcrypt hash of an unguessable value, used only so a request for
|
||||
// a wrong/missing MANAGER_EMAIL still pays the same bcrypt.compare cost
|
||||
// as a wrong password — otherwise "no such email" and "wrong password"
|
||||
// are distinguishable by response time.
|
||||
const DUMMY_HASH = bcrypt.hashSync("nextchair-manager-dummy-hash", 12);
|
||||
|
||||
export async function verifyManagerCredentials(email: string, password: string): Promise<boolean> {
|
||||
const expectedEmail = process.env.MANAGER_EMAIL?.trim().toLowerCase();
|
||||
const expectedHash = process.env.MANAGER_PASSWORD_HASH;
|
||||
if (!expectedEmail || !expectedHash) {
|
||||
throw new Error("MANAGER_EMAIL and MANAGER_PASSWORD_HASH must both be set.");
|
||||
}
|
||||
|
||||
const matchesEmail = email.trim().toLowerCase() === expectedEmail;
|
||||
const valid = await bcrypt.compare(password, matchesEmail ? expectedHash : DUMMY_HASH);
|
||||
return matchesEmail && valid;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Generates the exact same shape as stacks/blake-heritage/compose.yaml
|
||||
* and stacks/marios/compose.yaml (the two hand-written example stacks) —
|
||||
* keep these in sync if that shape changes.
|
||||
*/
|
||||
export function generateComposeYaml(slug: string): string {
|
||||
const containerName = `next-chair-${slug}`;
|
||||
const volumeName = `next-chair-media-${slug}`;
|
||||
return `name: ${containerName}
|
||||
|
||||
# Generated by the management UI — see stacks/blake-heritage/compose.yaml
|
||||
# for the hand-written original this mirrors.
|
||||
|
||||
services:
|
||||
${containerName}:
|
||||
image: \${NEXTCHAIR_IMAGE:-nextchair-app:latest}
|
||||
container_name: ${containerName}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ${volumeName}:/app/storage/media
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
volumes:
|
||||
${volumeName}:
|
||||
name: ${volumeName}
|
||||
|
||||
networks:
|
||||
next-chair-edge:
|
||||
name: next-chair-edge
|
||||
external: true
|
||||
`;
|
||||
}
|
||||
|
||||
export interface BusinessEnvValues {
|
||||
domain: string;
|
||||
databaseName: string;
|
||||
databaseUser: string;
|
||||
databasePassword: string;
|
||||
authSecret: string;
|
||||
installationToken: string;
|
||||
cronSecret: string;
|
||||
}
|
||||
|
||||
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
|
||||
export function generateBusinessEnv(values: BusinessEnvValues): string {
|
||||
return `# Generated by the management UI. See .env.business.example for what
|
||||
# each of these does.
|
||||
DOMAIN="${values.domain}"
|
||||
DATABASE_HOST="next-chair-mysql"
|
||||
DATABASE_PORT="3306"
|
||||
DATABASE_NAME="${values.databaseName}"
|
||||
DATABASE_USER="${values.databaseUser}"
|
||||
DATABASE_PASSWORD="${values.databasePassword}"
|
||||
AUTH_SECRET="${values.authSecret}"
|
||||
AUTH_TRUST_HOST="true"
|
||||
NEXT_PUBLIC_APP_URL="https://${values.domain}"
|
||||
INSTALLATION_TOKEN="${values.installationToken}"
|
||||
CRON_SECRET="${values.cronSecret}"
|
||||
|
||||
SUPER_ADMIN_EMAIL=""
|
||||
SUPER_ADMIN_PASSWORD=""
|
||||
SUPER_ADMIN_FIRST_NAME=""
|
||||
SUPER_ADMIN_LAST_NAME=""
|
||||
|
||||
AUTH_GOOGLE_ID=""
|
||||
AUTH_GOOGLE_SECRET=""
|
||||
|
||||
GOOGLE_CALENDAR_CLIENT_ID=""
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
||||
GOOGLE_CALENDAR_REDIRECT_URI="https://${values.domain}/api/staff/calendar/google/callback"
|
||||
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
||||
|
||||
MEDIA_MAX_UPLOAD_BYTES="5000000"
|
||||
|
||||
GOOGLE_PLACES_API_KEY=""
|
||||
|
||||
EMAIL_PROVIDER="console"
|
||||
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
||||
EMAIL_SMTP_PORT="587"
|
||||
EMAIL_SMTP_USERNAME=""
|
||||
EMAIL_SMTP_PASSWORD=""
|
||||
EMAIL_FROM_ADDRESS=""
|
||||
EMAIL_FROM_NAME=""
|
||||
|
||||
PUSH_PROVIDER="console"
|
||||
VAPID_PUBLIC_KEY=""
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
||||
VAPID_PRIVATE_KEY=""
|
||||
VAPID_SUBJECT="mailto:owner@example.com"
|
||||
`;
|
||||
}
|
||||
|
||||
export function generateCaddySite(slug: string, domain: string): string {
|
||||
return `${domain} {
|
||||
reverse_proxy next-chair-${slug}:3000
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Every call here runs `docker compose` with `cwd` set to a stack's own
|
||||
* directory (stacks/<slug>/) rather than passing explicit -f/--env-file
|
||||
* flags — compose already auto-loads `compose.yaml` and `.env` from the
|
||||
* current directory, and this is the same convention Dockge itself uses,
|
||||
* so a stack behaves identically whether it's started by this app, by
|
||||
* Dockge, or by hand from the command line.
|
||||
*
|
||||
* This app's own container needs the Docker socket mounted and the
|
||||
* `docker compose` CLI installed (see manager/Dockerfile) — these calls
|
||||
* are executed by the HOST's dockerd via that socket, not by a nested
|
||||
* Docker daemon inside this container.
|
||||
*/
|
||||
|
||||
export interface DockerCommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
async function runDockerCompose(stackDir: string, args: string[]): Promise<DockerCommandResult> {
|
||||
const result = await execFileAsync("docker", ["compose", ...args], {
|
||||
cwd: stackDir,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function composeUp(stackDir: string, options: { forceRecreate?: boolean } = {}): Promise<void> {
|
||||
const args = ["up", "-d"];
|
||||
if (options.forceRecreate) args.push("--force-recreate");
|
||||
await runDockerCompose(stackDir, args);
|
||||
}
|
||||
|
||||
export async function composeDown(stackDir: string): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["down"]);
|
||||
}
|
||||
|
||||
export async function composeRestart(stackDir: string): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["restart"]);
|
||||
}
|
||||
|
||||
export async function composeLogs(stackDir: string, tailLines = 200): Promise<string> {
|
||||
const result = await runDockerCompose(stackDir, ["logs", "--no-color", "--tail", String(tailLines)]);
|
||||
// Compose writes container logs to stderr, not stdout — this is normal
|
||||
// (docker's own `logs` command does the same), not a failure signal.
|
||||
return result.stdout + result.stderr;
|
||||
}
|
||||
|
||||
export type StackRunState = "running" | "stopped" | "unknown";
|
||||
|
||||
/** Best-effort — a stack that's never been started at all reports "stopped", not an error. */
|
||||
export async function composeStatus(stackDir: string): Promise<StackRunState> {
|
||||
try {
|
||||
const result = await runDockerCompose(stackDir, ["ps", "--status", "running", "--format", "json"]);
|
||||
return result.stdout.trim().length > 0 ? "running" : "stopped";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export async function composeExec(stackDir: string, serviceName: string, command: string[]): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["exec", "-T", serviceName, ...command]);
|
||||
}
|
||||
|
||||
/** Applies pending Prisma migrations inside a freshly-started business container — retries briefly since the container's Node process may take a moment to come up after `composeUp`. */
|
||||
export async function runMigrations(stackDir: string, serviceName: string): Promise<void> {
|
||||
const maxAttempts = 6;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
await composeExec(stackDir, serviceName, ["npx", "prisma", "migrate", "deploy"]);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("Migrations failed after retries.");
|
||||
}
|
||||
|
||||
/** Caddy doesn't watch its sites/ directory — adding or removing a business's .caddy file needs this to take effect. */
|
||||
export async function reloadCaddy(): Promise<void> {
|
||||
const containerName = process.env.CADDY_CONTAINER_NAME ?? "next-chair-caddy";
|
||||
await execFileAsync("docker", ["exec", containerName, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import mariadb from "mariadb";
|
||||
import { composeUp, reloadCaddy, runMigrations } from "./docker";
|
||||
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
|
||||
import { isValidSlug, stackDir, stackExists, stacksRoot } from "./stacks";
|
||||
|
||||
/** Hard safety check on top of isValidSlug (which already constrains the slug itself) — this is the one place that string ends up inside a raw SQL statement, where an identifier can't be parameter-bound the way a value can. */
|
||||
const SAFE_DB_IDENTIFIER = /^[a-z][a-z0-9_]{1,48}[a-z0-9]$/;
|
||||
/** Generated via randomBytes/hex below — always matches this, checked anyway as defense in depth before it goes into a SQL string literal. */
|
||||
const SAFE_GENERATED_SECRET = /^[a-f0-9]+$/;
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
return randomBytes(bytes).toString("hex");
|
||||
}
|
||||
|
||||
export interface ProvisionBusinessInput {
|
||||
slug: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface ProvisionBusinessResult {
|
||||
slug: string;
|
||||
domain: string;
|
||||
installUrl: string;
|
||||
}
|
||||
|
||||
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
|
||||
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||
throw new Error("Refusing to create a database/user from an unsafe identifier.");
|
||||
}
|
||||
if (!SAFE_GENERATED_SECRET.test(databasePassword)) {
|
||||
throw new Error("Refusing to create a user with an unexpected password format.");
|
||||
}
|
||||
|
||||
const host = process.env.MYSQL_HOST ?? "next-chair-mysql";
|
||||
const port = process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306;
|
||||
const rootPassword = process.env.MYSQL_ROOT_PASSWORD;
|
||||
if (!rootPassword) throw new Error("MYSQL_ROOT_PASSWORD must be set.");
|
||||
|
||||
const conn = await mariadb.createConnection({
|
||||
host,
|
||||
port,
|
||||
user: "root",
|
||||
password: rootPassword,
|
||||
// MySQL 8's default auth plugin (caching_sha2_password) needs to send
|
||||
// the client its RSA public key to encrypt the password, and the
|
||||
// mariadb driver refuses to fetch it over a plain connection unless
|
||||
// told to — see the main app's src/lib/db/mysql-pool-config.ts for
|
||||
// the full explanation of this exact error. Confirmed by hitting it
|
||||
// live: creating a business failed with "RSA public key is not
|
||||
// available client side" before this option was added here.
|
||||
allowPublicKeyRetrieval: true,
|
||||
});
|
||||
try {
|
||||
// Identifiers are backtick-quoted AND already validated against
|
||||
// SAFE_DB_IDENTIFIER above — belt and braces, since a SQL identifier
|
||||
// (unlike a value) can't be parameter-bound.
|
||||
await conn.query(`CREATE DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci`);
|
||||
await conn.query(`CREATE USER '${databaseUser}'@'%' IDENTIFIED BY '${databasePassword}'`);
|
||||
await conn.query(`GRANT ALL PRIVILEGES ON \`${databaseName}\`.* TO '${databaseUser}'@'%'`);
|
||||
await conn.query("FLUSH PRIVILEGES");
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionBusiness(input: ProvisionBusinessInput): Promise<ProvisionBusinessResult> {
|
||||
const slug = input.slug.trim().toLowerCase();
|
||||
const domain = input.domain.trim().toLowerCase();
|
||||
|
||||
if (!isValidSlug(slug)) {
|
||||
throw new Error("Slug must be lowercase letters, numbers, and hyphens, 3-50 characters, not a reserved name.");
|
||||
}
|
||||
if (!domain || domain.includes(" ")) {
|
||||
throw new Error("A valid domain is required.");
|
||||
}
|
||||
if (await stackExists(slug)) {
|
||||
throw new Error(`A stack named "${slug}" already exists.`);
|
||||
}
|
||||
|
||||
const databaseIdentifier = slug.replace(/-/g, "_");
|
||||
const databasePassword = randomHex(24);
|
||||
const authSecret = randomHex(32);
|
||||
const installationToken = randomHex(32);
|
||||
const cronSecret = randomHex(32);
|
||||
|
||||
await createDatabaseAndUser(databaseIdentifier, databaseIdentifier, databasePassword);
|
||||
|
||||
const dir = stackDir(slug);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
|
||||
await writeFile(
|
||||
path.join(dir, ".env"),
|
||||
generateBusinessEnv({
|
||||
domain,
|
||||
databaseName: databaseIdentifier,
|
||||
databaseUser: databaseIdentifier,
|
||||
databasePassword,
|
||||
authSecret,
|
||||
installationToken,
|
||||
cronSecret,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const sitesDir = path.join(stacksRoot(), "infra", "sites");
|
||||
await mkdir(sitesDir, { recursive: true });
|
||||
await writeFile(path.join(sitesDir, `${slug}.caddy`), generateCaddySite(slug, domain), "utf8");
|
||||
await reloadCaddy();
|
||||
|
||||
await composeUp(dir);
|
||||
await runMigrations(dir, `next-chair-${slug}`);
|
||||
|
||||
return { slug, domain, installUrl: `https://${domain}/install` };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Signed-cookie session for the manager app — adapted from (not imported
|
||||
* from) the main NextChair app's src/lib/booking-draft/sign.ts and
|
||||
* src/lib/security/secrets-match.ts. Deliberately copied rather than
|
||||
* shared: this app must never import anything from the main app's src/ —
|
||||
* a previous attempt at this exact tool was built as routes inside the
|
||||
* main app and repeatedly broke from transitively importing Prisma/
|
||||
* Auth.js/business data into a mode that has no business database at
|
||||
* all — see ../../README.md's "Why it's a separate app" for the full
|
||||
* history. Keeping this app's module graph fully separate is what makes
|
||||
* that failure structurally impossible to repeat here.
|
||||
*
|
||||
* HMAC-signed (tamper-evident), not encrypted — the payload is just an
|
||||
* email and an expiry, nothing sensitive to disclose to the browser
|
||||
* holding its own cookie. Session length: 7 days.
|
||||
*/
|
||||
|
||||
const CONTEXT = "nextchair-manager-session-v1";
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface ManagerSession {
|
||||
email: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function getSigningKey(): Buffer {
|
||||
const secret = process.env.MANAGER_SESSION_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("MANAGER_SESSION_SECRET must be set.");
|
||||
}
|
||||
return createHmac("sha256", secret).update(CONTEXT).digest();
|
||||
}
|
||||
|
||||
function sign(payload: string): string {
|
||||
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
|
||||
return `${encodedPayload}.${mac}`;
|
||||
}
|
||||
|
||||
function verifyToken(token: string): string | null {
|
||||
const separatorIndex = token.indexOf(".");
|
||||
if (separatorIndex === -1) return null;
|
||||
|
||||
const encodedPayload = token.slice(0, separatorIndex);
|
||||
const mac = token.slice(separatorIndex + 1);
|
||||
if (!encodedPayload || !mac) return null;
|
||||
|
||||
let payload: string;
|
||||
try {
|
||||
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
const macBuffer = Buffer.from(mac);
|
||||
const expectedBuffer = Buffer.from(expectedMac);
|
||||
if (macBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createSessionToken(email: string): string {
|
||||
const payload: ManagerSession = { email, exp: Date.now() + SESSION_TTL_MS };
|
||||
return sign(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/** Returns the session if the token is validly signed and not expired, otherwise null. Never throws on malformed input. */
|
||||
export function verifySessionToken(token: string | undefined): ManagerSession | null {
|
||||
if (!token) return null;
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
let session: ManagerSession;
|
||||
try {
|
||||
session = JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof session.email !== "string" || typeof session.exp !== "number") return null;
|
||||
if (Date.now() > session.exp) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same signal/reasoning as the main app's src/lib/security/cookie-
|
||||
* security.ts's isSecureDeployment() — NODE_ENV=production doesn't mean
|
||||
* TLS is actually in front of this app, so the cookie's Secure attribute
|
||||
* is derived from the deployer-declared public URL instead.
|
||||
*/
|
||||
export function isSecureDeployment(): boolean {
|
||||
return (process.env.MANAGER_PUBLIC_URL ?? "").startsWith("https://");
|
||||
}
|
||||
|
||||
export const SESSION_COOKIE_NAME = "manager_session";
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pure string logic, deliberately kept free of any Node-only import
|
||||
* (fs, child_process) so it's safe to use from both server code
|
||||
* (stacks.ts, provisioning.ts) and client components (the Add Business
|
||||
* form's live slug suggestion) without pulling Node APIs into a browser
|
||||
* bundle.
|
||||
*/
|
||||
|
||||
export const RESERVED_SLUGS = new Set(["infra", "manager"]);
|
||||
|
||||
const SLUG_PATTERN = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/;
|
||||
|
||||
export function isValidSlug(slug: string): boolean {
|
||||
return SLUG_PATTERN.test(slug) && !RESERVED_SLUGS.has(slug);
|
||||
}
|
||||
|
||||
export function suggestSlug(businessName: string): string {
|
||||
return businessName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readdir, readFile, access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { composeStatus, type StackRunState } from "./docker";
|
||||
import { RESERVED_SLUGS } from "./slug";
|
||||
|
||||
export { isValidSlug, suggestSlug } from "./slug";
|
||||
|
||||
export interface BusinessStackSummary {
|
||||
slug: string;
|
||||
domain: string | null;
|
||||
containerName: string;
|
||||
status: StackRunState;
|
||||
}
|
||||
|
||||
export function stacksRoot(): string {
|
||||
const dir = process.env.STACKS_DIR;
|
||||
if (!dir) throw new Error("STACKS_DIR must be set.");
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function stackDir(slug: string): string {
|
||||
return path.join(stacksRoot(), slug);
|
||||
}
|
||||
|
||||
/** Only ever reads the DOMAIN line — deliberately never returns the rest of a business's .env (database password, AUTH_SECRET, etc.) to the UI. */
|
||||
async function readDomain(envPath: string): Promise<string | null> {
|
||||
try {
|
||||
const content = await readFile(envPath, "utf8");
|
||||
for (const line of content.split("\n")) {
|
||||
const match = /^DOMAIN\s*=\s*"?([^"\n]*)"?\s*$/.exec(line.trim());
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listBusinessStacks(): Promise<BusinessStackSummary[]> {
|
||||
const root = stacksRoot();
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stacks: BusinessStackSummary[] = [];
|
||||
for (const slug of entries) {
|
||||
if (RESERVED_SLUGS.has(slug)) continue;
|
||||
const dir = path.join(root, slug);
|
||||
const composePath = path.join(dir, "compose.yaml");
|
||||
try {
|
||||
await access(composePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const domain = await readDomain(path.join(dir, ".env"));
|
||||
const status = await composeStatus(dir);
|
||||
stacks.push({ slug, domain, containerName: `next-chair-${slug}`, status });
|
||||
}
|
||||
|
||||
return stacks.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
}
|
||||
|
||||
export async function stackExists(slug: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path.join(stackDir(slug), "compose.yaml"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/session";
|
||||
|
||||
// Next.js 16 renamed the `middleware.ts` file convention to `proxy.ts`
|
||||
// (https://nextjs.org/docs/messages/middleware-to-proxy) — it now always
|
||||
// runs on the Node.js runtime, which is why plain `node:crypto` in
|
||||
// session.ts works fine here without any Edge-runtime workaround.
|
||||
|
||||
const PUBLIC_PATHS = ["/login"];
|
||||
|
||||
export default function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
if (PUBLIC_PATHS.some((path) => pathname.startsWith(path))) return NextResponse.next();
|
||||
|
||||
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
const session = verifySessionToken(token);
|
||||
if (!session) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Runs on every page and API route except static assets — this app has
|
||||
// no public-facing content at all, unlike the main app's proxy.ts,
|
||||
// which has to carve out installation/customer-facing exceptions.
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
name: next-chair-blake-heritage
|
||||
|
||||
# One business's app container on the shared multi-business VPS setup —
|
||||
# see stacks/infra/compose.yaml for the shared MySQL/Caddy/Dockge this
|
||||
# attaches to, and that file's header for the exact one-time setup steps
|
||||
# (create this business's database, copy .env.business.example, add a
|
||||
# Caddy site file) needed before this file can start.
|
||||
#
|
||||
# docker compose -f stacks/blake-heritage/compose.yaml --env-file stacks/blake-heritage/.env up -d
|
||||
#
|
||||
# `stacks/marios/compose.yaml` is the identical shape for the second
|
||||
# business — this is also the shape the management UI (manager/)
|
||||
# generates for every new business it provisions.
|
||||
#
|
||||
# Uses the shared prebuilt image (see the repo root's README/Dockerfile —
|
||||
# `docker build -t nextchair-app:latest .` once per code update) rather
|
||||
# than building from source per business, so adding or updating a
|
||||
# business is just starting/recreating a container, not a rebuild.
|
||||
|
||||
services:
|
||||
next-chair-blake-heritage:
|
||||
image: nextchair-app:latest
|
||||
container_name: next-chair-blake-heritage
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- next-chair-media-blake-heritage:/app/storage/media
|
||||
healthcheck:
|
||||
# No curl/wget in the node:22-alpine base image — a plain Node
|
||||
# request avoids adding a package just for this. See
|
||||
# src/app/api/health/route.ts: healthy as soon as the database is
|
||||
# reachable, even before installation completes.
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
volumes:
|
||||
next-chair-media-blake-heritage:
|
||||
name: next-chair-media-blake-heritage
|
||||
|
||||
networks:
|
||||
next-chair-edge:
|
||||
name: next-chair-edge
|
||||
external: true
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
# Let's Encrypt sends renewal/expiry notices here.
|
||||
email {$ACME_EMAIL}
|
||||
}
|
||||
|
||||
# One file per business in sites/ (see sites/.gitkeep) — Caddy doesn't
|
||||
# watch this directory for changes on its own, so adding/removing a file
|
||||
# needs `docker exec next-chair-caddy caddy reload --config
|
||||
# /etc/caddy/Caddyfile` (see stacks/infra/compose.yaml's header) to take
|
||||
# effect. The management UI (manager/) automates writing these files and
|
||||
# reloading.
|
||||
import sites/*.caddy
|
||||
@@ -0,0 +1,136 @@
|
||||
name: next-chair-infra
|
||||
|
||||
# Shared infrastructure for the multi-business VPS deployment: Caddy
|
||||
# (reverse proxy + automatic TLS via Let's Encrypt), one MySQL server
|
||||
# shared by every business (a separate database + user per business, NOT
|
||||
# one shared schema — see step 1 below), and Dockge (a web UI for
|
||||
# starting/stopping/inspecting these compose stacks by hand).
|
||||
#
|
||||
# Lives in stacks/infra/ so Dockge (which expects one directory per
|
||||
# stack, each with its own compose.yaml) can manage this stack too —
|
||||
# see stacks/blake-heritage/, stacks/marios/ for the same shape per
|
||||
# business. Deliberately separate from docker-compose.local.yaml at the
|
||||
# repo root, which is the single-business local/Docker-Desktop setup and
|
||||
# stays untouched by any of this.
|
||||
#
|
||||
# ── One-time setup ───────────────────────────────────────────────────────
|
||||
# docker network create next-chair-edge
|
||||
# cp .env.infra.example stacks/infra/.env (then fill in real values —
|
||||
# in particular STACKS_DIR must be this repo's absolute path on the
|
||||
# VPS, e.g. /opt/nextchair/stacks — see the STACKS_DIR comment in that
|
||||
# file for why it can't just be a relative path here)
|
||||
# docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
|
||||
#
|
||||
# ── Per business ──────────────────────────────────────────────────────
|
||||
# Use the management UI (manager/) once it's running — it automates all
|
||||
# of the steps below. To do it by hand instead:
|
||||
# 1. Create that business's database + user on the shared MySQL server.
|
||||
# There's no automatic way to do this per business via env vars —
|
||||
# the mysql:8.0 image's MYSQL_DATABASE/USER/PASSWORD vars only
|
||||
# provision ONE database at first boot, and /docker-entrypoint-
|
||||
# initdb.d scripts only ever run once against an empty data
|
||||
# directory — so this one step is manual, run once per business:
|
||||
# docker exec -it next-chair-mysql mysql -uroot -p
|
||||
# CREATE DATABASE blake_heritage CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
# CREATE USER 'blake_heritage'@'%' IDENTIFIED BY 'choose-a-strong-password';
|
||||
# GRANT ALL PRIVILEGES ON blake_heritage.* TO 'blake_heritage'@'%';
|
||||
# FLUSH PRIVILEGES;
|
||||
# 2. Copy .env.business.example to stacks/<slug>/.env, fill it in with
|
||||
# the credentials just created and that business's domain.
|
||||
# 3. Add stacks/infra/sites/<slug>.caddy (see that directory's own
|
||||
# note) and reload Caddy:
|
||||
# docker exec next-chair-caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
# 4. docker compose -f stacks/<slug>/compose.yaml --env-file stacks/<slug>/.env up -d
|
||||
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: next-chair-caddy
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
# Overridable for local testing (a machine already running
|
||||
# something else on 80/443) — a real deployment leaves these at
|
||||
# their defaults, since Let's Encrypt's HTTP challenge and normal
|
||||
# HTTPS traffic both expect the standard ports.
|
||||
- "${CADDY_HTTP_PORT:-80}:80"
|
||||
- "${CADDY_HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
# One .caddy file per business, imported by the Caddyfile — see
|
||||
# sites/.gitkeep's own note. Adding/removing a file here needs a
|
||||
# `caddy reload` (see the per-business setup steps above) to take
|
||||
# effect; Caddy doesn't watch this directory on its own.
|
||||
- ./sites:/etc/caddy/sites
|
||||
# Caddy's own automatic-HTTPS certificate storage — must persist
|
||||
# across restarts or every restart re-issues every certificate
|
||||
# against Let's Encrypt's rate limits.
|
||||
- next-chair-caddy-data:/data
|
||||
- next-chair-caddy-config:/config
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
next-chair-mysql:
|
||||
image: mysql:8.0
|
||||
container_name: next-chair-mysql
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
# No MYSQL_DATABASE/MYSQL_USER/MYSQL_PASSWORD here deliberately — this
|
||||
# server hosts one database per business (see step 1 above), not one
|
||||
# shared schema. Only MYSQL_ROOT_PASSWORD (in .env) is required for
|
||||
# the image to boot.
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
|
||||
ports:
|
||||
# Host-machine access (e.g. a GUI client over an SSH tunnel) — every
|
||||
# business's app container reaches this over the shared Docker
|
||||
# network via the service name `next-chair-mysql`, never this.
|
||||
- "${MYSQL_HOST_PORT:-3306}:3306"
|
||||
volumes:
|
||||
- next-chair-mysql-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-change-me-too}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
dockge:
|
||||
image: louislam/dockge:1
|
||||
container_name: next-chair-dockge
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DOCKGE_STACKS_DIR: "${STACKS_DIR}"
|
||||
ports:
|
||||
- "${DOCKGE_HOST_PORT:-5001}:5001"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- next-chair-dockge-data:/app/data
|
||||
# MUST be mounted at the identical absolute path on both host and
|
||||
# container — see the STACKS_DIR comment in .env.infra.example for
|
||||
# why (Dockge shells `docker compose` out to the HOST's dockerd via
|
||||
# the socket above, so a compose-file path only means the same
|
||||
# thing on both sides if the bind-mount doesn't change it).
|
||||
- "${STACKS_DIR}:${STACKS_DIR}"
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
volumes:
|
||||
next-chair-mysql-data:
|
||||
name: next-chair-mysql-data
|
||||
next-chair-caddy-data:
|
||||
name: next-chair-caddy-data
|
||||
next-chair-caddy-config:
|
||||
name: next-chair-caddy-config
|
||||
next-chair-dockge-data:
|
||||
name: next-chair-dockge-data
|
||||
|
||||
networks:
|
||||
next-chair-edge:
|
||||
name: next-chair-edge
|
||||
external: true
|
||||
@@ -0,0 +1,13 @@
|
||||
Keeps this otherwise-empty directory tracked in git.
|
||||
|
||||
Each business gets its own <slug>.caddy file here, e.g.:
|
||||
|
||||
blake-heritage.example.com {
|
||||
reverse_proxy next-chair-blake-heritage:3000
|
||||
}
|
||||
|
||||
Generated by the management UI (manager/) when a business is added, or
|
||||
written by hand for the by-hand setup steps in stacks/infra/compose.yaml's
|
||||
header comment. Real *.caddy files are gitignored (deployment-specific,
|
||||
not something to hand-author into the repo) — only this placeholder is
|
||||
tracked.
|
||||
@@ -0,0 +1,39 @@
|
||||
name: next-chair-manager
|
||||
|
||||
# The management UI (see manager/ at the repo root — its own README
|
||||
# explains why it's a fully separate app). Its own stack, separate from
|
||||
# stacks/infra/compose.yaml, since this is real application code that
|
||||
# gets rebuilt/updated far more often than the off-the-shelf infra
|
||||
# images (MySQL, Caddy, Dockge) — a code change here shouldn't require
|
||||
# touching the infra stack at all.
|
||||
#
|
||||
# cp manager/.env.example stacks/manager/.env (then fill in real values)
|
||||
# docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
|
||||
#
|
||||
# Requires stacks/infra/compose.yaml to already be running (shared MySQL,
|
||||
# Caddy, network). Add a Caddy site for it the same way as any business —
|
||||
# see stacks/infra/sites/.gitkeep — pointed at `next-chair-manager:3000`.
|
||||
|
||||
services:
|
||||
manager:
|
||||
build:
|
||||
context: ../../manager
|
||||
dockerfile: Dockerfile
|
||||
container_name: next-chair-manager
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# MUST be mounted at the identical absolute path on both host and
|
||||
# container — see manager/.env.example's STACKS_DIR comment. This
|
||||
# is how the manager writes new business stacks and shells `docker
|
||||
# compose` against them via the socket above.
|
||||
- "${STACKS_DIR}:${STACKS_DIR}"
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
networks:
|
||||
next-chair-edge:
|
||||
name: next-chair-edge
|
||||
external: true
|
||||
@@ -0,0 +1,55 @@
|
||||
name: next-chair-marios
|
||||
|
||||
# One business's app container on the shared multi-business VPS setup —
|
||||
# see stacks/infra/compose.yaml for the shared MySQL/Caddy/Dockge this
|
||||
# attaches to, and that file's header for the exact one-time setup steps
|
||||
# (create this business's database, copy .env.business.example, add a
|
||||
# Caddy site file) needed before this file can start.
|
||||
#
|
||||
# docker compose -f stacks/marios/compose.yaml --env-file stacks/marios/.env up -d
|
||||
#
|
||||
# `stacks/blake-heritage/compose.yaml` is the identical shape for the
|
||||
# first business — this is also the shape the management UI (manager/)
|
||||
# generates for every new business it provisions.
|
||||
#
|
||||
# Uses the shared prebuilt image (see the repo root's README/Dockerfile —
|
||||
# `docker build -t nextchair-app:latest .` once per code update) rather
|
||||
# than building from source per business, so adding or updating a
|
||||
# business is just starting/recreating a container, not a rebuild.
|
||||
|
||||
services:
|
||||
next-chair-marios:
|
||||
image: nextchair-app:latest
|
||||
container_name: next-chair-marios
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- next-chair-media-marios:/app/storage/media
|
||||
healthcheck:
|
||||
# No curl/wget in the node:22-alpine base image — a plain Node
|
||||
# request avoids adding a package just for this. See
|
||||
# src/app/api/health/route.ts: healthy as soon as the database is
|
||||
# reachable, even before installation completes.
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
volumes:
|
||||
next-chair-media-marios:
|
||||
name: next-chair-media-marios
|
||||
|
||||
networks:
|
||||
next-chair-edge:
|
||||
name: next-chair-edge
|
||||
external: true
|
||||
+2
-1
@@ -44,6 +44,7 @@
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"templates"
|
||||
"templates",
|
||||
"manager"
|
||||
]
|
||||
}
|
||||
+6
-1
@@ -9,6 +9,11 @@ export default defineConfig({
|
||||
// (real Postgres required) are named `*.integration.test.ts` and only
|
||||
// run via `npm run test:integration` — see docs/phase-3-summary.md.
|
||||
// e2e/ is Playwright's own suite (`npm run test:e2e`), not Vitest's.
|
||||
exclude: ["node_modules/**", ".next/**", "templates/**", "e2e/**", "**/*.integration.test.ts"],
|
||||
// "**/node_modules/**" (not "node_modules/**") is deliberate — the
|
||||
// latter only matches a top-level node_modules, not a nested one like
|
||||
// manager/node_modules, whose own dependencies (e.g. zod) ship test
|
||||
// files that would otherwise get picked up and fail for missing
|
||||
// dev-only deps that were never meant to run here at all.
|
||||
exclude: ["**/node_modules/**", ".next/**", "templates/**", "e2e/**", "manager/**", "**/*.integration.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -6,7 +6,10 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
globals: false,
|
||||
include: ["**/*.integration.test.ts"],
|
||||
exclude: ["node_modules/**", ".next/**", "templates/**"],
|
||||
// "**/node_modules/**" (not "node_modules/**") — see vitest.config.mts's
|
||||
// own comment for why this matters once a nested node_modules
|
||||
// (manager/node_modules) exists in the repo.
|
||||
exclude: ["**/node_modules/**", ".next/**", "templates/**", "manager/**"],
|
||||
// Concurrency tests (double-booking prevention) need real, unshared
|
||||
// DB connections and predictable ordering — run integration test
|
||||
// files one at a time.
|
||||
|
||||
Reference in new issue
Block a user