diff --git a/.forgejo/workflows/typecheck.yml b/.forgejo/workflows/typecheck.yml new file mode 100644 index 0000000..7b08772 --- /dev/null +++ b/.forgejo/workflows/typecheck.yml @@ -0,0 +1,33 @@ +name: typecheck + +on: + push: + branches: [main] + paths: + - '@platform/**/*.ts' + - '@platform/**/*.tsx' + - '@platform/**/tsconfig*.json' + - '@platform/package.json' + - '@platform/bun.lock' + workflow_dispatch: + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + lfs: false # never fetch the .archive/*.tar.zst blobs in CI + + - name: setup bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: install + working-directory: '@platform' + run: bun install --frozen-lockfile + + - name: typecheck + working-directory: '@platform' + run: bun run typecheck diff --git a/@platform/infrastructure/Caddyfile.local b/@platform/infrastructure/Caddyfile.local new file mode 100644 index 0000000..c20f542 --- /dev/null +++ b/@platform/infrastructure/Caddyfile.local @@ -0,0 +1,132 @@ +# Local development reverse proxy — atlilith V3 +# +# Resolves *.atlilith.apricot.lan domains to their Vite dev servers. +# Run: caddy run --config @platform/infrastructure/Caddyfile.local +# +# Uses internal TLS (mkcert) for .lan domains. +# +# Adding a new dev subdomain → add it to gen-local-certs.sh DOMAINS array +# (or include in the wildcard), add a server block below with `import local_tls`. + +{ + # auto_https off — TLS is explicit per-site via mkcert certs. + http_port 80 + https_port 443 + default_bind 0.0.0.0 :: + auto_https off +} + +# Unified mkcert wildcard for all *.atlilith.apricot.lan dev hosts. +# Regenerate the cert: @platform/scripts/gen-local-certs.sh +(local_tls) { + tls /var/home/lilith/Code/@projects/@atlilith/@platform/infrastructure/certs/_wildcard.atlilith.apricot.lan.crt /var/home/lilith/Code/@projects/@atlilith/@platform/infrastructure/certs/_wildcard.atlilith.apricot.lan.key +} + +# HTTP → HTTPS redirect for all .atlilith.apricot.lan domains +:80 { + redir https://{host}{uri} 301 +} + +# ─── Provider sites (per-instance) ───────────────────────────────────────── +# Quinn's instance keeps the quinn.apricot.lan hostnames during cutover; +# new providers get {provider}.atlilith.apricot.lan. + +https://atlilith.apricot.lan { + import local_tls + # Marketing landing (Vite on :5220) + handle { + reverse_proxy 127.0.0.1:5220 { + header_up Host {host} + } + } +} + +# ─── Provider portal (generic) ───────────────────────────────────────────── +https://portal.atlilith.apricot.lan { + import local_tls + handle { + reverse_proxy 127.0.0.1:5274 { + header_up Host {host} + } + } +} + +# ─── AI assistant ────────────────────────────────────────────────────────── +https://ai.atlilith.apricot.lan { + import local_tls + handle { + reverse_proxy 127.0.0.1:5276 { + header_up Host {host} + } + } +} + +# ─── Messenger ───────────────────────────────────────────────────────────── +https://m.atlilith.apricot.lan { + import local_tls + handle { + reverse_proxy 127.0.0.1:5275 { + header_up Host {host} + } + } +} + +# ─── Admin (platform-wide) ───────────────────────────────────────────────── +https://admin.atlilith.apricot.lan { + import local_tls + handle { + reverse_proxy 127.0.0.1:5221 { + header_up Host {host} + } + } +} + +# ─── SSO (auth) ──────────────────────────────────────────────────────────── +https://sso.atlilith.apricot.lan { + import local_tls + handle { + reverse_proxy 127.0.0.1:3045 { + header_up Host {host} + } + } +} + +# ─── API gateway (Hono) ──────────────────────────────────────────────────── +https://api.atlilith.apricot.lan { + import local_tls + handle { + reverse_proxy 127.0.0.1:3050 { + header_up Host {host} + } + } +} + +# ─── Analytics (org-analytics) ───────────────────────────────────────────── +https://data.atlilith.apricot.lan { + import local_tls + + # SSO auth gate (DEV_MODE: SSO always returns 200, transparent passthrough). + @protected not path /analytics/track/* + forward_auth @protected localhost:3045 { + uri /auth/validate + + @unauthed status 401 + handle_response @unauthed { + redir https://sso.atlilith.apricot.lan/login?redirect=https://{host}{uri} 302 + } + } + + # Public ingest path (write-key authenticated by collector). + handle /analytics/track/* { + reverse_proxy 127.0.0.1:4201 { + header_up X-Write-Key "dev-write-key" + } + } + + # Dashboard SPA + handle { + reverse_proxy 127.0.0.1:5211 { + header_up Host {host} + } + } +} diff --git a/@platform/infrastructure/compose.platform-db.yml b/@platform/infrastructure/compose.platform-db.yml new file mode 100644 index 0000000..69c8363 --- /dev/null +++ b/@platform/infrastructure/compose.platform-db.yml @@ -0,0 +1,22 @@ +services: + platform-db: + image: postgres:16-alpine + container_name: atlilith-platform-db + environment: + POSTGRES_DB: platform + POSTGRES_USER: platform + POSTGRES_PASSWORD: devpassword + ports: + - "25440:5432" + volumes: + - atlilith-platform-db-data:/var/lib/postgresql/data + - ./platform-db-init.sql:/docker-entrypoint-initdb.d/000_init.sql:ro + - ./sql/migrations:/docker-entrypoint-initdb.d/migrations:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U platform -d platform"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + atlilith-platform-db-data: diff --git a/@platform/infrastructure/compose.platform-mail.yml b/@platform/infrastructure/compose.platform-mail.yml new file mode 100644 index 0000000..df51761 --- /dev/null +++ b/@platform/infrastructure/compose.platform-mail.yml @@ -0,0 +1,20 @@ +services: + platform-mail: + image: axllent/mailpit:latest + container_name: atlilith-platform-mail + ports: + - "1026:1025" + - "8026:8025" + environment: + MP_MAX_MESSAGES: 5000 + MP_DATABASE: /data/mailpit.db + volumes: + - atlilith-platform-mail-data:/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8025/api/v1/info > /dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + atlilith-platform-mail-data: diff --git a/@platform/infrastructure/compose.platform-minio.yml b/@platform/infrastructure/compose.platform-minio.yml new file mode 100644 index 0000000..3264705 --- /dev/null +++ b/@platform/infrastructure/compose.platform-minio.yml @@ -0,0 +1,21 @@ +services: + platform-minio: + image: minio/minio:latest + container_name: atlilith-platform-minio + command: server /data --console-address ":9101" + environment: + MINIO_ROOT_USER: platform-dev + MINIO_ROOT_PASSWORD: devpassword + ports: + - "9100:9000" + - "9101:9101" + volumes: + - atlilith-platform-minio-data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + atlilith-platform-minio-data: diff --git a/@platform/infrastructure/gen-local-certs.sh b/@platform/infrastructure/gen-local-certs.sh new file mode 100755 index 0000000..a5cc41d --- /dev/null +++ b/@platform/infrastructure/gen-local-certs.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Generate mkcert certificates for all *.atlilith.apricot.lan dev subdomains. +# Run once on a new machine or when adding a new subdomain. +# Requires: mkcert (brew install mkcert / apt install mkcert) +# +# Usage: +# bash @platform/scripts/gen-local-certs.sh # generate missing +# bash @platform/scripts/gen-local-certs.sh --force # regenerate all + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +CERTS_DIR="$REPO_ROOT/@platform/infrastructure/certs" +FORCE=false + +for arg in "$@"; do [[ "$arg" == "--force" ]] && FORCE=true; done + +if ! command -v mkcert &>/dev/null; then + echo "ERROR: mkcert not found. Install it first:" >&2 + echo " Linux: brew install mkcert OR sudo apt install mkcert" >&2 + echo " macOS: brew install mkcert" >&2 + exit 1 +fi + +mkdir -p "$CERTS_DIR" + +# Single wildcard cert covers all subdomains. +WILDCARD_CRT="$CERTS_DIR/_wildcard.atlilith.apricot.lan.crt" +WILDCARD_KEY="$CERTS_DIR/_wildcard.atlilith.apricot.lan.key" + +if [[ "$FORCE" == "false" && -f "$WILDCARD_CRT" && -f "$WILDCARD_KEY" ]]; then + echo " [skip] wildcard cert already exists at $WILDCARD_CRT" +else + echo " [gen] wildcard cert for *.atlilith.apricot.lan + atlilith.apricot.lan" + mkcert \ + -cert-file "$WILDCARD_CRT" \ + -key-file "$WILDCARD_KEY" \ + "atlilith.apricot.lan" "*.atlilith.apricot.lan" +fi + +echo "" +echo "Certs at: $CERTS_DIR" +echo "" +echo "Next: (re)start Caddy to pick up changes:" +echo " manage-apps restart atlilith.proxy apricot" diff --git a/@platform/infrastructure/platform-db-init.sql b/@platform/infrastructure/platform-db-init.sql new file mode 100644 index 0000000..b69b841 --- /dev/null +++ b/@platform/infrastructure/platform-db-init.sql @@ -0,0 +1,54 @@ +-- atlilith V3 — base schema bootstrap. +-- Runs once when the docker postgres volume is fresh. +-- Migrations in sql/migrations/ run AFTER this (alphabetical by docker-entrypoint). + +CREATE SCHEMA IF NOT EXISTS platform; +CREATE SCHEMA IF NOT EXISTS analytics; +CREATE SCHEMA IF NOT EXISTS messenger; +CREATE SCHEMA IF NOT EXISTS booking; +CREATE SCHEMA IF NOT EXISTS content; +CREATE SCHEMA IF NOT EXISTS ops; + +-- Common extensions +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid() +CREATE EXTENSION IF NOT EXISTS "citext"; -- case-insensitive text (emails) + +-- Tenancy core: users table (Person tenant). +-- Migration 001_add_orgs.sql adds the Org overlay. +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z][a-z0-9-]{1,62}[a-z0-9]$'), + email CITEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +-- Updated_at maintenance (reused by org tables in migration 001) +CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_users_updated_at ON users; +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + +-- Seed: transquinnftw (inaugural Person tenant; Cocotte org seeded in 002). +INSERT INTO users (slug, email, display_name) +VALUES ('transquinnftw', 'booking@transquinnftw.com', 'Quinn') +ON CONFLICT (slug) DO NOTHING; + +-- Placeholder analytics_events table referenced by migration 001 +-- (real definition comes from the org-analytics feature later). +CREATE TABLE IF NOT EXISTS analytics_events ( + id BIGSERIAL PRIMARY KEY, + event_name TEXT NOT NULL, + user_id UUID NULL REFERENCES users(id), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now() +);