feat: add CI/CD pipeline, production Dockerfile, and deployment architecture

- Add production Dockerfile with bun build --compile, multi-stage Alpine build
- Add .dockerignore
- Swap bcrypt -> bcryptjs (pure JS, no native addons)
- Add programmatic migrations on startup via drizzle migrator
- Add /v1/version endpoint with APP_VERSION baked in at build time
- Add .gitea/workflows/ci.yml (lint + test with postgres/valkey services)
- Add .gitea/workflows/build.yml (version bump, build, push to registry)
- Update CLAUDE.md and docs/architecture.md to remove multi-tenancy
- Add docs/deployment.md covering DOKS + ArgoCD architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ryan Moon
2026-04-01 19:50:37 -05:00
parent ffef4c8727
commit c2b1073fef
15 changed files with 419 additions and 26 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.git
.gitea
docs
planning
deploy
infra
packages/admin
Dockerfile*
docker-compose*
*.md

View File

@@ -0,0 +1,77 @@
name: Build & Release
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.BOT_TOKEN }}
- name: Determine version bump
id: bump
run: |
COMMIT_MSG=$(git log -1 --pretty=%s)
if echo "$COMMIT_MSG" | grep -qiE "^breaking(\(.+\))?:|^.+!:"; then
echo "type=major" >> $GITHUB_OUTPUT
elif echo "$COMMIT_MSG" | grep -qiE "^feat(\(.+\))?:"; then
echo "type=minor" >> $GITHUB_OUTPUT
else
echo "type=patch" >> $GITHUB_OUTPUT
fi
- name: Bump version in package.json
id: version
run: |
cd packages/backend
npm version ${{ steps.bump.outputs.type }} --no-git-tag-version
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Commit version bump
run: |
git config user.name "lunarfront-bot"
git config user.email "bot@lunarfront.tech"
git remote set-url origin https://lunarfront-bot:${{ secrets.BOT_TOKEN }}@git.lunarfront.tech/ryan/lunarfront-app.git
git add packages/backend/package.json
git commit -m "chore: bump version to v${{ steps.version.outputs.version }}"
git push origin main
- name: Install Docker CLI
run: |
apt-get update -qq
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y docker-ce-cli
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.lunarfront.tech -u ryan --password-stdin
- name: Build and push
run: |
VERSION=${{ steps.version.outputs.version }}
SHA=$(git rev-parse --short HEAD)
docker build \
--build-arg APP_VERSION=$VERSION \
-t registry.lunarfront.tech/ryan/lunarfront-app:$VERSION \
-t registry.lunarfront.tech/ryan/lunarfront-app:$SHA \
-t registry.lunarfront.tech/ryan/lunarfront-app:latest \
.
docker push registry.lunarfront.tech/ryan/lunarfront-app:$VERSION
docker push registry.lunarfront.tech/ryan/lunarfront-app:$SHA
docker push registry.lunarfront.tech/ryan/lunarfront-app:latest
- name: Logout
if: always()
run: docker logout registry.lunarfront.tech

61
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,61 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ci:
runs-on: ubuntu-latest
container:
image: registry.lunarfront.tech/ryan/ci-runner:latest
credentials:
username: ryan
password: ${{ secrets.REGISTRY_TOKEN }}
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: lunarfront
POSTGRES_PASSWORD: lunarfront
POSTGRES_DB: lunarfront_test
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 3s
--health-retries 5
valkey:
image: valkey/valkey:8
options: >-
--health-cmd "valkey-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 5
env:
DATABASE_URL: postgresql://lunarfront:lunarfront@postgres:5432/lunarfront_test
REDIS_URL: redis://valkey:6379
JWT_SECRET: ci-secret
NODE_ENV: test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Run migrations
working-directory: packages/backend
run: bunx drizzle-kit migrate
- name: Test
run: bun run test

View File

