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:
@@ -0,0 +1,72 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
|
||||
interface AuthentikGroup {
|
||||
pk: string
|
||||
name: string
|
||||
attributes?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Thin wrapper around the Authentik API for the operations the provisioning
|
||||
// service needs. We never expose raw Authentik errors to API callers — they
|
||||
// surface as provisioningErrors.authentik strings.
|
||||
@Injectable()
|
||||
export class AuthentikClient {
|
||||
private readonly logger = new Logger(AuthentikClient.name)
|
||||
private readonly base: string
|
||||
private readonly token: string
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.base = config.getOrThrow<string>('AUTHENTIK_API_URL')
|
||||
this.token = config.getOrThrow<string>('AUTHENTIK_API_TOKEN')
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${this.base}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`Authentik ${init.method ?? 'GET'} ${path} → ${res.status}: ${body.slice(0, 200)}`)
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
// Idempotent: returns existing group if name is taken, creates otherwise.
|
||||
async ensureGroup(slug: string, attributes: Record<string, unknown> = {}): Promise<AuthentikGroup> {
|
||||
const search = await this.request<{ results: AuthentikGroup[] }>(
|
||||
`/core/groups/?name=${encodeURIComponent(slug)}`,
|
||||
)
|
||||
if (search.results.length > 0) {
|
||||
this.logger.log(`Authentik group "${slug}" already exists (pk=${search.results[0].pk})`)
|
||||
return search.results[0]
|
||||
}
|
||||
const created = await this.request<AuthentikGroup>('/core/groups/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
attributes: { role: 'tenant', slug, ...attributes },
|
||||
}),
|
||||
})
|
||||
this.logger.log(`Created Authentik group "${slug}" (pk=${created.pk})`)
|
||||
return created
|
||||
}
|
||||
|
||||
async deleteGroup(groupId: string): Promise<void> {
|
||||
const res = await fetch(`${this.base}/core/groups/${groupId}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${this.token}` },
|
||||
})
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`Authentik DELETE group ${groupId} → ${res.status}: ${body.slice(0, 200)}`)
|
||||
}
|
||||
this.logger.log(`Deleted Authentik group ${groupId}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { AuthentikClient } from './authentik.client.js'
|
||||
import { OcisClient } from './ocis.client.js'
|
||||
import { StalwartClient } from './stalwart.client.js'
|
||||
|
||||
@Module({
|
||||
providers: [AuthentikClient, StalwartClient, OcisClient],
|
||||
exports: [AuthentikClient, StalwartClient, OcisClient],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
|
||||
// OCIS provisioning is stubbed for now. Real implementation needs:
|
||||
// 1. Service-to-service auth via OIDC client_credentials (or admin user)
|
||||
// 2. Call the libregraph /graph/v1.0/drives endpoint to create a project space
|
||||
// 3. Assign the space to the tenant's group / users
|
||||
// Phase 4 ships the orchestration; OCIS hooks up in a follow-up.
|
||||
@Injectable()
|
||||
export class OcisClient {
|
||||
private readonly logger = new Logger(OcisClient.name)
|
||||
private readonly base: string
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.base = config.getOrThrow<string>('OCIS_API_URL')
|
||||
}
|
||||
|
||||
async ensureSpace(slug: string): Promise<{ id: string }> {
|
||||
this.logger.warn(`OCIS space provisioning is stubbed — would create space for "${slug}" at ${this.base}`)
|
||||
return { id: `stub-${slug}` }
|
||||
}
|
||||
|
||||
async deleteSpace(spaceId: string): Promise<void> {
|
||||
this.logger.warn(`OCIS space delete is stubbed — would delete ${spaceId} at ${this.base}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
|
||||
// Stalwart v0.16 removed the REST management API — all admin operations now go
|
||||
// through the JMAP /jmap endpoint with Principal/set, Domain/set, etc. method
|
||||
// calls. Implementing a JMAP client is meaningful work and out of scope for
|
||||
// Phase 4. Stubbed for now; the orchestration code records this as 'skipped'.
|
||||
//
|
||||
// TODO (follow-up): Build a minimal JMAP client that wraps Principal/set + the
|
||||
// DKIM key generation method. See https://stalw.art/docs/api/management/overview
|
||||
@Injectable()
|
||||
export class StalwartClient {
|
||||
private readonly logger = new Logger(StalwartClient.name)
|
||||
private readonly base: string
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.base = config.getOrThrow<string>('STALWART_API_URL')
|
||||
}
|
||||
|
||||
async ensureDomain(domain: string, _description?: string): Promise<{ name: string }> {
|
||||
this.logger.warn(
|
||||
`Stalwart domain provisioning is stubbed — would create "${domain}" via JMAP at ${this.base}/jmap`,
|
||||
)
|
||||
return { name: domain }
|
||||
}
|
||||
|
||||
async deleteDomain(domain: string): Promise<void> {
|
||||
this.logger.warn(`Stalwart domain delete is stubbed — would delete "${domain}"`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user