Business portal for organizations to manage PostGuard identity-based email signing. Built with SvelteKit, PostgreSQL, and Yivi/IRMA attribute-based authentication.
Production: business.postguard.eu
Staging: business.staging.postguard.eu
- Landing page with pricing and organization registration
- Portal — API key management, organization info, email audit log, DNS verification
- Admin panel — organization management, audit log, impersonation
- Yivi authentication — attribute-based login for both org users and admins
- Feature flags — every feature toggleable via environment variables
- SvelteKit with
adapter-node(server-side rendering) - Svelte 5 with runes (
$state,$derived,$props) - Drizzle ORM with
postgres.jsdriver - PostgreSQL 18
- SCSS with CSS custom properties (purple colorway)
- svelte-i18n (en-US, nl-NL)
- Docker and Docker Compose
- Node.js 24+ (for running checks locally)
# 1. Clone and install dependencies
git clone git@github.com:encryption4all/postguard-business.git
cd postguard-business
npm install
# 2. Copy the example environment file
cp .env.example .env
# 3. Start everything
docker compose upThis starts:
| Service | URL | Purpose |
|---|---|---|
| App | http://localhost:8080 | SvelteKit dev server (via nginx) |
| Adminer | http://localhost:8081 | Database admin UI |
| MailCrab | http://localhost:1080 | Email capture UI |
| IRMA server | http://localhost:8088 | Yivi/IRMA dev server |
The db-setup service automatically runs migrations and seeds a demo admin account + example organization on first start.
The seed script creates demo accounts that work with irma-demo attributes:
| Role | Attribute | Value |
|---|---|---|
| Admin | admin@postguard.eu |
|
| Full name | Jan de Admin |
|
| Phone | 0612345678 |
|
| Org user | info@acme.example.nl |
Admin login is at /auth/login/admin. Org login is at /auth/login.
Override admin credentials via ADMIN_EMAIL, ADMIN_FULL_NAME, ADMIN_PHONE in .env.
Toggle features via environment variables in .env:
| Flag | Controls |
|---|---|
FF_PRICING_PAGE |
Pricing page visibility |
FF_REGISTRATION |
Organization registration form |
FF_PORTAL_API_KEYS |
API key management in portal |
FF_PORTAL_ORG_INFO |
Organization info page |
FF_PORTAL_EMAIL_LOG |
Email audit log |
FF_PORTAL_DNS |
DNS verification page |
FF_ADMIN_PANEL |
Entire admin panel |
FF_ADMIN_ORG_STATUS |
Activate/suspend org buttons |
FF_ADMIN_AUDIT_LOG |
Admin audit log page |
FF_ADMIN_IMPERSONATION |
Admin impersonation feature |
In development mode, flags can also be toggled at runtime from the admin settings page.
Defined in src/lib/server/db/schema.ts using Drizzle's pgTable. Tables:
organizations— registered organizationsbusiness_api_keys— API keys (namedbusiness_to avoid collision with PKG'sapi_keystable)sessions— server-side sessions (hashed tokens)change_requests— org data change requests pending admin approvalemail_audit_log— signed email audit traildns_verifications— domain verification recordsadmin_accounts— admin usersadmin_audit_log— admin action audit trail
We use file-based SQL migrations (not drizzle-kit push). Migration files live in drizzle/migrations/ and are version-controlled.
Creating a new migration:
# 1. Edit the schema in src/lib/server/db/schema.ts
# 2. Generate the SQL migration
npm run db:generate
# 3. Review the generated SQL file in drizzle/migrations/
# 4. Commit the migration file alongside the schema changeRunning migrations locally:
npm run db:migrateMigration safety rules:
All migrations are checked for backward compatibility (both in a pre-commit hook and in CI). The following patterns are blocked:
DROP TABLE/DROP COLUMN/RENAME COLUMN/RENAME TABLESET NOT NULLwithout prior backfillADD COLUMN NOT NULLwithout aDEFAULTTRUNCATE
Use the expand/contract pattern for breaking changes:
- Release N: Add new column (nullable or with default). Old code ignores it.
- Release N+1: Code starts using the new column.
- Release N+2: Drop the old column (only after old code is fully gone).
Run the checker manually:
npm run db:checkThe business portal and the PKG server share the same PostgreSQL instance. To avoid table name collisions, business portal API keys are stored in business_api_keys (not api_keys).
| Command | Description |
|---|---|
npm run dev |
Start SvelteKit dev server |
npm run build |
Production build |
npm run check |
TypeScript + Svelte type checking |
npm run lint |
Prettier + ESLint |
npm run format |
Auto-format with Prettier |
npm run test |
Run unit + E2E tests |
npm run test:unit |
Vitest unit tests |
npm run test:e2e |
Playwright E2E tests |
npm run db:generate |
Generate SQL migration from schema changes |
npm run db:migrate |
Run pending migrations |
npm run db:push |
Push schema directly (dev only) |
npm run db:seed |
Seed demo data |
npm run db:studio |
Open Drizzle Studio |
npm run db:check |
Check migrations for dangerous patterns |
Husky runs two checks on every commit:
svelte-check --threshold warning— TypeScript + Svelte type checkingscripts/check-migrations.ts— migration safety rules
src/
routes/
(marketing)/ # Public pages (landing, pricing, register)
(portal)/portal/ # Authenticated org portal
(admin)/admin/ # Admin panel
auth/ # Login/logout
api/ # JSON API endpoints
irma/[...path]/ # IRMA server proxy (adds auth token)
health/ # Kubernetes health endpoint
lib/
server/
db/ # Drizzle schema + client
auth/ # Session + Yivi helpers
services/ # Business logic
components/ # Svelte components
stores/ # Svelte 5 rune-based stores
locales/ # i18n (en.json, nl.json)
feature-flags.ts # Feature flag system
global.scss # Design tokens (purple colorway)
drizzle/
migrations/ # SQL migration files (version-controlled)
scripts/
migrate.ts # Standalone migration runner
seed.ts # Demo data seeder
check-migrations.ts # Migration safety checker
docker/
Dockerfile # Production image
dev.Dockerfile # Dev image (hot reload)
nginx.dev.conf # Dev reverse proxy
entrypoint.sh # Production entrypoint (seed + start)
Runs on every push to main and on pull requests:
- Migration Safety — checks SQL migrations for backward-incompatible patterns
- Svelte Check — TypeScript + Svelte type checking
- Tests — Vitest + Playwright (with PostgreSQL service container)
- Release Please — creates GitHub releases with semantic versioning (main only)
- Build — multi-platform Docker images (amd64 + arm64)
- Finalize — merges into a multi-platform manifest on GHCR
| Trigger | Tag | Example |
|---|---|---|
| Push to main | edge |
ghcr.io/encryption4all/postguard-business:edge |
| Pull request | pr-N |
ghcr.io/encryption4all/postguard-business:pr-42 |
| Release | X.Y.Z |
ghcr.io/encryption4all/postguard-business:1.2.0 |
This project uses Release Please for automated semantic versioning based on Conventional Commits.
| Prefix | Version bump | Example |
|---|---|---|
fix: |
Patch (1.0.x) | fix: resolve login redirect loop |
feat: |
Minor (1.x.0) | feat: add email revocation |
feat!: or BREAKING CHANGE: |
Major (x.0.0) | feat!: change API key format |
Other prefixes (chore:, docs:, refactor:, test:) do not trigger a release.
- Write code using conventional commit messages
- Push to main — CI builds the
edgeimage - Release Please automatically opens/updates a release PR that:
- Bumps the version in
package.json - Updates
CHANGELOG.md - Collects all commits since the last release
- Bumps the version in
- Merge the release PR — this triggers:
- A GitHub release + git tag
- A Docker image tagged with the version (e.g.,
1.2.0)
- Deploy — update the image tag in
postguard-opsand apply Terraform
Staging automatically tracks the edge tag. After pushing to main:
# In postguard-ops/
terraform apply -var-file=environments/dev.tfvarsTerraform runs the migration Job first, then rolls out the new deployment.
After merging a release PR:
# In postguard-ops/
# Update postguard_business_image_tag in environments/prod.tfvars to the new version
terraform apply -var-file=environments/prod.tfvarsMigrations run as a Kubernetes Job (business-migrate-{tag}) before the deployment starts. The Job:
- Uses the same Docker image as the app
- Runs
scripts/migrate.tsagainst the production database - Must complete successfully before the deployment proceeds
- Retries up to 3 times, times out after 2 minutes
- Auto-cleans up after 5 minutes
If the migration fails, Terraform stops and the deployment does not proceed.
Note: For
edgedeploys, the Job name isbusiness-migrate-edgeand won't be recreated on subsequent applies with the same tag. Delete the old Job first:kubectl delete job business-migrate-edge -n <namespace>
Managed in postguard-ops via Terraform.
| Variable | Description |
|---|---|
deploy_business |
Enable/disable business portal |
postguard_business_image_tag |
Docker image tag to deploy |
business_host |
Public hostname |
business_database_user |
PostgreSQL user (key in K8s postgres secret) |
business_admin_secret_id |
Scaleway secret with admin credentials |
Internet → Ingress → business-svc:3000 → business-deployment
↓
PostgreSQL (Scaleway Managed)
↑
business-migrate Job (pre-deploy)
The IRMA/Yivi server is accessed through SvelteKit's backend proxy (/irma/[...path]), which adds the authentication token. The browser never communicates directly with the IRMA server.
MIT © Encryption 4 All