Skip to content
Merged
36 changes: 36 additions & 0 deletions docs/contributing/adding-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
64 changes: 64 additions & 0 deletions docs/contributing/architecture/feature-flags.md
Original file line number Diff line number Diff line change
@@ -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: <key>`; `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`).
3 changes: 3 additions & 0 deletions docs/contributing/architecture/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/contributing/architecture/primitives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
195 changes: 195 additions & 0 deletions e2e/admin-feature-flags.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
20 changes: 20 additions & 0 deletions packages/worker/client/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -159,6 +160,7 @@ export function App(handle: Handle<AppProps>) {
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)}`
Expand Down Expand Up @@ -252,6 +254,24 @@ export function App(handle: Handle<AppProps>) {
<a href="/account" mix={css(primaryLinkCss)}>
{sessionDisplayName}
</a>
{showDemoIndicator ? (
<span
data-testid="demo-indicator"
mix={css({
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.medium,
color: colors.textMuted,
border: `1px solid ${colors.border}`,
borderRadius: '0.375rem',
padding: `0 ${spacing.xs}`,
lineHeight: 1.6,
letterSpacing: '0.02em',
textTransform: 'uppercase',
})}
>
Demo
</span>
) : null}
<form
method="post"
action="/logout"
Expand Down
14 changes: 14 additions & 0 deletions packages/worker/client/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { type FeatureFlagKey } from '#worker/feature-flags/registry.ts'
import { type SessionInfo } from './session.ts'

/**
* Synchronous client gate for evaluated feature flags embedded in the
* session payload (SSR shell + `/session` refresh). Anonymous / missing
* sessions treat every flag as off.
*/
export function isFeatureFlagEnabled(
session: SessionInfo | null | undefined,
key: FeatureFlagKey,
): boolean {
return session?.featureFlags?.[key] === true
}
Loading
Loading