diff --git a/docs/contributing/adding-capabilities.md b/docs/contributing/adding-capabilities.md index 1e46508820..1d930076bc 100644 --- a/docs/contributing/adding-capabilities.md +++ b/docs/contributing/adding-capabilities.md @@ -94,6 +94,10 @@ Optional fields: - `requiredRole`: RBAC role required to see or execute the capability - `requiredPermission`: RBAC permission string required to see or execute the capability +- `featureFlag`: optional feature-flag key from + `#worker/feature-flags/registry.ts`; when set, the capability is hidden from + search and denied at execute time unless the flag evaluates enabled for the + calling user `defineDomainCapability` delegates to `defineCapability()`, which: @@ -141,6 +145,35 @@ current request. Do **not** cache role or permission decisions into Vectorize metadata, OAuth grants, package state, or session-scoped data; role revocation must take effect on the next request. +### Feature-flag-gated capabilities + +When a capability should only appear while a typed feature flag is enabled for +the caller, set `featureFlag` to a key from +`packages/worker/src/feature-flags/registry.ts`: + +```ts +export const exampleFlaggedCapability = defineDomainCapability( + capabilityDomainNames.example, + { + name: 'example_flagged_action', + description: 'Only available when the demo-indicator flag is on.', + featureFlag: 'demo-indicator', + inputSchema: z.object({}), + async handler(args, ctx) { + void args + void ctx + return { ok: true } + }, + }, +) +``` + +`getCapabilityRegistryForContext` resolves the caller's evaluated flag map once +per request (via `getFeatureFlagsForUser`) and passes it into the same access +filter used for `requiredRole` / `requiredPermission`. Execute-time assertions +reload that map when needed. Do **not** cache flag decisions into Vectorize +metadata or session-scoped data. + ### Admin domain The `admin` domain is for MCP-accessible account administration and the narrow @@ -172,6 +205,9 @@ Current admin capabilities: - `admin_user_create` - `admin_user_update` - `admin_audit_log_query` +- `admin_feature_flag_list` +- `admin_feature_flag_set` +- `admin_feature_flag_override` - `admin_platform_feedback_list` - `admin_platform_feedback_get` - `admin_platform_feedback_update` diff --git a/docs/contributing/architecture/feature-flags.md b/docs/contributing/architecture/feature-flags.md new file mode 100644 index 0000000000..36e66e83e4 --- /dev/null +++ b/docs/contributing/architecture/feature-flags.md @@ -0,0 +1,64 @@ +# Feature flags + +Admin-managed toggles for gating features while they are developed. Flags answer +exactly one question — "is this feature shipped for this user yet?" — and +deliberately overlap with nothing else: RBAC answers "who may do what" and +entitlements answer "who is allowed how much". + +Module: `packages/worker/src/feature-flags/` + +- `registry.ts` — the typed flag registry (`featureFlagDefinitions`, + `FeatureFlagKey`). Flags are created and removed only via code review by + editing this array; every gate site is compile-checked against it. +- `service.ts` — evaluation (`isFeatureEnabled`, `getFeatureFlagsForUser`) and + admin mutations (global state, per-user overrides, stale cleanup). +- `types.ts` — dependency-free transport types shared with the client tsconfig. + +## The registry-owns-existence invariant + +The database stores **state**, never **existence**. Migration +`0064-feature-flags.sql` adds two tables: + +- `feature_flags` — at most one global row per key: `enabled`, `rollout_percent` + (nullable), `note`, `updated_by`, `updated_at`. No row means "use the registry + default". +- `feature_flag_user_overrides` — per-user forced on/off, keyed by + `(flag_key, user_id)`, cascade-deleted with the user and covered by account + export/deletion targets. + +Removing a flag from the registry breaks the build at every remaining gate site; +leftover DB rows for removed keys surface as **stale** in the admin UI +(delete-only — `deleteStaleFeatureFlag` refuses keys still in the registry). + +## Evaluation precedence + +1. Per-user override row (wins over everything). +2. Global row: off → off; on with `rollout_percent` set → deterministic FNV-1a + bucket of `key:userId` compared to the percentage (anonymous users are + excluded from percentage rollouts); on without a percentage → on. +3. Registry `defaultEnabled`. + +Evaluation failures for authenticated users **fail closed** (all flags off) so a +default-on flag can never bypass an operator kill switch when D1 is unavailable. + +## Surfaces + +- **Admin UI**: `/admin/feature-flags` (+ `/admin/feature-flags.json` API), + admin-role gated, audited via `logAuditEvent` (`feature_flag_*` actions). +- **MCP capabilities**: `admin_feature_flag_list`, `admin_feature_flag_set`, + `admin_feature_flag_override` (admin role required, audited). +- **Client**: evaluated per-request in `loadSessionInfo` (cached per request in + `request-feature-flags-cache.ts`) and shipped as `session.featureFlags`; gate + UI with `isFeatureFlagEnabled(session, key)` from + `packages/worker/client/feature-flags.ts`. +- **Capability gating**: a capability definition may declare + `featureFlag: `; `access-control.ts` hides it from search and denies + execution when the flag evaluates off for the caller. Flag-gated capabilities + require an authenticated caller whose stable id resolves to a `users.id`; + anonymous callers, unresolvable identities, missing flag maps, and evaluation + failures all fail closed. The flag map is only resolved when the registry + actually contains a gated capability. + +The registry ships with one permanent flag, `demo-indicator`, which renders a +small badge in the app chrome and exists so the system stays exercised +end-to-end (`e2e/admin-feature-flags.spec.ts`). diff --git a/docs/contributing/architecture/index.md b/docs/contributing/architecture/index.md index bd6fae95c5..7f1c0029ce 100644 --- a/docs/contributing/architecture/index.md +++ b/docs/contributing/architecture/index.md @@ -20,6 +20,9 @@ to become. - [Entitlements](./entitlements.md): per-user plans (`users.plan`), per-plan resource limits, and the shared `assertWithinEntitlement` enforcement helper (NULL plan = legacy/unlimited). +- [Feature Flags](./feature-flags.md): code-registry flags with D1-backed global + state, percentage rollouts, and per-user overrides, managed at + `/admin/feature-flags`. - [Data Storage](./data-storage.md): what is stored in D1, KV, and Durable Objects. - [Usage Metering](./usage-metering.md): per-user usage events, the diff --git a/docs/contributing/architecture/primitives.yaml b/docs/contributing/architecture/primitives.yaml index b98df3d1ca..ea1dfb8c88 100644 --- a/docs/contributing/architecture/primitives.yaml +++ b/docs/contributing/architecture/primitives.yaml @@ -138,6 +138,15 @@ primitives: docs: - docs/contributing/architecture/entitlements.md + - id: feature-flags + group: auth + name: Feature flags + summary: Code-registry flags with D1 state, rollouts, and user overrides. + code: + - packages/worker/src/feature-flags/ + docs: + - docs/contributing/architecture/feature-flags.md + # --- User-facing assistant primitives ----------------------------------- - id: capability-registry group: assistant diff --git a/e2e/admin-feature-flags.spec.ts b/e2e/admin-feature-flags.spec.ts new file mode 100644 index 0000000000..13c3880ba4 --- /dev/null +++ b/e2e/admin-feature-flags.spec.ts @@ -0,0 +1,195 @@ +import { expect, test } from './playwright-utils.ts' + +test('admin feature flag lifecycle toggles the demo indicator', async ({ + page, + seedE2eUser, + login, +}) => { + const runId = Date.now() + const adminUser = await seedE2eUser({ + email: `ff-admin-${runId}@example.com`, + username: `ff-admin-${runId}`, + password: 'ff-admin-password', + admin: true, + }) + + await login({ + email: adminUser.email, + password: adminUser.password, + mode: 'login', + }) + await page.goto('/') + await expect(page.getByTestId('demo-indicator')).toHaveCount(0) + + await page.getByRole('link', { name: 'Admin', exact: true }).click() + await expect(page).toHaveURL(/\/admin\/users\/?$/) + await page.getByRole('link', { name: 'Feature flags', exact: true }).click() + await expect(page).toHaveURL(/\/admin\/feature-flags\/?$/) + await expect( + page.getByRole('heading', { name: 'Admin feature flags' }), + ).toBeVisible() + await expect(page.getByText('demo-indicator')).toBeVisible() + + const demoFlagSection = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'demo-indicator' }) }) + + // Shared e2e D1 may already have a global row from prior runs — start off. + const enabledCheckbox = demoFlagSection.getByLabel('Enabled') + if (await enabledCheckbox.isChecked()) { + await enabledCheckbox.uncheck() + await demoFlagSection + .getByRole('button', { name: 'Save', exact: true }) + .click() + await expect( + page.getByText('Saved global state for demo-indicator.'), + ).toBeVisible() + await page.reload() + await expect(page.getByTestId('demo-indicator')).toHaveCount(0) + } + + await expect(page.getByText(/default \(off\)|globally off/)).toBeVisible() + + await demoFlagSection.getByLabel('Enabled').check() + await demoFlagSection.getByLabel('Note').fill(`e2e-global-${runId}`) + await demoFlagSection + .getByRole('button', { name: 'Save', exact: true }) + .click() + await expect( + page.getByText('Saved global state for demo-indicator.'), + ).toBeVisible() + await expect(page.getByText('globally on')).toBeVisible() + await expect(page.getByText('Last updated')).toBeVisible() + await expect(page.getByText('Updated by')).toBeVisible() + await expect(page.getByText(`e2e-global-${runId}`)).toBeVisible() + + await page.reload() + await expect(page.getByTestId('demo-indicator')).toBeVisible() + + await demoFlagSection.getByLabel('Enabled').uncheck() + await demoFlagSection + .getByRole('button', { name: 'Save', exact: true }) + .click() + await expect( + page.getByText('Saved global state for demo-indicator.'), + ).toBeVisible() + await expect(page.getByText('globally off')).toBeVisible() + + await page.reload() + await expect(page.getByTestId('demo-indicator')).toHaveCount(0) +}) + +test('feature flag admin access and per-user overrides', async ({ + page, + seedE2eUser, + login, +}) => { + const runId = Date.now() + const adminUser = await seedE2eUser({ + email: `ff-access-admin-${runId}@example.com`, + username: `ff-access-admin-${runId}`, + password: 'ff-access-admin-password', + admin: true, + }) + const memberUser = await seedE2eUser({ + email: `ff-access-member-${runId}@example.com`, + username: `ff-access-member-${runId}`, + password: 'ff-access-member-password', + }) + + await login({ + email: memberUser.email, + password: memberUser.password, + mode: 'login', + }) + await page.goto('/admin/feature-flags') + await expect( + page.getByRole('heading', { name: 'Admin feature flags' }), + ).toBeHidden() + await expect(page.getByText('Forbidden')).toBeVisible() + await expect( + page.getByRole('link', { name: 'Admin', exact: true }), + ).toHaveCount(0) + await expect(page.getByTestId('demo-indicator')).toHaveCount(0) + + await page.context().clearCookies() + await login({ + email: adminUser.email, + password: adminUser.password, + mode: 'login', + }) + await page.goto('/admin/feature-flags') + await expect( + page.getByRole('heading', { name: 'Admin feature flags' }), + ).toBeVisible() + + const demoFlagSection = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'demo-indicator' }) }) + + // Keep the flag globally off so only the member override enables it. + const enabledCheckbox = demoFlagSection.getByLabel('Enabled') + if (await enabledCheckbox.isChecked()) { + await enabledCheckbox.uncheck() + await demoFlagSection + .getByRole('button', { name: 'Save', exact: true }) + .click() + await expect( + page.getByText('Saved global state for demo-indicator.'), + ).toBeVisible() + } + + await demoFlagSection.getByLabel('Username').fill(memberUser.username) + await demoFlagSection.getByLabel('State').selectOption('true') + await demoFlagSection + .getByRole('button', { name: 'Add override', exact: true }) + .click() + await expect( + page.getByText( + `Saved override for ${memberUser.username} on demo-indicator.`, + ), + ).toBeVisible() + + const memberOverrideRow = demoFlagSection + .locator('strong', { hasText: memberUser.username }) + .locator('xpath=../..') + await expect(memberOverrideRow.getByText(/Forced on/)).toBeVisible() + + await page.reload() + await expect(page.getByTestId('demo-indicator')).toHaveCount(0) + + await page.context().clearCookies() + await login({ + email: memberUser.email, + password: memberUser.password, + mode: 'login', + }) + await page.goto('/') + await expect(page.getByTestId('demo-indicator')).toBeVisible() + + await page.context().clearCookies() + await login({ + email: adminUser.email, + password: adminUser.password, + mode: 'login', + }) + await page.goto('/admin/feature-flags') + await expect( + page.getByRole('heading', { name: 'Admin feature flags' }), + ).toBeVisible() + await memberOverrideRow + .getByRole('button', { name: 'Remove', exact: true }) + .click() + await expect( + page.getByText(`Removed override for ${memberUser.username}.`), + ).toBeVisible() + + await page.context().clearCookies() + await login({ + email: memberUser.email, + password: memberUser.password, + mode: 'login', + }) + await page.goto('/') + await expect(page.getByTestId('demo-indicator')).toHaveCount(0) +}) diff --git a/packages/worker/client/app.tsx b/packages/worker/client/app.tsx index a7e3f86c21..35ee0a67a5 100644 --- a/packages/worker/client/app.tsx +++ b/packages/worker/client/app.tsx @@ -10,6 +10,7 @@ import { AppLoaderDataProvider } from './loader-data-context.tsx' import { NavigationProgress } from './navigation-progress.tsx' import { readRouterPathname, readRouterSearch } from './router-location.tsx' import { ScrollRestoration } from './scroll-restoration.tsx' +import { isFeatureFlagEnabled } from './feature-flags.ts' import { fetchSessionInfo, getSessionDisplayName, @@ -159,6 +160,7 @@ export function App(handle: Handle) { const showAuthLinks = isSessionReady && !isLoggedIn const showAdminLink = isLoggedIn && session != null && userHasRole(session, 'admin') + const showDemoIndicator = isFeatureFlagEnabled(session, 'demo-indicator') const oauthRedirectTo = currentPathname === '/oauth/authorize' ? `${currentPathname}${readRouterSearch(handle)}` @@ -252,6 +254,24 @@ export function App(handle: Handle) { {sessionDisplayName} + {showDemoIndicator ? ( + + Demo + + ) : null}
{ + const response = await fetch(adminFeatureFlagsApiPath, { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }) + if (response.status === 401) { + return routeLoaderRedirect('/login') + } + if (response.status === 403) { + throw new Error('You do not have permission to view feature flags.') + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load feature flags.') + } + return { adminFeatureFlags: payload } +} + +export function AdminFeatureFlagsRoute(handle: Handle) { + let status: PageStatus = 'loading' + let featureFlags: Array = [] + let message: string | null = null + let messageTone: 'info' | 'error' = 'info' + let actionState: ActionState = 'idle' + let lastLoadedHref = '' + let loadingForHref: string | null = null + let lastFailedHref: string | null = null + let loadRequestId = 0 + + function applyData(payload: AdminFeatureFlagsLoaderData) { + featureFlags = payload.featureFlags + status = 'ready' + message = null + messageTone = 'info' + } + + async function loadFeatureFlags() { + const href = readCurrentRouterHref(handle) + loadingForHref = href + const requestId = ++loadRequestId + try { + const response = await fetch(adminFeatureFlagsApiPath, { + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + if (requestId !== loadRequestId) return + if (response.status === 401) { + window.location.assign('/login') + return + } + if (response.status === 403) { + status = 'error' + message = 'You do not have permission to view feature flags.' + messageTone = 'error' + lastFailedHref = href + handle.update() + return + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load feature flags.') + } + applyData(payload) + lastLoadedHref = href + lastFailedHref = null + handle.update() + } catch (error) { + if (requestId !== loadRequestId) return + status = 'error' + message = + error instanceof Error ? error.message : 'Unable to load feature flags.' + messageTone = 'error' + lastFailedHref = href + handle.update() + } finally { + if (requestId === loadRequestId) loadingForHref = null + } + } + + async function submitAdminAction( + body: Record, + nextActionState: Exclude, + successMessage: string, + ): Promise { + actionState = nextActionState + message = null + messageTone = 'info' + handle.update() + try { + const response = await fetch(adminFeatureFlagsApiPath, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(body), + }) + if (response.status === 401) { + window.location.assign('/login') + return false + } + const payload = await readJson< + AdminFeatureFlagsLoaderData & { + ok?: boolean + error?: string + } + >(response) + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error ?? 'Unable to update feature flags.') + } + applyData(payload) + message = successMessage + messageTone = 'info' + return true + } catch (error) { + message = + error instanceof Error + ? error.message + : 'Unable to update feature flags.' + messageTone = 'error' + return false + } finally { + actionState = 'idle' + handle.update() + } + } + + function handleSaveGlobalSubmit(event: SubmitEvent, key: string) { + event.preventDefault() + if (!(event.currentTarget instanceof HTMLFormElement)) return + const formData = new FormData(event.currentTarget) + const enabled = formData.get('enabled') === 'on' + const rolloutRaw = String(formData.get('rolloutPercent') ?? '').trim() + const note = String(formData.get('note') ?? '') + void submitAdminAction( + { + action: 'set_global', + key, + enabled, + rolloutPercent: rolloutRaw === '' ? null : Number(rolloutRaw), + note, + }, + 'saving-global', + `Saved global state for ${key}.`, + ) + } + + function handleAddOverrideSubmit(event: SubmitEvent, key: string) { + event.preventDefault() + if (!(event.currentTarget instanceof HTMLFormElement)) return + const form = event.currentTarget + const formData = new FormData(form) + const username = String(formData.get('username') ?? '').trim() + const enabled = String(formData.get('enabled') ?? 'true') === 'true' + void submitAdminAction( + { + action: 'set_user_override', + key, + username, + enabled, + }, + 'saving-override', + `Saved override for ${username} on ${key}.`, + ).then((ok) => { + if (ok) form.reset() + }) + } + + const primaryButtonCss = getPrimaryButtonCss() + const secondaryButtonCss = getSecondaryButtonCss() + const dangerButtonCss = getDangerButtonCss() + + return () => { + const currentHref = readCurrentRouterHref(handle) + const routeData = isAdminFeatureFlagsPath(currentHref) + ? tryConsumeRouteLoaderData(handle, 'adminFeatureFlags', currentHref) + : undefined + if (routeData) { + applyData(routeData) + lastLoadedHref = currentHref + lastFailedHref = null + } + const needsStaleRefresh = + consumeStaleNavigationData(currentHref) && !routeData + const needsLoad = + (status === 'loading' || + currentHref !== lastLoadedHref || + needsStaleRefresh) && + currentHref !== lastFailedHref && + loadingForHref !== currentHref + if (!routeData && needsLoad && typeof document !== 'undefined') { + status = 'loading' + loadingForHref = currentHref + handle.queueTask(loadFeatureFlags) + } + const isMutating = actionState !== 'idle' + const registryFlags = featureFlags.filter((flag) => !flag.stale) + const staleFlags = featureFlags.filter((flag) => flag.stale) + + return ( + + + {status === 'loading' ? ( +

+ Loading feature flags… +

+ ) : null} + {message ? ( + + {message} + + ) : null} +
+ {registryFlags.map((flag) => ( +
+
+
+

+ + {flag.key} + +

+

+ {summarizeEffectiveSource(flag)} +

+
+

+ {flag.description ?? 'No description provided.'} +

+
+ + handleSaveGlobalSubmit(event, flag.key), + ), + ]} + > +
+ + + + +
+ {flag.global ? ( + + ) : ( +

+ No global row yet — saving creates one. Empty rollout means + all users when enabled. +

+ )} + + + {flag.overrides.length === 0 ? ( +

+ No overrides. +

+ ) : ( +
+ {flag.overrides.map((override) => ( +
+
+ {override.username} +

+ {override.enabled ? 'Forced on' : 'Forced off'} · + updated{' '} + {formatNullableTimestamp(override.updatedAt)} +

+
+ +
+ ))} +
+ )} +
+ handleAddOverrideSubmit(event, flag.key), + ), + ]} + > + + + +
+
+
+ ))} +
+ {staleFlags.length > 0 ? ( +
+

+ Stale flags +

+

+ These keys still have database rows but are no longer in the code + registry. +

+
+ {staleFlags.map((flag) => ( +
+
+
+ {flag.key} +

+ {summarizeEffectiveSource(flag)} + {flag.overrides.length > 0 + ? ` · ${flag.overrides.length} override(s)` + : ''} +

+
+ +
+
+ ))} +
+
+ ) : null} +
+ ) + } +} diff --git a/packages/worker/client/routes/index.tsx b/packages/worker/client/routes/index.tsx index b3cf701fb1..d2a0c38b60 100644 --- a/packages/worker/client/routes/index.tsx +++ b/packages/worker/client/routes/index.tsx @@ -41,6 +41,10 @@ import { adminInsightsRouteLoader, } from './admin-insights.tsx' import { AdminInvitesRoute, adminInvitesRouteLoader } from './admin-invites.tsx' +import { + AdminFeatureFlagsRoute, + adminFeatureFlagsRouteLoader, +} from './admin-feature-flags.tsx' import { AdminPlatformFeedbackRoute, adminPlatformFeedbackRouteLoader, @@ -99,6 +103,7 @@ export const clientRouteLoaders: Record = { '/admin': adminUsersRouteLoader, '/admin/users': adminUsersRouteLoader, '/admin/invites': adminInvitesRouteLoader, + '/admin/feature-flags': adminFeatureFlagsRouteLoader, '/admin/roles': adminRolesRouteLoader, '/admin/community-reports': adminCommunityReportsRouteLoader, '/admin/insights': adminInsightsRouteLoader, @@ -139,6 +144,7 @@ export const clientRoutes = { '/admin': , '/admin/users': , '/admin/invites': , + '/admin/feature-flags': , '/admin/roles': , '/admin/community-reports': , '/admin/insights': , diff --git a/packages/worker/client/session.ts b/packages/worker/client/session.ts index 48c62d67b8..0202c26319 100644 --- a/packages/worker/client/session.ts +++ b/packages/worker/client/session.ts @@ -6,6 +6,10 @@ import { type PermissionString, type RoleName, } from '#app/permissions.ts' +import { + featureFlagKeys, + type FeatureFlagKey, +} from '#worker/feature-flags/registry.ts' export type SessionInfo = { email: string @@ -13,6 +17,7 @@ export type SessionInfo = { username: string roles: Array permissions: Array + featureFlags: Record } export type SessionStatus = 'idle' | 'loading' | 'ready' @@ -54,8 +59,11 @@ export async function fetchSessionInfo( : '' const roles = readRoleNames(payload?.session?.roles) const permissions = readPermissionStrings(payload?.session?.permissions) + const featureFlags = readFeatureFlags(payload?.session?.featureFlags) const emailVerified = payload?.session?.emailVerified === true - return email ? { email, emailVerified, username, roles, permissions } : null + return email + ? { email, emailVerified, username, roles, permissions, featureFlags } + : null } catch { return null } @@ -94,3 +102,15 @@ function readPermissionStrings(value: unknown) { typeof entry === 'string' && isPermissionString(entry), ) } + +function readFeatureFlags(value: unknown): Record { + const source = + value && typeof value === 'object' + ? (value as Record) + : null + const flags = {} as Record + for (const key of featureFlagKeys) { + flags[key] = source?.[key] === true + } + return flags +} diff --git a/packages/worker/migrations/0064-feature-flags.sql b/packages/worker/migrations/0064-feature-flags.sql new file mode 100644 index 0000000000..53711b5183 --- /dev/null +++ b/packages/worker/migrations/0064-feature-flags.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS feature_flags ( + key TEXT PRIMARY KEY NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), + rollout_percent INTEGER CHECK (rollout_percent BETWEEN 0 AND 100), + note TEXT NOT NULL DEFAULT '', + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) +); + +CREATE TABLE IF NOT EXISTS feature_flag_user_overrides ( + flag_key TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (flag_key, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_feature_flag_user_overrides_user_id +ON feature_flag_user_overrides(user_id); diff --git a/packages/worker/src/app/account-data-targets.ts b/packages/worker/src/app/account-data-targets.ts index 3373b50795..ac92d0c3c9 100644 --- a/packages/worker/src/app/account-data-targets.ts +++ b/packages/worker/src/app/account-data-targets.ts @@ -170,6 +170,10 @@ export const accountUserDataTargets: ReadonlyArray = [ { kind: 'db_user_id', table: 'user_roles' }, { kind: 'db_user_id', table: 'passkeys' }, { kind: 'db_user_id', table: 'oauth_connections' }, + // Feature-flag overrides use the integer users.id FK (with ON DELETE + // CASCADE), but account deletion still issues an explicit DELETE so the + // cascade stays self-contained when FK enforcement is disabled. + { kind: 'db_user_id', table: 'feature_flag_user_overrides' }, // Two-factor verification rows are keyed by `target` = stringified db user // id rather than a user_id column, so they need the dedicated kind. { kind: 'db_user_target', table: 'verifications' }, diff --git a/packages/worker/src/app/admin-feature-flags-data.ts b/packages/worker/src/app/admin-feature-flags-data.ts new file mode 100644 index 0000000000..3df5532377 --- /dev/null +++ b/packages/worker/src/app/admin-feature-flags-data.ts @@ -0,0 +1,11 @@ +import { type AdminFeatureFlagsLoaderData } from '#app/loader-data.ts' +import { listFeatureFlagsForAdmin } from '#worker/feature-flags/service.ts' + +export async function loadAdminFeatureFlagsData( + env: Env, +): Promise { + return { + ok: true, + featureFlags: await listFeatureFlagsForAdmin(env.APP_DB), + } +} diff --git a/packages/worker/src/app/handlers/admin-feature-flags.node.test.ts b/packages/worker/src/app/handlers/admin-feature-flags.node.test.ts new file mode 100644 index 0000000000..8bda6dddef --- /dev/null +++ b/packages/worker/src/app/handlers/admin-feature-flags.node.test.ts @@ -0,0 +1,585 @@ +import { expect, test, vi } from 'vitest' +import { type PermissionString, type RoleName } from '#app/permissions.ts' +import { logAuditEventSpy } from '#worker/test-support/audit-log-spy.ts' + +const mockModule = vi.hoisted(() => ({ + readAuthenticatedAppUser: vi.fn(), +})) + +vi.mock('#app/authenticated-user.ts', () => ({ + readAuthenticatedAppUser: (...args: Array) => + mockModule.readAuthenticatedAppUser(...args), +})) + +vi.mock('#app/audit-log.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getRequestIp: () => '127.0.0.1', + logAuditEvent: (...args: Parameters) => + logAuditEventSpy(...args), + } +}) + +type GlobalRow = { + key: string + enabled: number + rollout_percent: number | null + note: string + updated_by: number | null + updated_at: string +} + +type OverrideRow = { + flag_key: string + user_id: number + enabled: number + updated_by: number | null + updated_at: string +} + +type UserRow = { + id: number + username: string +} + +function createAdminActor(roles: Array) { + const permissions: Array = roles.includes('admin') + ? ['read:user:any', 'update:user:any'] + : ['read:user:own'] + return { + sessionUserId: '1', + userId: 1, + email: 'admin@example.com', + username: 'admin-user', + displayName: 'admin-user', + roles, + permissions, + artifactOwnerIds: ['1'], + mcpUser: { + userId: 'stable-admin', + email: 'admin@example.com', + username: 'admin-user', + displayName: 'admin-user', + }, + } +} + +function createFeatureFlagsTestEnv( + input: { + globals?: Array + overrides?: Array + users?: Array + } = {}, +) { + const globals = new Map( + (input.globals ?? []).map((row) => [row.key, { ...row }]), + ) + const overrides = new Map( + (input.overrides ?? []).map((row) => [ + `${row.flag_key}:${row.user_id}`, + { ...row }, + ]), + ) + const users = new Map((input.users ?? []).map((row) => [row.id, { ...row }])) + let clock = 0 + + function nextTimestamp() { + clock += 1 + return `2026-07-19T00:00:${String(clock).padStart(2, '0')}.000Z` + } + + function normalize(query: string) { + return query.replace(/\s+/g, ' ').trim().toLowerCase() + } + + function createStatement(query: string, params: Array = []) { + const normalized = normalize(query) + return { + bind(...nextParams: Array) { + return createStatement(query, nextParams) + }, + async first() { + if (normalized.includes('select id from users where id = ?')) { + const user = users.get(Number(params[0])) + return (user ? { id: user.id } : null) as T | null + } + if (normalized.includes('select id from users where username = ?')) { + const username = String(params[0]) + for (const user of users.values()) { + if (user.username === username) { + return { id: user.id } as T + } + } + return null + } + if ( + normalized.includes('from feature_flag_user_overrides') && + normalized.includes('where flag_key = ? and user_id = ?') + ) { + const row = overrides.get(`${params[0]}:${params[1]}`) + return (row ? { enabled: row.enabled } : null) as T | null + } + if ( + normalized.includes('from feature_flags') && + normalized.includes('where key = ?') + ) { + const row = globals.get(String(params[0])) + return ( + row + ? { + enabled: row.enabled, + rollout_percent: row.rollout_percent, + } + : null + ) as T | null + } + throw new Error(`Unsupported first query: ${query}`) + }, + async all() { + if ( + normalized.includes('from feature_flags') && + !normalized.includes('where') + ) { + return { + results: [...globals.values()].map((row) => ({ ...row })), + meta: { changes: 0 }, + } as { results: Array; meta: { changes: number } } + } + if ( + normalized.includes('from feature_flag_user_overrides o') && + normalized.includes('join users u') + ) { + const rows = [...overrides.values()] + .map((row) => { + const user = users.get(row.user_id) + if (!user) return null + return { + flag_key: row.flag_key, + user_id: row.user_id, + enabled: row.enabled, + updated_at: row.updated_at, + username: user.username, + } + }) + .filter((row) => row !== null) + .sort((left, right) => { + const byKey = left.flag_key.localeCompare(right.flag_key) + if (byKey !== 0) return byKey + return left.username.localeCompare(right.username) + }) + return { + results: rows, + meta: { changes: 0 }, + } as { results: Array; meta: { changes: number } } + } + throw new Error(`Unsupported all query: ${query}`) + }, + async run() { + if ( + normalized.startsWith('insert into feature_flags') && + normalized.includes('on conflict(key) do update') + ) { + const key = String(params[0]) + const enabled = Number(params[1]) + const rolloutPercent = + params[2] === null || params[2] === undefined + ? null + : Number(params[2]) + const note = String(params[3] ?? '') + const updatedBy = Number(params[4]) + const updatedAt = nextTimestamp() + globals.set(key, { + key, + enabled, + rollout_percent: rolloutPercent, + note, + updated_by: updatedBy, + updated_at: updatedAt, + }) + return { meta: { changes: 1 } } + } + if ( + normalized.startsWith('insert into feature_flag_user_overrides') && + normalized.includes('on conflict(flag_key, user_id) do update') + ) { + const flagKey = String(params[0]) + const userId = Number(params[1]) + const enabled = Number(params[2]) + const updatedBy = Number(params[3]) + const updatedAt = nextTimestamp() + overrides.set(`${flagKey}:${userId}`, { + flag_key: flagKey, + user_id: userId, + enabled, + updated_by: updatedBy, + updated_at: updatedAt, + }) + return { meta: { changes: 1 } } + } + if ( + normalized.startsWith('delete from feature_flag_user_overrides') && + normalized.includes('where flag_key = ? and user_id = ?') + ) { + const mapKey = `${params[0]}:${params[1]}` + const existed = overrides.delete(mapKey) + return { meta: { changes: existed ? 1 : 0 } } + } + if ( + normalized.startsWith('delete from feature_flag_user_overrides') && + normalized.includes('where flag_key = ?') + ) { + const flagKey = String(params[0]) + let changes = 0 + for (const mapKey of [...overrides.keys()]) { + if (mapKey.startsWith(`${flagKey}:`)) { + overrides.delete(mapKey) + changes += 1 + } + } + return { meta: { changes } } + } + if ( + normalized.startsWith('delete from feature_flags') && + normalized.includes('where key = ?') + ) { + const existed = globals.delete(String(params[0])) + return { meta: { changes: existed ? 1 : 0 } } + } + throw new Error(`Unsupported run query: ${query}`) + }, + } + } + + return { + COOKIE_SECRET: 'secret', + APP_DB: { + prepare(query: string) { + return createStatement(query) + }, + async batch( + statements: Array<{ + run: () => Promise<{ meta: { changes: number } }> + }>, + ) { + const results = [] + for (const statement of statements) { + results.push(await statement.run()) + } + return results + }, + } as unknown as D1Database, + } +} + +const { createAdminFeatureFlagsApiHandler } = + await import('./admin-feature-flags.ts') + +test('admin feature flags API returns 403 without admin role', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['user']), + ) + const handler = createAdminFeatureFlagsApiHandler( + createFeatureFlagsTestEnv() as unknown as Env, + ) + const response = await handler.handler({ + request: new Request('https://example.com/admin/feature-flags.json', { + headers: { Accept: 'application/json' }, + }), + params: {}, + url: new URL('https://example.com/admin/feature-flags.json'), + } as never) + expect(response.status).toBe(403) +}) + +test('admin feature flags GET returns flag list', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const handler = createAdminFeatureFlagsApiHandler( + createFeatureFlagsTestEnv() as unknown as Env, + ) + const response = await handler.handler({ + request: new Request('https://example.com/admin/feature-flags.json', { + headers: { Accept: 'application/json' }, + }), + params: {}, + url: new URL('https://example.com/admin/feature-flags.json'), + } as never) + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload).toMatchObject({ + ok: true, + featureFlags: [ + { + key: 'demo-indicator', + stale: false, + defaultEnabled: false, + global: null, + overrides: [], + }, + ], + }) +}) + +test('admin feature flags set_global happy path', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + logAuditEventSpy.mockClear() + const handler = createAdminFeatureFlagsApiHandler( + createFeatureFlagsTestEnv() as unknown as Env, + ) + const response = await handler.handler({ + request: new Request('https://example.com/admin/feature-flags.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'set_global', + key: 'demo-indicator', + enabled: true, + rolloutPercent: 25, + note: 'canary', + }), + }), + params: {}, + url: new URL('https://example.com/admin/feature-flags.json'), + } as never) + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload).toMatchObject({ + ok: true, + featureFlags: [ + { + key: 'demo-indicator', + global: { + enabled: true, + rolloutPercent: 25, + note: 'canary', + updatedBy: 1, + }, + }, + ], + }) + expect(logAuditEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'admin', + action: 'feature_flag_set_global', + result: 'success', + reason: 'key=demo-indicator;enabled=true;rollout_percent=25', + }), + ) +}) + +test('admin feature flags set_global with bad key returns 400', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const handler = createAdminFeatureFlagsApiHandler( + createFeatureFlagsTestEnv() as unknown as Env, + ) + const response = await handler.handler({ + request: new Request('https://example.com/admin/feature-flags.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'set_global', + key: 'not-a-real-flag', + enabled: true, + rolloutPercent: null, + }), + }), + params: {}, + url: new URL('https://example.com/admin/feature-flags.json'), + } as never) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: 'Unknown or invalid feature flag key.', + }) +}) + +test('admin feature flags delete_stale on registry key returns 400', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const handler = createAdminFeatureFlagsApiHandler( + createFeatureFlagsTestEnv() as unknown as Env, + ) + const response = await handler.handler({ + request: new Request('https://example.com/admin/feature-flags.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'delete_stale', + key: 'demo-indicator', + }), + }), + params: {}, + url: new URL('https://example.com/admin/feature-flags.json'), + } as never) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: + 'Cannot delete registry feature flag "demo-indicator". Remove it from the code registry first.', + }) +}) + +test('admin feature flags set_global rejects overlong notes', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const handler = createAdminFeatureFlagsApiHandler( + createFeatureFlagsTestEnv() as unknown as Env, + ) + const response = await handler.handler({ + request: new Request('https://example.com/admin/feature-flags.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'set_global', + key: 'demo-indicator', + enabled: true, + rolloutPercent: null, + note: 'x'.repeat(501), + }), + }), + params: {}, + url: new URL('https://example.com/admin/feature-flags.json'), + } as never) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: 'note must be at most 500 characters.', + }) +}) + +test('admin feature flags set_user_override validates user identity and existence', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const handler = createAdminFeatureFlagsApiHandler( + createFeatureFlagsTestEnv({ + users: [ + { id: 1, username: 'admin-user' }, + { id: 2, username: 'jane' }, + ], + }) as unknown as Env, + ) + + const neither = await handler.handler({ + request: new Request('https://example.com/admin/feature-flags.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'set_user_override', + key: 'demo-indicator', + enabled: true, + }), + }), + params: {}, + url: new URL('https://example.com/admin/feature-flags.json'), + } as never) + expect(neither.status).toBe(400) + await expect(neither.json()).resolves.toMatchObject({ + ok: false, + error: 'Provide either userId or username.', + }) + + const both = await handler.handler({ + request: new Request('https://example.com/admin/feature-flags.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'set_user_override', + key: 'demo-indicator', + enabled: true, + userId: 2, + username: 'jane', + }), + }), + params: {}, + url: new URL('https://example.com/admin/feature-flags.json'), + } as never) + expect(both.status).toBe(400) + await expect(both.json()).resolves.toMatchObject({ + ok: false, + error: 'Provide either userId or username, not both.', + }) + + const missingId = await handler.handler({ + request: new Request('https://example.com/admin/feature-flags.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'set_user_override', + key: 'demo-indicator', + enabled: true, + userId: 404, + }), + }), + params: {}, + url: new URL('https://example.com/admin/feature-flags.json'), + } as never) + expect(missingId.status).toBe(404) + await expect(missingId.json()).resolves.toMatchObject({ + ok: false, + error: 'User not found.', + }) + + const byUsername = await handler.handler({ + request: new Request('https://example.com/admin/feature-flags.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'set_user_override', + key: 'demo-indicator', + enabled: true, + username: 'jane', + }), + }), + params: {}, + url: new URL('https://example.com/admin/feature-flags.json'), + } as never) + expect(byUsername.status).toBe(200) + await expect(byUsername.json()).resolves.toMatchObject({ + ok: true, + featureFlags: [ + { + key: 'demo-indicator', + overrides: [ + expect.objectContaining({ + userId: 2, + username: 'jane', + enabled: true, + }), + ], + }, + ], + }) +}) diff --git a/packages/worker/src/app/handlers/admin-feature-flags.ts b/packages/worker/src/app/handlers/admin-feature-flags.ts new file mode 100644 index 0000000000..6bf44472ee --- /dev/null +++ b/packages/worker/src/app/handlers/admin-feature-flags.ts @@ -0,0 +1,439 @@ +import { jsonResponse } from '#worker/json-response.ts' +import { type Action } from 'remix/router' +import { loadAdminFeatureFlagsData } from '#app/admin-feature-flags-data.ts' +import { getRequestIp, logAuditEvent } from '#app/audit-log.ts' +import { readAuthSessionResult } from '#app/auth-session.ts' +import { redirectToLogin } from '#app/auth-redirect.ts' +import { requireUserWithRole } from '#app/permissions-server.ts' +import { renderAppPage } from '#app/ssr-render.tsx' +import { type routes } from '#app/routes.ts' +import { isFeatureFlagKey } from '#worker/feature-flags/registry.ts' +import { + clearFeatureFlagUserOverride, + deleteStaleFeatureFlag, + setFeatureFlagGlobalState, + setFeatureFlagUserOverride, +} from '#worker/feature-flags/service.ts' + +export function createAdminFeatureFlagsHandler(env: Env) { + return { + middleware: [], + async handler({ request }) { + try { + await requireUserWithRole(request, env, 'admin') + } catch (error) { + if (error instanceof Response) return error + throw error + } + + const { session } = await readAuthSessionResult(request) + if (!session) { + return redirectToLogin(request) + } + + const adminFeatureFlags = await loadAdminFeatureFlagsData(env) + + return renderAppPage({ + request, + env, + title: 'Admin feature flags', + loaderData: { adminFeatureFlags }, + }) + }, + } satisfies Action +} + +export function createAdminFeatureFlagsApiHandler(env: Env) { + return { + middleware: [], + async handler({ request, url }) { + try { + if (request.method === 'GET') { + await requireUserWithRole(request, env, 'admin') + const payload = await loadAdminFeatureFlagsData(env) + return jsonResponse(payload) + } + + if (request.method !== 'POST') { + return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) + } + + const actor = await requireUserWithRole(request, env, 'admin') + const body = await request.json().catch(() => null) + if (!body || typeof body !== 'object') { + return jsonResponse( + { ok: false, error: 'Invalid request body.' }, + 400, + ) + } + + const action = readString(body, 'action') + if (action === 'set_global') { + return handleSetGlobalAction({ env, request, url, actor, body }) + } + if (action === 'set_user_override') { + return handleSetUserOverrideAction({ + env, + request, + url, + actor, + body, + }) + } + if (action === 'clear_user_override') { + return handleClearUserOverrideAction({ + env, + request, + url, + actor, + body, + }) + } + if (action === 'delete_stale') { + return handleDeleteStaleAction({ env, request, url, actor, body }) + } + + return jsonResponse({ ok: false, error: 'Invalid action.' }, 400) + } catch (error) { + if (error instanceof Response) return error + throw error + } + }, + } satisfies Action +} + +async function handleSetGlobalAction(input: { + env: Env + request: Request + url: URL + actor: Awaited> + body: object +}) { + const key = readString(input.body, 'key') + if (!key || !isFeatureFlagKey(key)) { + return jsonResponse( + { ok: false, error: 'Unknown or invalid feature flag key.' }, + 400, + ) + } + + const enabled = readBoolean(input.body, 'enabled') + if (enabled === null) { + return jsonResponse({ ok: false, error: 'enabled must be a boolean.' }, 400) + } + + const rolloutPercent = readRolloutPercent(input.body) + if (rolloutPercent === false) { + return jsonResponse( + { + ok: false, + error: 'rolloutPercent must be an integer between 0 and 100, or null.', + }, + 400, + ) + } + + const bodyRecord = input.body as Record + + try { + await setFeatureFlagGlobalState(input.env.APP_DB, { + key, + enabled, + rolloutPercent, + ...(Object.hasOwn(bodyRecord, 'note') ? { note: bodyRecord.note } : {}), + updatedBy: input.actor.userId, + }) + } catch (error) { + return jsonResponse( + { + ok: false, + error: + error instanceof Error + ? error.message + : 'Unable to update feature flag.', + }, + 400, + ) + } + + const requestIp = getRequestIp(input.request) ?? undefined + void logAuditEvent({ + category: 'admin', + action: 'feature_flag_set_global', + result: 'success', + email: input.actor.email, + ip: requestIp, + path: input.url.pathname, + reason: [ + `key=${key}`, + `enabled=${enabled}`, + `rollout_percent=${rolloutPercent === null ? 'null' : String(rolloutPercent)}`, + ].join(';'), + }) + + return jsonResponse(await loadAdminFeatureFlagsData(input.env)) +} + +async function handleSetUserOverrideAction(input: { + env: Env + request: Request + url: URL + actor: Awaited> + body: object +}) { + const key = readString(input.body, 'key') + if (!key || !isFeatureFlagKey(key)) { + return jsonResponse( + { ok: false, error: 'Unknown or invalid feature flag key.' }, + 400, + ) + } + + const enabled = readBoolean(input.body, 'enabled') + if (enabled === null) { + return jsonResponse({ ok: false, error: 'enabled must be a boolean.' }, 400) + } + + const resolvedUserId = await resolveOverrideUserId( + input.env.APP_DB, + input.body, + ) + if (resolvedUserId.status === 'invalid') { + return jsonResponse({ ok: false, error: resolvedUserId.error }, 400) + } + if (resolvedUserId.status === 'not_found') { + return jsonResponse({ ok: false, error: 'User not found.' }, 404) + } + const targetUserId = resolvedUserId.userId + + await setFeatureFlagUserOverride(input.env.APP_DB, { + key, + userId: targetUserId, + enabled, + updatedBy: input.actor.userId, + }) + + const requestIp = getRequestIp(input.request) ?? undefined + void logAuditEvent({ + category: 'admin', + action: 'feature_flag_set_user_override', + result: 'success', + email: input.actor.email, + ip: requestIp, + path: input.url.pathname, + reason: [ + `key=${key}`, + `target_user_id=${targetUserId}`, + `enabled=${enabled}`, + ].join(';'), + }) + + return jsonResponse(await loadAdminFeatureFlagsData(input.env)) +} + +async function handleClearUserOverrideAction(input: { + env: Env + request: Request + url: URL + actor: Awaited> + body: object +}) { + const key = readString(input.body, 'key') + if (!key || !isFeatureFlagKey(key)) { + return jsonResponse( + { ok: false, error: 'Unknown or invalid feature flag key.' }, + 400, + ) + } + + const userId = readPositiveIntField(input.body, 'userId') + if (userId === null) { + return jsonResponse({ ok: false, error: 'userId is required.' }, 400) + } + + const cleared = await clearFeatureFlagUserOverride(input.env.APP_DB, { + key, + userId, + }) + if (!cleared) { + return jsonResponse({ ok: false, error: 'User override not found.' }, 404) + } + + const requestIp = getRequestIp(input.request) ?? undefined + void logAuditEvent({ + category: 'admin', + action: 'feature_flag_clear_user_override', + result: 'success', + email: input.actor.email, + ip: requestIp, + path: input.url.pathname, + reason: [`key=${key}`, `target_user_id=${userId}`].join(';'), + }) + + return jsonResponse(await loadAdminFeatureFlagsData(input.env)) +} + +async function handleDeleteStaleAction(input: { + env: Env + request: Request + url: URL + actor: Awaited> + body: object +}) { + const key = readString(input.body, 'key') + if (!key) { + return jsonResponse( + { ok: false, error: 'Feature flag key is required.' }, + 400, + ) + } + + try { + const deleted = await deleteStaleFeatureFlag(input.env.APP_DB, key) + if (!deleted) { + return jsonResponse( + { ok: false, error: 'Stale feature flag not found.' }, + 404, + ) + } + } catch (error) { + return jsonResponse( + { + ok: false, + error: + error instanceof Error + ? error.message + : 'Unable to delete feature flag.', + }, + 400, + ) + } + + const requestIp = getRequestIp(input.request) ?? undefined + void logAuditEvent({ + category: 'admin', + action: 'feature_flag_delete_stale', + result: 'success', + email: input.actor.email, + ip: requestIp, + path: input.url.pathname, + reason: `key=${key}`, + }) + + return jsonResponse(await loadAdminFeatureFlagsData(input.env)) +} + +type ResolveOverrideUserIdResult = + | { status: 'ok'; userId: number } + | { status: 'invalid'; error: string } + | { status: 'not_found' } + +async function resolveOverrideUserId( + db: D1Database, + body: object, +): Promise { + const record = body as Record + const hasUserId = Object.hasOwn(record, 'userId') && record.userId != null + const username = readString(body, 'username') + const hasUsername = username !== null + + if (!hasUserId && !hasUsername) { + return { + status: 'invalid', + error: 'Provide either userId or username.', + } + } + if (hasUserId && hasUsername) { + return { + status: 'invalid', + error: 'Provide either userId or username, not both.', + } + } + + if (hasUserId) { + const userId = readPositiveIntField(body, 'userId') + if (userId === null) { + return { + status: 'invalid', + error: 'userId must be a positive integer.', + } + } + const row = await db + .prepare(`SELECT id FROM users WHERE id = ?`) + .bind(userId) + .first<{ id: number }>() + if (!row) { + return { status: 'not_found' } + } + return { status: 'ok', userId: row.id } + } + + if (!username) { + return { + status: 'invalid', + error: 'Provide either userId or username.', + } + } + const row = await db + .prepare(`SELECT id FROM users WHERE username = ?`) + .bind(username) + .first<{ id: number }>() + if (!row) { + return { status: 'not_found' } + } + return { status: 'ok', userId: row.id } +} + +function readRolloutPercent(body: object): number | null | false { + const value = (body as Record).rolloutPercent + if (value === null || value === undefined || value === '') { + return null + } + if (typeof value === 'number') { + if (!Number.isInteger(value) || value < 0 || value > 100) { + return false + } + return value + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value.trim()) + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + return false + } + return parsed + } + return false +} + +function readBoolean(body: object, key: string): boolean | null { + const value = (body as Record)[key] + if (typeof value === 'boolean') return value + if (value === 'true') return true + if (value === 'false') return false + return null +} + +function readPositiveIntField(body: object, key: string): number | null { + const value = (body as Record)[key] + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value.trim()) + if (Number.isInteger(parsed) && parsed > 0) { + return parsed + } + } + return null +} + +function readString(body: object, key: string) { + const value = (body as Record)[key] + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + return null +} diff --git a/packages/worker/src/app/handlers/session-handler.node.test.ts b/packages/worker/src/app/handlers/session-handler.node.test.ts index 8f81bfdaee..dce7f1c931 100644 --- a/packages/worker/src/app/handlers/session-handler.node.test.ts +++ b/packages/worker/src/app/handlers/session-handler.node.test.ts @@ -38,51 +38,66 @@ function createSessionTestDb() { }, ], ]) - return { - prepare(query: string) { - const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLowerCase() + + function createStatement(query: string, params: Array = []) { + const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLowerCase() + const executeAll = async () => { + if ( + normalizedQuery.startsWith('select') && + normalizedQuery.includes('from "users"') && + /"id"\s*=/.test(normalizedQuery) + ) { + const user = users.get(Number(params[0])) + return { + results: user ? [{ ...user }] : [], + meta: { changes: 0, last_row_id: 0 }, + } + } + if ( + normalizedQuery.includes('from user_roles ur') && + normalizedQuery.includes('join roles r') + ) { + return { + results: [], + meta: { changes: 0, last_row_id: 0 }, + } + } + // Feature-flag evaluation during /session refresh; empty state uses + // registry defaults without throwing. + if ( + normalizedQuery.includes('from feature_flags') || + normalizedQuery.includes('from feature_flag_user_overrides') + ) { + return { + results: [], + meta: { changes: 0, last_row_id: 0 }, + } + } return { - bind(...params: Array) { - const executeAll = async () => { - if ( - normalizedQuery.startsWith('select') && - normalizedQuery.includes('from "users"') && - /"id"\s*=/.test(normalizedQuery) - ) { - const user = users.get(Number(params[0])) - return { - results: user ? [{ ...user }] : [], - meta: { changes: 0, last_row_id: 0 }, - } - } - if ( - normalizedQuery.includes('from user_roles ur') && - normalizedQuery.includes('join roles r') - ) { - return { - results: [], - meta: { changes: 0, last_row_id: 0 }, - } - } - return { - results: [], - meta: { changes: 0, last_row_id: 0 }, - } - } - return { - async all() { - return executeAll() - }, - async first() { - const result = await executeAll() - return result.results[0] ?? null - }, - async run() { - return { meta: { changes: 0, last_row_id: 0 } } - }, - } - }, + results: [], + meta: { changes: 0, last_row_id: 0 }, } + } + return { + bind(...nextParams: Array) { + return createStatement(query, nextParams) + }, + async all() { + return executeAll() + }, + async first() { + const result = await executeAll() + return result.results[0] ?? null + }, + async run() { + return { meta: { changes: 0, last_row_id: 0 } } + }, + } + } + + return { + prepare(query: string) { + return createStatement(query) }, async exec() { return @@ -142,6 +157,9 @@ test('session handler only renews remembered sessions after the renewal window', username: 'session-user', roles: [], permissions: [], + featureFlags: { + 'demo-indicator': false, + }, }, }) if (scenario.expectSetCookie) { diff --git a/packages/worker/src/app/loader-data.ts b/packages/worker/src/app/loader-data.ts index bd0ec6acfb..f14016c5c3 100644 --- a/packages/worker/src/app/loader-data.ts +++ b/packages/worker/src/app/loader-data.ts @@ -3,6 +3,9 @@ import { type PublicCommunityListing, } from '#app/community-public-types.ts' import { type PermissionString, type RoleName } from '#app/permissions.ts' +import { type AdminFeatureFlag } from '#worker/feature-flags/types.ts' + +export type { AdminFeatureFlag } export type CommunityIndexLoaderData = { ok: true @@ -113,6 +116,11 @@ export type AdminInvitesLoaderData = { invites: Array } +export type AdminFeatureFlagsLoaderData = { + ok: true + featureFlags: Array +} + export type AdminUsageMetric = | 'execute' | 'package_export' @@ -628,6 +636,7 @@ export type AppLoaderData = { adminRoles?: AdminRolesLoaderData adminCommunityReports?: AdminCommunityReportsLoaderData adminInvites?: AdminInvitesLoaderData + adminFeatureFlags?: AdminFeatureFlagsLoaderData adminInsights?: AdminInsightsLoaderData adminPlatformFeedback?: AdminPlatformFeedbackLoaderData adminSystemEmail?: AdminSystemEmailLoaderData diff --git a/packages/worker/src/app/request-feature-flags-cache.ts b/packages/worker/src/app/request-feature-flags-cache.ts new file mode 100644 index 0000000000..71aecbc1e9 --- /dev/null +++ b/packages/worker/src/app/request-feature-flags-cache.ts @@ -0,0 +1,70 @@ +/** + * Per-request feature-flag evaluation cache. + * + * Used by `loadSessionInfo` (SSR app shell + `/session` refresh) so flag + * evaluation happens at most once per HTTP request. API handlers that only + * need auth should keep using `loadResolvedRequestAuth` / + * `readAuthenticatedAppUser` and will not hit this path. + * + * On evaluation failure for an authenticated user, every flag is forced off + * (fail closed) so a default-on registry flag cannot bypass a kill switch when + * D1 is unavailable. Anonymous requests still use registry defaults. + */ + +import { + featureFlagDefinitions, + featureFlagKeys, + type FeatureFlagKey, +} from '#worker/feature-flags/registry.ts' +import { getFeatureFlagsForUser } from '#worker/feature-flags/service.ts' + +export type EvaluatedFeatureFlags = Record + +const requestFeatureFlagsStore = new WeakMap< + Request, + Promise +>() + +function defaultFeatureFlags(): EvaluatedFeatureFlags { + const flags = {} as EvaluatedFeatureFlags + for (const definition of featureFlagDefinitions) { + flags[definition.key] = definition.defaultEnabled + } + return flags +} + +function disabledFeatureFlags(): EvaluatedFeatureFlags { + const flags = {} as EvaluatedFeatureFlags + for (const key of featureFlagKeys) { + flags[key] = false + } + return flags +} + +async function resolveRequestFeatureFlags( + env: Env, + userId: number | null, +): Promise { + if (userId === null) { + return defaultFeatureFlags() + } + try { + return await getFeatureFlagsForUser(env.APP_DB, userId) + } catch (error) { + console.error('Failed to load feature flags for authenticated user:', error) + return disabledFeatureFlags() + } +} + +export function loadRequestFeatureFlags( + request: Request, + env: Env, + userId: number | null, +): Promise { + let promise = requestFeatureFlagsStore.get(request) + if (!promise) { + promise = resolveRequestFeatureFlags(env, userId) + requestFeatureFlagsStore.set(request, promise) + } + return promise +} diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index d074bec30e..4873d195cd 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -13,6 +13,10 @@ import { createAdminInvitesApiHandler, createAdminInvitesHandler, } from '#app/handlers/admin-invites.ts' +import { + createAdminFeatureFlagsApiHandler, + createAdminFeatureFlagsHandler, +} from '#app/handlers/admin-feature-flags.ts' import { createAdminRolesApiHandler, createAdminRolesHandler, @@ -204,6 +208,9 @@ export function createAppRouter(env: Env) { adminInvites: createAdminInvitesHandler(env), adminInvitesApi: createAdminInvitesApiHandler(env), adminInvitesApiPost: createAdminInvitesApiHandler(env), + adminFeatureFlags: createAdminFeatureFlagsHandler(env), + adminFeatureFlagsApi: createAdminFeatureFlagsApiHandler(env), + adminFeatureFlagsApiPost: createAdminFeatureFlagsApiHandler(env), adminRoles: createAdminRolesHandler(env), adminRolesApi: createAdminRolesApiHandler(env), adminCommunityReports: createAdminCommunityReportsHandler(env), diff --git a/packages/worker/src/app/routes.ts b/packages/worker/src/app/routes.ts index 7ca97babc5..079a85243b 100644 --- a/packages/worker/src/app/routes.ts +++ b/packages/worker/src/app/routes.ts @@ -53,6 +53,9 @@ export const routes = route({ adminInvites: '/admin/invites', adminInvitesApi: '/admin/invites.json', adminInvitesApiPost: post('/admin/invites.json'), + adminFeatureFlags: '/admin/feature-flags', + adminFeatureFlagsApi: '/admin/feature-flags.json', + adminFeatureFlagsApiPost: post('/admin/feature-flags.json'), adminCommunityReports: '/admin/community-reports', adminCommunityReportsApi: '/admin/community-reports.json', adminCommunityReportsApiPost: post('/admin/community-reports.json'), diff --git a/packages/worker/src/app/session-info.ts b/packages/worker/src/app/session-info.ts index 4d9c9c0243..89876e55b4 100644 --- a/packages/worker/src/app/session-info.ts +++ b/packages/worker/src/app/session-info.ts @@ -1,5 +1,8 @@ import { loadResolvedRequestAuth } from '#app/request-auth-cache.ts' - +import { + loadRequestFeatureFlags, + type EvaluatedFeatureFlags, +} from '#app/request-feature-flags-cache.ts' import { type PermissionString, type RoleName } from '#app/permissions.ts' export type SessionInfo = { @@ -8,6 +11,7 @@ export type SessionInfo = { username: string roles: Array permissions: Array + featureFlags: EvaluatedFeatureFlags } export type LoadedSessionResult = { @@ -27,6 +31,12 @@ export async function loadSessionInfo( } } + const featureFlags = await loadRequestFeatureFlags( + request, + env, + resolved.user.userId, + ) + return { session: { email: resolved.user.email, @@ -34,6 +44,7 @@ export async function loadSessionInfo( username: resolved.user.username, roles: resolved.user.roles, permissions: resolved.user.permissions, + featureFlags, }, setCookie: resolved.setCookie, } diff --git a/packages/worker/src/app/ssr-render.node.test.ts b/packages/worker/src/app/ssr-render.node.test.ts index 192ca8844c..3fe86c8e37 100644 --- a/packages/worker/src/app/ssr-render.node.test.ts +++ b/packages/worker/src/app/ssr-render.node.test.ts @@ -68,48 +68,63 @@ type TestUser = { function createUserTestDb(users: Array) { const userRecords = new Map(users.map((user) => [user.id, { ...user }])) - return { - prepare(query: string) { - const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLowerCase() + + function createStatement(query: string, params: Array = []) { + const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLowerCase() + const executeAll = async () => { + if ( + normalizedQuery.startsWith('select') && + normalizedQuery.includes('from "users"') && + /"id"\s*=/.test(normalizedQuery) + ) { + const user = userRecords.get(Number(params[0])) + return { + results: user ? [{ ...user }] : [], + meta: { changes: 0, last_row_id: 0 }, + } + } + if (normalizedQuery.includes('from user_roles')) { + return { + results: [], + meta: { changes: 0, last_row_id: 0 }, + } + } + // Feature-flag evaluation during SSR session load; empty state uses + // registry defaults without throwing. + if ( + normalizedQuery.includes('from feature_flags') || + normalizedQuery.includes('from feature_flag_user_overrides') + ) { + return { + results: [], + meta: { changes: 0, last_row_id: 0 }, + } + } return { - bind(...params: Array) { - const executeAll = async () => { - if ( - normalizedQuery.startsWith('select') && - normalizedQuery.includes('from "users"') && - /"id"\s*=/.test(normalizedQuery) - ) { - const user = userRecords.get(Number(params[0])) - return { - results: user ? [{ ...user }] : [], - meta: { changes: 0, last_row_id: 0 }, - } - } - if (normalizedQuery.includes('from user_roles')) { - return { - results: [], - meta: { changes: 0, last_row_id: 0 }, - } - } - return { - results: [], - meta: { changes: 0, last_row_id: 0 }, - } - } - return { - async all() { - return executeAll() - }, - async first() { - const result = await executeAll() - return result.results[0] ?? null - }, - async run() { - return { meta: { changes: 0, last_row_id: 0 } } - }, - } - }, + results: [], + meta: { changes: 0, last_row_id: 0 }, } + } + return { + bind(...nextParams: Array) { + return createStatement(query, nextParams) + }, + async all() { + return executeAll() + }, + async first() { + const result = await executeAll() + return result.results[0] ?? null + }, + async run() { + return { meta: { changes: 0, last_row_id: 0 } } + }, + } + } + + return { + prepare(query: string) { + return createStatement(query) }, async exec() { return diff --git a/packages/worker/src/app/ssr-stubs/app-root.ts b/packages/worker/src/app/ssr-stubs/app-root.ts index 87825e6ed8..2bce15c66d 100644 --- a/packages/worker/src/app/ssr-stubs/app-root.ts +++ b/packages/worker/src/app/ssr-stubs/app-root.ts @@ -8,6 +8,7 @@ export type AppRootProps = { username: string roles: Array permissions: Array + featureFlags: Record } | null loaderData?: AppLoaderData notFound?: boolean diff --git a/packages/worker/src/feature-flags/registry.ts b/packages/worker/src/feature-flags/registry.ts new file mode 100644 index 0000000000..795625ec42 --- /dev/null +++ b/packages/worker/src/feature-flags/registry.ts @@ -0,0 +1,51 @@ +/** + * Typed registry of feature flags. Flags are created and removed only via + * code review by editing `featureFlagDefinitions`. The database stores + * runtime state (global toggles/rollouts and per-user overrides), not which + * flags exist. Removing a key from this array leaves any leftover DB rows + * visible as "stale" in the admin UI until an operator deletes them. + */ + +export type FeatureFlagDefinition = { + key: string + description: string + defaultEnabled: boolean +} + +export const featureFlagDefinitions = [ + { + key: 'demo-indicator', + defaultEnabled: false, + description: + 'Reserved for exercising the feature flag system end-to-end. When enabled it shows a small demo indicator in the app UI. Safe to toggle.', + }, +] as const satisfies ReadonlyArray + +export type FeatureFlagKey = (typeof featureFlagDefinitions)[number]['key'] + +export const featureFlagKeys: ReadonlyArray = + featureFlagDefinitions.map((definition) => definition.key) + +const featureFlagDefinitionByKey = new Map< + FeatureFlagKey, + FeatureFlagDefinition +>( + featureFlagDefinitions.map((definition) => [ + definition.key, + definition as FeatureFlagDefinition, + ]), +) + +export function isFeatureFlagKey(value: string): value is FeatureFlagKey { + return featureFlagDefinitionByKey.has(value as FeatureFlagKey) +} + +export function getFeatureFlagDefinition( + key: FeatureFlagKey, +): FeatureFlagDefinition { + const definition = featureFlagDefinitionByKey.get(key) + if (!definition) { + throw new Error(`Unknown feature flag key: ${key}`) + } + return definition +} diff --git a/packages/worker/src/feature-flags/service.node.test.ts b/packages/worker/src/feature-flags/service.node.test.ts new file mode 100644 index 0000000000..c3b3fc83ce --- /dev/null +++ b/packages/worker/src/feature-flags/service.node.test.ts @@ -0,0 +1,596 @@ +import { expect, test } from 'vitest' +import { + clearFeatureFlagUserOverride, + computeRolloutBucket, + deleteStaleFeatureFlag, + getFeatureFlagsForUser, + isFeatureEnabled, + listFeatureFlagsForAdmin, + setFeatureFlagGlobalState, + setFeatureFlagUserOverride, +} from './service.ts' + +type GlobalRow = { + key: string + enabled: number + rollout_percent: number | null + note: string + updated_by: number | null + updated_at: string +} + +type OverrideRow = { + flag_key: string + user_id: number + enabled: number + updated_by: number | null + updated_at: string +} + +type UserRow = { + id: number + username: string +} + +function createFeatureFlagsTestDb( + input: { + globals?: Array + overrides?: Array + users?: Array + } = {}, +) { + const globals = new Map( + (input.globals ?? []).map((row) => [row.key, { ...row }]), + ) + const overrides = new Map( + (input.overrides ?? []).map((row) => [ + `${row.flag_key}:${row.user_id}`, + { ...row }, + ]), + ) + const users = new Map((input.users ?? []).map((row) => [row.id, { ...row }])) + let clock = 0 + + function nextTimestamp() { + clock += 1 + return `2026-07-19T00:00:${String(clock).padStart(2, '0')}.000Z` + } + + function normalize(query: string) { + return query.replace(/\s+/g, ' ').trim().toLowerCase() + } + + function createStatement(query: string, params: Array = []) { + const normalized = normalize(query) + return { + bind(...nextParams: Array) { + return createStatement(query, nextParams) + }, + async first() { + if ( + normalized.includes('from feature_flag_user_overrides') && + normalized.includes('where flag_key = ? and user_id = ?') + ) { + const row = overrides.get(`${params[0]}:${params[1]}`) + return (row ? { enabled: row.enabled } : null) as T | null + } + if ( + normalized.includes('from feature_flags') && + normalized.includes('where key = ?') + ) { + const row = globals.get(String(params[0])) + return ( + row + ? { + enabled: row.enabled, + rollout_percent: row.rollout_percent, + } + : null + ) as T | null + } + throw new Error(`Unsupported first query: ${query}`) + }, + async all() { + if ( + normalized.includes('from feature_flags') && + !normalized.includes('where') + ) { + return { + results: [...globals.values()].map((row) => ({ ...row })), + meta: { changes: 0 }, + } as { results: Array; meta: { changes: number } } + } + if ( + normalized.includes('from feature_flag_user_overrides') && + normalized.includes('where user_id = ?') + ) { + const userId = Number(params[0]) + return { + results: [...overrides.values()] + .filter((row) => row.user_id === userId) + .map((row) => ({ + flag_key: row.flag_key, + enabled: row.enabled, + })), + meta: { changes: 0 }, + } as { results: Array; meta: { changes: number } } + } + if ( + normalized.includes('from feature_flag_user_overrides o') && + normalized.includes('join users u') + ) { + const rows = [...overrides.values()] + .map((row) => { + const user = users.get(row.user_id) + if (!user) return null + return { + flag_key: row.flag_key, + user_id: row.user_id, + enabled: row.enabled, + updated_at: row.updated_at, + username: user.username, + } + }) + .filter((row) => row !== null) + .sort((left, right) => { + const byKey = left.flag_key.localeCompare(right.flag_key) + if (byKey !== 0) return byKey + return left.username.localeCompare(right.username) + }) + return { + results: rows, + meta: { changes: 0 }, + } as { results: Array; meta: { changes: number } } + } + throw new Error(`Unsupported all query: ${query}`) + }, + async run() { + if ( + normalized.startsWith('insert into feature_flags') && + normalized.includes('on conflict(key) do update') + ) { + const key = String(params[0]) + const enabled = Number(params[1]) + const rolloutPercent = + params[2] === null || params[2] === undefined + ? null + : Number(params[2]) + // Emulates COALESCE(?, '') on insert / COALESCE(?, note) on update. + const noteParam = + params[3] === null || params[3] === undefined + ? null + : String(params[3]) + const note = noteParam ?? globals.get(key)?.note ?? '' + const updatedBy = Number(params[4]) + const updatedAt = nextTimestamp() + globals.set(key, { + key, + enabled, + rollout_percent: rolloutPercent, + note, + updated_by: updatedBy, + updated_at: updatedAt, + }) + return { meta: { changes: 1 } } + } + if ( + normalized.startsWith('insert into feature_flag_user_overrides') && + normalized.includes('on conflict(flag_key, user_id) do update') + ) { + const flagKey = String(params[0]) + const userId = Number(params[1]) + const enabled = Number(params[2]) + const updatedBy = Number(params[3]) + const updatedAt = nextTimestamp() + overrides.set(`${flagKey}:${userId}`, { + flag_key: flagKey, + user_id: userId, + enabled, + updated_by: updatedBy, + updated_at: updatedAt, + }) + return { meta: { changes: 1 } } + } + if ( + normalized.startsWith('delete from feature_flag_user_overrides') && + normalized.includes('where flag_key = ? and user_id = ?') + ) { + const mapKey = `${params[0]}:${params[1]}` + const existed = overrides.delete(mapKey) + return { meta: { changes: existed ? 1 : 0 } } + } + if ( + normalized.startsWith('delete from feature_flag_user_overrides') && + normalized.includes('where flag_key = ?') + ) { + const flagKey = String(params[0]) + let changes = 0 + for (const mapKey of [...overrides.keys()]) { + if (mapKey.startsWith(`${flagKey}:`)) { + overrides.delete(mapKey) + changes += 1 + } + } + return { meta: { changes } } + } + if ( + normalized.startsWith('delete from feature_flags') && + normalized.includes('where key = ?') + ) { + const existed = globals.delete(String(params[0])) + return { meta: { changes: existed ? 1 : 0 } } + } + throw new Error(`Unsupported run query: ${query}`) + }, + } + } + + const db = { + prepare(query: string) { + return createStatement(query) + }, + async batch( + statements: Array<{ run: () => Promise<{ meta: { changes: number } }> }>, + ) { + const results = [] + for (const statement of statements) { + results.push(await statement.run()) + } + return results + }, + globals, + overrides, + } as unknown as D1Database & { + globals: Map + overrides: Map + } + + return db +} + +test('isFeatureEnabled falls back to registry default when no DB state exists', async () => { + const db = createFeatureFlagsTestDb() + await expect(isFeatureEnabled(db, 'demo-indicator', 1)).resolves.toBe(false) + await expect(isFeatureEnabled(db, 'demo-indicator', null)).resolves.toBe( + false, + ) + await expect(getFeatureFlagsForUser(db, 1)).resolves.toEqual({ + 'demo-indicator': false, + }) +}) + +test('global on/off and percentage rollout evaluation', async () => { + const db = createFeatureFlagsTestDb() + + await setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: false, + rolloutPercent: null, + updatedBy: 9, + }) + await expect(isFeatureEnabled(db, 'demo-indicator', 1)).resolves.toBe(false) + + await setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: true, + rolloutPercent: null, + note: 'fully on', + updatedBy: 9, + }) + await expect(isFeatureEnabled(db, 'demo-indicator', 1)).resolves.toBe(true) + await expect(isFeatureEnabled(db, 'demo-indicator', null)).resolves.toBe(true) + + await setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: true, + rolloutPercent: 50, + updatedBy: 9, + }) + const userIn = 1 + const userOut = 2 + const bucketIn = computeRolloutBucket('demo-indicator', userIn) + const bucketOut = computeRolloutBucket('demo-indicator', userOut) + // Pick users whose buckets land on opposite sides of 50 for this key. + let enabledUser = userIn + let disabledUser = userOut + if (bucketIn >= 50 && bucketOut < 50) { + enabledUser = userOut + disabledUser = userIn + } else if (bucketIn >= 50 && bucketOut >= 50) { + for (let candidate = 3; candidate < 10_000; candidate += 1) { + if (computeRolloutBucket('demo-indicator', candidate) < 50) { + enabledUser = candidate + disabledUser = userIn + break + } + } + } else if (bucketIn < 50 && bucketOut < 50) { + for (let candidate = 3; candidate < 10_000; candidate += 1) { + if (computeRolloutBucket('demo-indicator', candidate) >= 50) { + disabledUser = candidate + enabledUser = userIn + break + } + } + } + await expect( + isFeatureEnabled(db, 'demo-indicator', enabledUser), + ).resolves.toBe(true) + await expect( + isFeatureEnabled(db, 'demo-indicator', disabledUser), + ).resolves.toBe(false) + await expect(isFeatureEnabled(db, 'demo-indicator', null)).resolves.toBe( + false, + ) + + await expect( + setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: true, + rolloutPercent: 101, + updatedBy: 9, + }), + ).rejects.toThrow(/rolloutPercent/) + await expect( + setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: true, + rolloutPercent: 12.5, + updatedBy: 9, + }), + ).rejects.toThrow(/rolloutPercent/) + + await expect( + setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: true, + rolloutPercent: null, + note: 42, + updatedBy: 9, + }), + ).rejects.toThrow(/note must be a string/) + await expect( + setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: true, + rolloutPercent: null, + note: 'x'.repeat(501), + updatedBy: 9, + }), + ).rejects.toThrow(/note must be at most 500 characters/) +}) + +test('omitting note preserves the existing operator note; empty string clears it', async () => { + const db = createFeatureFlagsTestDb() + + await setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: true, + rolloutPercent: null, + note: 'keep me', + updatedBy: 9, + }) + await setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: false, + rolloutPercent: null, + updatedBy: 9, + }) + expect(db.globals.get('demo-indicator')?.note).toBe('keep me') + + await setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: false, + rolloutPercent: null, + note: '', + updatedBy: 9, + }) + expect(db.globals.get('demo-indicator')?.note).toBe('') +}) + +test('computeRolloutBucket is deterministic and spreads across 0-99', () => { + expect(computeRolloutBucket('demo-indicator', 42)).toBe( + computeRolloutBucket('demo-indicator', 42), + ) + expect(computeRolloutBucket('demo-indicator', 1)).not.toBe( + computeRolloutBucket('other-flag', 1), + ) + + const buckets = new Set() + for (let userId = 1; userId <= 2_000; userId += 1) { + const bucket = computeRolloutBucket('demo-indicator', userId) + expect(bucket).toBeGreaterThanOrEqual(0) + expect(bucket).toBeLessThan(100) + buckets.add(bucket) + } + // Sanity: a decent spread across the 0–99 range for 2000 samples. + expect(buckets.size).toBeGreaterThan(80) +}) + +test('user override wins over global off and global on; clear restores evaluation', async () => { + const db = createFeatureFlagsTestDb() + + await setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: false, + rolloutPercent: null, + updatedBy: 1, + }) + await setFeatureFlagUserOverride(db, { + key: 'demo-indicator', + userId: 7, + enabled: true, + updatedBy: 1, + }) + await expect(isFeatureEnabled(db, 'demo-indicator', 7)).resolves.toBe(true) + await expect(isFeatureEnabled(db, 'demo-indicator', 8)).resolves.toBe(false) + await expect(getFeatureFlagsForUser(db, 7)).resolves.toEqual({ + 'demo-indicator': true, + }) + + await setFeatureFlagGlobalState(db, { + key: 'demo-indicator', + enabled: true, + rolloutPercent: null, + updatedBy: 1, + }) + await setFeatureFlagUserOverride(db, { + key: 'demo-indicator', + userId: 7, + enabled: false, + updatedBy: 1, + }) + await expect(isFeatureEnabled(db, 'demo-indicator', 7)).resolves.toBe(false) + await expect(isFeatureEnabled(db, 'demo-indicator', 8)).resolves.toBe(true) + + await expect( + clearFeatureFlagUserOverride(db, { key: 'demo-indicator', userId: 7 }), + ).resolves.toBe(true) + await expect(isFeatureEnabled(db, 'demo-indicator', 7)).resolves.toBe(true) + await expect( + clearFeatureFlagUserOverride(db, { key: 'demo-indicator', userId: 7 }), + ).resolves.toBe(false) +}) + +test('listFeatureFlagsForAdmin includes registry flags and stale DB-only keys', async () => { + const db = createFeatureFlagsTestDb({ + users: [ + { id: 3, username: 'alice' }, + { id: 4, username: 'bob' }, + ], + globals: [ + { + key: 'demo-indicator', + enabled: 1, + rollout_percent: 25, + note: 'rolling out', + updated_by: 1, + updated_at: '2026-07-01T00:00:00.000Z', + }, + { + key: 'retired-flag', + enabled: 0, + rollout_percent: null, + note: 'leftover', + updated_by: null, + updated_at: '2026-06-01T00:00:00.000Z', + }, + ], + overrides: [ + { + flag_key: 'demo-indicator', + user_id: 4, + enabled: 1, + updated_by: 1, + updated_at: '2026-07-02T00:00:00.000Z', + }, + { + flag_key: 'orphan-override', + user_id: 3, + enabled: 0, + updated_by: 1, + updated_at: '2026-07-03T00:00:00.000Z', + }, + ], + }) + + const listed = await listFeatureFlagsForAdmin(db) + expect(listed).toHaveLength(3) + + const demo = listed.find((flag) => flag.key === 'demo-indicator') + expect(demo).toMatchObject({ + key: 'demo-indicator', + stale: false, + defaultEnabled: false, + global: { + enabled: true, + rolloutPercent: 25, + note: 'rolling out', + updatedBy: 1, + }, + overrides: [ + { + userId: 4, + username: 'bob', + enabled: true, + }, + ], + }) + expect(demo?.description).toContain('exercising the feature flag system') + + const retired = listed.find((flag) => flag.key === 'retired-flag') + expect(retired).toEqual({ + key: 'retired-flag', + description: null, + defaultEnabled: null, + stale: true, + global: { + enabled: false, + rolloutPercent: null, + note: 'leftover', + updatedBy: null, + updatedAt: '2026-06-01T00:00:00.000Z', + }, + overrides: [], + }) + + const orphan = listed.find((flag) => flag.key === 'orphan-override') + expect(orphan).toEqual({ + key: 'orphan-override', + description: null, + defaultEnabled: null, + stale: true, + global: null, + overrides: [ + { + userId: 3, + username: 'alice', + enabled: false, + updatedAt: '2026-07-03T00:00:00.000Z', + }, + ], + }) +}) + +test('deleteStaleFeatureFlag refuses registry keys and removes stale rows', async () => { + const db = createFeatureFlagsTestDb({ + users: [{ id: 3, username: 'alice' }], + globals: [ + { + key: 'demo-indicator', + enabled: 1, + rollout_percent: null, + note: '', + updated_by: 1, + updated_at: '2026-07-01T00:00:00.000Z', + }, + { + key: 'retired-flag', + enabled: 1, + rollout_percent: null, + note: '', + updated_by: 1, + updated_at: '2026-07-01T00:00:00.000Z', + }, + ], + overrides: [ + { + flag_key: 'retired-flag', + user_id: 3, + enabled: 1, + updated_by: 1, + updated_at: '2026-07-01T00:00:00.000Z', + }, + ], + }) + + await expect(deleteStaleFeatureFlag(db, 'demo-indicator')).rejects.toThrow( + /Cannot delete registry feature flag/, + ) + expect(db.globals.has('demo-indicator')).toBe(true) + + await expect(deleteStaleFeatureFlag(db, 'retired-flag')).resolves.toBe(true) + expect(db.globals.has('retired-flag')).toBe(false) + expect(db.overrides.size).toBe(0) + await expect(deleteStaleFeatureFlag(db, 'retired-flag')).resolves.toBe(false) +}) diff --git a/packages/worker/src/feature-flags/service.ts b/packages/worker/src/feature-flags/service.ts new file mode 100644 index 0000000000..60918a082b --- /dev/null +++ b/packages/worker/src/feature-flags/service.ts @@ -0,0 +1,369 @@ +import { fnv1a32 } from '@kody-internal/shared/fnv1a.ts' +import { + featureFlagDefinitions, + featureFlagKeys, + getFeatureFlagDefinition, + isFeatureFlagKey, + type FeatureFlagKey, +} from './registry.ts' +import { type AdminFeatureFlag } from './types.ts' + +export type { AdminFeatureFlag } from './types.ts' + +const maxFeatureFlagNoteLength = 500 + +type GlobalFlagRow = { + key: string + enabled: number + rollout_percent: number | null + note: string + updated_by: number | null + updated_at: string +} + +type OverrideFlagRow = { + flag_key: string + user_id: number + enabled: number + updated_at: string + username: string +} + +type OverrideEnabledRow = { + flag_key: string + enabled: number +} + +/** + * Deterministic 0–99 bucket for percentage rollouts. Exported for tests. + */ +export function computeRolloutBucket(key: string, userId: number): number { + return fnv1a32(`${key}:${userId}`) % 100 +} + +function evaluateFlagState(input: { + key: string + userId: number | null + overrideEnabled: boolean | null + global: { enabled: boolean; rolloutPercent: number | null } | null + defaultEnabled: boolean +}): boolean { + if (input.overrideEnabled !== null) { + return input.overrideEnabled + } + if (input.global) { + if (!input.global.enabled) return false + if (input.global.rolloutPercent === null) return true + if (input.userId === null) return false + return ( + computeRolloutBucket(input.key, input.userId) < + input.global.rolloutPercent + ) + } + return input.defaultEnabled +} + +function assertValidRolloutPercent(rolloutPercent: number | null) { + if (rolloutPercent === null) return + if ( + !Number.isInteger(rolloutPercent) || + rolloutPercent < 0 || + rolloutPercent > 100 + ) { + throw new Error('rolloutPercent must be an integer between 0 and 100.') + } +} + +function normalizeFeatureFlagNote(note: unknown): string | null { + if (note === undefined) { + return null + } + if (typeof note !== 'string') { + throw new Error('note must be a string.') + } + const trimmed = note.trim() + if (trimmed.length > maxFeatureFlagNoteLength) { + throw new Error( + `note must be at most ${maxFeatureFlagNoteLength} characters.`, + ) + } + return trimmed +} + +export async function isFeatureEnabled( + db: D1Database, + key: FeatureFlagKey, + userId: number | null, +): Promise { + if (userId !== null) { + const override = await db + .prepare( + `SELECT enabled + FROM feature_flag_user_overrides + WHERE flag_key = ? AND user_id = ?`, + ) + .bind(key, userId) + .first<{ enabled: number }>() + if (override) { + return override.enabled === 1 + } + } + + const global = await db + .prepare( + `SELECT enabled, rollout_percent + FROM feature_flags + WHERE key = ?`, + ) + .bind(key) + .first<{ enabled: number; rollout_percent: number | null }>() + + return evaluateFlagState({ + key, + userId, + overrideEnabled: null, + global: global + ? { + enabled: global.enabled === 1, + rolloutPercent: global.rollout_percent, + } + : null, + defaultEnabled: getFeatureFlagDefinition(key).defaultEnabled, + }) +} + +export async function getFeatureFlagsForUser( + db: D1Database, + userId: number | null, +): Promise> { + const globalResult = await db + .prepare( + `SELECT key, enabled, rollout_percent + FROM feature_flags`, + ) + .all<{ key: string; enabled: number; rollout_percent: number | null }>() + const globalByKey = new Map( + (globalResult.results ?? []).map((row) => [row.key, row]), + ) + + const overrideByKey = new Map() + if (userId !== null) { + const overrideResult = await db + .prepare( + `SELECT flag_key, enabled + FROM feature_flag_user_overrides + WHERE user_id = ?`, + ) + .bind(userId) + .all() + for (const row of overrideResult.results ?? []) { + overrideByKey.set(row.flag_key, row.enabled === 1) + } + } + + const flags = {} as Record + for (const key of featureFlagKeys) { + const global = globalByKey.get(key) + const overrideEnabled = overrideByKey.has(key) + ? (overrideByKey.get(key) ?? false) + : null + flags[key] = evaluateFlagState({ + key, + userId, + overrideEnabled, + global: global + ? { + enabled: global.enabled === 1, + rolloutPercent: global.rollout_percent, + } + : null, + defaultEnabled: getFeatureFlagDefinition(key).defaultEnabled, + }) + } + return flags +} + +export async function setFeatureFlagGlobalState( + db: D1Database, + input: { + key: FeatureFlagKey + enabled: boolean + rolloutPercent: number | null + note?: unknown + updatedBy: number + }, +): Promise { + assertValidRolloutPercent(input.rolloutPercent) + // null note = "leave unchanged" on update ('' on first insert); callers + // omit the field to preserve an existing operator note. + const note = normalizeFeatureFlagNote(input.note) + await db + .prepare( + `INSERT INTO feature_flags (key, enabled, rollout_percent, note, updated_by, updated_at) + VALUES (?, ?, ?, COALESCE(?, ''), ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + enabled = excluded.enabled, + rollout_percent = excluded.rollout_percent, + note = COALESCE(?, feature_flags.note), + updated_by = excluded.updated_by, + updated_at = CURRENT_TIMESTAMP`, + ) + .bind( + input.key, + input.enabled ? 1 : 0, + input.rolloutPercent, + note, + input.updatedBy, + note, + ) + .run() +} + +export async function setFeatureFlagUserOverride( + db: D1Database, + input: { + key: FeatureFlagKey + userId: number + enabled: boolean + updatedBy: number + }, +): Promise { + await db + .prepare( + `INSERT INTO feature_flag_user_overrides (flag_key, user_id, enabled, updated_by, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(flag_key, user_id) DO UPDATE SET + enabled = excluded.enabled, + updated_by = excluded.updated_by, + updated_at = CURRENT_TIMESTAMP`, + ) + .bind(input.key, input.userId, input.enabled ? 1 : 0, input.updatedBy) + .run() +} + +export async function clearFeatureFlagUserOverride( + db: D1Database, + input: { key: FeatureFlagKey; userId: number }, +): Promise { + const result = await db + .prepare( + `DELETE FROM feature_flag_user_overrides + WHERE flag_key = ? AND user_id = ?`, + ) + .bind(input.key, input.userId) + .run() + return (result.meta.changes ?? 0) > 0 +} + +export async function listFeatureFlagsForAdmin( + db: D1Database, +): Promise> { + const globalResult = await db + .prepare( + `SELECT key, enabled, rollout_percent, note, updated_by, updated_at + FROM feature_flags`, + ) + .all() + const globalByKey = new Map( + (globalResult.results ?? []).map((row) => [row.key, row]), + ) + + const overrideResult = await db + .prepare( + `SELECT o.flag_key, o.user_id, o.enabled, o.updated_at, u.username + FROM feature_flag_user_overrides o + JOIN users u ON u.id = o.user_id + ORDER BY o.flag_key ASC, u.username ASC`, + ) + .all() + const overridesByKey = new Map< + string, + Array + >() + for (const row of overrideResult.results ?? []) { + const list = overridesByKey.get(row.flag_key) ?? [] + list.push({ + userId: row.user_id, + username: row.username, + enabled: row.enabled === 1, + updatedAt: row.updated_at, + }) + overridesByKey.set(row.flag_key, list) + } + + const flags: Array = [] + const seenKeys = new Set() + + for (const definition of featureFlagDefinitions) { + seenKeys.add(definition.key) + const global = globalByKey.get(definition.key) + flags.push({ + key: definition.key, + description: definition.description, + defaultEnabled: definition.defaultEnabled, + stale: false, + global: global + ? { + enabled: global.enabled === 1, + rolloutPercent: global.rollout_percent, + note: global.note, + updatedBy: global.updated_by, + updatedAt: global.updated_at, + } + : null, + overrides: overridesByKey.get(definition.key) ?? [], + }) + } + + const staleKeys = new Set() + for (const key of globalByKey.keys()) { + if (!seenKeys.has(key)) staleKeys.add(key) + } + for (const key of overridesByKey.keys()) { + if (!seenKeys.has(key)) staleKeys.add(key) + } + + for (const key of [...staleKeys].sort()) { + const global = globalByKey.get(key) + flags.push({ + key, + description: null, + defaultEnabled: null, + stale: true, + global: global + ? { + enabled: global.enabled === 1, + rolloutPercent: global.rollout_percent, + note: global.note, + updatedBy: global.updated_by, + updatedAt: global.updated_at, + } + : null, + overrides: overridesByKey.get(key) ?? [], + }) + } + + return flags +} + +export async function deleteStaleFeatureFlag( + db: D1Database, + key: string, +): Promise { + if (isFeatureFlagKey(key)) { + throw new Error( + `Cannot delete registry feature flag "${key}". Remove it from the code registry first.`, + ) + } + + const results = await db.batch([ + db + .prepare(`DELETE FROM feature_flag_user_overrides WHERE flag_key = ?`) + .bind(key), + db.prepare(`DELETE FROM feature_flags WHERE key = ?`).bind(key), + ]) + + return ( + (results[0]?.meta.changes ?? 0) > 0 || (results[1]?.meta.changes ?? 0) > 0 + ) +} diff --git a/packages/worker/src/feature-flags/types.ts b/packages/worker/src/feature-flags/types.ts new file mode 100644 index 0000000000..e6dd5f85cb --- /dev/null +++ b/packages/worker/src/feature-flags/types.ts @@ -0,0 +1,24 @@ +/** + * Shared admin feature-flag shape used by the worker service and client + * loader/route types. Kept dependency-free so the client tsconfig can include + * it without pulling in D1-typed modules. + */ +export type AdminFeatureFlag = { + key: string + description: string | null + defaultEnabled: boolean | null + stale: boolean + global: { + enabled: boolean + rolloutPercent: number | null + note: string + updatedBy: number | null + updatedAt: string + } | null + overrides: Array<{ + userId: number + username: string + enabled: boolean + updatedAt: string + }> +} diff --git a/packages/worker/src/mcp/capabilities/access-control.node.test.ts b/packages/worker/src/mcp/capabilities/access-control.node.test.ts new file mode 100644 index 0000000000..bc77f15080 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/access-control.node.test.ts @@ -0,0 +1,205 @@ +import { expect, test } from 'vitest' +import { createMcpCallerContext } from '#mcp/context.ts' +import { + assertCallerCanAccessCapability, + callerCanAccessCapability, + filterCapabilityRegistryForCaller, + type CallerFeatureFlags, +} from './access-control.ts' +import { type BuiltCapabilityRegistry } from './build-capability-registry.ts' +import { type Capability } from './types.ts' + +function createFlagMap(enabled: boolean): CallerFeatureFlags { + return { 'demo-indicator': enabled } +} + +function createFlaggedCapability(name = 'example_flagged'): Capability { + return { + name, + domain: 'meta', + description: 'Flagged capability for access-control tests.', + keywords: [], + readOnly: true, + idempotent: true, + destructive: false, + featureFlag: 'demo-indicator', + source: 'builtin', + inputSchema: { type: 'object', properties: {} }, + inputTypeDefinition: 'type ExampleFlaggedInput = Record', + async handler() { + return { ok: true } + }, + } +} + +function createOpenCapability(name = 'example_open'): Capability { + return { + name, + domain: 'meta', + description: 'Ungated capability for access-control tests.', + keywords: [], + readOnly: true, + idempotent: true, + destructive: false, + source: 'builtin', + inputSchema: { type: 'object', properties: {} }, + inputTypeDefinition: 'type ExampleOpenInput = Record', + async handler() { + return { ok: true } + }, + } +} + +function createRegistry( + capabilities: Array, +): BuiltCapabilityRegistry { + const capabilityMap = Object.fromEntries( + capabilities.map((capability) => [capability.name, capability]), + ) + return { + capabilityList: capabilities, + capabilityDomains: [ + { + name: 'meta', + description: 'Meta domain for access-control tests.', + }, + ], + capabilityDomainDescriptionsByName: { + meta: 'Meta domain for access-control tests.', + }, + capabilityMap: capabilityMap as BuiltCapabilityRegistry['capabilityMap'], + capabilitySpecs: Object.fromEntries( + capabilities.map((capability) => [ + capability.name, + { + name: capability.name, + domain: capability.domain, + description: capability.description, + keywords: capability.keywords, + readOnly: capability.readOnly, + idempotent: capability.idempotent, + destructive: capability.destructive, + ...(capability.featureFlag + ? { featureFlag: capability.featureFlag } + : {}), + source: capability.source, + inputFields: [], + requiredInputFields: [], + outputFields: [], + inputSchema: capability.inputSchema, + inputTypeDefinition: capability.inputTypeDefinition, + }, + ]), + ) as BuiltCapabilityRegistry['capabilitySpecs'], + capabilityToolDescriptors: {}, + capabilityHandlers: Object.fromEntries( + capabilities.map((capability) => [capability.name, capability.handler]), + ) as BuiltCapabilityRegistry['capabilityHandlers'], + } +} + +test('featureFlag-gated capabilities are denied and hidden when the flag is off', async () => { + const callerContext = createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-1', + email: 'user@example.com', + displayName: 'user', + roles: ['user'], + }, + }) + const flagged = createFlaggedCapability() + const open = createOpenCapability() + const disabledFlags = createFlagMap(false) + + expect(callerCanAccessCapability(callerContext, flagged, disabledFlags)).toBe( + false, + ) + expect(callerCanAccessCapability(callerContext, open, disabledFlags)).toBe( + true, + ) + + await expect( + assertCallerCanAccessCapability(callerContext, flagged, { + featureFlags: disabledFlags, + }), + ).rejects.toThrow(/lacks required feature flag "demo-indicator"/) + + const filtered = filterCapabilityRegistryForCaller( + createRegistry([flagged, open]), + callerContext, + disabledFlags, + ) + expect(filtered.capabilityMap.example_flagged).toBeUndefined() + expect(filtered.capabilityMap.example_open).toBeTruthy() +}) + +test('featureFlag-gated capabilities are allowed when the flag is on', async () => { + const callerContext = createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-1', + email: 'user@example.com', + displayName: 'user', + roles: ['user'], + }, + }) + const flagged = createFlaggedCapability() + const enabledFlags = createFlagMap(true) + + expect(callerCanAccessCapability(callerContext, flagged, enabledFlags)).toBe( + true, + ) + await expect( + assertCallerCanAccessCapability(callerContext, flagged, { + featureFlags: enabledFlags, + }), + ).resolves.toBeUndefined() + + const filtered = filterCapabilityRegistryForCaller( + createRegistry([flagged]), + callerContext, + enabledFlags, + ) + expect(filtered.capabilityMap.example_flagged).toBeTruthy() +}) + +test('featureFlag-gated capabilities fail closed when the flag map is missing', () => { + const callerContext = createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-1', + email: 'user@example.com', + displayName: 'user', + roles: ['user'], + }, + }) + const flagged = createFlaggedCapability() + expect(callerCanAccessCapability(callerContext, flagged)).toBe(false) + expect(callerCanAccessCapability(callerContext, flagged, null)).toBe(false) +}) + +test('featureFlag-gated capabilities require an authenticated caller', async () => { + const anonymousContext = createMcpCallerContext({ + baseUrl: 'https://example.com', + }) + const flagged = createFlaggedCapability() + const enabledFlags = createFlagMap(true) + + expect( + callerCanAccessCapability(anonymousContext, flagged, enabledFlags), + ).toBe(false) + await expect( + assertCallerCanAccessCapability(anonymousContext, flagged, { + featureFlags: enabledFlags, + }), + ).rejects.toThrow(/Authenticated MCP user is required/) + + const filtered = filterCapabilityRegistryForCaller( + createRegistry([flagged, createOpenCapability()]), + anonymousContext, + enabledFlags, + ) + expect(filtered.capabilityMap.example_flagged).toBeUndefined() + expect(filtered.capabilityMap.example_open).toBeTruthy() +}) diff --git a/packages/worker/src/mcp/capabilities/access-control.ts b/packages/worker/src/mcp/capabilities/access-control.ts index 7f98233f85..7b36bf593e 100644 --- a/packages/worker/src/mcp/capabilities/access-control.ts +++ b/packages/worker/src/mcp/capabilities/access-control.ts @@ -5,14 +5,22 @@ import { userHasPermission, userHasRole, } from '#app/permissions.ts' +import { + type FeatureFlagKey, + featureFlagKeys, +} from '#worker/feature-flags/registry.ts' +import { getFeatureFlagsForUser } from '#worker/feature-flags/service.ts' +import { findUserRowByStableUserId } from '#worker/user-id.ts' import { type BuiltCapabilityRegistry } from './build-capability-registry.ts' import { type Capability, type CapabilitySpec } from './types.ts' type CapabilityAccessRequirement = Pick< Capability | CapabilitySpec, - 'name' | 'requiredRole' | 'requiredPermission' + 'name' | 'requiredRole' | 'requiredPermission' | 'featureFlag' > +export type CallerFeatureFlags = Readonly> + type McpUserAccessContext = { roles?: Array permissions?: Array @@ -36,28 +44,99 @@ function hasRequiredPermission( ) } +function disabledFeatureFlags(): CallerFeatureFlags { + return Object.fromEntries( + featureFlagKeys.map((key) => [key, false]), + ) as Record +} + +async function resolveFeatureFlagUserId( + db: D1Database, + callerContext: McpCallerContext, +): Promise { + const user = callerContext.user + if (!user?.userId) return null + const row = await findUserRowByStableUserId<{ + id: number + email: string + stable_user_id: string | null + }>({ + db, + stableUserId: user.userId, + select: 'SELECT id, email, stable_user_id FROM users', + }) + return row?.id ?? null +} + +/** + * Resolve the caller's evaluated feature-flag map once per request. Used by + * registry filtering (search/list) so access checks stay synchronous. + * + * Fail-closed rules: anonymous callers and authenticated callers whose stable + * id cannot be resolved to a `users.id` get every flag off, so gated + * capabilities never appear with a different flag state than the same user's + * app session would compute. + */ +export async function resolveCallerFeatureFlags( + env: Env, + callerContext: McpCallerContext, +): Promise { + if (!env.APP_DB) return disabledFeatureFlags() + if (!callerContext.user?.userId) return disabledFeatureFlags() + try { + const userId = await resolveFeatureFlagUserId(env.APP_DB, callerContext) + if (userId === null) return disabledFeatureFlags() + return await getFeatureFlagsForUser(env.APP_DB, userId) + } catch { + // Fail closed: gated capabilities stay hidden when evaluation fails. + return disabledFeatureFlags() + } +} + export function callerCanAccessCapability( callerContext: McpCallerContext, capability: CapabilityAccessRequirement, + featureFlags?: CallerFeatureFlags | null, ) { const requiredRole = capability.requiredRole const requiredPermission = capability.requiredPermission - if (!requiredRole && !requiredPermission) return true + const requiredFeatureFlag = capability.featureFlag + if (!requiredRole && !requiredPermission && !requiredFeatureFlag) { + return true + } + // Flag-gated capabilities also require an authenticated caller: flags are + // evaluated per user, so there is no meaningful anonymous flag state. const user = getUserAccessContext(callerContext) if (!user) return false if (requiredRole && !hasRequiredRole(user, requiredRole)) return false if (requiredPermission && !hasRequiredPermission(user, requiredPermission)) { return false } + if (requiredFeatureFlag) { + // Fail closed when the per-request flag map was not resolved. + if (!featureFlags) return false + if (featureFlags[requiredFeatureFlag] !== true) return false + } return true } -export function assertCallerCanAccessCapability( +export async function assertCallerCanAccessCapability( callerContext: McpCallerContext, capability: CapabilityAccessRequirement, + options: { + featureFlags?: CallerFeatureFlags | null + env?: Env + } = {}, ) { - if (callerCanAccessCapability(callerContext, capability)) return + let featureFlags = options.featureFlags + if (capability.featureFlag && featureFlags == null && options.env) { + featureFlags = await resolveCallerFeatureFlags(options.env, callerContext) + } + + if (callerCanAccessCapability(callerContext, capability, featureFlags)) { + return + } const user = getUserAccessContext(callerContext) if (!user) { @@ -81,15 +160,21 @@ export function assertCallerCanAccessCapability( `MCP user lacks required permission "${capability.requiredPermission}" for capability "${capability.name}".`, ) } + if (capability.featureFlag) { + throw new Error( + `MCP user lacks required feature flag "${capability.featureFlag}" for capability "${capability.name}".`, + ) + } throw new Error(`MCP user cannot access capability "${capability.name}".`) } export function filterCapabilityRegistryForCaller( registry: BuiltCapabilityRegistry, callerContext: McpCallerContext, + featureFlags?: CallerFeatureFlags | null, ): BuiltCapabilityRegistry { const capabilityList = registry.capabilityList.filter((capability) => - callerCanAccessCapability(callerContext, capability), + callerCanAccessCapability(callerContext, capability, featureFlags), ) if (capabilityList.length === registry.capabilityList.length) { return registry diff --git a/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-capabilities.node.test.ts b/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-capabilities.node.test.ts new file mode 100644 index 0000000000..fe8d235d96 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-capabilities.node.test.ts @@ -0,0 +1,451 @@ +import { expect, test, vi } from 'vitest' +import { createMcpCallerContext } from '#mcp/context.ts' + +vi.unmock('#app/audit-log.ts') +import { adminFeatureFlagListCapability } from './admin-feature-flag-list.ts' +import { adminFeatureFlagOverrideCapability } from './admin-feature-flag-override.ts' +import { adminFeatureFlagSetCapability } from './admin-feature-flag-set.ts' + +type UserRow = { + id: number + username: string + email: string + stable_user_id: string | null +} + +type GlobalRow = { + key: string + enabled: number + rollout_percent: number | null + note: string + updated_by: number | null + updated_at: string +} + +type OverrideRow = { + flag_key: string + user_id: number + enabled: number + updated_by: number | null + updated_at: string +} + +type AuditEventRow = { + id: number + category: string + action: string + result: string + email_hash: string | null + ip_hash: string | null + client_id: string | null + path: string | null + reason: string | null + timestamp: string +} + +function normalizeQuery(query: string) { + return query.replace(/\s+/g, ' ').trim().toLowerCase() +} + +function createFeatureFlagCapabilityTestDb(input: { + users: Array + globals?: Array + overrides?: Array +}) { + const users = input.users.map((user) => ({ ...user })) + const globals = new Map( + (input.globals ?? []).map((row) => [row.key, { ...row }]), + ) + const overrides = new Map( + (input.overrides ?? []).map((row) => [ + `${row.flag_key}:${row.user_id}`, + { ...row }, + ]), + ) + const auditEvents: Array = [] + let clock = 0 + + function nextTimestamp() { + clock += 1 + return `2026-07-19T00:00:${String(clock).padStart(2, '0')}.000Z` + } + + function createStatement(query: string, params: Array = []) { + const normalized = normalizeQuery(query) + return { + bind(...nextParams: Array) { + return createStatement(query, nextParams) + }, + async first() { + if ( + normalized.includes('from users') && + normalized.includes('where stable_user_id = ?') + ) { + const stableUserId = String(params[0]) + return (users.find((user) => user.stable_user_id === stableUserId) ?? + null) as T | null + } + if ( + normalized.includes('select id from users where id = ?') || + (normalized.includes('from users') && + normalized.includes('where id = ?') && + !normalized.includes('stable_user_id')) + ) { + return (users.find((user) => user.id === Number(params[0])) ?? + null) as T | null + } + if (normalized.includes('select id from users where username = ?')) { + const username = String(params[0]) + const user = users.find((row) => row.username === username) + return user ? ({ id: user.id } as T) : null + } + if ( + normalized.includes('from feature_flag_user_overrides') && + normalized.includes('where flag_key = ? and user_id = ?') + ) { + const row = overrides.get(`${params[0]}:${params[1]}`) + return (row ? { enabled: row.enabled } : null) as T | null + } + if ( + normalized.includes('from feature_flags') && + normalized.includes('where key = ?') + ) { + const row = globals.get(String(params[0])) + return ( + row + ? { + enabled: row.enabled, + rollout_percent: row.rollout_percent, + } + : null + ) as T | null + } + return null + }, + async all() { + if ( + normalized.includes('from feature_flags') && + !normalized.includes('where') + ) { + return { + results: [...globals.values()].map((row) => ({ + ...row, + })) as Array, + } + } + if ( + normalized.includes('from feature_flag_user_overrides o') && + normalized.includes('join users u') + ) { + const rows = [...overrides.values()] + .map((row) => { + const user = users.find((entry) => entry.id === row.user_id) + if (!user) return null + return { + flag_key: row.flag_key, + user_id: row.user_id, + enabled: row.enabled, + updated_at: row.updated_at, + username: user.username, + } + }) + .filter((row) => row !== null) + .sort((left, right) => { + const byKey = left.flag_key.localeCompare(right.flag_key) + if (byKey !== 0) return byKey + return left.username.localeCompare(right.username) + }) + return { results: rows as Array } + } + if ( + normalized.includes('from feature_flag_user_overrides') && + normalized.includes('where user_id = ?') + ) { + const userId = Number(params[0]) + return { + results: [...overrides.values()] + .filter((row) => row.user_id === userId) + .map((row) => ({ + flag_key: row.flag_key, + enabled: row.enabled, + })) as Array, + } + } + if ( + normalized.startsWith( + 'select id, email, stable_user_id from users', + ) && + !normalized.includes('where') + ) { + return { + results: users.map((user) => ({ + id: user.id, + email: user.email, + stable_user_id: user.stable_user_id, + })) as Array, + } + } + return { results: [] as Array } + }, + async run() { + if ( + normalized.startsWith('insert into feature_flags') && + normalized.includes('on conflict(key) do update') + ) { + const key = String(params[0]) + const enabled = Number(params[1]) + const rolloutPercent = + params[2] === null || params[2] === undefined + ? null + : Number(params[2]) + const note = String(params[3] ?? '') + const updatedBy = Number(params[4]) + const updatedAt = nextTimestamp() + globals.set(key, { + key, + enabled, + rollout_percent: rolloutPercent, + note, + updated_by: updatedBy, + updated_at: updatedAt, + }) + return { meta: { changes: 1 } } + } + if ( + normalized.startsWith('insert into feature_flag_user_overrides') && + normalized.includes('on conflict(flag_key, user_id) do update') + ) { + const flagKey = String(params[0]) + const userId = Number(params[1]) + const enabled = Number(params[2]) + const updatedBy = Number(params[3]) + const updatedAt = nextTimestamp() + overrides.set(`${flagKey}:${userId}`, { + flag_key: flagKey, + user_id: userId, + enabled, + updated_by: updatedBy, + updated_at: updatedAt, + }) + return { meta: { changes: 1 } } + } + if ( + normalized.startsWith('delete from feature_flag_user_overrides') && + normalized.includes('where flag_key = ? and user_id = ?') + ) { + const existed = overrides.delete(`${params[0]}:${params[1]}`) + return { meta: { changes: existed ? 1 : 0 } } + } + if (normalized.startsWith('insert into audit_events')) { + auditEvents.push({ + id: auditEvents.length + 1, + category: String(params[0]), + action: String(params[1]), + result: String(params[2]), + email_hash: params[3] ? String(params[3]) : null, + ip_hash: params[4] ? String(params[4]) : null, + client_id: params[5] ? String(params[5]) : null, + path: params[6] ? String(params[6]) : null, + reason: params[7] ? String(params[7]) : null, + timestamp: String(params[8]), + }) + } + return { meta: { changes: 1 } } + }, + } + } + + const db = { + prepare(query: string) { + return createStatement(query) + }, + } as unknown as D1Database + + return { db, auditEvents, globals, overrides, users } +} + +function createAdminCapabilityContext(db: D1Database) { + return { + env: { APP_DB: db } as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'admin-stable', + email: 'admin@example.com', + displayName: 'admin', + roles: ['admin'], + }, + }), + } +} + +test('admin_feature_flag_list returns registry flags with description metadata', async () => { + const { db, auditEvents } = createFeatureFlagCapabilityTestDb({ + users: [ + { + id: 1, + username: 'admin', + email: 'admin@example.com', + stable_user_id: 'admin-stable', + }, + ], + }) + const ctx = createAdminCapabilityContext(db) + + const result = await adminFeatureFlagListCapability.handler({}, ctx) + expect(result.flags).toEqual([ + expect.objectContaining({ + key: 'demo-indicator', + description: expect.stringContaining( + 'exercising the feature flag system', + ), + defaultEnabled: false, + stale: false, + global: null, + overrides: [], + }), + ]) + expect(auditEvents).toEqual([ + expect.objectContaining({ + action: 'admin_feature_flag_list', + result: 'success', + }), + ]) +}) + +test('admin_feature_flag_set updates global state and rejects unknown keys', async () => { + const { db, auditEvents, globals } = createFeatureFlagCapabilityTestDb({ + users: [ + { + id: 1, + username: 'admin', + email: 'admin@example.com', + stable_user_id: 'admin-stable', + }, + ], + }) + const ctx = createAdminCapabilityContext(db) + + const result = await adminFeatureFlagSetCapability.handler( + { + key: 'demo-indicator', + enabled: true, + rolloutPercent: 25, + note: 'gradual rollout', + }, + ctx, + ) + expect(result.flag).toMatchObject({ + key: 'demo-indicator', + global: { + enabled: true, + rolloutPercent: 25, + note: 'gradual rollout', + updatedBy: 1, + }, + }) + expect(globals.get('demo-indicator')).toMatchObject({ + enabled: 1, + rollout_percent: 25, + updated_by: 1, + }) + + await expect( + adminFeatureFlagSetCapability.handler( + { key: 'not-a-real-flag', enabled: true }, + ctx, + ), + ).rejects.toThrow(/Unknown feature flag key "not-a-real-flag"/) + + expect(auditEvents.map((event) => event.result)).toEqual([ + 'success', + 'failure', + ]) +}) + +test('admin_feature_flag_override sets and clears per-user overrides', async () => { + const { db, overrides } = createFeatureFlagCapabilityTestDb({ + users: [ + { + id: 1, + username: 'admin', + email: 'admin@example.com', + stable_user_id: 'admin-stable', + }, + { + id: 2, + username: 'jane', + email: 'jane@example.com', + stable_user_id: 'jane-stable', + }, + ], + }) + const ctx = createAdminCapabilityContext(db) + + const setByUsername = await adminFeatureFlagOverrideCapability.handler( + { key: 'demo-indicator', username: 'jane', enabled: true }, + ctx, + ) + expect(setByUsername).toMatchObject({ + cleared: false, + flag: { + key: 'demo-indicator', + overrides: [ + expect.objectContaining({ + userId: 2, + username: 'jane', + enabled: true, + }), + ], + }, + }) + expect(overrides.get('demo-indicator:2')).toMatchObject({ + enabled: 1, + updated_by: 1, + }) + + const cleared = await adminFeatureFlagOverrideCapability.handler( + { key: 'demo-indicator', userId: 2, clear: true }, + ctx, + ) + expect(cleared.cleared).toBe(true) + expect(cleared.flag.overrides).toEqual([]) + expect(overrides.has('demo-indicator:2')).toBe(false) +}) + +test('admin_feature_flag_override rejects invalid keys and missing users', async () => { + const { db } = createFeatureFlagCapabilityTestDb({ + users: [ + { + id: 1, + username: 'admin', + email: 'admin@example.com', + stable_user_id: 'admin-stable', + }, + ], + }) + const ctx = createAdminCapabilityContext(db) + + await expect( + adminFeatureFlagOverrideCapability.handler( + { key: 'missing-flag', username: 'admin', enabled: true }, + ctx, + ), + ).rejects.toThrow(/Unknown feature flag key "missing-flag"/) + + await expect( + adminFeatureFlagOverrideCapability.handler( + { key: 'demo-indicator', username: 'nobody', enabled: true }, + ctx, + ), + ).rejects.toThrow(/User not found for username "nobody"/) + + await expect( + adminFeatureFlagOverrideCapability.handler( + { key: 'demo-indicator', userId: 1, clear: true }, + ctx, + ), + ).rejects.toThrow( + /No override exists for feature flag "demo-indicator" and userId 1/, + ) +}) diff --git a/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-list.ts b/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-list.ts new file mode 100644 index 0000000000..b5256eeeba --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-list.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { + emptyCapabilityInputSchema, + type CapabilityContext, +} from '#mcp/capabilities/types.ts' +import { listFeatureFlagsForAdmin } from '#worker/feature-flags/service.ts' +import { + adminCapabilityAccess, + auditAdminCapabilityInvocation, +} from './admin-shared.ts' +import { adminFeatureFlagSchema } from './feature-flag-shared.ts' + +const outputSchema = z.object({ + flags: z.array(adminFeatureFlagSchema), +}) + +export const adminFeatureFlagListCapability = defineDomainCapability( + capabilityDomainNames.admin, + { + ...adminCapabilityAccess, + name: 'admin_feature_flag_list', + description: + 'List feature flags for admin review, including registry metadata, global state, and per-user overrides. Admin-only; never returns user content.', + keywords: [ + 'admin', + 'feature flags', + 'flags', + 'rollout', + 'overrides', + 'toggles', + ], + inputSchema: emptyCapabilityInputSchema, + outputSchema, + async handler(_args, ctx: CapabilityContext) { + return auditAdminCapabilityInvocation( + ctx, + 'admin_feature_flag_list', + async () => { + const flags = await listFeatureFlagsForAdmin(ctx.env.APP_DB) + return { flags } + }, + ) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-override.ts b/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-override.ts new file mode 100644 index 0000000000..c7eea0ba8a --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-override.ts @@ -0,0 +1,143 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { type CapabilityContext } from '#mcp/capabilities/types.ts' +import { + clearFeatureFlagUserOverride, + listFeatureFlagsForAdmin, + setFeatureFlagUserOverride, +} from '#worker/feature-flags/service.ts' +import { + adminMutationCapabilityAccess, + auditAdminCapabilityInvocation, +} from './admin-shared.ts' +import { + adminFeatureFlagSchema, + assertFeatureFlagKey, + resolveActingAdminUserId, + resolveTargetUserId, +} from './feature-flag-shared.ts' + +const inputSchema = z + .object({ + key: z.string().min(1).describe('Feature flag key from the code registry.'), + userId: z + .number() + .int() + .positive() + .optional() + .describe('Numeric users.id for the override target.'), + username: z + .string() + .optional() + .describe('Username for the override target when userId is omitted.'), + enabled: z + .boolean() + .optional() + .describe('Override enabled state. Required unless clear is true.'), + clear: z + .boolean() + .optional() + .describe( + 'When true, remove the per-user override instead of setting it.', + ), + }) + .superRefine((value, context) => { + const clear = value.clear === true + if (clear && value.enabled !== undefined) { + context.addIssue({ + code: 'custom', + message: 'Do not provide enabled when clear is true.', + path: ['enabled'], + }) + } + if (!clear && value.enabled === undefined) { + context.addIssue({ + code: 'custom', + message: 'Provide enabled when setting an override, or clear: true.', + path: ['enabled'], + }) + } + if (value.userId === undefined && value.username === undefined) { + context.addIssue({ + code: 'custom', + message: 'Provide either userId or username.', + path: ['userId'], + }) + } + if (value.userId !== undefined && value.username !== undefined) { + context.addIssue({ + code: 'custom', + message: 'Provide either userId or username, not both.', + path: ['username'], + }) + } + }) + +const outputSchema = z.object({ + flag: adminFeatureFlagSchema, + cleared: z.boolean(), +}) + +export const adminFeatureFlagOverrideCapability = defineDomainCapability( + capabilityDomainNames.admin, + { + ...adminMutationCapabilityAccess, + name: 'admin_feature_flag_override', + description: + 'Set or clear a per-user feature flag override by userId or username. Admin-only; never returns user content.', + keywords: [ + 'admin', + 'feature flags', + 'flags', + 'override', + 'per-user', + 'toggle', + ], + inputSchema, + outputSchema, + async handler(args, ctx: CapabilityContext) { + return auditAdminCapabilityInvocation( + ctx, + 'admin_feature_flag_override', + async () => { + const key = assertFeatureFlagKey(args.key) + const targetUserId = await resolveTargetUserId(ctx.env.APP_DB, { + userId: args.userId, + username: args.username, + }) + const clear = args.clear === true + if (clear) { + const cleared = await clearFeatureFlagUserOverride(ctx.env.APP_DB, { + key, + userId: targetUserId, + }) + if (!cleared) { + throw new Error( + `No override exists for feature flag "${key}" and userId ${targetUserId}.`, + ) + } + } else { + const updatedBy = await resolveActingAdminUserId(ctx) + await setFeatureFlagUserOverride(ctx.env.APP_DB, { + key, + userId: targetUserId, + enabled: args.enabled === true, + updatedBy, + }) + } + const flags = await listFeatureFlagsForAdmin(ctx.env.APP_DB) + const flag = flags.find((entry) => entry.key === key) + if (!flag) { + throw new Error(`Feature flag "${key}" was not found after update.`) + } + return { flag, cleared: clear } + }, + { + successReason: ({ flag, cleared }) => + `key=${flag.key};cleared=${cleared};overrides=${flag.overrides.length}`, + }, + ) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-set.ts b/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-set.ts new file mode 100644 index 0000000000..b1af535c3b --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/admin-feature-flag-set.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { type CapabilityContext } from '#mcp/capabilities/types.ts' +import { + listFeatureFlagsForAdmin, + setFeatureFlagGlobalState, +} from '#worker/feature-flags/service.ts' +import { + adminMutationCapabilityAccess, + auditAdminCapabilityInvocation, +} from './admin-shared.ts' +import { + adminFeatureFlagSchema, + assertFeatureFlagKey, + resolveActingAdminUserId, +} from './feature-flag-shared.ts' + +const inputSchema = z.object({ + key: z.string().min(1).describe('Feature flag key from the code registry.'), + enabled: z.boolean().describe('Whether the flag is globally enabled.'), + rolloutPercent: z + .number() + .int() + .min(0) + .max(100) + .nullable() + .optional() + .describe( + 'Optional 0–100 percentage rollout when enabled. Null or omitted means 100% of users once enabled.', + ), + note: z + .string() + .optional() + .describe('Optional operator note stored with the global flag state.'), +}) + +const outputSchema = z.object({ + flag: adminFeatureFlagSchema, +}) + +export const adminFeatureFlagSetCapability = defineDomainCapability( + capabilityDomainNames.admin, + { + ...adminMutationCapabilityAccess, + name: 'admin_feature_flag_set', + description: + 'Set global enabled state and optional percentage rollout for one registry feature flag. Admin-only; never returns user content.', + keywords: [ + 'admin', + 'feature flags', + 'flags', + 'rollout', + 'toggle', + 'enable', + 'disable', + ], + inputSchema, + outputSchema, + async handler(args, ctx: CapabilityContext) { + return auditAdminCapabilityInvocation( + ctx, + 'admin_feature_flag_set', + async () => { + const key = assertFeatureFlagKey(args.key) + const updatedBy = await resolveActingAdminUserId(ctx) + await setFeatureFlagGlobalState(ctx.env.APP_DB, { + key, + enabled: args.enabled, + rolloutPercent: + args.rolloutPercent === undefined ? null : args.rolloutPercent, + note: args.note, + updatedBy, + }) + const flags = await listFeatureFlagsForAdmin(ctx.env.APP_DB) + const flag = flags.find((entry) => entry.key === key) + if (!flag) { + throw new Error(`Feature flag "${key}" was not found after update.`) + } + return { flag } + }, + { + successReason: ({ flag }) => + `key=${flag.key};enabled=${flag.global?.enabled ?? false};rollout=${flag.global?.rolloutPercent ?? 'null'}`, + }, + ) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/admin/domain.ts b/packages/worker/src/mcp/capabilities/admin/domain.ts index 9c317fad02..0111fb19b8 100644 --- a/packages/worker/src/mcp/capabilities/admin/domain.ts +++ b/packages/worker/src/mcp/capabilities/admin/domain.ts @@ -1,6 +1,9 @@ import { defineDomain } from '#mcp/capabilities/define-domain.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { adminAuditLogQueryCapability } from './admin-audit-log-query.ts' +import { adminFeatureFlagListCapability } from './admin-feature-flag-list.ts' +import { adminFeatureFlagOverrideCapability } from './admin-feature-flag-override.ts' +import { adminFeatureFlagSetCapability } from './admin-feature-flag-set.ts' import { adminPlatformFeedbackGetCapability } from './admin-platform-feedback-get.ts' import { adminPlatformFeedbackListCapability } from './admin-platform-feedback-list.ts' import { adminPlatformFeedbackUpdateCapability } from './admin-platform-feedback-update.ts' @@ -15,7 +18,7 @@ import { adminUserUpdateCapability } from './admin-user-update.ts' export const adminDomain = defineDomain({ name: capabilityDomainNames.admin, description: - 'Admin-only operator capabilities for account metadata, operator-owned system email, and attributed platform feedback users explicitly submit for admin review; never exposes unrelated user content such as packages, secrets, memories, jobs, or user inbox email.', + 'Admin-only operator capabilities for account metadata, feature flags, operator-owned system email, and attributed platform feedback users explicitly submit for admin review; never exposes unrelated user content such as packages, secrets, memories, jobs, or user inbox email.', keywords: [ 'admin', 'rbac', @@ -24,6 +27,7 @@ export const adminDomain = defineDomain({ 'roles', 'plans', 'audit', + 'feature flags', 'system email', 'platform feedback', ], @@ -34,6 +38,9 @@ export const adminDomain = defineDomain({ adminUserUpdateCapability, adminAuditLogQueryCapability, adminUserUsageCapability, + adminFeatureFlagListCapability, + adminFeatureFlagSetCapability, + adminFeatureFlagOverrideCapability, adminSystemEmailListCapability, adminSystemEmailGetCapability, adminPlatformFeedbackListCapability, diff --git a/packages/worker/src/mcp/capabilities/admin/feature-flag-shared.ts b/packages/worker/src/mcp/capabilities/admin/feature-flag-shared.ts new file mode 100644 index 0000000000..49899f32ba --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/feature-flag-shared.ts @@ -0,0 +1,95 @@ +import { z } from 'zod' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { type CapabilityContext } from '#mcp/capabilities/types.ts' +import { + featureFlagKeys, + isFeatureFlagKey, + type FeatureFlagKey, +} from '#worker/feature-flags/registry.ts' +import { findUserRowByStableUserId } from '#worker/user-id.ts' + +export const adminFeatureFlagOverrideSchema = z.object({ + userId: z.number().int().positive(), + username: z.string(), + enabled: z.boolean(), + updatedAt: z.string(), +}) + +export const adminFeatureFlagSchema = z.object({ + key: z.string(), + description: z.string().nullable(), + defaultEnabled: z.boolean().nullable(), + stale: z.boolean(), + global: z + .object({ + enabled: z.boolean(), + rolloutPercent: z.number().int().min(0).max(100).nullable(), + note: z.string(), + updatedBy: z.number().int().positive().nullable(), + updatedAt: z.string(), + }) + .nullable(), + overrides: z.array(adminFeatureFlagOverrideSchema), +}) + +export function assertFeatureFlagKey(key: string): FeatureFlagKey { + if (!isFeatureFlagKey(key)) { + throw new Error( + `Unknown feature flag key "${key}". Valid keys: ${featureFlagKeys.join(', ')}.`, + ) + } + return key +} + +export async function resolveActingAdminUserId( + ctx: CapabilityContext, +): Promise { + const user = requireMcpUser(ctx.callerContext) + const row = await findUserRowByStableUserId<{ + id: number + email: string + stable_user_id: string | null + }>({ + db: ctx.env.APP_DB, + stableUserId: user.userId, + select: 'SELECT id, email, stable_user_id FROM users', + }) + if (!row) { + throw new Error('Authenticated admin account was not found.') + } + return row.id +} + +export async function resolveTargetUserId( + db: D1Database, + input: { userId?: number; username?: string }, +): Promise { + if (input.userId !== undefined && input.username !== undefined) { + throw new Error('Provide either userId or username, not both.') + } + if (input.userId !== undefined) { + const row = await db + .prepare(`SELECT id FROM users WHERE id = ?`) + .bind(input.userId) + .first<{ id: number }>() + if (!row) { + throw new Error(`User not found for userId ${input.userId}.`) + } + return row.id + } + if (input.username !== undefined) { + const username = input.username.trim() + if (!username) { + throw new Error('username must not be empty.') + } + const row = await db + .prepare(`SELECT id FROM users WHERE username = ?`) + .bind(username) + .first<{ id: number }>() + if (!row) { + throw new Error(`User not found for username "${username}".`) + } + return row.id + } + throw new Error('Provide either userId or username.') +} diff --git a/packages/worker/src/mcp/capabilities/build-capability-registry.ts b/packages/worker/src/mcp/capabilities/build-capability-registry.ts index fa3ebdfde7..135c6e81a5 100644 --- a/packages/worker/src/mcp/capabilities/build-capability-registry.ts +++ b/packages/worker/src/mcp/capabilities/build-capability-registry.ts @@ -105,6 +105,9 @@ function createCapabilitySpecs(capabilities: Array) { ...(capability.requiredPermission ? { requiredPermission: capability.requiredPermission } : {}), + ...(capability.featureFlag + ? { featureFlag: capability.featureFlag } + : {}), source: capability.source ?? 'builtin', ...(capability.remoteConnector ? { remoteConnector: capability.remoteConnector } diff --git a/packages/worker/src/mcp/capabilities/define-capability.ts b/packages/worker/src/mcp/capabilities/define-capability.ts index 7e7a70a093..917a463fed 100644 --- a/packages/worker/src/mcp/capabilities/define-capability.ts +++ b/packages/worker/src/mcp/capabilities/define-capability.ts @@ -48,6 +48,7 @@ export function defineCapability< ...(definition.requiredPermission ? { requiredPermission: definition.requiredPermission } : {}), + ...(definition.featureFlag ? { featureFlag: definition.featureFlag } : {}), source, ...(definition.remoteConnector ? { remoteConnector: definition.remoteConnector } @@ -71,7 +72,9 @@ export function defineCapability< async handler(args, ctx) { const startedAt = performance.now() const { baseUrl, hasUser } = callerContextFields(ctx.callerContext) - assertCallerCanAccessCapability(ctx.callerContext, definition) + await assertCallerCanAccessCapability(ctx.callerContext, definition, { + env: ctx.env, + }) let parsedArgs: InferCapabilitySchema try { diff --git a/packages/worker/src/mcp/capabilities/registry.node.test.ts b/packages/worker/src/mcp/capabilities/registry.node.test.ts index 1994d23c55..2f16148614 100644 --- a/packages/worker/src/mcp/capabilities/registry.node.test.ts +++ b/packages/worker/src/mcp/capabilities/registry.node.test.ts @@ -11,6 +11,28 @@ import { test('getCapabilityRegistryForContext bypasses connector caches when no connectors are attached', async () => { clearCapabilityRegistryCacheForTests() const get = vi.fn() + const prepare = vi.fn((query: string) => { + const normalized = query.replace(/\s+/g, ' ').trim().toLowerCase() + return { + bind() { + return this + }, + async all() { + if (normalized.includes('feature_flag')) { + throw new Error( + 'Feature-flag queries must not run when no capability declares featureFlag', + ) + } + return { results: [], meta: { changes: 0 } } + }, + async first() { + return null + }, + async run() { + return { meta: { changes: 0 } } + }, + } + }) const env = { REMOTE_CONNECTOR_SESSION: { idFromName(name: string) { @@ -18,6 +40,9 @@ test('getCapabilityRegistryForContext bypasses connector caches when no connecto }, get, }, + APP_DB: { + prepare, + }, } as unknown as Env const callerContext = createMcpCallerContext({ baseUrl: 'https://heykody.dev', @@ -35,6 +60,11 @@ test('getCapabilityRegistryForContext bypasses connector caches when no connecto }) expect(get).not.toHaveBeenCalled() + expect( + prepare.mock.calls.some(([query]) => + String(query).toLowerCase().includes('feature_flag'), + ), + ).toBe(false) expect(registry.capabilityMap).toBe(getStaticRegistry().capabilityMap) }) diff --git a/packages/worker/src/mcp/capabilities/registry.ts b/packages/worker/src/mcp/capabilities/registry.ts index bb18cd77c9..47c3349318 100644 --- a/packages/worker/src/mcp/capabilities/registry.ts +++ b/packages/worker/src/mcp/capabilities/registry.ts @@ -2,7 +2,11 @@ import { buildCapabilityRegistry, type BuiltCapabilityRegistry, } from './build-capability-registry.ts' -import { filterCapabilityRegistryForCaller } from './access-control.ts' +import { + filterCapabilityRegistryForCaller, + resolveCallerFeatureFlags, + type CallerFeatureFlags, +} from './access-control.ts' import { builtinDomains } from './builtin-domains.ts' import { synthesizeRemoteToolDomain } from './remote-connector/index.ts' import { synthesizeMcpServerToolDomain } from './mcp-server/index.ts' @@ -187,8 +191,28 @@ async function buildCapabilityRegistryForDynamicSources(input: { function filterRegistryForContext(input: { registry: BuiltCapabilityRegistry callerContext: McpCallerContext + featureFlags: CallerFeatureFlags | null }) { - return filterCapabilityRegistryForCaller(input.registry, input.callerContext) + return filterCapabilityRegistryForCaller( + input.registry, + input.callerContext, + input.featureFlags, + ) +} + +async function resolveFeatureFlagsForRegistry(input: { + env: Env + callerContext: McpCallerContext + registry: BuiltCapabilityRegistry +}): Promise { + // Skip the D1 reads when nothing in this registry is gated by a flag. + // `callerCanAccessCapability` already fails closed on a missing map. + if ( + !input.registry.capabilityList.some((capability) => capability.featureFlag) + ) { + return null + } + return resolveCallerFeatureFlags(input.env, input.callerContext) } async function loadEnabledMcpServerRefs(input: { @@ -253,9 +277,15 @@ export async function getCapabilityRegistryForContext(input: { const refs = normalizeRemoteConnectorRefs(input.callerContext) const userId = input.callerContext.user?.userId ?? null if (!userId) { + const registry = getStaticRegistry() return filterRegistryForContext({ - registry: getStaticRegistry(), + registry, callerContext: input.callerContext, + featureFlags: await resolveFeatureFlagsForRegistry({ + env: input.env, + callerContext: input.callerContext, + registry, + }), }) } const [mcpServerRefs, openApiBindings] = await Promise.all([ @@ -273,9 +303,15 @@ export async function getCapabilityRegistryForContext(input: { mcpServerRefs.length === 0 && openApiBindings.length === 0 ) { + const registry = getStaticRegistry() return filterRegistryForContext({ - registry: getStaticRegistry(), + registry, callerContext: input.callerContext, + featureFlags: await resolveFeatureFlagsForRegistry({ + env: input.env, + callerContext: input.callerContext, + registry, + }), }) } const [snapshots, mcpServerSnapshots] = await Promise.all([ @@ -318,6 +354,11 @@ export async function getCapabilityRegistryForContext(input: { return filterRegistryForContext({ registry, callerContext: input.callerContext, + featureFlags: await resolveFeatureFlagsForRegistry({ + env: input.env, + callerContext: input.callerContext, + registry, + }), }) } diff --git a/packages/worker/src/mcp/capabilities/types.ts b/packages/worker/src/mcp/capabilities/types.ts index 5d54006268..2bff43d1ce 100644 --- a/packages/worker/src/mcp/capabilities/types.ts +++ b/packages/worker/src/mcp/capabilities/types.ts @@ -1,6 +1,7 @@ import { type JsonSchemaToolDescriptor } from '@cloudflare/codemode' import { z, type ZodType } from 'zod' import { type PermissionString, type RoleName } from '#app/permissions.ts' +import { type FeatureFlagKey } from '#worker/feature-flags/registry.ts' import { type CapabilityDomain } from './domain-metadata.ts' import { type McpCallerContext } from '@kody-internal/shared/chat.ts' @@ -68,6 +69,7 @@ export type CapabilityDefinition< destructive?: boolean requiredRole?: RoleName requiredPermission?: PermissionString + featureFlag?: FeatureFlagKey source?: CapabilitySource remoteConnector?: CapabilityRemoteConnectorMetadata mcpServer?: CapabilityMcpServerMetadata @@ -95,6 +97,7 @@ export type Capability = { destructive: boolean requiredRole?: RoleName requiredPermission?: PermissionString + featureFlag?: FeatureFlagKey source: CapabilitySource remoteConnector?: CapabilityRemoteConnectorMetadata mcpServer?: CapabilityMcpServerMetadata @@ -119,6 +122,7 @@ export type CapabilitySpec = { destructive: boolean requiredRole?: RoleName requiredPermission?: PermissionString + featureFlag?: FeatureFlagKey source: CapabilitySource remoteConnector?: CapabilityRemoteConnectorMetadata mcpServer?: CapabilityMcpServerMetadata diff --git a/packages/worker/src/mcp/run-kody-registry.ts b/packages/worker/src/mcp/run-kody-registry.ts index 48f749a844..365acdf31a 100644 --- a/packages/worker/src/mcp/run-kody-registry.ts +++ b/packages/worker/src/mcp/run-kody-registry.ts @@ -423,7 +423,9 @@ async function buildKodyToolContext( Object.entries(capabilityMap).map(([capabilityName, capability]) => [ capabilityName, async (args: unknown) => { - assertCallerCanAccessCapability(callerContext, capability) + await assertCallerCanAccessCapability(callerContext, capability, { + env, + }) const resolveSecretValue = options?.resolveSecretValue ?? createCapabilityInputSecretResolver( diff --git a/packages/worker/tsconfig-client.json b/packages/worker/tsconfig-client.json index 4670d3f4af..30c689e8da 100644 --- a/packages/worker/tsconfig-client.json +++ b/packages/worker/tsconfig-client.json @@ -11,7 +11,8 @@ "baseUrl": ".", "paths": { "#app/*": ["./src/app/*"], - "#client/*": ["./client/*"] + "#client/*": ["./client/*"], + "#worker/*": ["./src/*"] } }, "include": [ @@ -26,7 +27,9 @@ "./src/app/community-listing-icon.tsx", "./src/app/community-public-types.ts", "./src/app/loader-data.ts", - "./src/app/safe-redirect.ts" + "./src/app/safe-redirect.ts", + "./src/feature-flags/registry.ts", + "./src/feature-flags/types.ts" ], "exclude": ["./client/**/*.test.ts", "./client/**/*.spec.ts"] }