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.
Implements Phase 3 from docs/NEXT-STEPS.md.
Mongoose schemas (services/provisioning/src/schemas/):
- Tenant: slug, name, status, plan, domains, billingInfo, plus handles for
Authentik group, OCIS space, and Stalwart domain (set in Phase 4)
- User: authentikSubjectId, tenantIds[], email, name, role, platformAdmin flag
- Subscription: tenantId, plan, status, Stripe IDs (unused until Phase 4)
Auth (services/provisioning/src/auth/):
- JwtAuthGuard verifies Authentik access tokens against the provider's JWKS
with issuer + audience checks. Uses NODE_EXTRA_CA_CERTS to trust the
mkcert root for the local Authentik cert
- ActorService resolves the verified JWT into a Mongo User document — every
controller reads tenantIds + platformAdmin from the DB, not the token
- CurrentUser decorator extracts the JWT payload onto controllers
CRUD modules:
- /tenants, /users, /subscriptions with create/read/update/delete
- /users/me upserts the caller's User record on every request, syncing email,
name, tenantIds, and platformAdmin from the JWT's groups claim — the only
place we read JWT.groups outside the bootstrap
Why DB-derived authz: putting all group memberships in the JWT doesn't scale
past ~50 tenants per user (header/cookie size limits, no mid-session
revocation, stale data until re-login). JWT now carries identity only; the
DB is the source of truth for who can see what.
Seed (SeedService.OnApplicationBootstrap): idempotent creation of the
default 'dezky' tenant + matching subscription. User records are created on
first /users/me hit.
Infrastructure:
- Traefik label exposes provisioning at https://api.dezky.local (dev only)
- api.dezky.local added to Docker network aliases on Traefik
- mkcert root CA mounted into the provisioning container for JWKS fetch
- Authentik 'groups' scope mapping created + attached to dezky-portal
provider; portal now requests it as a scope
- nuxt.config.ts portal: exposeAccessToken=true so Nitro forwards token;
NUXT_OIDC_TOKEN_KEY fixed to base64-encoded 32 bytes (was hex, causing
"Invalid key length" once exposeAccessToken turned on)
Portal: apps/portal/server/api/me.get.ts is a scaffolding route that
forwards the user's access token to provisioning and returns profile +
tenants + subscriptions — verifies the full chain end to end.