chore(services): rename services/provisioning -> services/platform-api

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.
This commit is contained in:
Ronni Baslund
2026-05-24 00:35:01 +02:00
parent fb3d7aa716
commit 22b2583f0b
49 changed files with 66 additions and 60 deletions
@@ -0,0 +1,119 @@
import { Injectable, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { OcisClient } from '../integrations/ocis.client.js'
import { StalwartClient } from '../integrations/stalwart.client.js'
import {
IntegrationState,
Tenant,
TenantDocument,
} from '../schemas/tenant.schema.js'
// Orchestrates provisioning across Authentik / Stalwart / OCIS. Each step is
// independent — one failure doesn't roll back the others — and the per-step
// status is recorded on the tenant document so the operation is idempotent
// when retried.
@Injectable()
export class ProvisioningService {
private readonly logger = new Logger(ProvisioningService.name)
constructor(
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
private readonly authentik: AuthentikClient,
private readonly stalwart: StalwartClient,
private readonly ocis: OcisClient,
) {}
// Runs all integrations and writes back per-step state. Returns the refreshed
// tenant doc so the controller can return it to the caller.
async reconcile(tenant: TenantDocument): Promise<TenantDocument> {
this.logger.log(`Reconciling tenant "${tenant.slug}"`)
await this.runStep(tenant, 'authentik', async () => {
const group = await this.authentik.ensureGroup(tenant.slug, { tenantId: tenant.id })
tenant.authentikGroupId = String(group.pk)
})
// Stalwart + OCIS are stubbed — the upstream call no-ops and we record the
// honest 'skipped' state by returning it from the step.
await this.runStep(tenant, 'stalwart', async () => {
const domain = this.domainFor(tenant.slug)
await this.stalwart.ensureDomain(domain, `Mail domain for tenant ${tenant.slug}`)
tenant.stalwartDomain = domain
return 'skipped'
})
await this.runStep(tenant, 'ocis', async () => {
const space = await this.ocis.ensureSpace(tenant.slug)
tenant.ocisSpaceId = space.id
return 'skipped'
})
// If every required integration is either 'ok' or 'skipped' (not 'error' /
// 'pending'), activate the tenant. Skipped steps don't block activation —
// they just won't have their resources wired up yet.
const keys = ['authentik', 'stalwart', 'ocis'] as const
const allSettled = keys.every((k) => {
const s = tenant.provisioningStatus[k]
return s === 'ok' || s === 'skipped'
})
if (allSettled && tenant.status === 'pending') {
tenant.status = 'active'
}
// Mongoose doesn't auto-detect mutations inside nested subdocuments — flag
// these paths as modified so the save() actually persists our changes.
tenant.markModified('provisioningStatus')
tenant.markModified('provisioningErrors')
await tenant.save()
return tenant
}
// Step returns its terminal state explicitly. Returning void means "this step
// ran a real upstream call successfully" — that's mapped to 'ok'. Returning a
// specific state ('skipped', etc.) lets stub integrations be honest about
// not actually doing the work.
private async runStep(
tenant: TenantDocument,
key: 'authentik' | 'stalwart' | 'ocis',
work: () => Promise<IntegrationState | void>,
): Promise<void> {
try {
const result = await work()
tenant.provisioningStatus[key] = result ?? 'ok'
if (tenant.provisioningErrors[key]) delete tenant.provisioningErrors[key]
} catch (err) {
const msg = (err as Error).message
tenant.provisioningStatus[key] = 'error'
tenant.provisioningErrors[key] = msg
this.logger.error(`Tenant "${tenant.slug}" — ${key} step failed: ${msg}`)
}
}
// Maps tenant slug → mail domain. Production should use a real registered
// domain (e.g. acme.dezky.com); locally we use the .local hierarchy.
private domainFor(slug: string): string {
return `${slug}.dezky.local`
}
// Best-effort cleanup. Called when a tenant is hard-deleted (not soft-deleted).
async tearDown(tenant: TenantDocument): Promise<void> {
if (tenant.authentikGroupId) {
await this.authentik.deleteGroup(tenant.authentikGroupId).catch((err) => {
this.logger.error(`Failed to delete Authentik group: ${(err as Error).message}`)
})
}
if (tenant.stalwartDomain) {
await this.stalwart.deleteDomain(tenant.stalwartDomain).catch((err) => {
this.logger.error(`Failed to delete Stalwart domain: ${(err as Error).message}`)
})
}
if (tenant.ocisSpaceId) {
await this.ocis.deleteSpace(tenant.ocisSpaceId).catch((err) => {
this.logger.error(`Failed to delete OCIS space: ${(err as Error).message}`)
})
}
}
}