chore: initial scaffold with running local stack and portal auth
Brings up Dezky's local development environment end-to-end: Infrastructure (docker-compose): - Traefik v3.7 reverse proxy with mkcert TLS (v3.2 couldn't speak Docker API 1.54) - Postgres + Mongo + Redis with healthchecks and init script for per-service users - Authentik 2025.10 (server + worker) as OIDC IdP - Stalwart v0.16 mail server (image renamed from stalwartlabs/mail-server) - OCIS 7.0 with PROXY_TLS=false and OCIS_CONFIG_DIR=/etc/ocis so init writes where the server reads - Collabora office, plus the portal + provisioning service stubs - Docker network aliases on Traefik so containers resolve auth.dezky.local etc. through the network (not host /etc/hosts) - Docker socket mount parameterized for macOS Docker Desktop symlink path Authentik provisioning (done via API after stack boot): - ocis-provider (public client) + OCIS Files application - dezky-portal provider (confidential) + Dezky Portal application - Admin API token bound to akadmin manually since 2025.10's AUTHENTIK_BOOTSTRAP_TOKEN env var doesn't auto-materialize a token row Portal (apps/portal): - Nuxt 3 with nuxt-oidc-auth 1.0.0-beta.11 against generic 'oidc' preset - Global auth middleware; login at /auth/oidc/login redirects to Authentik - Visual implementation of Claude Design 'Auth' canvas: AuthShell, NodeMark, Auth* sub-components, design tokens as CSS custom properties - Pages: auth/login, auth/expired, auth/disabled, index (post-login landing) - mkcert root CA mounted into the portal so Node fetch trusts Authentik's self-signed cert (NODE_EXTRA_CA_CERTS) — dev only Docs: - AUTHENTIK-SETUP.md updated with manual token bind + portal provider scripted alternative - NEXT-STEPS.md: Phase 1 and Phase 2 marked done with file locations and dev-mode caveats Dev-mode shortcuts that need to be revisited before prod: - skipAccessTokenParsing on the OIDC config - NODE_EXTRA_CA_CERTS mkcert mount - Bootstrap password still the generated value in .env - Authentik admin token (dezky-bootstrap-token) is non-expiring
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Dezky Local Development — Environment Variables
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Copy this file to .env and fill in the values.
|
||||||
|
# Generate secure random values with: openssl rand -hex 32
|
||||||
|
#
|
||||||
|
# DO NOT commit .env to git.
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Database root passwords
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
POSTGRES_ROOT_PASSWORD=changeme_use_openssl_rand
|
||||||
|
MONGO_ROOT_PASSWORD=changeme_use_openssl_rand
|
||||||
|
REDIS_PASSWORD=changeme_use_openssl_rand
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Per-service DB passwords
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
AUTHENTIK_DB_PASSWORD=changeme_use_openssl_rand
|
||||||
|
OCIS_DB_PASSWORD=changeme_use_openssl_rand
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Authentik
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# AUTHENTIK_SECRET_KEY must be 50+ chars
|
||||||
|
AUTHENTIK_SECRET_KEY=changeme_run_openssl_rand_hex_50
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD=admin_change_this_after_first_login
|
||||||
|
# AUTHENTIK_BOOTSTRAP_TOKEN is used by the provisioning service to call Authentik API
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN=changeme_use_openssl_rand_hex_32
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Stalwart Mail
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
STALWART_ADMIN_PASSWORD=changeme_use_openssl_rand
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# OCIS
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
OCIS_ADMIN_PASSWORD=changeme_use_openssl_rand
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Collabora
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
COLLABORA_ADMIN_PASSWORD=changeme_use_openssl_rand
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# TLS certificates (mkcert generated)
|
||||||
|
infrastructure/docker-compose/certs/*.pem
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
dist/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
.nitro/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Docker volumes data (when bind-mounted)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Temporary
|
||||||
|
tmp/
|
||||||
|
.tmp/
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Dezky — Local Development Environment Setup
|
||||||
|
|
||||||
|
> **For Claude Code:** This is a complete handover document for spinning up the Dezky platform locally for development. Everything you need to know is here.
|
||||||
|
|
||||||
|
## Mission for this session
|
||||||
|
|
||||||
|
The user (Ronni) has already:
|
||||||
|
- Selected brand: **Dezky** (dezky.com pending purchase)
|
||||||
|
- Run `mkcert` on macOS to generate local TLS certificates
|
||||||
|
- Decided on production target: Hetzner AX41-NVMe + Object Storage + Storage Box
|
||||||
|
- Defined the full stack (see Stack section)
|
||||||
|
|
||||||
|
Your job: Get the entire Dezky platform running locally via Docker Compose so Ronni can develop against it. The user has solid technical fluency (NestJS, Nuxt, MongoDB, Docker, Kubernetes) — skip basic explanations, but be thorough on Dezky-specific configuration.
|
||||||
|
|
||||||
|
## Stack — what we're spinning up
|
||||||
|
|
||||||
|
All components are Apache 2.0 / MIT licensed for clean commercial multi-tenant hosting.
|
||||||
|
|
||||||
|
| Service | Image | Purpose | Local URL |
|
||||||
|
|---------|-------|---------|-----------|
|
||||||
|
| Traefik | `traefik:v3.2` | Reverse proxy + TLS termination | `https://traefik.dezky.local:8443` |
|
||||||
|
| PostgreSQL | `postgres:16-alpine` | Shared RDBMS for Authentik, OCIS | (internal) |
|
||||||
|
| MongoDB | `mongo:7` | Portal application data | (internal) |
|
||||||
|
| Redis | `redis:7-alpine` | Cache + session store | (internal) |
|
||||||
|
| Authentik | `ghcr.io/goauthentik/server:2025.10` | Identity provider, OIDC/SAML | `https://auth.dezky.local` |
|
||||||
|
| Stalwart Mail | `stalwartlabs/mail-server:latest` | Mail (SMTP/IMAP/JMAP/CalDAV) | `https://mail.dezky.local` |
|
||||||
|
| OCIS | `owncloud/ocis:7.0` | File storage (S3-compatible backend) | `https://files.dezky.local` |
|
||||||
|
| Collabora | `collabora/code:latest` | Office document editor inside OCIS | `https://office.dezky.local` |
|
||||||
|
| Portal stub | (built from `./apps/portal`) | Nuxt 3 customer portal | `https://app.dezky.local` |
|
||||||
|
| Provisioning | (built from `./services/provisioning`) | NestJS provisioning worker | (internal, port 3001) |
|
||||||
|
|
||||||
|
**NOT included in this dev setup** (added in later phases):
|
||||||
|
- Jitsi Meet (4-5 sub-containers — see `docker-compose.optional.yml` when ready)
|
||||||
|
- Zulip (resource-heavy — added separately when chat features are needed)
|
||||||
|
|
||||||
|
## Critical setup invariants
|
||||||
|
|
||||||
|
1. **All code in English** — variable names, comments, schema fields, routes, function names. UI strings may be Danish for end users only.
|
||||||
|
2. **TypeScript everywhere** in apps and services.
|
||||||
|
3. **mkcert TLS certs** — the user has already run `mkcert -install` and generated wildcard certs. They will be placed in `infrastructure/docker-compose/certs/`.
|
||||||
|
4. **All hostnames use `.dezky.local`** in development. `/etc/hosts` entries required (see `scripts/setup-hosts.sh`).
|
||||||
|
5. **PostgreSQL is shared** among Authentik and OCIS. Each gets its own database and user (see `configs/postgres/init.sql`).
|
||||||
|
6. **MongoDB is dedicated** to the portal application data (matches user's existing stack pattern: Målerportal, TurtleLootLine).
|
||||||
|
7. **Secrets are .env-based** for dev — production will use SOPS/sealed-secrets in k3s.
|
||||||
|
|
||||||
|
## Expected user environment
|
||||||
|
|
||||||
|
- macOS (uses `brew` for installs)
|
||||||
|
- Docker Desktop or OrbStack running
|
||||||
|
- mkcert installed and root CA trusted
|
||||||
|
- 16+ GB RAM (stack uses ~10 GB at peak)
|
||||||
|
- `pnpm` for Node workspaces
|
||||||
|
- `git` for version control
|
||||||
|
|
||||||
|
If anything is missing, instruct the user with the exact `brew install` command.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dezky/
|
||||||
|
├── CLAUDE.md # This file
|
||||||
|
├── README.md # Human-facing setup guide
|
||||||
|
├── .env.example # All required env vars
|
||||||
|
├── .gitignore
|
||||||
|
├── apps/
|
||||||
|
│ └── portal/ # Nuxt 3 portal (stub for now)
|
||||||
|
├── services/
|
||||||
|
│ └── provisioning/ # NestJS worker (stub for now)
|
||||||
|
├── packages/ # Shared TypeScript packages (empty for now)
|
||||||
|
├── infrastructure/
|
||||||
|
│ └── docker-compose/
|
||||||
|
│ ├── docker-compose.yml # Main stack
|
||||||
|
│ ├── docker-compose.optional.yml # Jitsi + Zulip (later)
|
||||||
|
│ ├── certs/ # mkcert TLS certs go here
|
||||||
|
│ │ ├── dezky.local.pem # User places mkcert output here
|
||||||
|
│ │ └── dezky.local-key.pem
|
||||||
|
│ └── configs/
|
||||||
|
│ ├── traefik/
|
||||||
|
│ │ ├── traefik.yml # Static config
|
||||||
|
│ │ └── dynamic.yml # TLS + middleware
|
||||||
|
│ ├── stalwart/
|
||||||
|
│ │ └── config.toml # Mail server config
|
||||||
|
│ ├── postgres/
|
||||||
|
│ │ └── init.sql # Create DBs/users
|
||||||
|
│ ├── authentik/
|
||||||
|
│ │ └── blueprints/ # (Optional) Pre-configured flows
|
||||||
|
│ └── ocis/
|
||||||
|
│ └── (config files mounted at runtime)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── bootstrap.sh # One-command setup
|
||||||
|
│ ├── setup-hosts.sh # /etc/hosts entries
|
||||||
|
│ ├── setup-certs.sh # Copy mkcert certs to right place
|
||||||
|
│ └── reset.sh # Nuke and start fresh
|
||||||
|
└── docs/
|
||||||
|
├── SERVICES.md # Per-service reference
|
||||||
|
├── AUTHENTIK-SETUP.md # First-time Authentik walkthrough
|
||||||
|
├── TROUBLESHOOTING.md # Common issues
|
||||||
|
└── NEXT-STEPS.md # What to do after setup works
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sequence for Claude Code to execute
|
||||||
|
|
||||||
|
When the user runs `claude` in the project directory, walk through:
|
||||||
|
|
||||||
|
### Phase 1: Verify environment (5 min)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check that required tools are installed
|
||||||
|
docker --version # Need 24.0+
|
||||||
|
docker compose version # Need v2
|
||||||
|
mkcert -version # Need 1.4+
|
||||||
|
pnpm --version # Need 9+
|
||||||
|
node --version # Need 20+
|
||||||
|
```
|
||||||
|
|
||||||
|
If anything is missing, halt and instruct user with brew install commands.
|
||||||
|
|
||||||
|
### Phase 2: Cert setup (2 min)
|
||||||
|
|
||||||
|
The user has already run `mkcert -install`. We need to:
|
||||||
|
|
||||||
|
1. Check if `infrastructure/docker-compose/certs/dezky.local.pem` exists
|
||||||
|
2. If not, run mkcert for the wildcard domain:
|
||||||
|
```bash
|
||||||
|
cd infrastructure/docker-compose/certs
|
||||||
|
mkcert "*.dezky.local" "dezky.local" "localhost" "127.0.0.1" "::1"
|
||||||
|
mv ./_wildcard.dezky.local+4.pem dezky.local.pem
|
||||||
|
mv ./_wildcard.dezky.local+4-key.pem dezky.local-key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: DNS setup (2 min)
|
||||||
|
|
||||||
|
Run `scripts/setup-hosts.sh` which adds these to `/etc/hosts` (requires sudo):
|
||||||
|
|
||||||
|
```
|
||||||
|
127.0.0.1 dezky.local app.dezky.local auth.dezky.local
|
||||||
|
127.0.0.1 mail.dezky.local files.dezky.local meet.dezky.local
|
||||||
|
127.0.0.1 chat.dezky.local office.dezky.local
|
||||||
|
127.0.0.1 traefik.dezky.local
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Environment variables (2 min)
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and generate secure random values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Generate 64-char hex for each *_SECRET / *_KEY
|
||||||
|
openssl rand -hex 32 # run multiple times, paste into .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5: First boot (10 min)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infrastructure/docker-compose
|
||||||
|
docker compose pull # Get all images first
|
||||||
|
docker compose up -d postgres redis mongo
|
||||||
|
# Wait for healthchecks
|
||||||
|
docker compose logs -f postgres
|
||||||
|
# When ready, start identity layer
|
||||||
|
docker compose up -d authentik-server authentik-worker
|
||||||
|
# Wait for Authentik to be ready
|
||||||
|
docker compose logs -f authentik-server
|
||||||
|
# Then start everything else
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 6: Authentik initial setup (15 min)
|
||||||
|
|
||||||
|
Open `https://auth.dezky.local` and walk through:
|
||||||
|
1. Create admin user (akadmin)
|
||||||
|
2. Configure OIDC providers for: ocis, portal, stalwart
|
||||||
|
3. Test SSO end-to-end
|
||||||
|
|
||||||
|
See `docs/AUTHENTIK-SETUP.md` for the exact steps.
|
||||||
|
|
||||||
|
### Phase 7: Verify it all works (5 min)
|
||||||
|
|
||||||
|
- `https://app.dezky.local` — Portal landing page should load
|
||||||
|
- `https://auth.dezky.local` — Authentik login screen
|
||||||
|
- `https://files.dezky.local` — OCIS should redirect to Authentik for SSO
|
||||||
|
- `https://mail.dezky.local` — Stalwart admin UI
|
||||||
|
|
||||||
|
## Important context for working on this project
|
||||||
|
|
||||||
|
### Stack rationale (DO NOT suggest replacing components)
|
||||||
|
|
||||||
|
These choices were made deliberately after extensive license/architecture research:
|
||||||
|
|
||||||
|
- **Stalwart over Mailcow**: Modern Rust, ActiveSync built-in, JMAP support, single binary
|
||||||
|
- **OCIS over Nextcloud**: Apache 2.0 vs AGPL+trademark fees for whitelabel
|
||||||
|
- **Zulip over Element/Mattermost/Rocket.Chat**: Only truly open-core-free chat option
|
||||||
|
- **Authentik over Keycloak**: Better multi-tenancy, MIT license, simpler config
|
||||||
|
- **Hetzner over AWS/GCP**: 100% EU sovereignty pitch — this is core to the business
|
||||||
|
|
||||||
|
### Code conventions
|
||||||
|
|
||||||
|
- **TypeScript strict mode** in apps/services
|
||||||
|
- **English only** in all code identifiers, comments, schema fields
|
||||||
|
- **Conventional Commits** for git messages: `feat:`, `fix:`, `chore:`, `docs:`
|
||||||
|
- **Prefer prose comments** over heavy JSDoc — explain *why*, not *what*
|
||||||
|
- **MongoDB** for portal app data (consistent with Målerportal, TurtleLootLine)
|
||||||
|
- **PostgreSQL** for services that require it (Authentik, OCIS)
|
||||||
|
|
||||||
|
### Production target (for reference, not deploy now)
|
||||||
|
|
||||||
|
Eventually moves to single Hetzner AX41-NVMe (€39/mo) with:
|
||||||
|
- Stalwart on bare-metal (not Docker)
|
||||||
|
- k3s for all other services
|
||||||
|
- Hetzner Object Storage (€5/mo) for OCIS S3 backend
|
||||||
|
- Storage Box BX11 (€3.20/mo) for Restic backups
|
||||||
|
- Storage Box BX11 in Helsinki (€3.20/mo) for DR
|
||||||
|
|
||||||
|
But locally, everything runs in Docker Compose with mkcert TLS.
|
||||||
|
|
||||||
|
## What to do if stuck
|
||||||
|
|
||||||
|
- **TLS not working**: Verify `mkcert -CAROOT` matches what Docker has access to. On macOS, the root CA must be trusted in the system keychain.
|
||||||
|
- **Authentik won't start**: PostgreSQL needs to be fully ready first. Check `docker compose logs postgres` for `database system is ready to accept connections`.
|
||||||
|
- **OCIS OIDC fails**: Authentik issuer URL must be reachable from inside the OCIS container. Use the Docker network hostname, not the public URL.
|
||||||
|
- **Stalwart port 25 conflicts**: macOS may have postfix running. Disable with `sudo launchctl unload /System/Library/LaunchDaemons/org.postfix.master.plist`.
|
||||||
|
- **Cert errors in browser**: Make sure `mkcert -install` has been run AND browser has been restarted.
|
||||||
|
|
||||||
|
See `docs/TROUBLESHOOTING.md` for detailed solutions.
|
||||||
|
|
||||||
|
## After local dev works
|
||||||
|
|
||||||
|
1. Build out the Nuxt portal (`apps/portal`) — start with auth flow via Authentik OIDC
|
||||||
|
2. Build the provisioning service (`services/provisioning`) — first endpoint: create tenant
|
||||||
|
3. Wire portal → provisioning → Authentik/OCIS/Stalwart admin APIs
|
||||||
|
4. Add Zulip + Jitsi when ready (`docker-compose.optional.yml`)
|
||||||
|
5. When portal MVP is solid → migrate to Hetzner AX41 production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last updated:** 2026-05-23
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Dezky
|
||||||
|
|
||||||
|
> Sovereign workspace platform for European businesses.
|
||||||
|
> Mail, files, calendar, video meetings — all EU-hosted, all open source.
|
||||||
|
|
||||||
|
## Quick start (local development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and enter
|
||||||
|
git clone <repo-url> dezky
|
||||||
|
cd dezky
|
||||||
|
|
||||||
|
# 2. Run bootstrap (handles everything)
|
||||||
|
./scripts/bootstrap.sh
|
||||||
|
|
||||||
|
# 3. Open the portal
|
||||||
|
open https://app.dezky.local
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap script:
|
||||||
|
- Checks prerequisites (Docker, mkcert, openssl)
|
||||||
|
- Generates wildcard TLS certificate via mkcert
|
||||||
|
- Adds /etc/hosts entries (with your permission)
|
||||||
|
- Generates secure random secrets in `.env`
|
||||||
|
- Pulls Docker images
|
||||||
|
- Starts all services in correct order
|
||||||
|
- Prints next-step instructions
|
||||||
|
|
||||||
|
## Service URLs (local development)
|
||||||
|
|
||||||
|
| Service | URL | Purpose |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| Portal | https://app.dezky.local | Customer-facing landing & launcher |
|
||||||
|
| Authentik | https://auth.dezky.local | Identity provider (OIDC/SAML) |
|
||||||
|
| Files (OCIS) | https://files.dezky.local | File storage & sharing |
|
||||||
|
| Mail (Stalwart) | https://mail.dezky.local | Mail server admin UI |
|
||||||
|
| Office | https://office.dezky.local | Collabora Online editor |
|
||||||
|
| Traefik | https://traefik.dezky.local | Reverse proxy dashboard |
|
||||||
|
|
||||||
|
## What's in this repo
|
||||||
|
|
||||||
|
```
|
||||||
|
dezky/
|
||||||
|
├── apps/portal/ Nuxt 3 customer portal
|
||||||
|
├── services/provisioning/ NestJS provisioning worker
|
||||||
|
├── packages/ Shared TypeScript libraries
|
||||||
|
├── infrastructure/
|
||||||
|
│ └── docker-compose/ Local development stack
|
||||||
|
├── scripts/ Setup, reset, helpers
|
||||||
|
└── docs/ Service references & guides
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- macOS or Linux (Windows users: use WSL2)
|
||||||
|
- Docker Desktop 24+ or OrbStack
|
||||||
|
- mkcert (`brew install mkcert`)
|
||||||
|
- pnpm 9+ (`brew install pnpm`)
|
||||||
|
- Node.js 20+
|
||||||
|
- 16 GB RAM recommended
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start everything
|
||||||
|
docker compose -f infrastructure/docker-compose/docker-compose.yml up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose -f infrastructure/docker-compose/docker-compose.yml logs -f [service]
|
||||||
|
|
||||||
|
# Stop everything (keeps data)
|
||||||
|
docker compose -f infrastructure/docker-compose/docker-compose.yml down
|
||||||
|
|
||||||
|
# Nuke and restart (DESTROYS DATA)
|
||||||
|
./scripts/reset.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This is a multi-tenant SaaS platform. Each tenant gets:
|
||||||
|
- Isolated Authentik OIDC tenant
|
||||||
|
- Custom subdomain (e.g. `customer-name.dezky.local`)
|
||||||
|
- Mail domain in Stalwart with auto-generated DKIM
|
||||||
|
- Dedicated OCIS space hierarchy
|
||||||
|
- Branded launcher in the portal
|
||||||
|
|
||||||
|
All components are Apache 2.0 / MIT licensed — no per-seat fees, full whitelabel rights.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
The production target is a single Hetzner AX41-NVMe server (€39/mo) with:
|
||||||
|
- Stalwart on bare-metal
|
||||||
|
- k3s for all other services
|
||||||
|
- Hetzner Object Storage (€5/mo) as OCIS S3 backend
|
||||||
|
- Storage Box BX11 (€3.20/mo) for Restic backups
|
||||||
|
- Storage Box BX11 in Helsinki (€3.20/mo) for DR
|
||||||
|
|
||||||
|
See `docs/PRODUCTION-DEPLOYMENT.md` (TBD) for migration plan.
|
||||||
|
|
||||||
|
## Stack rationale
|
||||||
|
|
||||||
|
These choices are deliberate after extensive license/architecture research. See `CLAUDE.md` for the full reasoning.
|
||||||
|
|
||||||
|
| Component | License | Why this one |
|
||||||
|
|-----------|---------|--------------|
|
||||||
|
| Stalwart Mail | Apache 2.0 | Modern Rust, ActiveSync built-in, JMAP support |
|
||||||
|
| OCIS | Apache 2.0 | Cleaner license than Nextcloud (AGPL+trademark) |
|
||||||
|
| Zulip | Apache 2.0 | Only truly open-core-free chat option |
|
||||||
|
| Authentik | MIT | Better multi-tenancy than Keycloak |
|
||||||
|
| Hetzner | N/A | 100% EU sovereignty — core to business |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Application code: MIT (own code)
|
||||||
|
Third-party services: see individual service licenses in stack.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#__nuxt {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/* Dezky design tokens — workspace surface (light/bone).
|
||||||
|
Ported from project/platform-tokens.jsx (THEMES.light + ACCENTS.signal). */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Surface */
|
||||||
|
--bg: #F4F3EE; /* bone */
|
||||||
|
--surface: #FAFAF7; /* paper */
|
||||||
|
--elevated: #FFFFFF;
|
||||||
|
--border: #E6E4DC; /* fog */
|
||||||
|
--border-hi: #D4D2C8;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #0A0A0A; /* carbon */
|
||||||
|
--text-dim: rgba(10, 10, 10, 0.55);
|
||||||
|
--text-mute: rgba(10, 10, 10, 0.4);
|
||||||
|
|
||||||
|
/* Sidebar (always-dark for brand consistency) */
|
||||||
|
--side-bg: #0A0A0A;
|
||||||
|
--side-surf: #141413;
|
||||||
|
--side-text: #F4F3EE;
|
||||||
|
|
||||||
|
/* Brand accent */
|
||||||
|
--accent: #D4FF3A; /* signal */
|
||||||
|
--accent-fg: #0A0A0A;
|
||||||
|
--signal: #D4FF3A;
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--ok: #1F8A5B;
|
||||||
|
--warn: #E89A1F;
|
||||||
|
--bad: #E23030;
|
||||||
|
--info: #2A6FDB;
|
||||||
|
|
||||||
|
/* Type */
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-display: 'Inter Tight', 'Inter', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, 'Menlo', monospace;
|
||||||
|
|
||||||
|
/* Field input surface */
|
||||||
|
--input-bg: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--bg: #0A0A0A;
|
||||||
|
--surface: #141413;
|
||||||
|
--elevated: #1C1C1A;
|
||||||
|
--border: #262622;
|
||||||
|
--border-hi: #33332E;
|
||||||
|
--text: #F4F3EE;
|
||||||
|
--text-dim: rgba(244, 243, 238, 0.72);
|
||||||
|
--text-mute: rgba(244, 243, 238, 0.45);
|
||||||
|
--ok: #34C77B;
|
||||||
|
--warn: #F0B14A;
|
||||||
|
--bad: #F05858;
|
||||||
|
--info: #4D8BE8;
|
||||||
|
--input-bg: rgba(244, 243, 238, 0.04);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Brand mark — a stylized "d" inside a squircle. Ported from project/logos.jsx.
|
||||||
|
// The geometry is computed so the letterform sits ~16u inside the 100x100 viewBox.
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
size?: number
|
||||||
|
fg?: string
|
||||||
|
accent?: string
|
||||||
|
bowlR?: number
|
||||||
|
stemW?: number
|
||||||
|
contR?: number
|
||||||
|
dStyle?: 'donut' | 'solid' | 'outline'
|
||||||
|
dotPos?: 'corner' | 'tittle' | 'orbit' | 'none'
|
||||||
|
dotR?: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
size: 22,
|
||||||
|
fg: '#0a0a0a',
|
||||||
|
accent: '#d4ff3a',
|
||||||
|
bowlR: 14,
|
||||||
|
stemW: 7,
|
||||||
|
contR: 22,
|
||||||
|
dStyle: 'donut',
|
||||||
|
dotPos: 'corner',
|
||||||
|
dotR: 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const geometry = computed(() => {
|
||||||
|
const overlap = props.stemW * 0.55
|
||||||
|
const cy = 52
|
||||||
|
const cx = 50 - props.stemW / 2 + overlap / 2
|
||||||
|
const stemX = cx + props.bowlR - overlap
|
||||||
|
const stemRight = stemX + props.stemW
|
||||||
|
const capR = props.stemW / 2
|
||||||
|
const stemTop = 26
|
||||||
|
const stemBottom = cy + props.bowlR
|
||||||
|
const holeR = Math.max(2.5, props.bowlR - props.stemW - 0.5)
|
||||||
|
|
||||||
|
const bowlPath =
|
||||||
|
`M ${cx - props.bowlR} ${cy} ` +
|
||||||
|
`a ${props.bowlR} ${props.bowlR} 0 1 0 ${props.bowlR * 2} 0 ` +
|
||||||
|
`a ${props.bowlR} ${props.bowlR} 0 1 0 ${-props.bowlR * 2} 0 Z`
|
||||||
|
|
||||||
|
const counterPath =
|
||||||
|
`M ${cx - holeR} ${cy} ` +
|
||||||
|
`a ${holeR} ${holeR} 0 1 0 ${holeR * 2} 0 ` +
|
||||||
|
`a ${holeR} ${holeR} 0 1 0 ${-holeR * 2} 0 Z`
|
||||||
|
|
||||||
|
const stemPath =
|
||||||
|
`M ${stemX} ${stemTop + capR} ` +
|
||||||
|
`a ${capR} ${capR} 0 0 1 ${props.stemW} 0 ` +
|
||||||
|
`L ${stemRight} ${stemBottom} ` +
|
||||||
|
`L ${stemX} ${stemBottom} Z`
|
||||||
|
|
||||||
|
const dotByPos = {
|
||||||
|
corner: { x: 74, y: 26 },
|
||||||
|
tittle: { x: stemX + props.stemW / 2, y: stemTop - props.dotR - 3 },
|
||||||
|
orbit: { x: cx - props.bowlR - props.dotR - 2, y: cy - props.bowlR - props.dotR - 2 },
|
||||||
|
none: null,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return { bowlPath, counterPath, stemPath, dot: dotByPos[props.dotPos] }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
aria-label="Dezky"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<rect x="8" y="8" width="84" height="84" :rx="contR" :fill="fg" />
|
||||||
|
<g v-if="dStyle === 'donut'" fill="#0a0a0a">
|
||||||
|
<path :d="geometry.bowlPath + ' ' + geometry.counterPath" fill-rule="evenodd" />
|
||||||
|
<path :d="geometry.stemPath" />
|
||||||
|
</g>
|
||||||
|
<g v-else-if="dStyle === 'solid'" fill="#0a0a0a">
|
||||||
|
<path :d="geometry.bowlPath" />
|
||||||
|
<path :d="geometry.stemPath" />
|
||||||
|
</g>
|
||||||
|
<circle
|
||||||
|
v-if="geometry.dot"
|
||||||
|
:cx="geometry.dot.x"
|
||||||
|
:cy="geometry.dot.y"
|
||||||
|
:r="dotR"
|
||||||
|
:fill="accent"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Minimal Lucide-style line icons. Add more as needed from project/platform-tokens.jsx.
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
name: 'mail' | 'shield' | 'key' | 'check' | 'external' | 'arrowRight'
|
||||||
|
size?: number
|
||||||
|
stroke?: string
|
||||||
|
strokeWidth?: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
size: 16,
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.6,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
:stroke="stroke"
|
||||||
|
:stroke-width="strokeWidth"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
style="flex-shrink: 0"
|
||||||
|
>
|
||||||
|
<template v-if="name === 'mail'">
|
||||||
|
<rect x="2.5" y="5" width="19" height="14" rx="2" />
|
||||||
|
<path d="M3 7l9 6 9-6" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="name === 'shield'">
|
||||||
|
<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="name === 'key'">
|
||||||
|
<circle cx="9" cy="14" r="4" />
|
||||||
|
<path d="M12.5 11L20 3.5l-2-2-2 2 2 2-2 2 2 2" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="name === 'check'">
|
||||||
|
<path d="M5 12l5 5L20 7" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="name === 'external'">
|
||||||
|
<path d="M14 4h6v6" />
|
||||||
|
<path d="M20 4l-9 9" />
|
||||||
|
<path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="name === 'arrowRight'">
|
||||||
|
<path d="M5 12h14" />
|
||||||
|
<path d="M13 5l7 7-7 7" />
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
variant?: 'primary' | 'dark' | 'ghost'
|
||||||
|
fullWidth?: boolean
|
||||||
|
type?: 'button' | 'submit'
|
||||||
|
disabled?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
variant: 'primary',
|
||||||
|
fullWidth: true,
|
||||||
|
type: 'button',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:type="type"
|
||||||
|
:disabled="disabled"
|
||||||
|
:data-variant="variant"
|
||||||
|
:data-full-width="fullWidth ? '' : null"
|
||||||
|
>
|
||||||
|
<slot name="leading" />
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
button {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: filter 120ms ease, transform 60ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[data-full-width] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[data-variant='primary'] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[data-variant='dark'] {
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
border: 1px solid var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[data-variant='ghost'] {
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
filter: brightness(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="footer-link">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.footer-link {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link :deep(a) {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
border-bottom: 1px solid currentColor;
|
||||||
|
padding-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link :deep(a:hover) {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
eyebrow?: string
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="heading">
|
||||||
|
<div v-if="eyebrow" class="eyebrow">{{ eyebrow }}</div>
|
||||||
|
<h1 class="title">{{ title }}</h1>
|
||||||
|
<p v-if="body || $slots.body" class="body">
|
||||||
|
<slot name="body">{{ body }}</slot>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.heading {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
margin: 10px 0 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
modelValue?: string
|
||||||
|
placeholder?: string
|
||||||
|
type?: string
|
||||||
|
mono?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
type: 'text',
|
||||||
|
mono: false,
|
||||||
|
readonly: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
defineEmits<{ 'update:modelValue': [string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">{{ label }}</span>
|
||||||
|
<div class="control" :class="{ mono }">
|
||||||
|
<input
|
||||||
|
:type="type"
|
||||||
|
:value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:readonly="readonly"
|
||||||
|
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<slot name="trailing" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-mute);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control:focus-within {
|
||||||
|
border-color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control.mono input,
|
||||||
|
.control.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Reusable framing for auth screens — top bar, centered card, status footer.
|
||||||
|
// Workspace variant only (bone surface). Operator variant deferred.
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
tenant?: string
|
||||||
|
status?: string
|
||||||
|
statusTone?: 'ok' | 'warn' | 'bad'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
tenant: 'dezky',
|
||||||
|
status: 'All services operational',
|
||||||
|
statusTone: 'ok',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="auth-shell">
|
||||||
|
<header class="bar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-tile">
|
||||||
|
<NodeMark :size="22" fg="#0a0a0a" accent="#D4FF3A" />
|
||||||
|
</span>
|
||||||
|
<span class="brand-name">{{ tenant }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="hostname">app.dezky.local</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="stage">
|
||||||
|
<div class="card">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="strip">
|
||||||
|
<div class="status">
|
||||||
|
<span class="dot" :data-tone="statusTone" />
|
||||||
|
<span>{{ status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="links">
|
||||||
|
<span>terms</span>
|
||||||
|
<span>privacy</span>
|
||||||
|
<span>status</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tile {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hostname {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 36px 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
box-shadow: 0 18px 50px rgba(10, 10, 10, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip {
|
||||||
|
padding: 12px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot[data-tone='warn'] {
|
||||||
|
background: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot[data-tone='bad'] {
|
||||||
|
background: var(--bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Nuxt 3 configuration for Dezky portal
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2026-01-01',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
modules: ['nuxt-oidc-auth'],
|
||||||
|
|
||||||
|
css: ['~/assets/styles/tokens.css', '~/assets/styles/base.css'],
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
link: [
|
||||||
|
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||||
|
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
mongodbUri: process.env.MONGODB_URI,
|
||||||
|
apiBase: process.env.NUXT_API_BASE,
|
||||||
|
public: {
|
||||||
|
authUrl: process.env.NUXT_PUBLIC_AUTH_URL,
|
||||||
|
portalUrl: process.env.NUXT_PUBLIC_PORTAL_URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
oidc: {
|
||||||
|
defaultProvider: 'oidc',
|
||||||
|
session: {
|
||||||
|
expirationCheck: true,
|
||||||
|
automaticRefresh: true,
|
||||||
|
},
|
||||||
|
middleware: {
|
||||||
|
globalMiddlewareEnabled: true,
|
||||||
|
customLoginPage: true,
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
// Generic OIDC against our Authentik instance (provider preset key MUST be one of
|
||||||
|
// apple, auth0, cognito, entra, github, keycloak, logto, microsoft, oidc, paypal, zitadel).
|
||||||
|
oidc: {
|
||||||
|
clientId: process.env.NUXT_OIDC_CLIENT_ID || '',
|
||||||
|
clientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
|
||||||
|
redirectUri: process.env.NUXT_OIDC_REDIRECT_URI || '',
|
||||||
|
authorizationUrl: 'https://auth.dezky.local/application/o/authorize/',
|
||||||
|
tokenUrl: 'https://auth.dezky.local/application/o/token/',
|
||||||
|
userInfoUrl: 'https://auth.dezky.local/application/o/userinfo/',
|
||||||
|
logoutUrl: 'https://auth.dezky.local/application/o/dezky-portal/end-session/',
|
||||||
|
// Discovery URL — used by id_token validation to fetch JWKS + issuer
|
||||||
|
openIdConfiguration:
|
||||||
|
'https://auth.dezky.local/application/o/dezky-portal/.well-known/openid-configuration',
|
||||||
|
scope: ['openid', 'profile', 'email'],
|
||||||
|
userNameClaim: 'preferred_username',
|
||||||
|
responseType: 'code',
|
||||||
|
grantType: 'authorization_code',
|
||||||
|
pkce: true,
|
||||||
|
// Authentik's access tokens aren't always parseable as JWT — skip strict parsing
|
||||||
|
skipAccessTokenParsing: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
hmr: {
|
||||||
|
protocol: 'wss',
|
||||||
|
clientPort: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
routeRules: {
|
||||||
|
'/api/**': { cors: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@dezky/portal",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Dezky customer-facing portal — Nuxt 3",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev --host 0.0.0.0 --port 3000",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"typecheck": "nuxt typecheck",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nuxt": "^3.13.0",
|
||||||
|
"nuxt-oidc-auth": "1.0.0-beta.11",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.12.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ auth: false })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const accountEmail = computed(() => (route.query.email as string) || 'this account')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuthShell>
|
||||||
|
<div class="badge" data-tone="bad">
|
||||||
|
<UiIcon name="shield" :size="28" />
|
||||||
|
</div>
|
||||||
|
<AuthHeading
|
||||||
|
eyebrow="Access denied"
|
||||||
|
title="This account is suspended"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
The account <b>{{ accountEmail }}</b> has been suspended by a workspace admin.
|
||||||
|
Contact them if you think this is wrong.
|
||||||
|
</template>
|
||||||
|
</AuthHeading>
|
||||||
|
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-eyebrow">Workspace admin</div>
|
||||||
|
<div class="admin-name">Your administrator</div>
|
||||||
|
<div class="admin-email">contact via your organization</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AuthButton variant="dark">
|
||||||
|
<template #leading>
|
||||||
|
<UiIcon name="mail" :size="14" />
|
||||||
|
</template>
|
||||||
|
Email your admin
|
||||||
|
</AuthButton>
|
||||||
|
|
||||||
|
<AuthFooterLink>
|
||||||
|
<NuxtLink to="/auth/login">← sign in with a different account</NuxtLink>
|
||||||
|
</AuthFooterLink>
|
||||||
|
</AuthShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.badge {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[data-tone='bad'] {
|
||||||
|
background: rgba(226, 48, 48, 0.1);
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-email {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ auth: false })
|
||||||
|
|
||||||
|
async function signInAgain() {
|
||||||
|
await navigateTo('/auth/oidc/login', { external: true })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuthShell
|
||||||
|
status="Some services degraded · authentik"
|
||||||
|
status-tone="warn"
|
||||||
|
>
|
||||||
|
<div class="badge" data-tone="warn">
|
||||||
|
<UiIcon name="shield" :size="28" />
|
||||||
|
</div>
|
||||||
|
<AuthHeading
|
||||||
|
eyebrow="Session ended"
|
||||||
|
title="You were signed out"
|
||||||
|
body="For your security we ended your idle session. Sign in to pick up where you left off."
|
||||||
|
/>
|
||||||
|
<AuthButton variant="primary" @click="signInAgain">Sign in again</AuthButton>
|
||||||
|
<AuthFooterLink>
|
||||||
|
not you? <span class="sep">·</span> <NuxtLink to="/auth/login">use a different account</NuxtLink>
|
||||||
|
</AuthFooterLink>
|
||||||
|
</AuthShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.badge {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[data-tone='warn'] {
|
||||||
|
background: rgba(232, 154, 31, 0.12);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Custom login page (nuxt-oidc-auth customLoginPage=true means it won't auto-bounce).
|
||||||
|
// We intercept the click and kick off the OIDC flow via the module's helper.
|
||||||
|
|
||||||
|
definePageMeta({ auth: false })
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
|
||||||
|
async function signIn() {
|
||||||
|
await navigateTo('/auth/oidc/login', { external: true })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuthShell>
|
||||||
|
<AuthHeading
|
||||||
|
eyebrow="Sign in"
|
||||||
|
title="Welcome back"
|
||||||
|
body="Sign in to continue to your workspace."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form @submit.prevent="signIn">
|
||||||
|
<AuthInput
|
||||||
|
v-model="email"
|
||||||
|
label="Work email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<AuthButton type="submit" variant="primary">Continue with email</AuthButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider">
|
||||||
|
<span class="line" />
|
||||||
|
<span class="or">OR</span>
|
||||||
|
<span class="line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AuthButton variant="dark" @click="signIn">
|
||||||
|
<template #leading>
|
||||||
|
<UiIcon name="key" :size="14" />
|
||||||
|
</template>
|
||||||
|
Single sign-on (SSO)
|
||||||
|
</AuthButton>
|
||||||
|
|
||||||
|
<AuthFooterLink>
|
||||||
|
No account? Talk to your admin.
|
||||||
|
</AuthFooterLink>
|
||||||
|
</AuthShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.or {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Post-login landing. Auth middleware (nuxt-oidc-auth) gates access — anonymous
|
||||||
|
// visitors get bounced to /login by the customLoginPage config in nuxt.config.ts.
|
||||||
|
const { user, logout } = useOidcAuth()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="bar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-tile">
|
||||||
|
<NodeMark :size="22" />
|
||||||
|
</span>
|
||||||
|
<span class="brand-name">dezky</span>
|
||||||
|
</div>
|
||||||
|
<div class="me">
|
||||||
|
<span class="email">{{ user?.userInfo?.email || user?.userName }}</span>
|
||||||
|
<button class="logout" @click="logout()">sign out</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="stage">
|
||||||
|
<section class="hero">
|
||||||
|
<p class="eyebrow">Workspace · welcome</p>
|
||||||
|
<h1>Hi, {{ user?.userInfo?.name || user?.userName }}.</h1>
|
||||||
|
<p class="tagline">Sovereign workspace platform · all your services in one place.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<a href="https://files.dezky.local" target="_blank" class="tile">
|
||||||
|
<span class="tile-name">Files</span>
|
||||||
|
<span class="tile-meta">OCIS · S3-backed storage</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://mail.dezky.local" target="_blank" class="tile">
|
||||||
|
<span class="tile-name">Mail</span>
|
||||||
|
<span class="tile-meta">Stalwart · JMAP/IMAP/SMTP</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://office.dezky.local" target="_blank" class="tile">
|
||||||
|
<span class="tile-name">Office</span>
|
||||||
|
<span class="tile-meta">Collabora · document editing</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://auth.dezky.local" target="_blank" class="tile">
|
||||||
|
<span class="tile-name">Identity</span>
|
||||||
|
<span class="tile-meta">Authentik · SSO & access</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tile {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.me {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover {
|
||||||
|
background: rgba(10, 10, 10, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
flex: 1;
|
||||||
|
padding: 48px 24px;
|
||||||
|
max-width: 960px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 40px;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
margin: 12px 0 0 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
transition: border-color 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:hover {
|
||||||
|
border-color: var(--border-hi);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Generated
+8122
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
|||||||
|
# Authentik First-Time Setup
|
||||||
|
|
||||||
|
After the bootstrap script completes, Authentik is running but needs to be configured. This guide walks through the initial setup.
|
||||||
|
|
||||||
|
## 1. Access Authentik
|
||||||
|
|
||||||
|
Open https://auth.dezky.local in your browser.
|
||||||
|
|
||||||
|
If you see a TLS warning, mkcert root CA isn't trusted yet. Run:
|
||||||
|
```bash
|
||||||
|
mkcert -install
|
||||||
|
```
|
||||||
|
Then restart your browser.
|
||||||
|
|
||||||
|
## 2. Initial admin setup
|
||||||
|
|
||||||
|
Authentik bootstraps with admin credentials from `.env`:
|
||||||
|
|
||||||
|
- **URL:** https://auth.dezky.local/if/flow/initial-setup/
|
||||||
|
- **Email:** admin@dezky.local
|
||||||
|
- **Password:** Value of `AUTHENTIK_BOOTSTRAP_PASSWORD` in `.env`
|
||||||
|
|
||||||
|
On first login, change the password immediately.
|
||||||
|
|
||||||
|
## 3. Configure OIDC providers
|
||||||
|
|
||||||
|
Each Dezky service that uses SSO needs an OIDC provider configured in Authentik.
|
||||||
|
|
||||||
|
### 3.1 Create OCIS provider
|
||||||
|
|
||||||
|
1. Go to **Admin Interface** → **Applications** → **Providers**
|
||||||
|
2. Click **Create**
|
||||||
|
3. Select **OAuth2/OpenID Provider**
|
||||||
|
4. Configure:
|
||||||
|
- **Name:** `ocis-provider`
|
||||||
|
- **Authorization flow:** `default-provider-authorization-implicit-consent`
|
||||||
|
- **Client type:** Public
|
||||||
|
- **Client ID:** `ocis-web`
|
||||||
|
- **Redirect URIs:**
|
||||||
|
```
|
||||||
|
https://files.dezky.local/
|
||||||
|
https://files.dezky.local/oidc-callback
|
||||||
|
```
|
||||||
|
- **Signing Key:** `authentik Self-signed Certificate`
|
||||||
|
- **Scopes:** openid, profile, email
|
||||||
|
5. Save
|
||||||
|
|
||||||
|
### 3.2 Create OCIS application
|
||||||
|
|
||||||
|
1. Go to **Applications** → **Applications**
|
||||||
|
2. Click **Create**
|
||||||
|
3. Configure:
|
||||||
|
- **Name:** `OCIS Files`
|
||||||
|
- **Slug:** `ocis`
|
||||||
|
- **Provider:** `ocis-provider` (just created)
|
||||||
|
- **Launch URL:** https://files.dezky.local
|
||||||
|
4. Save
|
||||||
|
|
||||||
|
### 3.3 Create portal provider
|
||||||
|
|
||||||
|
Same steps as OCIS, but with:
|
||||||
|
- **Provider name:** `dezky-portal`
|
||||||
|
- **Client ID:** `dezky-portal`
|
||||||
|
- **Redirect URIs:** `https://app.dezky.local/api/auth/callback`
|
||||||
|
- **Client type:** Confidential (Authentik will generate a Client Secret)
|
||||||
|
|
||||||
|
Then create the matching application:
|
||||||
|
- **Name:** `Dezky Portal` → slug auto-generates as `dezky-portal`
|
||||||
|
- **Provider:** `dezky-portal` (from above)
|
||||||
|
- **Launch URL:** `https://app.dezky.local`
|
||||||
|
|
||||||
|
The resulting issuer URL is `https://auth.dezky.local/application/o/dezky-portal/` — note the slug includes `dezky-`.
|
||||||
|
|
||||||
|
After creating, copy the generated client secret into `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
PORTAL_OIDC_CLIENT_ID=dezky-portal
|
||||||
|
PORTAL_OIDC_CLIENT_SECRET=<paste from Authentik provider page>
|
||||||
|
PORTAL_OIDC_ISSUER=https://auth.dezky.local/application/o/dezky-portal/
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yml` passes these to the portal container as `NUXT_OIDC_*`, which `nuxt-oidc-auth` (added in Phase 2) consumes.
|
||||||
|
|
||||||
|
### Scripted alternative
|
||||||
|
|
||||||
|
If you don't want to click through the UI, this one-shot uses the API token from section 4:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(grep ^AUTHENTIK_BOOTSTRAP_TOKEN .env | cut -d= -f2)
|
||||||
|
BASE=https://auth.dezky.local/api/v3
|
||||||
|
AUTH="Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# Pull the same authorization flow + signing key + scope mappings that OCIS uses
|
||||||
|
FLOW=$(curl -k -s -H "$AUTH" "$BASE/flows/instances/?slug=default-provider-authorization-implicit-consent" | jq -r '.results[0].pk')
|
||||||
|
KEY=$(curl -k -s -H "$AUTH" "$BASE/providers/oauth2/?search=ocis" | jq -r '.results[0].signing_key')
|
||||||
|
MAPS=$(curl -k -s -H "$AUTH" "$BASE/providers/oauth2/?search=ocis" | jq -c '.results[0].property_mappings')
|
||||||
|
|
||||||
|
# Create provider
|
||||||
|
PK=$(curl -k -s -X POST -H "$AUTH" -H "Content-Type: application/json" "$BASE/providers/oauth2/" -d "{
|
||||||
|
\"name\": \"dezky-portal\",
|
||||||
|
\"client_id\": \"dezky-portal\",
|
||||||
|
\"client_type\": \"confidential\",
|
||||||
|
\"authorization_flow\": \"$FLOW\",
|
||||||
|
\"signing_key\": \"$KEY\",
|
||||||
|
\"redirect_uris\": [{\"matching_mode\": \"strict\", \"url\": \"https://app.dezky.local/api/auth/callback\"}],
|
||||||
|
\"property_mappings\": $MAPS,
|
||||||
|
\"sub_mode\": \"hashed_user_id\",
|
||||||
|
\"issuer_mode\": \"per_provider\"
|
||||||
|
}" | jq -r '.pk')
|
||||||
|
|
||||||
|
# Create app
|
||||||
|
curl -k -s -X POST -H "$AUTH" -H "Content-Type: application/json" "$BASE/core/applications/" -d "{
|
||||||
|
\"name\": \"Dezky Portal\",
|
||||||
|
\"slug\": \"dezky-portal\",
|
||||||
|
\"provider\": $PK,
|
||||||
|
\"meta_launch_url\": \"https://app.dezky.local\"
|
||||||
|
}" >/dev/null
|
||||||
|
|
||||||
|
# Read the generated secret and write to .env
|
||||||
|
SECRET=$(curl -k -s -H "$AUTH" "$BASE/providers/oauth2/$PK/" | jq -r '.client_secret')
|
||||||
|
cat >> .env <<EOF
|
||||||
|
|
||||||
|
PORTAL_OIDC_CLIENT_ID=dezky-portal
|
||||||
|
PORTAL_OIDC_CLIENT_SECRET=$SECRET
|
||||||
|
PORTAL_OIDC_ISSUER=https://auth.dezky.local/application/o/dezky-portal/
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Create Stalwart provider (when wiring mail SSO later)
|
||||||
|
|
||||||
|
Note: Stalwart's OIDC integration is configured in `infrastructure/docker-compose/configs/stalwart/config.toml`. For local dev with internal users, OIDC is optional.
|
||||||
|
|
||||||
|
## 4. Get the API token for provisioning service
|
||||||
|
|
||||||
|
The provisioning service needs to call Authentik's API to create tenants, users, and applications. `.env` holds a pre-generated value in `AUTHENTIK_BOOTSTRAP_TOKEN`, but Authentik 2025.10 does **not** materialize that env var into a usable API token on first boot. You need to create the token once and bind it to `akadmin`.
|
||||||
|
|
||||||
|
### One-time setup
|
||||||
|
|
||||||
|
Run this from the project root — it reads the value from `.env` and inserts it as a non-expiring API token for `akadmin`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(grep ^AUTHENTIK_BOOTSTRAP_TOKEN .env | cut -d= -f2)
|
||||||
|
docker compose -f infrastructure/docker-compose/docker-compose.yml exec -T \
|
||||||
|
-e BOOTSTRAP_TOKEN="$TOKEN" authentik-server ak shell -c "
|
||||||
|
import os
|
||||||
|
from authentik.core.models import User, Token, TokenIntents
|
||||||
|
admin = User.objects.get(username='akadmin')
|
||||||
|
Token.objects.update_or_create(
|
||||||
|
identifier='dezky-bootstrap-token',
|
||||||
|
defaults={
|
||||||
|
'user': admin,
|
||||||
|
'intent': TokenIntents.INTENT_API,
|
||||||
|
'expiring': False,
|
||||||
|
'key': os.environ['BOOTSTRAP_TOKEN'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print('Token bound to akadmin')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative: create the token through the UI — **Directory → Tokens & App passwords → Create**, set `Intent: API`, `User: akadmin`, then copy the key into `.env` and restart the provisioning service.
|
||||||
|
|
||||||
|
### Verify it works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -H "Authorization: Bearer $(grep ^AUTHENTIK_BOOTSTRAP_TOKEN .env | cut -d= -f2)" \
|
||||||
|
https://auth.dezky.local/api/v3/core/users/
|
||||||
|
```
|
||||||
|
|
||||||
|
A 200 response with the user list (including `akadmin`) means the token is live. A 403 `{"detail":"Token invalid/expired"}` means the one-time setup above hasn't been run yet.
|
||||||
|
|
||||||
|
### When you need to re-run this
|
||||||
|
|
||||||
|
After `docker compose down -v` (or any reset that wipes the postgres volume), the token row is gone and you'll need to recreate it. The value in `.env` doesn't need to change — re-running the shell snippet above re-binds it.
|
||||||
|
|
||||||
|
## 5. Multi-tenancy strategy
|
||||||
|
|
||||||
|
For local dev, you can either:
|
||||||
|
|
||||||
|
**Option A: Single Authentik tenant, multiple groups**
|
||||||
|
- All Dezky test users in one Authentik instance
|
||||||
|
- Tenants modeled as Authentik groups
|
||||||
|
- Simpler for dev, less realistic
|
||||||
|
|
||||||
|
**Option B: Authentik tenants (production-mode)**
|
||||||
|
- Each Dezky customer = Authentik tenant
|
||||||
|
- Tenant subdomain pattern: `{tenant}.auth.dezky.local`
|
||||||
|
- More realistic but more setup overhead
|
||||||
|
|
||||||
|
For dev, start with Option A. The provisioning service should be built to support Option B from day one (data model includes `tenantId`).
|
||||||
|
|
||||||
|
## 6. Test SSO flow end-to-end
|
||||||
|
|
||||||
|
1. Open incognito browser to https://files.dezky.local
|
||||||
|
2. OCIS should redirect to https://auth.dezky.local for login
|
||||||
|
3. Log in with admin@dezky.local
|
||||||
|
4. Should redirect back to OCIS, logged in
|
||||||
|
|
||||||
|
If this works, OIDC integration is solid.
|
||||||
|
|
||||||
|
## Common issues
|
||||||
|
|
||||||
|
### "Issuer URL does not match"
|
||||||
|
|
||||||
|
OCIS expects exact match between `OCIS_OIDC_ISSUER` and the `iss` claim in the JWT.
|
||||||
|
|
||||||
|
Check the issuer in Authentik:
|
||||||
|
- Admin Interface → Providers → ocis-provider → Configure
|
||||||
|
- Note the **Issuer URL** at the bottom
|
||||||
|
- Update `OCIS_OIDC_ISSUER` in `docker-compose.yml` to match exactly
|
||||||
|
|
||||||
|
### "Client authentication failed"
|
||||||
|
|
||||||
|
Public clients (Client Type: Public) don't need a client secret.
|
||||||
|
Confidential clients need the secret added to the consumer's config.
|
||||||
|
|
||||||
|
For OCIS, use Public type.
|
||||||
|
For the portal (which has server-side auth), use Confidential.
|
||||||
|
|
||||||
|
### TLS verification fails between containers
|
||||||
|
|
||||||
|
Inside the Docker network, services use Docker-internal hostnames (e.g. `authentik-server`). TLS verification can fail because the cert is for `auth.dezky.local`, not `authentik-server`.
|
||||||
|
|
||||||
|
For service-to-service auth, use the issuer URL with `OCIS_INSECURE=true` only for dev. Production will use proper certs.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Next Steps — After Local Stack Is Running
|
||||||
|
|
||||||
|
Once `./scripts/bootstrap.sh` completes successfully and all services are reachable, here's the development roadmap.
|
||||||
|
|
||||||
|
## Phase 1: Verify everything works (day 1) — done
|
||||||
|
|
||||||
|
- [x] `https://app.dezky.local` shows portal landing page (now the new auth design / post-login home)
|
||||||
|
- [x] `https://auth.dezky.local` shows Authentik login
|
||||||
|
- [x] Log into Authentik as admin *(still using generated `AUTHENTIK_BOOTSTRAP_PASSWORD` from `.env` — rotate before exposing to anyone else)*
|
||||||
|
- [x] Follow `docs/AUTHENTIK-SETUP.md` to configure OIDC providers (ocis + dezky-portal)
|
||||||
|
- [x] Test OCIS SSO end-to-end (login from `https://files.dezky.local`)
|
||||||
|
- [x] Verify Stalwart admin UI loads at `https://mail.dezky.local/login` *(root path 404s — admin SPA is at `/login`)*
|
||||||
|
|
||||||
|
## Phase 2: Build portal authentication (week 1) — done
|
||||||
|
|
||||||
|
Goal: Users can log in to the portal via Authentik.
|
||||||
|
|
||||||
|
- [x] Add `nuxt-oidc-auth` to `apps/portal` (`1.0.0-beta.11`)
|
||||||
|
- [x] Configure Authentik as OIDC provider (generic `oidc` preset with explicit URLs + discovery)
|
||||||
|
- [x] Implement login/logout flows (`/auth/oidc/login`, `/auth/oidc/logout` from the module)
|
||||||
|
- [x] Display logged-in user info on the portal home (`pages/index.vue` uses `useOidcAuth()`)
|
||||||
|
- [x] Add protected routes (`globalMiddlewareEnabled: true`; public pages opt out via `definePageMeta({ auth: false })`)
|
||||||
|
|
||||||
|
### Where things live
|
||||||
|
|
||||||
|
| Concern | File |
|
||||||
|
|---|---|
|
||||||
|
| OIDC module config | `apps/portal/nuxt.config.ts` (`oidc` block) |
|
||||||
|
| Custom login page | `apps/portal/pages/auth/login.vue` |
|
||||||
|
| Error states (expired / disabled) | `apps/portal/pages/auth/{expired,disabled}.vue` |
|
||||||
|
| Post-login landing | `apps/portal/pages/index.vue` |
|
||||||
|
| Visual shell + tokens | `apps/portal/components/auth/*`, `assets/styles/tokens.css` |
|
||||||
|
| Brand mark | `apps/portal/components/NodeMark.vue` |
|
||||||
|
|
||||||
|
### Dev-mode caveats (clean up before prod)
|
||||||
|
|
||||||
|
- `skipAccessTokenParsing: true` in the OIDC config — Authentik's access tokens in this setup aren't reliably JWT-parseable; production should re-evaluate
|
||||||
|
- `openIdConfiguration` is pinned to the discovery URL because the generic `oidc` preset doesn't ship a default — required for id_token JWKS validation
|
||||||
|
- `docker-compose.yml` mounts `infrastructure/docker-compose/certs/mkcert-root.pem` into the portal at `/etc/ssl/mkcert-root.pem` and sets `NODE_EXTRA_CA_CERTS` so Node fetch trusts the mkcert root CA. In prod, replace with real CA-signed certs
|
||||||
|
- Traefik has Docker network aliases for `auth.dezky.local`, `app.dezky.local`, etc. so container-to-Authentik fetch resolves inside the network without going through host `/etc/hosts`
|
||||||
|
|
||||||
|
## Phase 3: Tenant data model (week 1-2)
|
||||||
|
|
||||||
|
Goal: MongoDB schema for tenants, users, subscriptions.
|
||||||
|
|
||||||
|
- [ ] Define Mongoose schemas in `services/provisioning/src/schemas/`
|
||||||
|
- [ ] Tenant schema: id, name, slug, status, plan, billingInfo, domains
|
||||||
|
- [ ] User schema: id, tenantId, email, name, role, authentikId
|
||||||
|
- [ ] Subscription schema: tenantId, plan, status, stripeCustomerId
|
||||||
|
- [ ] Add CRUD endpoints in NestJS
|
||||||
|
|
||||||
|
Schema example:
|
||||||
|
```typescript
|
||||||
|
// services/provisioning/src/schemas/tenant.schema.ts
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class Tenant {
|
||||||
|
@Prop({ required: true, unique: true })
|
||||||
|
slug: string
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
name: string
|
||||||
|
|
||||||
|
@Prop({ enum: ['pending', 'active', 'suspended', 'deleted'], default: 'pending' })
|
||||||
|
status: string
|
||||||
|
|
||||||
|
@Prop({ type: [String], default: [] })
|
||||||
|
domains: string[]
|
||||||
|
|
||||||
|
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
|
||||||
|
plan: string
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
authentikGroupId?: string
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
ocisSpaceId?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 4: Provisioning automation (week 2-3)
|
||||||
|
|
||||||
|
Goal: Sign up creates tenant resources across all services.
|
||||||
|
|
||||||
|
- [ ] Endpoint: `POST /tenants` — creates tenant in MongoDB
|
||||||
|
- [ ] Worker: triggers Authentik tenant/group creation via API
|
||||||
|
- [ ] Worker: configures Stalwart domain + DKIM via admin API
|
||||||
|
- [ ] Worker: creates OCIS space
|
||||||
|
- [ ] Worker: emails customer with onboarding info
|
||||||
|
|
||||||
|
Authentik API examples:
|
||||||
|
```typescript
|
||||||
|
// Create group (tenant) in Authentik
|
||||||
|
await authentikClient.coreGroupsCreate({
|
||||||
|
name: tenant.slug,
|
||||||
|
attributes: { tenantId: tenant.id, plan: tenant.plan },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
await authentikClient.coreUsersCreate({
|
||||||
|
username: user.email,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
groups: [authentikGroupId],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 5: Custom webmail (week 3-4)
|
||||||
|
|
||||||
|
Goal: Branded webmail client using Stalwart's JMAP API.
|
||||||
|
|
||||||
|
- [ ] Add JMAP client library to portal
|
||||||
|
- [ ] Build inbox view in Nuxt
|
||||||
|
- [ ] Build compose dialog
|
||||||
|
- [ ] Build message view with thread support
|
||||||
|
- [ ] Style to match Dezky branding
|
||||||
|
|
||||||
|
JMAP is a modern JSON-RPC protocol — clean to work with.
|
||||||
|
|
||||||
|
## Phase 6: Production migration prep (week 4+)
|
||||||
|
|
||||||
|
When the local stack is solid and you have 2-3 pilot customers interested:
|
||||||
|
|
||||||
|
- [ ] Order Hetzner AX41-NVMe
|
||||||
|
- [ ] Order Storage Box BX11 (Falkenstein)
|
||||||
|
- [ ] Enable Hetzner Object Storage (bucket: dezky-ocis-prod)
|
||||||
|
- [ ] Build Terraform module for Hetzner provisioning
|
||||||
|
- [ ] Build Ansible playbook for bare-metal Stalwart deployment
|
||||||
|
- [ ] Set up k3s on the cloud server
|
||||||
|
- [ ] Migrate compose to Helm charts
|
||||||
|
- [ ] Configure Let's Encrypt via cert-manager
|
||||||
|
- [ ] Set up Restic backup jobs to Storage Box + B2
|
||||||
|
|
||||||
|
## Phase 7: Add Zulip and Jitsi (when chat/video needed)
|
||||||
|
|
||||||
|
These were excluded from MVP for simplicity. When ready:
|
||||||
|
|
||||||
|
- [ ] Create `infrastructure/docker-compose/docker-compose.optional.yml`
|
||||||
|
- [ ] Add Zulip stack (server + db + worker)
|
||||||
|
- [ ] Add Jitsi stack (web + prosody + jicofo + jvb)
|
||||||
|
- [ ] Configure OIDC integration with Authentik
|
||||||
|
- [ ] Add to portal launcher
|
||||||
|
|
||||||
|
## Decisions still open
|
||||||
|
|
||||||
|
These need to be made before public launch:
|
||||||
|
|
||||||
|
- [ ] Final pricing tiers (MVP, Pro, Enterprise)
|
||||||
|
- [ ] dezky.com purchase decision ($3,000 via BrandBucket)
|
||||||
|
- [ ] Final logo design (4 directions explored, need to pick one)
|
||||||
|
- [ ] Legal entity structure for the new business
|
||||||
|
- [ ] DPA (databehandleraftale) template
|
||||||
|
- [ ] Customer support process (ticket system choice)
|
||||||
|
|
||||||
|
## Long-term architecture goals
|
||||||
|
|
||||||
|
- [ ] Multi-region deployment (Hetzner Falkenstein + Helsinki)
|
||||||
|
- [ ] Disaster recovery: cross-DC Restic copies
|
||||||
|
- [ ] ISO 27001 certification via Vanta
|
||||||
|
- [ ] GDPR Article 30 record of processing activities
|
||||||
|
- [ ] SOC 2 (later, for enterprise customers)
|
||||||
|
- [ ] Customer-facing status page (Uptime Kuma or cstate)
|
||||||
|
- [ ] Public documentation site
|
||||||
|
- [ ] Self-service migration tooling from M365
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# Services Reference
|
||||||
|
|
||||||
|
Per-service details: what each one does, where its config lives, and how to debug it.
|
||||||
|
|
||||||
|
## Traefik
|
||||||
|
|
||||||
|
**Image:** `traefik:v3.2`
|
||||||
|
**Container:** `dezky-traefik`
|
||||||
|
**URL:** https://traefik.dezky.local (dashboard)
|
||||||
|
**Purpose:** Reverse proxy, TLS termination, service discovery via Docker labels
|
||||||
|
|
||||||
|
**Config:**
|
||||||
|
- Static: `configs/traefik/traefik.yml`
|
||||||
|
- Dynamic: `configs/traefik/dynamic.yml` (TLS certs)
|
||||||
|
- Certs: `certs/dezky.local.pem` + `certs/dezky.local-key.pem`
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f traefik
|
||||||
|
# Open https://traefik.dezky.local for dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PostgreSQL
|
||||||
|
|
||||||
|
**Image:** `postgres:16-alpine`
|
||||||
|
**Container:** `dezky-postgres`
|
||||||
|
**Internal hostname:** `postgres`
|
||||||
|
**Purpose:** Shared RDBMS for Authentik and OCIS (future)
|
||||||
|
|
||||||
|
**Databases:**
|
||||||
|
- `authentik` (owner: `authentik`)
|
||||||
|
- `ocis` (owner: `ocis`, reserved for future use)
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
# Shell access
|
||||||
|
docker compose exec postgres psql -U postgres
|
||||||
|
|
||||||
|
# Check users
|
||||||
|
\du
|
||||||
|
|
||||||
|
# Check databases
|
||||||
|
\l
|
||||||
|
|
||||||
|
# Connect to specific DB
|
||||||
|
\c authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MongoDB
|
||||||
|
|
||||||
|
**Image:** `mongo:7`
|
||||||
|
**Container:** `dezky-mongo`
|
||||||
|
**Internal hostname:** `mongo`
|
||||||
|
**Purpose:** Portal application data
|
||||||
|
|
||||||
|
**Connection:**
|
||||||
|
```
|
||||||
|
mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
docker compose exec mongo mongosh -u root -p $(grep MONGO_ROOT_PASSWORD .env | cut -d= -f2)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redis
|
||||||
|
|
||||||
|
**Image:** `redis:7-alpine`
|
||||||
|
**Container:** `dezky-redis`
|
||||||
|
**Internal hostname:** `redis`
|
||||||
|
**Purpose:** Cache and session store (used by Authentik)
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
docker compose exec redis redis-cli -a $(grep REDIS_PASSWORD .env | cut -d= -f2)
|
||||||
|
> KEYS *
|
||||||
|
> INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentik
|
||||||
|
|
||||||
|
**Image:** `ghcr.io/goauthentik/server:2025.10`
|
||||||
|
**Containers:** `dezky-authentik` (server) + `dezky-authentik-worker`
|
||||||
|
**URL:** https://auth.dezky.local
|
||||||
|
**Purpose:** Identity provider, SSO, MFA
|
||||||
|
|
||||||
|
**First-time setup:**
|
||||||
|
- URL: https://auth.dezky.local/if/flow/initial-setup/
|
||||||
|
- Email: `admin@dezky.local`
|
||||||
|
- Password: `AUTHENTIK_BOOTSTRAP_PASSWORD` from `.env`
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- Base: https://auth.dezky.local/api/v3
|
||||||
|
- Auth: `Authorization: Bearer <AUTHENTIK_BOOTSTRAP_TOKEN>`
|
||||||
|
- Docs: https://auth.dezky.local/api/v3/
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f authentik-server authentik-worker
|
||||||
|
|
||||||
|
# Check API health
|
||||||
|
curl https://auth.dezky.local/-/health/ready/
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/AUTHENTIK-SETUP.md` for OIDC configuration steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stalwart Mail
|
||||||
|
|
||||||
|
**Image:** `stalwartlabs/mail-server:latest`
|
||||||
|
**Container:** `dezky-stalwart`
|
||||||
|
**URL:** https://mail.dezky.local
|
||||||
|
**Purpose:** Mail server (SMTP/IMAP/JMAP/CalDAV/CardDAV/ActiveSync)
|
||||||
|
|
||||||
|
**Ports exposed:**
|
||||||
|
- 25 (SMTP)
|
||||||
|
- 465 (SMTPS)
|
||||||
|
- 587 (Submission)
|
||||||
|
- 143 (IMAP)
|
||||||
|
- 993 (IMAPS)
|
||||||
|
- 4190 (ManageSieve)
|
||||||
|
|
||||||
|
**Config:** `configs/stalwart/config.toml`
|
||||||
|
**Data:** Docker volume `dezky_stalwart_data`
|
||||||
|
|
||||||
|
**Admin login:**
|
||||||
|
- User: `admin`
|
||||||
|
- Password: `STALWART_ADMIN_PASSWORD` from `.env`
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f stalwart
|
||||||
|
|
||||||
|
# Test SMTP
|
||||||
|
swaks --to test@dezky.local --from sender@example.com --server mail.dezky.local:25
|
||||||
|
|
||||||
|
# Check ports
|
||||||
|
docker compose port stalwart 25
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OCIS
|
||||||
|
|
||||||
|
**Image:** `owncloud/ocis:7.0`
|
||||||
|
**Container:** `dezky-ocis`
|
||||||
|
**URL:** https://files.dezky.local
|
||||||
|
**Purpose:** File storage, sharing, sync
|
||||||
|
|
||||||
|
**OIDC config:**
|
||||||
|
- Issuer: `https://auth.dezky.local/application/o/ocis/`
|
||||||
|
- Client ID: `ocis-web` (configured in Authentik)
|
||||||
|
- Auto-provision: enabled (creates OCIS user on first SSO login)
|
||||||
|
|
||||||
|
**Admin login:**
|
||||||
|
- User: `admin`
|
||||||
|
- Password: `OCIS_ADMIN_PASSWORD` from `.env`
|
||||||
|
|
||||||
|
**Storage backend:**
|
||||||
|
- Dev: local filesystem inside volume `dezky_ocis_data`
|
||||||
|
- Prod: will switch to S3 (Hetzner Object Storage)
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f ocis
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
curl -k https://files.dezky.local/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collabora
|
||||||
|
|
||||||
|
**Image:** `collabora/code:latest`
|
||||||
|
**Container:** `dezky-collabora`
|
||||||
|
**URL:** https://office.dezky.local
|
||||||
|
**Purpose:** Office document editing inside OCIS
|
||||||
|
|
||||||
|
**Integration with OCIS:**
|
||||||
|
- OCIS must be configured to use Collabora as its office editor
|
||||||
|
- See: OCIS app config → "wopiserver"
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f collabora
|
||||||
|
|
||||||
|
# Discovery endpoint (used by OCIS)
|
||||||
|
curl -k https://office.dezky.local/hosting/discovery
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Portal (Nuxt 3)
|
||||||
|
|
||||||
|
**Container:** `dezky-portal`
|
||||||
|
**URL:** https://app.dezky.local
|
||||||
|
**Source:** `apps/portal/`
|
||||||
|
**Purpose:** Customer-facing portal, launcher, custom webmail
|
||||||
|
|
||||||
|
**Stack:**
|
||||||
|
- Nuxt 3
|
||||||
|
- Vue 3 + TypeScript
|
||||||
|
- Vite dev server
|
||||||
|
- pnpm for dependencies
|
||||||
|
|
||||||
|
**Hot reload:**
|
||||||
|
- File changes in `apps/portal/` trigger HMR automatically
|
||||||
|
- Vite watches via polling (configured in `nuxt.config.ts`)
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- `NUXT_PUBLIC_AUTH_URL`: Authentik URL (client-side)
|
||||||
|
- `NUXT_API_BASE`: provisioning service URL (server-side)
|
||||||
|
- `MONGODB_URI`: MongoDB connection string
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f portal
|
||||||
|
|
||||||
|
# Shell into container
|
||||||
|
docker compose exec portal sh
|
||||||
|
> pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provisioning Service (NestJS)
|
||||||
|
|
||||||
|
**Container:** `dezky-provisioning`
|
||||||
|
**Port:** 3001 (internal only)
|
||||||
|
**Source:** `services/provisioning/`
|
||||||
|
**Purpose:** Tenant lifecycle, billing webhooks, service orchestration
|
||||||
|
|
||||||
|
**Endpoints to implement:**
|
||||||
|
- `POST /tenants` — Create tenant
|
||||||
|
- `GET /tenants/:id` — Get tenant
|
||||||
|
- `PATCH /tenants/:id` — Update tenant
|
||||||
|
- `POST /tenants/:id/users` — Add user to tenant
|
||||||
|
- `POST /webhooks/stripe` — Billing events
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- `MONGODB_URI`: Portal data store
|
||||||
|
- `AUTHENTIK_API_URL` + `AUTHENTIK_API_TOKEN`
|
||||||
|
- `STALWART_API_URL` + `STALWART_ADMIN_USER/PASSWORD`
|
||||||
|
- `OCIS_API_URL`
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f provisioning
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
docker compose exec provisioning wget -qO- http://localhost:3001/health
|
||||||
|
```
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
Common issues and fixes when running the Dezky local development stack.
|
||||||
|
|
||||||
|
## TLS / Certificate issues
|
||||||
|
|
||||||
|
### Browser shows "Not Secure" or certificate warning
|
||||||
|
|
||||||
|
mkcert root CA isn't trusted in your browser yet.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkcert -install
|
||||||
|
```
|
||||||
|
|
||||||
|
Then **fully restart your browser** (quit, not just close window).
|
||||||
|
|
||||||
|
### Certificate not loading in Traefik
|
||||||
|
|
||||||
|
Verify the cert files exist:
|
||||||
|
```bash
|
||||||
|
ls -la infrastructure/docker-compose/certs/
|
||||||
|
# Should show:
|
||||||
|
# dezky.local.pem
|
||||||
|
# dezky.local-key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
If they're named differently (e.g. `_wildcard.dezky.local+1.pem`), rename them:
|
||||||
|
```bash
|
||||||
|
cd infrastructure/docker-compose/certs/
|
||||||
|
mv _wildcard.dezky.local+*.pem dezky.local.pem
|
||||||
|
mv _wildcard.dezky.local+*-key.pem dezky.local-key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart Traefik:
|
||||||
|
```bash
|
||||||
|
docker compose restart traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service-to-service TLS errors
|
||||||
|
|
||||||
|
Inside Docker, services talk via internal hostnames (e.g. `authentik-server:9000`), not `auth.dezky.local`. Internal traffic uses HTTP, not HTTPS. Only Traefik handles TLS termination.
|
||||||
|
|
||||||
|
If a service config has `https://authentik-server`, change it to `http://authentik-server`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Container startup issues
|
||||||
|
|
||||||
|
### Authentik fails to start
|
||||||
|
|
||||||
|
Most common cause: PostgreSQL not ready yet, or password mismatch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check postgres is healthy
|
||||||
|
docker compose ps postgres
|
||||||
|
# Should show "healthy" in STATUS
|
||||||
|
|
||||||
|
# Check Authentik DB user exists
|
||||||
|
docker compose exec postgres psql -U postgres -c "\du"
|
||||||
|
# Should list "authentik" as a user
|
||||||
|
|
||||||
|
# Check Authentik logs
|
||||||
|
docker compose logs authentik-server | tail -50
|
||||||
|
```
|
||||||
|
|
||||||
|
If password is wrong, reset and re-bootstrap:
|
||||||
|
```bash
|
||||||
|
./scripts/reset.sh
|
||||||
|
./scripts/bootstrap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port 25 conflict (Stalwart fails to bind)
|
||||||
|
|
||||||
|
macOS often has Postfix running by default:
|
||||||
|
```bash
|
||||||
|
sudo launchctl unload /System/Library/LaunchDaemons/org.postfix.master.plist 2>/dev/null || true
|
||||||
|
sudo launchctl stop org.postfix.master 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Or just disable the SMTP port mapping in `docker-compose.yml` for local dev:
|
||||||
|
```yaml
|
||||||
|
stalwart:
|
||||||
|
# ports:
|
||||||
|
# - "25:25" # comment out if conflicting
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port 80/443 conflict (Traefik fails to bind)
|
||||||
|
|
||||||
|
Another service is using those ports.
|
||||||
|
```bash
|
||||||
|
# Find what's using port 80
|
||||||
|
sudo lsof -nP -i:80 -sTCP:LISTEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Common culprits: nginx, apache, Caddy, other Docker stacks. Stop them or change Traefik to use 8080/8443.
|
||||||
|
|
||||||
|
### OCIS crashes on first start
|
||||||
|
|
||||||
|
OCIS needs to initialize before running. The compose file does this via:
|
||||||
|
```yaml
|
||||||
|
command: ["-c", "ocis init --insecure true || true && ocis server"]
|
||||||
|
```
|
||||||
|
|
||||||
|
If init fails:
|
||||||
|
```bash
|
||||||
|
# Manually init
|
||||||
|
docker compose run --rm ocis ocis init --insecure true
|
||||||
|
|
||||||
|
# Then start
|
||||||
|
docker compose up -d ocis
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DNS / hostname issues
|
||||||
|
|
||||||
|
### `app.dezky.local` doesn't resolve
|
||||||
|
|
||||||
|
Check /etc/hosts:
|
||||||
|
```bash
|
||||||
|
grep dezky.local /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
Should see entries pointing 127.0.0.1 to all hostnames. If missing, run:
|
||||||
|
```bash
|
||||||
|
./scripts/bootstrap.sh # Will offer to add them
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
```bash
|
||||||
|
echo "127.0.0.1 dezky.local app.dezky.local auth.dezky.local mail.dezky.local files.dezky.local office.dezky.local meet.dezky.local chat.dezky.local traefik.dezky.local" | sudo tee -a /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browser DNS cache holding old entry
|
||||||
|
|
||||||
|
Clear browser cache, or test from terminal:
|
||||||
|
```bash
|
||||||
|
ping app.dezky.local
|
||||||
|
# Should return 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
If terminal resolves but browser doesn't:
|
||||||
|
- Chrome: chrome://net-internals/#dns → Clear host cache
|
||||||
|
- Firefox: about:networking#dns → Clear DNS cache
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentik OIDC integration issues
|
||||||
|
|
||||||
|
### "Invalid issuer URL"
|
||||||
|
|
||||||
|
The `iss` claim in the JWT must match exactly what the consuming service expects.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# In docker-compose.yml for OCIS:
|
||||||
|
OCIS_OIDC_ISSUER: https://auth.dezky.local/application/o/ocis/
|
||||||
|
```
|
||||||
|
|
||||||
|
The trailing slash matters. Authentik issues with trailing slash by default.
|
||||||
|
|
||||||
|
Verify the actual issuer:
|
||||||
|
```bash
|
||||||
|
curl -s https://auth.dezky.local/application/o/ocis/.well-known/openid-configuration | jq .issuer
|
||||||
|
```
|
||||||
|
|
||||||
|
### "redirect_uri not allowed"
|
||||||
|
|
||||||
|
The OAuth provider in Authentik must list every redirect URI the client might use.
|
||||||
|
|
||||||
|
For OCIS:
|
||||||
|
```
|
||||||
|
https://files.dezky.local/
|
||||||
|
https://files.dezky.local/oidc-callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Add both. Patterns matter — exact match.
|
||||||
|
|
||||||
|
### Login loop (redirects forever)
|
||||||
|
|
||||||
|
Usually caused by:
|
||||||
|
1. **Time mismatch** between container and host. Check `docker compose exec ocis date` matches host clock.
|
||||||
|
2. **Cookie domain mismatch.** Cookies set for `.dezky.local` should work across subdomains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hot reload not working
|
||||||
|
|
||||||
|
### Nuxt portal doesn't rebuild on file changes
|
||||||
|
|
||||||
|
The volume mount works on macOS but file watching needs explicit polling:
|
||||||
|
|
||||||
|
Add to `apps/portal/nuxt.config.ts`:
|
||||||
|
```ts
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
interval: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### NestJS provisioning doesn't restart
|
||||||
|
|
||||||
|
Same issue. The `start:dev` command uses nodemon under the hood. Make sure your `package.json` has:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"start:dev": "nest start --watch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data and reset issues
|
||||||
|
|
||||||
|
### Want to keep data but restart services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart [service-name]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Want to reset just one service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop authentik-server authentik-worker
|
||||||
|
docker volume rm dezky_authentik_media dezky_authentik_certs
|
||||||
|
docker compose up -d authentik-server authentik-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full reset (nuclear option)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/reset.sh
|
||||||
|
./scripts/bootstrap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance issues
|
||||||
|
|
||||||
|
### Stack is using too much RAM
|
||||||
|
|
||||||
|
Check usage:
|
||||||
|
```bash
|
||||||
|
docker stats --no-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
Top RAM consumers are usually:
|
||||||
|
- Zulip (4-6 GB) — disabled in main compose
|
||||||
|
- Jitsi (2-4 GB) — disabled in main compose
|
||||||
|
- Authentik server + worker (~1 GB each)
|
||||||
|
- OCIS (~1 GB)
|
||||||
|
- Collabora (1-2 GB if active document open)
|
||||||
|
|
||||||
|
For low-memory machines, disable services you're not using:
|
||||||
|
```bash
|
||||||
|
docker compose stop collabora # Save ~1 GB
|
||||||
|
docker compose stop ocis # Save ~1 GB if not testing files
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS Docker is slow
|
||||||
|
|
||||||
|
OrbStack is significantly faster than Docker Desktop on macOS:
|
||||||
|
```bash
|
||||||
|
brew install --cask orbstack
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in Docker Desktop, enable VirtioFS for bind mount performance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logs and debugging
|
||||||
|
|
||||||
|
### See logs from one service
|
||||||
|
```bash
|
||||||
|
docker compose logs -f authentik-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### See logs from multiple services
|
||||||
|
```bash
|
||||||
|
docker compose logs -f authentik-server authentik-worker postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inspect a container
|
||||||
|
```bash
|
||||||
|
docker compose exec authentik-server sh
|
||||||
|
# or
|
||||||
|
docker compose exec postgres psql -U postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### See what's running
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network debugging — can services reach each other?
|
||||||
|
```bash
|
||||||
|
docker compose exec ocis ping -c 3 authentik-server
|
||||||
|
docker compose exec ocis curl -v http://authentik-server:9000/-/health/ready/
|
||||||
|
```
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Dezky PostgreSQL initialization
|
||||||
|
# Creates databases and users for Authentik and OCIS.
|
||||||
|
# Passwords come from env vars set in docker-compose.yml.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||||
|
-- Authentik
|
||||||
|
CREATE USER authentik WITH PASSWORD '${AUTHENTIK_DB_PASSWORD}';
|
||||||
|
CREATE DATABASE authentik WITH OWNER authentik ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'C' TEMPLATE template0;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE authentik TO authentik;
|
||||||
|
|
||||||
|
-- OCIS (reserved for future use; OCIS uses internal storage in dev)
|
||||||
|
CREATE USER ocis WITH PASSWORD '${OCIS_DB_PASSWORD}';
|
||||||
|
CREATE DATABASE ocis WITH OWNER ocis ENCODING 'UTF8' TEMPLATE template0;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE ocis TO ocis;
|
||||||
|
EOSQL
|
||||||
|
|
||||||
|
# Grant schema permissions inside each newly created DB
|
||||||
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname authentik <<-EOSQL
|
||||||
|
GRANT ALL ON SCHEMA public TO authentik;
|
||||||
|
EOSQL
|
||||||
|
|
||||||
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname ocis <<-EOSQL
|
||||||
|
GRANT ALL ON SCHEMA public TO ocis;
|
||||||
|
EOSQL
|
||||||
|
|
||||||
|
echo "Dezky PostgreSQL initialization complete."
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Stalwart Mail Server — Local Development Configuration
|
||||||
|
#
|
||||||
|
# This is a minimal config for local dev. Production config will have:
|
||||||
|
# - Real TLS certs (Let's Encrypt)
|
||||||
|
# - DKIM signing with real keys
|
||||||
|
# - SPF/DMARC enforcement
|
||||||
|
# - Rspamd integration
|
||||||
|
# - Hetzner Object Storage for blob storage
|
||||||
|
#
|
||||||
|
# Reference: https://stalw.art/docs
|
||||||
|
|
||||||
|
[server]
|
||||||
|
hostname = "mail.dezky.local"
|
||||||
|
|
||||||
|
[server.listener]
|
||||||
|
"smtp" = { bind = "[::]:25", protocol = "smtp" }
|
||||||
|
"submission" = { bind = "[::]:587", protocol = "smtp", tls.implicit = false }
|
||||||
|
"submissions" = { bind = "[::]:465", protocol = "smtp", tls.implicit = true }
|
||||||
|
"imap" = { bind = "[::]:143", protocol = "imap", tls.implicit = false }
|
||||||
|
"imaps" = { bind = "[::]:993", protocol = "imap", tls.implicit = true }
|
||||||
|
"sieve" = { bind = "[::]:4190", protocol = "managesieve" }
|
||||||
|
"http" = { bind = "[::]:8080", protocol = "http" }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Storage — RocksDB embedded for local dev (single-binary simplicity)
|
||||||
|
# Production will use PostgreSQL backend
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
[store."rocksdb"]
|
||||||
|
type = "rocksdb"
|
||||||
|
path = "/opt/stalwart/data"
|
||||||
|
compression = "lz4"
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
data = "rocksdb"
|
||||||
|
fts = "rocksdb"
|
||||||
|
blob = "rocksdb"
|
||||||
|
lookup = "rocksdb"
|
||||||
|
directory = "internal"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Directory — internal user store for local dev
|
||||||
|
# Production will wire OIDC to Authentik
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
[directory."internal"]
|
||||||
|
type = "internal"
|
||||||
|
store = "rocksdb"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# TLS — Self-signed in dev, Traefik terminates the public-facing HTTPS
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
[certificate."default"]
|
||||||
|
cert = "%{file:/opt/stalwart/etc/tls/cert.pem}%"
|
||||||
|
private-key = "%{file:/opt/stalwart/etc/tls/key.pem}%"
|
||||||
|
default = true
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Authentication for local development
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
[authentication]
|
||||||
|
fallback-admin.user = "admin"
|
||||||
|
fallback-admin.secret = "$env{STALWART_ADMIN_PASSWORD}"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Resolver — use the Docker DNS for local dev
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
[resolver]
|
||||||
|
type = "system"
|
||||||
|
preserve-intermediates = true
|
||||||
|
concurrency = 2
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Logging
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
[tracer."stdout"]
|
||||||
|
type = "stdout"
|
||||||
|
level = "info"
|
||||||
|
ansi = false
|
||||||
|
enable = true
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Spam filtering — disabled in dev (no Rspamd configured)
|
||||||
|
# Production: integrate Rspamd via milter
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
[spam-filter]
|
||||||
|
enable = false
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Local development hint:
|
||||||
|
# After first boot, create your first mailbox by visiting
|
||||||
|
# https://mail.dezky.local and using admin credentials.
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Traefik dynamic configuration — TLS certificates and middleware
|
||||||
|
#
|
||||||
|
# Uses the wildcard mkcert certificate for all *.dezky.local hostnames.
|
||||||
|
# This file is watched and reloaded automatically by Traefik.
|
||||||
|
|
||||||
|
tls:
|
||||||
|
certificates:
|
||||||
|
- certFile: /certs/dezky.local.pem
|
||||||
|
keyFile: /certs/dezky.local-key.pem
|
||||||
|
stores:
|
||||||
|
- default
|
||||||
|
|
||||||
|
stores:
|
||||||
|
default:
|
||||||
|
defaultCertificate:
|
||||||
|
certFile: /certs/dezky.local.pem
|
||||||
|
keyFile: /certs/dezky.local-key.pem
|
||||||
|
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
# Strong security headers for all services
|
||||||
|
secure-headers:
|
||||||
|
headers:
|
||||||
|
frameDeny: false # OCIS/Collabora need iframes
|
||||||
|
sslRedirect: true
|
||||||
|
browserXssFilter: true
|
||||||
|
contentTypeNosniff: true
|
||||||
|
forceSTSHeader: true
|
||||||
|
stsIncludeSubdomains: true
|
||||||
|
stsPreload: true
|
||||||
|
stsSeconds: 15552000
|
||||||
|
customFrameOptionsValue: "SAMEORIGIN"
|
||||||
|
|
||||||
|
# CORS for API calls between portal and provisioning service
|
||||||
|
cors:
|
||||||
|
headers:
|
||||||
|
accessControlAllowMethods:
|
||||||
|
- "GET"
|
||||||
|
- "POST"
|
||||||
|
- "PUT"
|
||||||
|
- "PATCH"
|
||||||
|
- "DELETE"
|
||||||
|
- "OPTIONS"
|
||||||
|
accessControlAllowOriginListRegex:
|
||||||
|
- "^https://([a-z0-9-]+\\.)?dezky\\.local$"
|
||||||
|
accessControlAllowHeaders:
|
||||||
|
- "Content-Type"
|
||||||
|
- "Authorization"
|
||||||
|
- "X-Requested-With"
|
||||||
|
accessControlMaxAge: 86400
|
||||||
|
addVaryHeader: true
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Traefik static configuration for Dezky local development
|
||||||
|
#
|
||||||
|
# Provides TLS termination using mkcert-generated wildcard certificate.
|
||||||
|
# Auto-discovers services via Docker labels.
|
||||||
|
|
||||||
|
global:
|
||||||
|
checkNewVersion: false
|
||||||
|
sendAnonymousUsage: false
|
||||||
|
|
||||||
|
api:
|
||||||
|
dashboard: true
|
||||||
|
insecure: true # OK for local dev only — exposes dashboard on :8080
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: INFO
|
||||||
|
|
||||||
|
accessLog: {}
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
http:
|
||||||
|
redirections:
|
||||||
|
entryPoint:
|
||||||
|
to: websecure
|
||||||
|
scheme: https
|
||||||
|
permanent: true
|
||||||
|
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
http:
|
||||||
|
tls: {}
|
||||||
|
|
||||||
|
providers:
|
||||||
|
docker:
|
||||||
|
endpoint: "unix:///var/run/docker.sock"
|
||||||
|
exposedByDefault: false
|
||||||
|
network: dezky
|
||||||
|
watch: true
|
||||||
|
|
||||||
|
file:
|
||||||
|
filename: /etc/traefik/dynamic.yml
|
||||||
|
watch: true
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
# Dezky — Local Development Stack
|
||||||
|
#
|
||||||
|
# Start: docker compose up -d
|
||||||
|
# Logs: docker compose logs -f [service]
|
||||||
|
# Stop: docker compose down
|
||||||
|
# Reset: docker compose down -v (WARNING: deletes all data)
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# 1. mkcert root CA installed (mkcert -install)
|
||||||
|
# 2. Wildcard cert generated in ./certs/dezky.local.pem
|
||||||
|
# 3. /etc/hosts entries added (run scripts/setup-hosts.sh)
|
||||||
|
# 4. .env file created from .env.example
|
||||||
|
|
||||||
|
name: dezky
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dezky:
|
||||||
|
name: dezky
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
mongo_data:
|
||||||
|
redis_data:
|
||||||
|
authentik_media:
|
||||||
|
authentik_certs:
|
||||||
|
authentik_templates:
|
||||||
|
stalwart_data:
|
||||||
|
ocis_config:
|
||||||
|
ocis_data:
|
||||||
|
portal_node_modules:
|
||||||
|
provisioning_node_modules:
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Traefik — Reverse proxy with TLS termination via mkcert
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7
|
||||||
|
container_name: dezky-traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "8443:8080" # Dashboard
|
||||||
|
volumes:
|
||||||
|
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
|
||||||
|
- ./configs/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./configs/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
|
- ./certs:/certs:ro
|
||||||
|
networks:
|
||||||
|
dezky:
|
||||||
|
aliases:
|
||||||
|
- traefik.dezky.local
|
||||||
|
- auth.dezky.local
|
||||||
|
- app.dezky.local
|
||||||
|
- files.dezky.local
|
||||||
|
- mail.dezky.local
|
||||||
|
- office.dezky.local
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.dashboard.rule=Host(`traefik.dezky.local`)
|
||||||
|
- traefik.http.routers.dashboard.service=api@internal
|
||||||
|
- traefik.http.routers.dashboard.tls=true
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# PostgreSQL — Shared RDBMS (Authentik, OCIS)
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: dezky-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD}
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
AUTHENTIK_DB_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||||
|
OCIS_DB_PASSWORD: ${OCIS_DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./configs/postgres/init.sh:/docker-entrypoint-initdb.d/init.sh:ro
|
||||||
|
networks: [dezky]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# MongoDB — Portal application data
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
container_name: dezky-mongo
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: root
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
|
||||||
|
MONGO_INITDB_DATABASE: dezky
|
||||||
|
volumes:
|
||||||
|
- mongo_data:/data/db
|
||||||
|
networks: [dezky]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Redis — Cache + session store
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: dezky-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --save 60 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks: [dezky]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Authentik — Identity provider (OIDC/SAML SSO)
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
authentik-server:
|
||||||
|
image: ghcr.io/goauthentik/server:2025.10
|
||||||
|
container_name: dezky-authentik
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_REDIS__HOST: redis
|
||||||
|
AUTHENTIK_REDIS__PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: postgres
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||||
|
AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: admin@dezky.local
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- authentik_media:/media
|
||||||
|
- authentik_certs:/certs
|
||||||
|
- authentik_templates:/templates
|
||||||
|
- ./configs/authentik/blueprints:/blueprints/custom:ro
|
||||||
|
networks: [dezky]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.authentik.rule=Host(`auth.dezky.local`)
|
||||||
|
- traefik.http.routers.authentik.tls=true
|
||||||
|
- traefik.http.services.authentik.loadbalancer.server.port=9000
|
||||||
|
|
||||||
|
authentik-worker:
|
||||||
|
image: ghcr.io/goauthentik/server:2025.10
|
||||||
|
container_name: dezky-authentik-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
command: worker
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_REDIS__HOST: redis
|
||||||
|
AUTHENTIK_REDIS__PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: postgres
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||||
|
volumes:
|
||||||
|
- authentik_media:/media
|
||||||
|
- authentik_certs:/certs
|
||||||
|
- authentik_templates:/templates
|
||||||
|
networks: [dezky]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Stalwart Mail — Mail server (SMTP/IMAP/JMAP/CalDAV/CardDAV/ActiveSync)
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
stalwart:
|
||||||
|
image: stalwartlabs/stalwart:v0.16
|
||||||
|
container_name: dezky-stalwart
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "25:25" # SMTP
|
||||||
|
- "465:465" # SMTPS
|
||||||
|
- "587:587" # Submission
|
||||||
|
- "143:143" # IMAP
|
||||||
|
- "993:993" # IMAPS
|
||||||
|
- "4190:4190" # ManageSieve
|
||||||
|
environment:
|
||||||
|
STALWART_FQDN: mail.dezky.local
|
||||||
|
volumes:
|
||||||
|
- stalwart_data:/opt/stalwart
|
||||||
|
- ./configs/stalwart/config.toml:/opt/stalwart/etc/config.toml:ro
|
||||||
|
networks: [dezky]
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.stalwart.rule=Host(`mail.dezky.local`)
|
||||||
|
- traefik.http.routers.stalwart.tls=true
|
||||||
|
- traefik.http.services.stalwart.loadbalancer.server.port=8080
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# OCIS — File storage with S3-compatible backend
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
ocis:
|
||||||
|
image: owncloud/ocis:7.0
|
||||||
|
container_name: dezky-ocis
|
||||||
|
restart: unless-stopped
|
||||||
|
entrypoint: /bin/sh
|
||||||
|
command: ["-c", "ocis init --insecure true || true && ocis server"]
|
||||||
|
environment:
|
||||||
|
OCIS_URL: https://files.dezky.local
|
||||||
|
OCIS_LOG_LEVEL: warn
|
||||||
|
OCIS_INSECURE: "true" # dev only — self-signed certs
|
||||||
|
PROXY_HTTP_ADDR: 0.0.0.0:9200
|
||||||
|
PROXY_TLS: "false" # Traefik terminates TLS; OCIS speaks plain HTTP internally
|
||||||
|
OCIS_OIDC_ISSUER: https://auth.dezky.local/application/o/ocis/
|
||||||
|
WEB_OIDC_CLIENT_ID: ocis-web
|
||||||
|
PROXY_AUTOPROVISION_ACCOUNTS: "true"
|
||||||
|
PROXY_USER_OIDC_CLAIM: preferred_username
|
||||||
|
PROXY_USER_CS3_CLAIM: username
|
||||||
|
OCIS_ADMIN_USER_ID: ""
|
||||||
|
IDM_CREATE_DEMO_USERS: "false"
|
||||||
|
IDM_ADMIN_PASSWORD: ${OCIS_ADMIN_PASSWORD}
|
||||||
|
STORAGE_USERS_DRIVER: ocis # Local filesystem in dev
|
||||||
|
STORAGE_SYSTEM_DRIVER: ocis
|
||||||
|
OCIS_CONFIG_DIR: /etc/ocis
|
||||||
|
OCIS_BASE_DATA_PATH: /var/lib/ocis
|
||||||
|
volumes:
|
||||||
|
- ocis_config:/etc/ocis
|
||||||
|
- ocis_data:/var/lib/ocis
|
||||||
|
networks: [dezky]
|
||||||
|
depends_on:
|
||||||
|
- authentik-server
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.ocis.rule=Host(`files.dezky.local`)
|
||||||
|
- traefik.http.routers.ocis.tls=true
|
||||||
|
- traefik.http.services.ocis.loadbalancer.server.port=9200
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Collabora — Office document editor (integrated into OCIS)
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
collabora:
|
||||||
|
image: collabora/code:latest
|
||||||
|
container_name: dezky-collabora
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
aliasgroup1: https://files\\.dezky\\.local:443
|
||||||
|
DONT_GEN_SSL_CERT: "true"
|
||||||
|
extra_params: --o:ssl.enable=false --o:ssl.termination=true --o:welcome.enable=false
|
||||||
|
username: admin
|
||||||
|
password: ${COLLABORA_ADMIN_PASSWORD}
|
||||||
|
networks: [dezky]
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.collabora.rule=Host(`office.dezky.local`)
|
||||||
|
- traefik.http.routers.collabora.tls=true
|
||||||
|
- traefik.http.services.collabora.loadbalancer.server.port=9980
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Portal — Nuxt 3 customer portal (development mode with HMR)
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
portal:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: dezky-portal
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
NUXT_HOST: 0.0.0.0
|
||||||
|
NUXT_PORT: 3000
|
||||||
|
NUXT_PUBLIC_AUTH_URL: https://auth.dezky.local
|
||||||
|
NUXT_PUBLIC_PORTAL_URL: https://app.dezky.local
|
||||||
|
NUXT_API_BASE: http://provisioning:3001
|
||||||
|
MONGODB_URI: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
|
||||||
|
# OIDC (confidential client) — used by Nuxt server middleware
|
||||||
|
NUXT_OIDC_CLIENT_ID: ${PORTAL_OIDC_CLIENT_ID}
|
||||||
|
NUXT_OIDC_CLIENT_SECRET: ${PORTAL_OIDC_CLIENT_SECRET}
|
||||||
|
NUXT_OIDC_ISSUER: ${PORTAL_OIDC_ISSUER}
|
||||||
|
NUXT_OIDC_REDIRECT_URI: https://app.dezky.local/auth/oidc/callback
|
||||||
|
# Session encryption (required by nuxt-oidc-auth)
|
||||||
|
NUXT_OIDC_TOKEN_KEY: ${NUXT_OIDC_TOKEN_KEY}
|
||||||
|
NUXT_OIDC_SESSION_SECRET: ${NUXT_OIDC_SESSION_SECRET}
|
||||||
|
NUXT_OIDC_AUTH_SESSION_SECRET: ${NUXT_OIDC_AUTH_SESSION_SECRET}
|
||||||
|
# Trust mkcert root CA for Node fetch (dev only)
|
||||||
|
NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
|
||||||
|
volumes:
|
||||||
|
- ../../apps/portal:/app
|
||||||
|
- portal_node_modules:/app/node_modules
|
||||||
|
- ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
|
||||||
|
networks: [dezky]
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.portal.rule=Host(`app.dezky.local`) || Host(`dezky.local`)
|
||||||
|
- traefik.http.routers.portal.tls=true
|
||||||
|
- traefik.http.services.portal.loadbalancer.server.port=3000
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Provisioning service — NestJS worker for tenant lifecycle
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
provisioning:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: dezky-provisioning
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm start:dev"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PORT: 3001
|
||||||
|
MONGODB_URI: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
|
||||||
|
AUTHENTIK_API_URL: http://authentik-server:9000/api/v3
|
||||||
|
AUTHENTIK_API_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
|
||||||
|
STALWART_API_URL: http://stalwart:8080
|
||||||
|
STALWART_ADMIN_USER: admin
|
||||||
|
STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD}
|
||||||
|
OCIS_API_URL: http://ocis:9200
|
||||||
|
volumes:
|
||||||
|
- ../../services/provisioning:/app
|
||||||
|
- provisioning_node_modules:/app/node_modules
|
||||||
|
networks: [dezky]
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
Executable
+265
@@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Dezky local development bootstrap
|
||||||
|
# Run this once when setting up the project for the first time.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/bootstrap.sh
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Colors for output
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Determine project root
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
COMPOSE_DIR="$PROJECT_ROOT/infrastructure/docker-compose"
|
||||||
|
CERTS_DIR="$COMPOSE_DIR/certs"
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Dezky Local Development Bootstrap ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Step 1: Check prerequisites
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
info "Step 1: Checking prerequisites..."
|
||||||
|
|
||||||
|
check_command() {
|
||||||
|
if ! command -v "$1" &> /dev/null; then
|
||||||
|
error "$1 is not installed."
|
||||||
|
echo " Install with: $2"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "$1 found: $(command -v "$1")"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_command docker "Install Docker Desktop or OrbStack from https://orbstack.dev"
|
||||||
|
check_command mkcert "brew install mkcert"
|
||||||
|
check_command openssl "Should be preinstalled on macOS"
|
||||||
|
|
||||||
|
if ! docker compose version &> /dev/null; then
|
||||||
|
error "Docker Compose v2 not available."
|
||||||
|
echo " Update Docker Desktop or install OrbStack."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Docker Compose v2 available"
|
||||||
|
|
||||||
|
# Check Docker daemon is running
|
||||||
|
if ! docker info &> /dev/null; then
|
||||||
|
error "Docker daemon not running. Start Docker Desktop / OrbStack first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Docker daemon running"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Step 2: Generate TLS certificates
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
info "Step 2: Setting up TLS certificates..."
|
||||||
|
|
||||||
|
mkdir -p "$CERTS_DIR"
|
||||||
|
cd "$CERTS_DIR"
|
||||||
|
|
||||||
|
if [[ -f "dezky.local.pem" && -f "dezky.local-key.pem" ]]; then
|
||||||
|
ok "TLS certificates already exist in $CERTS_DIR"
|
||||||
|
else
|
||||||
|
info "Generating wildcard certificate for *.dezky.local..."
|
||||||
|
|
||||||
|
if ! mkcert -CAROOT &> /dev/null; then
|
||||||
|
warn "mkcert root CA not found. Running mkcert -install..."
|
||||||
|
mkcert -install
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkcert "*.dezky.local" "dezky.local" "localhost" "127.0.0.1" "::1"
|
||||||
|
|
||||||
|
# Normalize filenames (mkcert adds counts)
|
||||||
|
mv ./_wildcard.dezky.local+*.pem dezky.local.pem 2>/dev/null || true
|
||||||
|
mv ./_wildcard.dezky.local+*-key.pem dezky.local-key.pem 2>/dev/null || true
|
||||||
|
|
||||||
|
ok "TLS certificates generated"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Step 3: Update /etc/hosts
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
info "Step 3: Setting up /etc/hosts entries..."
|
||||||
|
|
||||||
|
HOSTS_ENTRIES=(
|
||||||
|
"dezky.local"
|
||||||
|
"app.dezky.local"
|
||||||
|
"auth.dezky.local"
|
||||||
|
"mail.dezky.local"
|
||||||
|
"files.dezky.local"
|
||||||
|
"office.dezky.local"
|
||||||
|
"meet.dezky.local"
|
||||||
|
"chat.dezky.local"
|
||||||
|
"traefik.dezky.local"
|
||||||
|
)
|
||||||
|
|
||||||
|
MISSING_ENTRIES=()
|
||||||
|
for entry in "${HOSTS_ENTRIES[@]}"; do
|
||||||
|
if ! grep -q "127.0.0.1[[:space:]]\+.*\b${entry}\b" /etc/hosts; then
|
||||||
|
MISSING_ENTRIES+=("$entry")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#MISSING_ENTRIES[@]} -eq 0 ]]; then
|
||||||
|
ok "All /etc/hosts entries already present"
|
||||||
|
else
|
||||||
|
warn "Missing /etc/hosts entries: ${MISSING_ENTRIES[*]}"
|
||||||
|
echo ""
|
||||||
|
echo "Add the following line to /etc/hosts (requires sudo):"
|
||||||
|
echo ""
|
||||||
|
echo "127.0.0.1 ${HOSTS_ENTRIES[*]}"
|
||||||
|
echo ""
|
||||||
|
read -p "Add these entries automatically? (requires sudo) [y/N] " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
HOSTS_LINE="127.0.0.1 ${HOSTS_ENTRIES[*]}"
|
||||||
|
echo "$HOSTS_LINE" | sudo tee -a /etc/hosts > /dev/null
|
||||||
|
ok "Added /etc/hosts entries"
|
||||||
|
else
|
||||||
|
warn "Skipping /etc/hosts setup — you must add entries manually before continuing"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Step 4: Generate .env file
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
info "Step 4: Setting up .env file..."
|
||||||
|
|
||||||
|
if [[ -f "$PROJECT_ROOT/.env" ]]; then
|
||||||
|
ok ".env file already exists"
|
||||||
|
else
|
||||||
|
info "Generating .env with secure random values..."
|
||||||
|
|
||||||
|
cp "$PROJECT_ROOT/.env.example" "$PROJECT_ROOT/.env"
|
||||||
|
|
||||||
|
# Replace all 'changeme_*' placeholders with actual random values
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
SED_INPLACE=(-i '')
|
||||||
|
else
|
||||||
|
SED_INPLACE=(-i)
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [[ "$line" =~ ^([A-Z_]+)=changeme ]]; then
|
||||||
|
VAR_NAME="${BASH_REMATCH[1]}"
|
||||||
|
|
||||||
|
if [[ "$VAR_NAME" == "AUTHENTIK_SECRET_KEY" ]]; then
|
||||||
|
NEW_VALUE=$(openssl rand -hex 50)
|
||||||
|
else
|
||||||
|
NEW_VALUE=$(openssl rand -hex 32)
|
||||||
|
fi
|
||||||
|
|
||||||
|
sed "${SED_INPLACE[@]}" "s|^${VAR_NAME}=changeme.*|${VAR_NAME}=${NEW_VALUE}|" "$PROJECT_ROOT/.env"
|
||||||
|
fi
|
||||||
|
done < "$PROJECT_ROOT/.env.example"
|
||||||
|
|
||||||
|
ok ".env generated with secure random values"
|
||||||
|
warn "Default admin password generated. Check .env for AUTHENTIK_BOOTSTRAP_PASSWORD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Step 5: Pull Docker images
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
info "Step 5: Pulling Docker images (this may take a few minutes)..."
|
||||||
|
|
||||||
|
cd "$COMPOSE_DIR"
|
||||||
|
docker compose pull
|
||||||
|
ok "All images pulled"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Step 6: Start the stack in stages
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
info "Step 6: Starting services..."
|
||||||
|
|
||||||
|
info "Starting database layer (postgres, mongo, redis)..."
|
||||||
|
docker compose up -d postgres mongo redis
|
||||||
|
|
||||||
|
info "Waiting for databases to be healthy..."
|
||||||
|
sleep 10
|
||||||
|
for service in postgres mongo redis; do
|
||||||
|
until [[ "$(docker inspect --format='{{.State.Health.Status}}' dezky-$service 2>/dev/null)" == "healthy" ]]; do
|
||||||
|
echo -n "."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
ok "$service is healthy"
|
||||||
|
done
|
||||||
|
|
||||||
|
info "Starting Traefik reverse proxy..."
|
||||||
|
docker compose up -d traefik
|
||||||
|
|
||||||
|
info "Starting Authentik..."
|
||||||
|
docker compose up -d authentik-server authentik-worker
|
||||||
|
|
||||||
|
info "Waiting 30 seconds for Authentik to bootstrap..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
info "Starting application services..."
|
||||||
|
docker compose up -d stalwart ocis collabora
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "Stack started"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
# Final instructions
|
||||||
|
# ────────────────────────────────────────
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Setup Complete ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Service URLs:"
|
||||||
|
echo " Portal: https://app.dezky.local"
|
||||||
|
echo " Authentik (auth): https://auth.dezky.local"
|
||||||
|
echo " Mail (admin): https://mail.dezky.local"
|
||||||
|
echo " Files (OCIS): https://files.dezky.local"
|
||||||
|
echo " Office: https://office.dezky.local"
|
||||||
|
echo " Traefik: https://traefik.dezky.local"
|
||||||
|
echo ""
|
||||||
|
echo "First-time Authentik admin login:"
|
||||||
|
echo " URL: https://auth.dezky.local/if/flow/initial-setup/"
|
||||||
|
echo " Email: admin@dezky.local"
|
||||||
|
echo " Password: (see AUTHENTIK_BOOTSTRAP_PASSWORD in .env)"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Configure Authentik (see docs/AUTHENTIK-SETUP.md)"
|
||||||
|
echo " 2. Configure OCIS OIDC provider in Authentik"
|
||||||
|
echo " 3. Start building the portal in apps/portal/"
|
||||||
|
echo ""
|
||||||
|
echo "Useful commands:"
|
||||||
|
echo " Logs: docker compose -f $COMPOSE_DIR/docker-compose.yml logs -f [service]"
|
||||||
|
echo " Stop: docker compose -f $COMPOSE_DIR/docker-compose.yml down"
|
||||||
|
echo " Reset: $PROJECT_ROOT/scripts/reset.sh"
|
||||||
|
echo ""
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Reset the Dezky local stack — destroys all data, restarts fresh.
|
||||||
|
# Useful when something is broken and you want to start over.
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
COMPOSE_DIR="$(cd "$SCRIPT_DIR/../infrastructure/docker-compose" && pwd)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ WARNING: This will DESTROY ALL local data:"
|
||||||
|
echo " - All databases"
|
||||||
|
echo " - All file uploads"
|
||||||
|
echo " - All Authentik configuration"
|
||||||
|
echo " - All mail in Stalwart"
|
||||||
|
echo ""
|
||||||
|
read -p "Are you absolutely sure? Type 'yes' to confirm: " CONFIRM
|
||||||
|
|
||||||
|
if [[ "$CONFIRM" != "yes" ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$COMPOSE_DIR"
|
||||||
|
|
||||||
|
echo "Stopping all services..."
|
||||||
|
docker compose down -v
|
||||||
|
|
||||||
|
echo "Removing dangling volumes..."
|
||||||
|
docker volume prune -f --filter "label=com.docker.compose.project=dezky" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Reset complete. Run scripts/bootstrap.sh to start over."
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@dezky/provisioning",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Dezky tenant provisioning worker — NestJS",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"test": "jest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.4.0",
|
||||||
|
"@nestjs/core": "^10.4.0",
|
||||||
|
"@nestjs/platform-fastify": "^10.4.0",
|
||||||
|
"@nestjs/config": "^3.3.0",
|
||||||
|
"@nestjs/mongoose": "^10.1.0",
|
||||||
|
"mongoose": "^8.7.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.4.0",
|
||||||
|
"@nestjs/testing": "^10.4.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.12.0"
|
||||||
|
}
|
||||||
Generated
+3092
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { ConfigModule } from '@nestjs/config'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { HealthController } from './health.controller.js'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
MongooseModule.forRoot(process.env.MONGODB_URI ?? 'mongodb://localhost:27017/dezky'),
|
||||||
|
],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common'
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'dezky-provisioning',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Dezky Provisioning Service — Entry point
|
||||||
|
// Handles tenant lifecycle: create, suspend, delete, billing webhooks.
|
||||||
|
|
||||||
|
import { NestFactory } from '@nestjs/core'
|
||||||
|
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
|
||||||
|
import { AppModule } from './app.module.js'
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
AppModule,
|
||||||
|
new FastifyAdapter({ logger: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: /^https:\/\/([a-z0-9-]+\.)?dezky\.local$/,
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? 3001)
|
||||||
|
await app.listen(port, '0.0.0.0')
|
||||||
|
|
||||||
|
console.log(`Provisioning service listening on http://0.0.0.0:${port}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((err) => {
|
||||||
|
console.error('Failed to start provisioning service', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user