O.0 prep from OPERATOR-PLAN.md. Mechanical refactor before adding partner management and operator-specific endpoints. The service now owns more than just provisioning orchestration (it'll soon own partners, tenant lifecycle actions, multi-audience JWT validation), so the name 'platform-api' reflects its scope better. What changed: - Directory: services/provisioning/ -> services/platform-api/ - Package: @dezky/provisioning -> @dezky/platform-api - Docker: container_name dezky-provisioning -> dezky-platform-api; compose service key 'provisioning' -> 'platform-api'; volume provisioning_node_modules -> platform_api_node_modules - Portal: PROVISIONING_INTERNAL_URL env var -> PLATFORM_API_INTERNAL_URL, default URL http://provisioning:3001 -> http://platform-api:3001 in all three proxy routes (me.get.ts, tenants/index.post.ts, tenants/[slug]/ reconcile.post.ts), plus NUXT_API_BASE updated - Health endpoint service identifier and main.ts log lines updated to 'dezky-platform-api' - Docs swept: README, CLAUDE.md, SERVICES.md, AUTHENTIK-SETUP.md, NEXT-STEPS.md, TROUBLESHOOTING.md, OPERATOR-PLAN.md, traefik/dynamic.yml What deliberately stays: - Internal module names ProvisioningService / ProvisioningModule (those describe an orchestration sub-concern, not the service's purpose) - Tenant.provisioningStatus / provisioningErrors field names (state per integration, not service name) - File services/platform-api/src/tenants/provisioning.service.ts - 'Hetzner provisioning' references in production-prep docs (infrastructure provisioning, unrelated) Verified end-to-end after rename: /api/me returns 200 with profile + 2 tenants + subscription, /api/tenants/dezky/reconcile returns 200 with Authentik integration still ok. OPERATOR-PLAN.md O.0 checkboxes ticked.
10 KiB
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
https://app.dezky.localshows portal landing page (now the new auth design / post-login home)https://auth.dezky.localshows Authentik login- Log into Authentik as admin (still using generated
AUTHENTIK_BOOTSTRAP_PASSWORDfrom.env— rotate before exposing to anyone else) - Follow
docs/AUTHENTIK-SETUP.mdto configure OIDC providers (ocis + dezky-portal) - Test OCIS SSO end-to-end (login from
https://files.dezky.local) - 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.
- Add
nuxt-oidc-authtoapps/portal(1.0.0-beta.11) - Configure Authentik as OIDC provider (generic
oidcpreset with explicit URLs + discovery) - Implement login/logout flows (
/auth/oidc/login,/auth/oidc/logoutfrom the module) - Display logged-in user info on the portal home (
pages/index.vueusesuseOidcAuth()) - Add protected routes (
globalMiddlewareEnabled: true; public pages opt out viadefinePageMeta({ 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: truein the OIDC config — Authentik's access tokens in this setup aren't reliably JWT-parseable; production should re-evaluateopenIdConfigurationis pinned to the discovery URL because the genericoidcpreset doesn't ship a default — required for id_token JWKS validationdocker-compose.ymlmountsinfrastructure/docker-compose/certs/mkcert-root.peminto the portal at/etc/ssl/mkcert-root.pemand setsNODE_EXTRA_CA_CERTSso 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) — done
- Mongoose schemas in
services/platform-api/src/schemas/(Tenant, User, Subscription) - Tenant: slug, name, status, plan, domains, authentikGroupId, ocisSpaceId, stalwartDomain, billingInfo
- User: authentikSubjectId, tenantIds[], email, name, role, active, lastLoginAt
- Subscription: tenantId, plan, status, stripeCustomerId, stripeSubscriptionId, period dates
- CRUD endpoints behind
JwtAuthGuard(validates Authentik JWT via JWKS) - Group-based authorization: users see only tenants whose slug matches one of their Authentik
groups;dezky-platform-adminsgroup has global access - Idempotent seed (
SeedService) creates thedezkytenant + matching subscription on bootstrap - platform-api exposed at
https://api.dezky.local(Traefik label, dev only) and via internalhttp://platform-api:3001 - Portal Nitro route at
/api/meforwards the user's encrypted access token to platform-api — verified end-to-end
Endpoints
| Method | Path | Notes |
|---|---|---|
| GET | /health |
open |
| POST/GET | /tenants, /tenants/:slug |
platform admin to create/delete; tenant members can read+update their own |
| GET | /users/me |
upserts the user on first call from JWT claims |
| GET/POST/PATCH/DELETE | /users[/:subject] |
platform admin for mutations |
| GET/POST/PATCH | /subscriptions[/:slug] |
platform admin for mutations |
Dev-mode caveats (clean up before prod)
NUXT_OIDC_TOKEN_KEYmust be base64-encoded 32 bytes (openssl rand -base64 32) — NOT hex. Module silently fails with "Invalid key length" if wrong- Portal config has
exposeAccessToken: trueso Nitro routes can forward the token; token still never reaches the browser - The
dezkygroup in Authentik is the single tenant for dev. New tenants in Phase 4 need to create matching Authentik groups - A
dezky-platform-adminsgroup doesn't exist yet — for now akadmin's membership inauthentik Adminsdoes NOT grant platform-admin rights. Create that group if you want admin-only endpoints to work for you
Phase 4: Provisioning automation (week 2-3) — partial
Orchestration ships, two of three integrations are still stubs pending upstream-specific work.
POST /tenantswrites tenant and triggers reconciliation in one callPOST /tenants/:slug/reconcileretries provisioning for an existing tenant — idempotent, useful when an upstream was down or external state drifted- Per-step state recorded on
Tenant.provisioningStatus(ok / skipped / error / pending) +Tenant.provisioningErrorsfor the last failure message; tenant auto-activates when all steps settle - Worker: Authentik group creation (real, idempotent)
- Worker: Stalwart domain + DKIM (stubbed — v0.16 dropped REST in favor of JMAP, see follow-up below)
- Worker: OCIS space (stubbed — needs libregraph
/drivesendpoint with service-to-service auth) - Worker: onboarding email (no SMTP wired yet)
Where things live
| Concern | File |
|---|---|
| Integration clients | services/platform-api/src/integrations/{authentik,stalwart,ocis}.client.ts |
| Orchestration | services/platform-api/src/tenants/provisioning.service.ts |
/tenants/:slug/reconcile |
services/platform-api/src/tenants/tenants.controller.ts |
| Portal proxy routes | apps/portal/server/api/tenants/index.post.ts + [slug]/reconcile.post.ts |
Quick smoke test
From the portal in the browser (signed in), in DevTools:
// Create a fresh tenant
await fetch('/api/tenants', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ slug: 'acme', name: 'Acme Co', plan: 'pro' })
}).then(r => r.json())
// Re-run provisioning (idempotent)
await fetch('/api/tenants/acme/reconcile', { method: 'POST' }).then(r => r.json())
Response should include provisioningStatus: { authentik: 'ok', stalwart: 'skipped', ocis: 'skipped' } and status: 'active'. Verify the Authentik
group exists via the admin UI at /if/admin/#/identity/groups.
Stub follow-up work
Stalwart (JMAP) — v0.16 moved management off REST.
Need a minimal JMAP client that wraps Domain/set (create), Domain/get
(idempotency check), Principal/set (DKIM-keyed signing identity). Auth
via the persistent admin's bearer token from the OAuth flow we already use
for the web UI.
OCIS (libregraph) — POST /graph/v1.0/drives with body
{ "name": "<slug>", "driveType": "project" }. Needs service-to-service
auth: either an OIDC client_credentials grant (requires registering a new
Authentik provider for the worker) or the IDM admin user's bearer token.
Authentik API examples (for the eventual user-creation flow)
// Create user
await authentikClient.coreUsersCreate({
username: user.email,
email: user.email,
name: user.name,
groups: [authentikGroupId],
})
Operator portal — out-of-band track
operator.dezky.local (internal admin portal — separate Nuxt app, separate
Authentik OAuth client, real CRUD for tenants + partners). Plan and decisions
captured in OPERATOR-PLAN.md.
Touches platform-api substantially:
- Service rename
services/provisioning→services/platform-api(prep) - New
Partnerschema + CRUD endpoints - Tenant lifecycle actions (suspend/resume/plan change)
- Audience-aware JwtAuthGuard for operator-only mutations
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