@@ -24,8 +24,7 @@
## Database ## Database
- Dev: `lunarfront` on localhost:5432 - Dev: `lunarfront` on localhost:5432
- Test: `lunarfront_test` on localhost:5432 - Test: `lunarfront_test` on localhost:5432
- Multi-tenant: `company_id` (uuid FK) on all domain tables for tenant isolation - Each deployed instance has its own isolated database — no multi-tenancy, no `company_id`
- `location_id` (uuid FK) on tables that need per-location scoping (inventory, transactions, drawer)
- Migrations via Drizzle Kit (`bunx drizzle-kit generate`, `bunx drizzle-kit migrate`) - Migrations via Drizzle Kit (`bunx drizzle-kit generate`, `bunx drizzle-kit migrate`)
## Key Entity Names ## Key Entity Names

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM oven/bun:1.3.11-alpine AS deps
WORKDIR /app
COPY package.json bun.lock ./
COPY packages/shared/package.json packages/shared/
COPY packages/backend/package.json packages/backend/
RUN bun install --frozen-lockfile
FROM oven/bun:1.3.11-alpine AS build
ARG APP_VERSION=dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/backend/node_modules ./packages/backend/node_modules
COPY packages/shared ./packages/shared
COPY packages/backend ./packages/backend
COPY package.json ./
COPY tsconfig.base.json ./
WORKDIR /app/packages/backend
RUN bun build src/main.ts --compile --outfile /app/server \
--define "process.env.APP_VERSION='${APP_VERSION}'"
FROM alpine:3.21
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /app/server ./server
COPY --from=build /app/packages/backend/src/db/migrations ./migrations
ENV MIGRATIONS_DIR=/app/migrations
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:8000/v1/health || exit 1
USER app
CMD ["./server"]

View File

@@ -66,7 +66,8 @@
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@lunarfront/shared": "workspace:*", "@lunarfront/shared": "workspace:*",
"bcrypt": "^6", "@types/bcryptjs": "^3.0.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.38", "drizzle-orm": "^0.38",
"fastify": "^5", "fastify": "^5",
"fastify-plugin": "^5", "fastify-plugin": "^5",
@@ -75,7 +76,6 @@
"zod": "^4", "zod": "^4",
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5",
"@types/node": "^22", "@types/node": "^22",
"drizzle-kit": "^0.30", "drizzle-kit": "^0.30",
"pino-pretty": "^13", "pino-pretty": "^13",
@@ -530,7 +530,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bcrypt": ["@types/bcrypt@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ=="], "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/date-arithmetic": ["@types/date-arithmetic@4.1.4", "", {}, "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw=="], "@types/date-arithmetic": ["@types/date-arithmetic@4.1.4", "", {}, "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw=="],
@@ -614,7 +614,7 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="],
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
@@ -928,10 +928,6 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],

View File

