Backend (platform-api): computed tenant health plus industry/brandColor; partner-scoped tenant update/suspend/resume guarded by assertPartnerOwnsTenant; enriched partner users (MFA + access level) with invite/remove; partner settings and whitelabel branding persistence; Authentik authenticator counting and group removal. Audit on every mutation.
Frontend (portal): all five partner pages on real data — dashboard alerts, customers edit/suspend, team MFA/access with invite/remove, editable settings, branding fetch/save.
Operator: dashboard and infrastructure service health driven by real liveness probes; fabricated uptime/p95/error-rate removed.
Move partner domain types out of data/customers.ts into types/partner.ts so the fixture data exports can be removed later without breaking type imports. Add usePartnerTenants / usePartnerMrr composables wrapping the shared-key partner fetches.
Upgrade both Nuxt apps to Nuxt 4.4.6 (vue-tsc 3, TypeScript 5.6, undici 7) and add a root tsconfig.json to each app. Fix the strict-null / noUncheckedIndexedAccess errors surfaced by Nuxt 4's stricter generated tsconfig and vue-tsc 3. Drop the nuxt-oidc-auth pnpm patch (Nuxt 4 fixes the prepare:types crash natively).
Client-side helper for the portal to consume feature flags. Hits platform-api
through a new portal-side proxy that derives the tenant slug from the
signed-in user's JWT groups — so callers don't pass a slug, they just check
`useFeatureFlag('key')`.
apps/portal/server/api/flags/evaluate.post.ts:
- Reads access token from the nuxt-oidc-auth session
- Decodes the JWT and picks the first non-admin group as the tenant slug
(admin groups: dezky-platform-admins, "authentik Admins"). Filters
duplicates Authentik double-lists via policy bindings.
- Forwards { tenantSlug } to platform-api POST /flags/evaluate
- Caller can still pass an explicit tenantSlug in the request body to
override the auto-derivation (rare).
apps/portal/composables/useFeatureFlag.ts:
- Singleton module-level state shared across every component — one bulk
eval per session, not one per flag check
- `useFeatureFlag(key)` → ComputedRef<boolean>, lazily triggers the first
eval, fail-closed (every flag stays false on error)
- `useFeatureFlags()` → { flags, ready, pending, refresh } for the rare
case where you need the full map or want to re-evaluate (long-lived
session, admin flipped a flag mid-flight)
- Returns refs that update once the bulk eval lands; gated UI stays
hidden during the ~25ms round trip
apps/portal/nuxt.config.ts:
- Vite 7 `server.allowedHosts` set to ['app.dezky.local'] — same fix we
already shipped on the operator side; without it, the proxy returned a
plaintext 403 "Blocked request" instead of forwarding.
Verified end-to-end: signed in to app.dezky.local, hit /api/flags/evaluate
with no body → 200 with the full truth map (same shape as the operator's
direct eval), latency ~25ms, explicit-slug override returns identical
results.
- Add _verify-token.get.ts to both operator and portal — decodes the
access token stored in the nuxt-oidc-auth session and echoes iss/aud/
sub/groups. Used to confirm operator tokens carry aud=dezky-operator
and portal tokens carry aud=dezky-portal. Listed in NEXT-STEPS.md as
throwaway, to be removed when proper verification surfaces exist.
- OPERATOR-PLAN.md O.9 marked done with the actual claims captured + the
Mongo-side verification of attach + suspend flows.
- NEXT-STEPS.md: replaced the "Operator portal — out-of-band track"
section with a "shipped + follow-ups" version. The 9-item follow-up
list (impersonation, audit, flags, incidents, support, partner
portal, env switcher, on-call, workspace impersonation) is now the
authoritative roadmap, not buried inside OPERATOR-PLAN.md.
JwtAuthGuard now accepts a comma-separated AUTHENTIK_AUDIENCE
('dezky-portal,dezky-operator'). jose.jwtVerify takes an array and succeeds
on any match — both customer-portal and operator-portal tokens validate
against this service. Per-endpoint guards restrict further.
New OperatorGuard enforces operator-only mutations:
1. JWT audience claim includes 'dezky-operator' (proof from the token
alone that this is a privileged session)
2. ActorService-resolved User has platformAdmin=true (DB check so
revocation works without waiting for the token to expire)
Both required; either alone is insufficient.
Partner module:
- Partner schema: slug, name, domain, status, marginPct, contactInfo,
billingInfo. marginPct is one number per partner (decided in grilling)
- CRUD endpoints under @UseGuards(JwtAuthGuard, OperatorGuard) — every
partner mutation requires operator scope
- GET /partners returns each row with a computed customers count from
aggregating Tenant.partnerId. MRR aggregation deferred until
Subscription gains a price column
- GET /partners/:slug/tenants for the partner detail view
- DELETE soft-terminates (status='terminated') — never hard-delete
because tenants may still reference the partner
Tenant changes:
- partnerId?: Types.ObjectId (ref Partner, indexed sparse) added to
Tenant schema
- UpdateTenantDto accepts partnerId so PATCH can attach/detach
- POST /tenants/:slug/suspend and /resume — operator-only via
OperatorGuard. PATCH already covers plan/domains/partnerId changes
Smoke test: customer-portal session sends POST /api/partners through the
portal proxy → 403 "This endpoint requires an operator-scoped token". The
positive test (operator-token → 200) waits for O.3 when there's an
operator app to mint the right token.
apps/portal/server/api/partners/index.post.ts is a temporary verification
proxy — delete once the operator portal exists.
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.
Phase 4 from docs/NEXT-STEPS.md. POST /tenants now writes Mongo AND drives
external service provisioning. A new POST /tenants/:slug/reconcile endpoint
retries the orchestration — useful when an upstream was down at create time
or external state drifted out of band.
Integration clients (services/provisioning/src/integrations/):
- AuthentikClient: real implementation. ensureGroup() is idempotent — looks
up the group by name, creates if missing, returns either way. Group
attributes record the tenant slug + Mongo id so we can trace back
- StalwartClient: stubbed. v0.16 removed the REST management API in favor
of JMAP, which is significantly more work to wrap. TODO comment points
to https://stalw.art/docs/api/management/overview for the follow-up
- OcisClient: stubbed. Needs libregraph /drives endpoint with service-to-
service auth via OIDC client_credentials
Orchestration (provisioning.service.ts):
- Each step runs independently; one failure doesn't roll back the others
- Per-step state recorded on Tenant.provisioningStatus (ok/skipped/error/
pending) plus error message on Tenant.provisioningErrors
- Steps return their own terminal state — 'skipped' for stubs, void
defaults to 'ok' for real integrations
- Mongoose markModified() required for nested subdoc mutations to persist
- Tenant auto-flips status: pending → active when all steps are ok|skipped
Portal proxy routes (apps/portal/server/api/tenants/):
- POST /api/tenants and POST /api/tenants/:slug/reconcile forward the
signed-in user's access token to the provisioning service. Lets the
browser drive provisioning without minting tokens by hand. Will be
replaced by a real "create workspace" flow with UI later
docker-compose: AUTHENTIK_API_URL/STALWART_API_URL/OCIS_API_URL now point
at the public Traefik-routed hostnames (with mkcert CA mounted into the
provisioning container so Node fetch trusts them). Previously these
pointed at internal Docker hostnames which doesn't work for Authentik
because of TLS issuer mismatch against the JWT.
- docker-compose: add STALWART_RECOVERY_ADMIN env so the env-file password
works as a permanent recovery login. Without this, Stalwart prints a
one-time bootstrap password to the logs and discards it after first setup
- portal: mail tile now links to /admin/ (the real Stalwart admin SPA),
not /login (which is the OAuth client authorization UI for IMAP/SMTP
clients like Thunderbird — confusing and unrelated)
The persistent admin (admin@dezky.local) was created via Stalwart's setup
wizard at /admin/init and lives in the stalwart_data volume. Recovery admin
in env is the "I lost the wizard credentials" escape hatch.
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.
Brings up Dezky's local development environment end-to-end:
Infrastructure (docker-compose):
- Traefik v3.7 reverse proxy with mkcert TLS (v3.2 couldn't speak Docker API 1.54)
- Postgres + Mongo + Redis with healthchecks and init script for per-service users
- Authentik 2025.10 (server + worker) as OIDC IdP
- Stalwart v0.16 mail server (image renamed from stalwartlabs/mail-server)
- OCIS 7.0 with PROXY_TLS=false and OCIS_CONFIG_DIR=/etc/ocis so init writes
where the server reads
- Collabora office, plus the portal + provisioning service stubs
- Docker network aliases on Traefik so containers resolve auth.dezky.local etc.
through the network (not host /etc/hosts)
- Docker socket mount parameterized for macOS Docker Desktop symlink path
Authentik provisioning (done via API after stack boot):
- ocis-provider (public client) + OCIS Files application
- dezky-portal provider (confidential) + Dezky Portal application
- Admin API token bound to akadmin manually since 2025.10's
AUTHENTIK_BOOTSTRAP_TOKEN env var doesn't auto-materialize a token row
Portal (apps/portal):
- Nuxt 3 with nuxt-oidc-auth 1.0.0-beta.11 against generic 'oidc' preset
- Global auth middleware; login at /auth/oidc/login redirects to Authentik
- Visual implementation of Claude Design 'Auth' canvas: AuthShell, NodeMark,
Auth* sub-components, design tokens as CSS custom properties
- Pages: auth/login, auth/expired, auth/disabled, index (post-login landing)
- mkcert root CA mounted into the portal so Node fetch trusts Authentik's
self-signed cert (NODE_EXTRA_CA_CERTS) — dev only
Docs:
- AUTHENTIK-SETUP.md updated with manual token bind + portal provider scripted
alternative
- NEXT-STEPS.md: Phase 1 and Phase 2 marked done with file locations and
dev-mode caveats
Dev-mode shortcuts that need to be revisited before prod:
- skipAccessTokenParsing on the OIDC config
- NODE_EXTRA_CA_CERTS mkcert mount
- Bootstrap password still the generated value in .env
- Authentik admin token (dezky-bootstrap-token) is non-expiring