feat(portal): useFeatureFlag composable + /api/flags/evaluate proxy

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.
This commit is contained in:
Ronni Baslund
2026-05-24 19:26:55 +02:00
parent 868a305539
commit 7f8516295c
3 changed files with 128 additions and 0 deletions
@@ -0,0 +1,50 @@
// Forwards to platform-api's POST /flags/evaluate. The platform-api endpoint
// requires an explicit tenantSlug; we derive it from the JWT here so portal
// consumers don't have to know which tenant they're "in" — the answer is
// always the first non-admin group on their token.
//
// Caller can override by passing { tenantSlug } in the body, but in practice
// the portal serves end users with a single tenant in dev.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
// Group names that belong to platform-wide admin rather than a single tenant.
// Kept here (not env-driven) because the value is fixed by the Authentik
// bootstrap script — see services/platform-api/src/users/users.controller.ts
// where the same name is used as the admin bootstrap group.
const ADMIN_GROUPS = new Set(['dezky-platform-admins', 'authentik Admins'])
function decodeJwtClaims(token: string): Record<string, unknown> {
const parts = token.split('.')
if (parts.length < 2) throw new Error('Not a JWT')
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4)
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
}
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
// Allow explicit override; otherwise derive from the JWT.
const body = (await readBody(event).catch(() => null)) as { tenantSlug?: string } | null
let tenantSlug = body?.tenantSlug
if (!tenantSlug) {
const claims = decodeJwtClaims(accessToken)
const groups = (claims.groups as string[] | undefined) ?? []
// Authentik double-lists each group via policy bindings; dedupe + filter.
const tenantGroups = Array.from(new Set(groups)).filter((g) => !ADMIN_GROUPS.has(g))
tenantSlug = tenantGroups[0]
}
if (!tenantSlug) {
throw createError({ statusCode: 400, statusMessage: 'No tenant slug available for this user' })
}
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/flags/evaluate`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: { tenantSlug },
})
})