@@ -56,11 +56,10 @@ src/
### Request Flow ### Request Flow
1. Fastify receives request 1. Fastify receives request
2. `onRequest` hook sets `companyId` from header 2. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
3. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active` 3. `requirePermission` preHandler checks user has required permission slug
4. `requirePermission` preHandler checks user has required permission slug 4. Route handler validates input with Zod, calls service, returns response
5. Route handler validates input with Zod, calls service, returns response 5. Error handler catches typed errors and maps to HTTP status codes
6. Error handler catches typed errors and maps to HTTP status codes
### Permission Inheritance ### Permission Inheritance
@@ -109,9 +108,9 @@ src/
| Theme | Zustand store, persisted to localStorage | | Theme | Zustand store, persisted to localStorage |
| Component state | React `useState` | | Component state | React `useState` |
## Multi-Tenancy ## Deployment Model
Every domain table has a `company_id` column. All queries filter by the authenticated user's company. Location-scoped tables (inventory, transactions) additionally filter by `location_id`. Each customer runs as a fully isolated deployment — their own Kubernetes namespace on DOKS, their own database on the shared managed Postgres instance. There is no multi-tenancy in the application layer. No `company_id`, no row-level isolation. One instance = one customer.
## Database ## Database

193
docs/deployment.md Normal file
View File

@@ -0,0 +1,193 @@
# Infrastructure & Deployment Architecture
## Overview
LunarFront runs on DigitalOcean. Each customer is a fully isolated deployment — their own Kubernetes namespace, their own database. There is no multi-tenancy in the application layer.
**Stack:** DOKS (Kubernetes) · ArgoCD · Helm · Gitea · Terraform · Ansible
The guiding principle is simplicity — Docker Compose on Droplets for Gitea, Kubernetes for customer app instances. No surprise bills.
---
## Monthly Cost
| Resource | Spec | Cost |
|---|---|---|
| DOKS cluster | Control plane | $12/mo |
| DOKS nodes (2x s-2vcpu-4gb) | Runs all customer apps | ~$48/mo |
| Managed Postgres (shared) | All customer databases | $15-25/mo |
| Managed Redis (shared) | All customer queues/cache | $15/mo |
| Gitea Droplet | 1 vCPU, 1GB RAM | $6/mo |
| Gitea Postgres | Dedicated managed Postgres | $15/mo |
| Spaces | Registry, backups, files | ~$21/mo |
| **Fixed total** | | **~$132-142/mo** |
| **Per customer** | New database in shared Postgres | **~$0 marginal** |
---
## Environments
| Environment | Hostname | Purpose |
|---|---|---|
| Production (per customer) | `{customer}.app.lunarfront.tech` | Live customer instance on DOKS |
| Gitea | `git.lunarfront.tech` | Source control, CI/CD, container registry |
| Dev | Local / feature namespace on DOKS | Testing and staging |
---
## DNS
Managed through Cloudflare. Records defined in Terraform.
| Record | Type | Points To |
|---|---|---|
| `git.lunarfront.tech` | A | Gitea Droplet |
| `git-ssh.lunarfront.tech` | A | Gitea Droplet (SSH port 2222) |
| `registry.lunarfront.tech` | A | Gitea Droplet (container registry) |
| `{customer}.app.lunarfront.tech` | CNAME | DOKS load balancer |
---
## Infrastructure (Terraform)
Terraform manages all DigitalOcean and Cloudflare resources. State stored in Spaces.
```
/terraform
main.tf providers, backend (Spaces state)
variables.tf
cluster.tf DOKS cluster + node pools
databases.tf shared Postgres, shared Redis
gitea.tf Gitea Droplet
spaces.tf files, backups, tf-state buckets
dns.tf Cloudflare DNS records
outputs.tf cluster endpoint, DB URLs
terraform.tfvars secrets (gitignored)
```
### State Backend
```hcl
terraform {
backend "s3" {
endpoint = "https://nyc3.digitaloceanspaces.com"
bucket = "lunarfront-terraform-state"
key = "terraform.tfstate"
region = "us-east-1"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
force_path_style = true
}
}
```
---
## Gitea (Source Control + CI/CD)
Gitea runs on its own Droplet with a dedicated managed Postgres. It hosts:
- Source code repositories
- Gitea Actions CI/CD pipelines
- Docker container registry (`registry.lunarfront.tech`)
Managed by Ansible. See `lunarfront-infra/ansible/roles/gitea/`.
### CI Pipeline (Gitea Actions)
On push to `main`:
1. Run lint + unit tests using the shared `ci-runner` image
2. Build Docker image, push to `registry.lunarfront.tech/ryan/lunarfront-app:{sha}`
3. Update the Helm chart `values.yaml` with the new image tag
4. ArgoCD detects the change and syncs all customer deployments
---
## Kubernetes (DOKS + ArgoCD)
All customer app instances run on a single DOKS cluster managed by ArgoCD (GitOps).
### Repository Structure
```
/gitops
apps/
customer-acme/
values.yaml
customer-foo/
values.yaml
chart/
Chart.yaml
templates/
deployment.yaml
service.yaml
ingress.yaml
job-migrate.yaml # runs drizzle-kit migrate on deploy
secret.yaml
```
### Per-Customer values.yaml
```yaml
customer: acme
subdomain: acme
image:
repository: registry.lunarfront.tech/ryan/lunarfront-app
tag: "abc123"
database:
url: "postgresql://lunarfront:pass@db-host:25060/customer_acme?sslmode=require"
redis:
url: "rediss://..."
env:
JWT_SECRET: "..."
NODE_ENV: production
```
### Adding a New Customer
1. `CREATE DATABASE customer_x;` on the shared managed Postgres
2. Add `gitops/apps/customer-x/values.yaml`
3. Push — ArgoCD syncs, migration Job runs, instance is live
4. Add DNS CNAME in Terraform
---
## Database Strategy
One managed Postgres cluster shared across all customers. Each customer gets their own isolated database (`CREATE DATABASE customer_x`). No cross-customer queries are possible at the database level.
- New customer = new database, no new infrastructure cost
- Managed DO Postgres handles backups, failover, and SSL
- Resize the cluster as total load grows
---
## Day-to-Day Workflow
```bash
# Provision infrastructure from scratch
terraform init && terraform apply
# Configure Gitea server
ansible-playbook ansible/gitea.yml -i ansible/inventory.ini
# Normal deploy flow — push to main
git push origin main
# → Gitea Actions: lint + test + build image
# → Image pushed to registry
# → Helm chart values updated
# → ArgoCD syncs all customer deployments automatically
# Add a new customer
# 1. Create database
psql $DATABASE_URL -c "CREATE DATABASE customer_x;"
# 2. Add values file, push
git add gitops/apps/customer-x/values.yaml && git push
# Done — ArgoCD handles the rest
```

View File

@@ -23,7 +23,8 @@
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@lunarfront/shared": "workspace:*", "@lunarfront/shared": "workspace:*",
"bcrypt": "^6", "@types/bcryptjs": "^3.0.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.38", "drizzle-orm": "^0.38",
"fastify": "^5", "fastify": "^5",
"fastify-plugin": "^5", "fastify-plugin": "^5",
@@ -32,10 +33,9 @@
"zod": "^4" "zod": "^4"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@types/node": "^22",
"drizzle-kit": "^0.30", "drizzle-kit": "^0.30",
"pino-pretty": "^13", "pino-pretty": "^13",
"@types/node": "^22", "typescript": "^5"
"@types/bcrypt": "^5"
} }
} }

View File

@@ -42,8 +42,8 @@ async function seed() {
const adminPassword = process.env.ADMIN_PASSWORD ?? 'admin1234' const adminPassword = process.env.ADMIN_PASSWORD ?? 'admin1234'
const [adminUser] = await sql`SELECT id FROM "user" WHERE email = 'admin@lunarfront.dev'` const [adminUser] = await sql`SELECT id FROM "user" WHERE email = 'admin@lunarfront.dev'`
if (!adminUser) { if (!adminUser) {
const bcrypt = await import('bcrypt') const bcrypt = await import('bcryptjs')
const hashedPw = await (bcrypt.default || bcrypt).hash(adminPassword, 10) const hashedPw = await bcrypt.hash(adminPassword, 10)
const [user] = await sql`INSERT INTO "user" (email, password_hash, first_name, last_name, role) VALUES ('admin@lunarfront.dev', ${hashedPw}, 'Admin', 'User', 'admin') RETURNING id` const [user] = await sql`INSERT INTO "user" (email, password_hash, first_name, last_name, role) VALUES ('admin@lunarfront.dev', ${hashedPw}, 'Admin', 'User', 'admin') RETURNING id`
const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1` const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
if (adminRole) { if (adminRole) {

View File

@@ -1,4 +1,7 @@
import Fastify from 'fastify' import Fastify from 'fastify'
import { drizzle } from 'drizzle-orm/postgres-js'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import postgres from 'postgres'
import rateLimit from '@fastify/rate-limit' import rateLimit from '@fastify/rate-limit'
import { databasePlugin } from './plugins/database.js' import { databasePlugin } from './plugins/database.js'
import { redisPlugin } from './plugins/redis.js' import { redisPlugin } from './plugins/redis.js'
@@ -8,6 +11,7 @@ import { authPlugin } from './plugins/auth.js'
import { devAuthPlugin } from './plugins/dev-auth.js' import { devAuthPlugin } from './plugins/dev-auth.js'
import { storagePlugin } from './plugins/storage.js' import { storagePlugin } from './plugins/storage.js'
import { healthRoutes } from './routes/v1/health.js' import { healthRoutes } from './routes/v1/health.js'
import { versionRoutes } from './routes/v1/version.js'
import { authRoutes } from './routes/v1/auth.js' import { authRoutes } from './routes/v1/auth.js'
import { accountRoutes } from './routes/v1/accounts.js' import { accountRoutes } from './routes/v1/accounts.js'
import { inventoryRoutes } from './routes/v1/inventory.js' import { inventoryRoutes } from './routes/v1/inventory.js'
@@ -92,6 +96,7 @@ export async function buildApp() {
// Core routes — always available // Core routes — always available
await app.register(healthRoutes, { prefix: '/v1' }) await app.register(healthRoutes, { prefix: '/v1' })
await app.register(versionRoutes, { prefix: '/v1' })
await app.register(authRoutes, { prefix: '/v1' }) await app.register(authRoutes, { prefix: '/v1' })
await app.register(accountRoutes, { prefix: '/v1' }) await app.register(accountRoutes, { prefix: '/v1' })
await app.register(rbacRoutes, { prefix: '/v1' }) await app.register(rbacRoutes, { prefix: '/v1' })
@@ -138,7 +143,20 @@ export async function buildApp() {
return app return app
} }
async function runMigrations() {
const connectionString = process.env.DATABASE_URL
if (!connectionString) throw new Error('DATABASE_URL is required')
const migrationsFolder = process.env.MIGRATIONS_DIR ?? './src/db/migrations'
const sql = postgres(connectionString, { max: 1 })
const db = drizzle(sql)
await migrate(db, { migrationsFolder })
await sql.end()
}
async function start() { async function start() {
await runMigrations()
const app = await buildApp() const app = await buildApp()
const port = parseInt(process.env.PORT ?? '8000', 10) const port = parseInt(process.env.PORT ?? '8000', 10)

View File

@@ -1,5 +1,5 @@
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import bcrypt from 'bcrypt' import bcrypt from 'bcryptjs'
import { users } from '../db/schema/users.js' import { users } from '../db/schema/users.js'
import { RbacService } from '../services/rbac.service.js' import { RbacService } from '../services/rbac.service.js'
import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify' import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'

View File

@@ -1,6 +1,6 @@
import type { FastifyPluginAsync } from 'fastify' import type { FastifyPluginAsync } from 'fastify'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import bcrypt from 'bcrypt' import bcrypt from 'bcryptjs'
import { RegisterSchema, LoginSchema } from '@lunarfront/shared/schemas' import { RegisterSchema, LoginSchema } from '@lunarfront/shared/schemas'
import { users } from '../../db/schema/users.js' import { users } from '../../db/schema/users.js'

View File

@@ -0,0 +1,7 @@
import type { FastifyPluginAsync } from 'fastify'
export const versionRoutes: FastifyPluginAsync = async (app) => {
app.get('/version', async (_request, reply) => {
reply.send({ version: process.env.APP_VERSION ?? 'dev' })
})
}

View File

@@ -5,7 +5,7 @@ import { userRoles } from '../db/schema/rbac.js'
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js' import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
import type { PaginationInput } from '@lunarfront/shared/schemas' import type { PaginationInput } from '@lunarfront/shared/schemas'
import { randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'crypto' import { randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'crypto'
import bcrypt from 'bcrypt' import bcrypt from 'bcryptjs'
// --- Encryption key held in memory --- // --- Encryption key held in memory ---
let derivedKey: Buffer | null = null let derivedKey: Buffer | null = null