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
|
generated
|
||||||
.git
|
.git
|
||||||
.gitea
|
.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.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|||||||
+14
-15
@@ -1,10 +1,11 @@
|
|||||||
# One business's config on the shared multi-business VPS setup — see
|
# 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
|
# (creating this business's database on the shared MySQL server) and
|
||||||
# docker-compose.<business>.yaml for how this file is used. Copy this to
|
# stacks/<slug>/compose.yaml for how this file is used. Copy this to
|
||||||
# `.env.business.<slug>` (e.g. `.env.business.blake-heritage` — kept out
|
# `stacks/<slug>/.env` (e.g. `stacks/blake-heritage/.env` — kept out of
|
||||||
# of git, see .gitignore) per business and fill in real values. Nothing
|
# git, see .gitignore) per business and fill in real values. Nothing here
|
||||||
# here is a real credential.
|
# 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
|
# The app starts and runs a complete first-install flow with ONLY the
|
||||||
# "Required for basic startup" section below filled in — every other
|
# "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.
|
# disabled state rather than crashing when its variables are left blank.
|
||||||
|
|
||||||
# ── Required for basic startup ─────────────────────────────────────────
|
# ── Required for basic startup ─────────────────────────────────────────
|
||||||
# The public domain this business is served on — used by Traefik's
|
# The public domain this business is served on — must match the .caddy
|
||||||
# routing label (see docker-compose.<business>.yaml) to decide which
|
# site file's own domain (see stacks/infra/sites/.gitkeep) and that
|
||||||
# container handles requests for it, so this must be set and must match
|
# domain's real DNS (pointed at the VPS) before Caddy can issue a
|
||||||
# the domain's real DNS (pointed at the VPS) before Traefik can issue a
|
|
||||||
# certificate for it.
|
# certificate for it.
|
||||||
DOMAIN="blake-heritage.example.com"
|
DOMAIN="blake-heritage.example.com"
|
||||||
|
|
||||||
# The app reaches MySQL via the shared infra's service hostname
|
# 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
|
# 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
|
# "CREATE DATABASE ..." step) — this file doesn't create them, it only
|
||||||
# tells the app what to connect as.
|
# tells the app what to connect as.
|
||||||
DATABASE_HOST="next-chair-mysql"
|
DATABASE_HOST="next-chair-mysql"
|
||||||
@@ -44,10 +44,9 @@ INSTALLATION_TOKEN="replace-me"
|
|||||||
CRON_SECRET="replace-me"
|
CRON_SECRET="replace-me"
|
||||||
|
|
||||||
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
||||||
# Platform-level account with every Owner permission. Not run
|
# Platform-level account with every Owner permission for THIS business.
|
||||||
# automatically by this compose file (that's a Docker-entrypoint-only
|
# Not run automatically by this compose file (that's a Docker-entrypoint-
|
||||||
# behaviour, see docker/entrypoint.sh) — run it manually, once, the same
|
# only behaviour, see docker/entrypoint.sh) — run it manually, once:
|
||||||
# way migrations were run for the Vercel/Hostinger deployment earlier:
|
|
||||||
# docker exec -it next-chair-blake-heritage npx tsx scripts/ensure-super-admin.ts
|
# 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
|
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install
|
||||||
# wizard's own administrator account instead.
|
# wizard's own administrator account instead.
|
||||||
|
|||||||
+22
-6
@@ -1,12 +1,12 @@
|
|||||||
# Shared infrastructure config — see docker-compose.infra.yaml for the
|
# Shared infrastructure config — see stacks/infra/compose.yaml for the
|
||||||
# full setup sequence. Copy this to `.env.infra` (kept out of git — see
|
# full setup sequence. Copy this to `stacks/infra/.env` (kept out of git
|
||||||
# .gitignore) and fill in real values.
|
# — see .gitignore) and fill in real values.
|
||||||
#
|
#
|
||||||
# docker network create next-chair-edge
|
# 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
|
# ── 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"
|
MYSQL_ROOT_PASSWORD="replace-me"
|
||||||
# Host-machine port MySQL is reachable on, e.g. for a GUI client over an
|
# 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
|
# SSH tunnel — every business's app container always reaches it via the
|
||||||
@@ -14,7 +14,23 @@ MYSQL_ROOT_PASSWORD="replace-me"
|
|||||||
# this.
|
# this.
|
||||||
MYSQL_HOST_PORT=3306
|
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
|
# Let's Encrypt sends renewal/expiry notices here — use a real address
|
||||||
# you actually check, not a placeholder.
|
# you actually check, not a placeholder.
|
||||||
ACME_EMAIL="you@example.com"
|
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
|
.env.production
|
||||||
|
|
||||||
# per-deployment production env files (multi-business VPS setup — see
|
# per-deployment production env files (multi-business VPS setup — see
|
||||||
# docker-compose.infra.yaml / docker-compose.<business>.yaml). Named
|
# stacks/infra/compose.yaml / stacks/<business>/compose.yaml). Every real
|
||||||
# .env.business.* (not .env.<business> directly) specifically so this one
|
# stack's .env lives at stacks/<name>/.env (Dockge's own directory
|
||||||
# wildcard rule keeps covering every future business without another
|
# convention) — this one wildcard rule covers every current and future
|
||||||
# .gitignore edit. The template itself (.env.business.example) stays
|
# stack without another .gitignore edit. .env.infra.example and
|
||||||
# tracked, same as .env.local.example above.
|
# .env.business.example (the templates you copy into each stacks/<name>/
|
||||||
.env.infra
|
# directory) stay tracked, same as .env.local.example above.
|
||||||
.env.business.*
|
stacks/*/.env
|
||||||
!.env.business.example
|
|
||||||
|
# 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
|
||||||
prisma/dev.db
|
prisma/dev.db
|
||||||
@@ -57,10 +62,6 @@ yarn-error.log*
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
/generated/prisma
|
/generated/prisma
|
||||||
.env.example
|
|
||||||
.env.local.example
|
|
||||||
.env.example
|
|
||||||
.env.example
|
|
||||||
|
|
||||||
.agents/
|
.agents/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -1,3 +1,51 @@
|
|||||||
# NextChair
|
# 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[]} */
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
{ ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**"] },
|
{ ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**", "manager/**"] },
|
||||||
...nextCoreWebVitals,
|
...nextCoreWebVitals,
|
||||||
...nextTypescript,
|
...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": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"templates"
|
"templates",
|
||||||
|
"manager"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+6
-1
@@ -9,6 +9,11 @@ export default defineConfig({
|
|||||||
// (real Postgres required) are named `*.integration.test.ts` and only
|
// (real Postgres required) are named `*.integration.test.ts` and only
|
||||||
// run via `npm run test:integration` — see docs/phase-3-summary.md.
|
// 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.
|
// 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",
|
environment: "node",
|
||||||
globals: false,
|
globals: false,
|
||||||
include: ["**/*.integration.test.ts"],
|
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
|
// Concurrency tests (double-booking prevention) need real, unshared
|
||||||
// DB connections and predictable ordering — run integration test
|
// DB connections and predictable ordering — run integration test
|
||||||
// files one at a time.
|
// files one at a time.
|
||||||
|
|||||||
Reference in new issue
Block a user