diff --git a/apps/portal/composables/useFeatureFlag.ts b/apps/portal/composables/useFeatureFlag.ts
new file mode 100644
index 0000000..248b010
--- /dev/null
+++ b/apps/portal/composables/useFeatureFlag.ts
@@ -0,0 +1,75 @@
+// Client-side feature-flag helper. One bulk eval per session, shared across
+// every component via module-level state. Hits POST /api/flags/evaluate —
+// the portal-side proxy derives the tenant from the signed-in user's JWT
+// so callers don't pass a slug.
+//
+// Usage:
+// const enabled = useFeatureFlag('new_inbox_view')
+//
+//
+// Multi-key:
+// const { flags, ready, refresh } = useFeatureFlags()
+// ready → ref true once the first eval has resolved
+// flags → ref<{ key: boolean }>
+// refresh() → re-fetch (e.g. after an admin flipped a flag while the user is
+// online; rare, mostly useful in long-lived sessions)
+//
+// The composable is intentionally NOT a Promise-returning function. It
+// returns refs that update once the bulk eval lands; consumers can
+// `` and the panel appears when the answer comes in.
+// Flags evaluated before the first response are reactive `false`, so a
+// gated feature stays hidden during the (typically <50ms) round-trip.
+
+type FlagMap = Record
+
+// Singleton state shared by every caller in the app.
+const flags = ref({})
+const ready = ref(false)
+const pending = ref(false)
+let inflight: Promise | null = null
+
+async function load(): Promise {
+ if (inflight) return inflight
+ pending.value = true
+ inflight = $fetch('/api/flags/evaluate', { method: 'POST' })
+ .then((map) => {
+ flags.value = map ?? {}
+ ready.value = true
+ return flags.value
+ })
+ .catch((err) => {
+ // Eval failures shouldn't break the app — every flag stays `false`
+ // (fail-closed). Surface to the console for debugging.
+ // eslint-disable-next-line no-console
+ console.warn('[useFeatureFlag] eval failed', err)
+ ready.value = true
+ return flags.value
+ })
+ .finally(() => {
+ pending.value = false
+ inflight = null
+ })
+ return inflight
+}
+
+export const useFeatureFlags = () => {
+ // Kick off the first load lazily. Safe to call from setup — `$fetch` will
+ // resolve client-side; on SSR we just don't have a session yet anyway.
+ if (import.meta.client && !ready.value && !pending.value) {
+ void load()
+ }
+ return {
+ flags,
+ ready,
+ pending,
+ refresh: load,
+ }
+}
+
+export const useFeatureFlag = (key: string): ComputedRef => {
+ // Touch the loader so single-key consumers don't have to import useFeatureFlags.
+ if (import.meta.client && !ready.value && !pending.value) {
+ void load()
+ }
+ return computed(() => flags.value[key] === true)
+}
diff --git a/apps/portal/nuxt.config.ts b/apps/portal/nuxt.config.ts
index c2c8579..6cd4aa4 100644
--- a/apps/portal/nuxt.config.ts
+++ b/apps/portal/nuxt.config.ts
@@ -75,6 +75,9 @@ export default defineNuxtConfig({
protocol: 'wss',
clientPort: 443,
},
+ // Vite 7's strict allowedHosts blocks anything not in this list with a
+ // plaintext 403. We serve the portal behind Traefik on app.dezky.local.
+ allowedHosts: ['app.dezky.local'],
},
},
diff --git a/apps/portal/server/api/flags/evaluate.post.ts b/apps/portal/server/api/flags/evaluate.post.ts
new file mode 100644
index 0000000..963e77d
--- /dev/null
+++ b/apps/portal/server/api/flags/evaluate.post.ts
@@ -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 {
+ 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 },
+ })
+})