|
1 | | -# sv |
| 1 | +# PostGuard for Business |
2 | 2 |
|
3 | | -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). |
| 3 | +Business portal for organizations to manage PostGuard identity-based email signing. Built with SvelteKit, PostgreSQL, and Yivi/IRMA attribute-based authentication. |
4 | 4 |
|
5 | | -## Creating a project |
| 5 | +**Production:** `business.postguard.eu` |
| 6 | +**Staging:** `business.staging.postguard.eu` |
6 | 7 |
|
7 | | -If you're seeing this, you've probably already done this step. Congrats! |
| 8 | +## Features |
8 | 9 |
|
9 | | -```sh |
10 | | -# create a new project |
11 | | -npx sv create my-app |
| 10 | +- **Landing page** with pricing and organization registration |
| 11 | +- **Portal** — API key management, organization info, email audit log, DNS verification |
| 12 | +- **Admin panel** — organization management, audit log, impersonation |
| 13 | +- **Yivi authentication** — attribute-based login for both org users and admins |
| 14 | +- **Feature flags** — every feature toggleable via environment variables |
| 15 | + |
| 16 | +## Tech stack |
| 17 | + |
| 18 | +- [SvelteKit](https://svelte.dev/docs/kit) with `adapter-node` (server-side rendering) |
| 19 | +- [Svelte 5](https://svelte.dev/docs/svelte) with runes (`$state`, `$derived`, `$props`) |
| 20 | +- [Drizzle ORM](https://orm.drizzle.team) with `postgres.js` driver |
| 21 | +- PostgreSQL 18 |
| 22 | +- SCSS with CSS custom properties (purple colorway) |
| 23 | +- [svelte-i18n](https://github.com/kaisermann/svelte-i18n) (en-US, nl-NL) |
| 24 | + |
| 25 | +## Quick start |
| 26 | + |
| 27 | +### Prerequisites |
| 28 | + |
| 29 | +- [Docker](https://docs.docker.com/get-docker/) and Docker Compose |
| 30 | +- [Node.js 24+](https://nodejs.org/) (for running checks locally) |
| 31 | + |
| 32 | +### Running locally |
| 33 | + |
| 34 | +```bash |
| 35 | +# 1. Clone and install dependencies |
| 36 | +git clone git@github.com:encryption4all/postguard-business.git |
| 37 | +cd postguard-business |
| 38 | +npm install |
| 39 | + |
| 40 | +# 2. Copy the example environment file |
| 41 | +cp .env.example .env |
| 42 | + |
| 43 | +# 3. Start everything |
| 44 | +docker compose up |
| 45 | +``` |
| 46 | + |
| 47 | +This starts: |
| 48 | + |
| 49 | +| Service | URL | Purpose | |
| 50 | +|---------|-----|---------| |
| 51 | +| **App** | http://localhost:8080 | SvelteKit dev server (via nginx) | |
| 52 | +| **Adminer** | http://localhost:8081 | Database admin UI | |
| 53 | +| **MailCrab** | http://localhost:1080 | Email capture UI | |
| 54 | +| **IRMA server** | http://localhost:8088 | Yivi/IRMA dev server | |
| 55 | + |
| 56 | +The `db-setup` service automatically runs migrations and seeds a demo admin account + example organization on first start. |
| 57 | + |
| 58 | +### Demo credentials |
| 59 | + |
| 60 | +The seed script creates demo accounts that work with `irma-demo` attributes: |
| 61 | + |
| 62 | +| Role | Attribute | Value | |
| 63 | +|------|-----------|-------| |
| 64 | +| **Admin** | Email | `admin@postguard.eu` | |
| 65 | +| | Full name | `Jan de Admin` | |
| 66 | +| | Phone | `0612345678` | |
| 67 | +| **Org user** | Email | `info@acme.example.nl` | |
| 68 | + |
| 69 | +Admin login is at `/auth/login/admin`. Org login is at `/auth/login`. |
| 70 | + |
| 71 | +Override admin credentials via `ADMIN_EMAIL`, `ADMIN_FULL_NAME`, `ADMIN_PHONE` in `.env`. |
| 72 | + |
| 73 | +### Feature flags |
| 74 | + |
| 75 | +Toggle features via environment variables in `.env`: |
| 76 | + |
| 77 | +| Flag | Controls | |
| 78 | +|------|----------| |
| 79 | +| `FF_PRICING_PAGE` | Pricing page visibility | |
| 80 | +| `FF_REGISTRATION` | Organization registration form | |
| 81 | +| `FF_PORTAL_API_KEYS` | API key management in portal | |
| 82 | +| `FF_PORTAL_ORG_INFO` | Organization info page | |
| 83 | +| `FF_PORTAL_EMAIL_LOG` | Email audit log | |
| 84 | +| `FF_PORTAL_DNS` | DNS verification page | |
| 85 | +| `FF_ADMIN_PANEL` | Entire admin panel | |
| 86 | +| `FF_ADMIN_ORG_STATUS` | Activate/suspend org buttons | |
| 87 | +| `FF_ADMIN_AUDIT_LOG` | Admin audit log page | |
| 88 | +| `FF_ADMIN_IMPERSONATION` | Admin impersonation feature | |
| 89 | + |
| 90 | +In development mode, flags can also be toggled at runtime from the admin settings page. |
| 91 | + |
| 92 | +## Database |
| 93 | + |
| 94 | +### Schema |
| 95 | + |
| 96 | +Defined in `src/lib/server/db/schema.ts` using Drizzle's `pgTable`. Tables: |
| 97 | + |
| 98 | +- `organizations` — registered organizations |
| 99 | +- `business_api_keys` — API keys (named `business_` to avoid collision with PKG's `api_keys` table) |
| 100 | +- `sessions` — server-side sessions (hashed tokens) |
| 101 | +- `change_requests` — org data change requests pending admin approval |
| 102 | +- `email_audit_log` — signed email audit trail |
| 103 | +- `dns_verifications` — domain verification records |
| 104 | +- `admin_accounts` — admin users |
| 105 | +- `admin_audit_log` — admin action audit trail |
| 106 | + |
| 107 | +### Migrations |
| 108 | + |
| 109 | +We use file-based SQL migrations (not `drizzle-kit push`). Migration files live in `drizzle/migrations/` and are version-controlled. |
| 110 | + |
| 111 | +**Creating a new migration:** |
| 112 | + |
| 113 | +```bash |
| 114 | +# 1. Edit the schema in src/lib/server/db/schema.ts |
| 115 | + |
| 116 | +# 2. Generate the SQL migration |
| 117 | +npm run db:generate |
| 118 | + |
| 119 | +# 3. Review the generated SQL file in drizzle/migrations/ |
| 120 | + |
| 121 | +# 4. Commit the migration file alongside the schema change |
| 122 | +``` |
| 123 | + |
| 124 | +**Running migrations locally:** |
| 125 | + |
| 126 | +```bash |
| 127 | +npm run db:migrate |
| 128 | +``` |
| 129 | + |
| 130 | +**Migration safety rules:** |
| 131 | + |
| 132 | +All migrations are checked for backward compatibility (both in a pre-commit hook and in CI). The following patterns are blocked: |
| 133 | + |
| 134 | +- `DROP TABLE` / `DROP COLUMN` / `RENAME COLUMN` / `RENAME TABLE` |
| 135 | +- `SET NOT NULL` without prior backfill |
| 136 | +- `ADD COLUMN NOT NULL` without a `DEFAULT` |
| 137 | +- `TRUNCATE` |
| 138 | + |
| 139 | +Use the **expand/contract** pattern for breaking changes: |
| 140 | + |
| 141 | +1. **Release N:** Add new column (nullable or with default). Old code ignores it. |
| 142 | +2. **Release N+1:** Code starts using the new column. |
| 143 | +3. **Release N+2:** Drop the old column (only after old code is fully gone). |
| 144 | + |
| 145 | +Run the checker manually: |
| 146 | + |
| 147 | +```bash |
| 148 | +npm run db:check |
12 | 149 | ``` |
13 | 150 |
|
14 | | -To recreate this project with the same configuration: |
| 151 | +### Shared database |
| 152 | + |
| 153 | +The 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`). |
| 154 | + |
| 155 | +## Development |
| 156 | + |
| 157 | +### Available scripts |
| 158 | + |
| 159 | +| Command | Description | |
| 160 | +|---------|-------------| |
| 161 | +| `npm run dev` | Start SvelteKit dev server | |
| 162 | +| `npm run build` | Production build | |
| 163 | +| `npm run check` | TypeScript + Svelte type checking | |
| 164 | +| `npm run lint` | Prettier + ESLint | |
| 165 | +| `npm run format` | Auto-format with Prettier | |
| 166 | +| `npm run test` | Run unit + E2E tests | |
| 167 | +| `npm run test:unit` | Vitest unit tests | |
| 168 | +| `npm run test:e2e` | Playwright E2E tests | |
| 169 | +| `npm run db:generate` | Generate SQL migration from schema changes | |
| 170 | +| `npm run db:migrate` | Run pending migrations | |
| 171 | +| `npm run db:push` | Push schema directly (dev only) | |
| 172 | +| `npm run db:seed` | Seed demo data | |
| 173 | +| `npm run db:studio` | Open Drizzle Studio | |
| 174 | +| `npm run db:check` | Check migrations for dangerous patterns | |
| 175 | + |
| 176 | +### Pre-commit hooks |
| 177 | + |
| 178 | +[Husky](https://typicode.github.io/husky/) runs two checks on every commit: |
| 179 | + |
| 180 | +1. `svelte-check --threshold warning` — TypeScript + Svelte type checking |
| 181 | +2. `scripts/check-migrations.ts` — migration safety rules |
| 182 | + |
| 183 | +### Project structure |
15 | 184 |
|
16 | | -```sh |
17 | | -# recreate this project |
18 | | -npx sv@0.15.1 create --template minimal --types ts --add vitest="usages:unit" playwright sveltekit-adapter="adapter:node" drizzle="database:postgresql+postgresql:postgres.js+docker:no" eslint prettier --no-install . |
19 | 185 | ``` |
| 186 | +src/ |
| 187 | + routes/ |
| 188 | + (marketing)/ # Public pages (landing, pricing, register) |
| 189 | + (portal)/portal/ # Authenticated org portal |
| 190 | + (admin)/admin/ # Admin panel |
| 191 | + auth/ # Login/logout |
| 192 | + api/ # JSON API endpoints |
| 193 | + irma/[...path]/ # IRMA server proxy (adds auth token) |
| 194 | + health/ # Kubernetes health endpoint |
| 195 | + lib/ |
| 196 | + server/ |
| 197 | + db/ # Drizzle schema + client |
| 198 | + auth/ # Session + Yivi helpers |
| 199 | + services/ # Business logic |
| 200 | + components/ # Svelte components |
| 201 | + stores/ # Svelte 5 rune-based stores |
| 202 | + locales/ # i18n (en.json, nl.json) |
| 203 | + feature-flags.ts # Feature flag system |
| 204 | + global.scss # Design tokens (purple colorway) |
| 205 | +drizzle/ |
| 206 | + migrations/ # SQL migration files (version-controlled) |
| 207 | +scripts/ |
| 208 | + migrate.ts # Standalone migration runner |
| 209 | + seed.ts # Demo data seeder |
| 210 | + check-migrations.ts # Migration safety checker |
| 211 | +docker/ |
| 212 | + Dockerfile # Production image |
| 213 | + dev.Dockerfile # Dev image (hot reload) |
| 214 | + nginx.dev.conf # Dev reverse proxy |
| 215 | + entrypoint.sh # Production entrypoint (seed + start) |
| 216 | +``` |
| 217 | + |
| 218 | +## CI/CD |
| 219 | + |
| 220 | +### Pipeline (`.github/workflows/ci.yml`) |
| 221 | + |
| 222 | +Runs on every push to `main` and on pull requests: |
| 223 | + |
| 224 | +1. **Migration Safety** — checks SQL migrations for backward-incompatible patterns |
| 225 | +2. **Svelte Check** — TypeScript + Svelte type checking |
| 226 | +3. **Tests** — Vitest + Playwright (with PostgreSQL service container) |
| 227 | +4. **Release Please** — creates GitHub releases with semantic versioning (main only) |
| 228 | +5. **Build** — multi-platform Docker images (amd64 + arm64) |
| 229 | +6. **Finalize** — merges into a multi-platform manifest on GHCR |
| 230 | + |
| 231 | +### Docker image tags |
| 232 | + |
| 233 | +| Trigger | Tag | Example | |
| 234 | +|---------|-----|---------| |
| 235 | +| Push to main | `edge` | `ghcr.io/encryption4all/postguard-business:edge` | |
| 236 | +| Pull request | `pr-N` | `ghcr.io/encryption4all/postguard-business:pr-42` | |
| 237 | +| Release | `X.Y.Z` | `ghcr.io/encryption4all/postguard-business:1.2.0` | |
20 | 238 |
|
21 | | -## Developing |
| 239 | +## Releases |
22 | 240 |
|
23 | | -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: |
| 241 | +This project uses [Release Please](https://github.com/googleapis/release-please) for automated semantic versioning based on [Conventional Commits](https://www.conventionalcommits.org/). |
24 | 242 |
|
25 | | -```sh |
26 | | -npm run dev |
| 243 | +### Commit message format |
27 | 244 |
|
28 | | -# or start the server and open the app in a new browser tab |
29 | | -npm run dev -- --open |
| 245 | +| Prefix | Version bump | Example | |
| 246 | +|--------|-------------|---------| |
| 247 | +| `fix:` | Patch (1.0.x) | `fix: resolve login redirect loop` | |
| 248 | +| `feat:` | Minor (1.x.0) | `feat: add email revocation` | |
| 249 | +| `feat!:` or `BREAKING CHANGE:` | Major (x.0.0) | `feat!: change API key format` | |
| 250 | + |
| 251 | +Other prefixes (`chore:`, `docs:`, `refactor:`, `test:`) do not trigger a release. |
| 252 | + |
| 253 | +### Release workflow |
| 254 | + |
| 255 | +1. **Write code** using conventional commit messages |
| 256 | +2. **Push to main** — CI builds the `edge` image |
| 257 | +3. **Release Please** automatically opens/updates a release PR that: |
| 258 | + - Bumps the version in `package.json` |
| 259 | + - Updates `CHANGELOG.md` |
| 260 | + - Collects all commits since the last release |
| 261 | +4. **Merge the release PR** — this triggers: |
| 262 | + - A GitHub release + git tag |
| 263 | + - A Docker image tagged with the version (e.g., `1.2.0`) |
| 264 | +5. **Deploy** — update the image tag in `postguard-ops` and apply Terraform |
| 265 | + |
| 266 | +### Deploying to staging |
| 267 | + |
| 268 | +Staging automatically tracks the `edge` tag. After pushing to main: |
| 269 | + |
| 270 | +```bash |
| 271 | +# In postguard-ops/ |
| 272 | +terraform apply -var-file=environments/dev.tfvars |
30 | 273 | ``` |
31 | 274 |
|
32 | | -## Building |
| 275 | +Terraform runs the migration Job first, then rolls out the new deployment. |
| 276 | + |
| 277 | +### Deploying to production |
33 | 278 |
|
34 | | -To create a production version of your app: |
| 279 | +After merging a release PR: |
35 | 280 |
|
36 | | -```sh |
37 | | -npm run build |
| 281 | +```bash |
| 282 | +# In postguard-ops/ |
| 283 | +# Update postguard_business_image_tag in environments/prod.tfvars to the new version |
| 284 | +terraform apply -var-file=environments/prod.tfvars |
38 | 285 | ``` |
39 | 286 |
|
40 | | -You can preview the production build with `npm run preview`. |
| 287 | +### Kubernetes migration Job |
| 288 | + |
| 289 | +Migrations run as a Kubernetes Job (`business-migrate-{tag}`) before the deployment starts. The Job: |
| 290 | + |
| 291 | +- Uses the same Docker image as the app |
| 292 | +- Runs `scripts/migrate.ts` against the production database |
| 293 | +- Must complete successfully before the deployment proceeds |
| 294 | +- Retries up to 3 times, times out after 2 minutes |
| 295 | +- Auto-cleans up after 5 minutes |
| 296 | + |
| 297 | +If the migration fails, Terraform stops and the deployment does not proceed. |
| 298 | + |
| 299 | +> **Note:** For `edge` deploys, the Job name is `business-migrate-edge` and won't be recreated on subsequent applies with the same tag. Delete the old Job first: `kubectl delete job business-migrate-edge -n <namespace>` |
| 300 | +
|
| 301 | +## Infrastructure |
| 302 | + |
| 303 | +Managed in [postguard-ops](https://github.com/encryption4all/postguard-ops) via Terraform. |
| 304 | + |
| 305 | +### Key Terraform variables |
| 306 | + |
| 307 | +| Variable | Description | |
| 308 | +|----------|-------------| |
| 309 | +| `deploy_business` | Enable/disable business portal | |
| 310 | +| `postguard_business_image_tag` | Docker image tag to deploy | |
| 311 | +| `business_host` | Public hostname | |
| 312 | +| `business_database_user` | PostgreSQL user (key in K8s `postgres` secret) | |
| 313 | +| `business_admin_secret_id` | Scaleway secret with admin credentials | |
| 314 | + |
| 315 | +### Architecture |
| 316 | + |
| 317 | +``` |
| 318 | +Internet → Ingress → business-svc:3000 → business-deployment |
| 319 | + ↓ |
| 320 | + PostgreSQL (Scaleway Managed) |
| 321 | + ↑ |
| 322 | + business-migrate Job (pre-deploy) |
| 323 | +``` |
41 | 324 |
|
42 | | -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. |
| 325 | +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. |
0 commit comments