Skip to content
Open
2 changes: 2 additions & 0 deletions openspec/changes/feature-flags-admin/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-11
83 changes: 83 additions & 0 deletions openspec/changes/feature-flags-admin/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
## Context

Shieldmaiden has no existing feature-flag mechanism. Toggleable behavior today is either a Vuex/UI concern (e.g. `userSettings`) or hardcoded. The first concrete need is a kill switch for the AI monster generator (`src/components/npcs/GenerateMonster.vue` → `POST /ai/generate-monster` in `src-ssr/api/index.js` → the external `MONSTER_GENERATOR_API_URL` service), so it can be turned off quickly if the external API misbehaves or gets expensive, without a release.

The codebase already has a `/admin` section (`src/layouts/admin.vue`, gated client-side via `preFetch` on `store.getters.userInfo.admin`) with several simple admin pages that read/write Firebase Realtime Database paths directly from the client SDK (e.g. `src/services/promotions.js`, `src/views/Admin/Promotions.vue`), and a small standalone Vuex module pattern for lightweight admin-adjacent state (`src/store/modules/contentReports.js`). The app runs in Quasar SSR mode behind Express + PM2 in a long-running Docker container (`src-ssr/index.js`, `Dockerfile`), but per the product decision below, flags are read fresh on every render rather than cached for the process lifetime — so the container's lifetime doesn't matter here.

## Goals / Non-Goals

**Goals:**
- A fixed, code-defined registry of boolean flags (id, label, default), extensible by adding an entry — no schema migration needed for a new flag.
- Firebase-backed storage so an admin toggle is durable and consistent with how other admin tools already persist state.
- An admin page consistent with the existing `/admin` pages' look, auth gating, and direct-Firebase-write style.
- A flag change is visible on the next page load/reload (client re-fetch on boot, fresh read on every SSR render) — confirmed with the user that this is the desired freshness bar, explicitly **not** requiring a redeploy or a live push to open tabs.
- The `monster_generator` flag is enforced in two places: hiding the client entry point (UX) and rejecting the server endpoint (the actual kill switch).

**Non-Goals:**
- Real-time/live propagation to already-open tabs (no `onValue`/`.on("value")` listeners for flags, no websockets).
- Per-user, per-tier, or percentage-rollout flags — this is a single global boolean per flag.
- Admin-created/deleted flags — the registry is code-only; the admin page only toggles existing entries.
- A generic "requires redeploy" build-time-constant model — explicitly rejected in favor of the simpler Firebase-read-per-load model (see Decision 1).

## Decisions

### 1. Freshness model: read fresh per load, not cached for the server process lifetime

Two designs were considered for how a toggle reaches running clients:
- **(a) Read fresh on every load/render.** The client fetches flag state once during app boot (mirroring `checkExtensionInstalled` in `src/store/modules/general.js`); SSR reads it fresh on every `renderToString` call (since Quasar SSR already re-runs boot/store `initialize` per request). A reload always shows the current value. No redeploy needed, ever.
- **(b) Cache in memory for the life of the SSR Node process.** Read once at process boot into a module-level singleton in `src-ssr`, outside the per-request Vuex lifecycle. A reload hits the same process and gets the stale value; only a new deploy (new PM2/Docker process) re-reads and picks up the change.

Confirmed with the user: **(a)**. It's simpler (reuses the existing per-request boot fetch pattern, no new server-lifetime cache module to build/reason about), matches how every other piece of admin-toggled data in this app already works (promotions, vouchers — visible next load, no redeploy), and still fully satisfies the actual need (a fast kill switch that doesn't require a release).

### 2. Registry lives in one shared module, importable from both client and server

`src/utils/featureFlags.js` exports a plain object/array registry, e.g.:
```js
const FEATURE_FLAGS = {
monster_generator: { label: "AI Monster Generator", default: true },
};
```
This file has no Firebase or Vue dependency, so it can be `require`d from `src-ssr/api/index.js` (CommonJS-compatible, following the existing pattern where `src-ssr/api/index.js` already imports plain services from `src/services/`) and imported from the Vuex module and admin page. One registry, one source of truth for ids/labels/defaults on both sides.

**Alternative considered:** duplicate the registry (one for client, one for server). Rejected — guarantees drift (e.g. a flag id typo'd differently in each place silently fails open).

### 3. Storage shape and read/write helpers

Firebase path: `feature_flags/<flag_id>` → `{ enabled: boolean }` (object, not a bare boolean) to leave room for future metadata (e.g. `updated_at`) without a breaking shape change, consistent with `promotions`' shape.

`src/services/featureFlags.js` (mirrors `src/services/promotions.js` / `src/services/contentReports.js`):
- `getAllFlags()` — one `.once("value")` read of `feature_flags`, merged over the registry defaults so every registered flag always has a defined value even if never written.
- `setFlagEnabled(id, enabled)` — `FEATURE_FLAGS_REF.child(id).child("enabled").set(enabled)`.

`src/store/modules/featureFlags.js` (namespaced, mirrors `contentReports.js`):
- State: `{ flags: {} }` (id → boolean, already merged with defaults).
- Action `fetch_flags()` — calls the service once, commits the result. Called from `general.js`'s `initialize()` action, in both the authenticated and unauthenticated branches (flags aren't user-specific, and future flags may gate public-facing behavior).
- Getter `isFlagEnabled: (state) => (id) => state.flags[id] ?? FEATURE_FLAGS[id]?.default ?? true` — reads the fetched value, falling back to the registry default (covers "fetch hasn't resolved yet" and "fetch failed" the same way).

Server side (`src-ssr/api/index.js`): a small local helper using the existing `admin.database()` instance already initialized in that file:
```js
async function isFlagEnabled(id) {
const snap = await admin.database().ref(`feature_flags/${id}/enabled`).once("value");
const val = snap.val();
return val === null ? (FEATURE_FLAGS[id]?.default ?? true) : val;
}
```
No new service class needed server-side — this mirrors the existing inline `admin.database()` usage already in that route handler for tiers/users/patrons.

### 4. Admin UI: simple list with toggles, no new nav pattern

`src/views/Admin/FeatureFlags.vue`: one `q-table`/`q-list` iterating the registry, each row a `q-toggle` bound to the flag's current stored state, calling `setFlagEnabled` on change (optimistic local update + Firebase write, same interaction pattern as `Promotions.vue`'s enable/disable buttons). Added to `src/views/Admin/index.vue`'s `items` list and as a new child route under `/admin` in `src/router/routes.js`, following the existing `vouchers`/`promotions` route shape exactly (flat child, no nested `:id`).

**Alternative considered:** reuse `q-table` with row actions like `Promotions.vue`. Rejected as overkill for a handful of boolean rows — a plain list with inline toggles is less code and clearer for this shape of data.

### 5. Monster generator enforcement in two places

- **Client (UX only):** in `EditNpc.vue`, wrap the existing "Generate from description" `<button>` in `v-if="isFlagEnabled('monster_generator')"` (mapped getter from the new module). This just avoids showing an option that will fail.
- **Server (the actual kill switch):** in `src-ssr/api/index.js`'s `router.post("/ai/generate-monster", ...)`, check `isFlagEnabled('monster_generator')` immediately after auth (before the credit lookup and before calling `MonsterGenerator.generateMonster`), returning a 4xx with a clear message if disabled. This is what actually stops cost/abuse even if someone calls the endpoint directly, and is why the flag is checked here rather than relying on the client hide alone.

## Risks / Trade-offs

- **[Risk]** A client that already has the New Monster dialog's history/tab open when a flag is disabled can still submit a request that then gets rejected server-side. → **Mitigation:** acceptable per the non-reactive requirement; the server rejection returns a clear error message rather than a generic 500.
- **[Risk]** Firebase read failure for a flag makes the feature behave as if enabled (fail-open) rather than disabled (fail-closed). → **Mitigation:** deliberate choice — a transient read error should never accidentally take down a feature; for a true kill switch (server-side), an admin can retry, and read failures are logged the same way other `admin.database()` reads already are in that file.
- **[Trade-off]** No live propagation means a determined admin trying to stop live abuse mid-incident still needs users to reload, or can rely on the server-side check being effective on that user's *next* request regardless of open tabs (Express reads the flag fresh per request, not cached) — so the practical kill-switch latency is actually "next API call," not "next page load," which is fast enough for the stated use case.
- **[Risk]** Firebase security rules for the new `feature_flags` path aren't managed in this repo. → **Mitigation:** called out explicitly in the proposal's Impact section and as a task; must be verified/added in the Firebase console (or wherever rules are currently managed) before relying on the admin-only write restriction.
29 changes: 29 additions & 0 deletions openspec/changes/feature-flags-admin/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## Why

We currently have no way to turn a feature off without shipping a code change. The AI monster generator (`GenerateMonster.vue` -> `POST /ai/generate-monster` -> the external `MONSTER_GENERATOR_API_URL` service) is the first case where we want a fast kill switch — e.g. if the external generator API goes down, gets too expensive, or starts producing bad output, we want to disable it in seconds from an admin page rather than cutting a release.

## What Changes

- Add a small, general-purpose feature flag system: a fixed registry of named boolean flags, defined in code, with their on/off state stored in Firebase and managed from a new `/admin/feature-flags` page.
- Flags are **not real-time**: toggling a flag updates the stored value immediately, but already-open tabs are not pushed an update. The new value is picked up the next time a page is loaded/reloaded (client re-fetches once on boot; SSR re-reads on every render). No redeploy is required, and no live socket/listener is added.
- Add the first flag, `monster_generator`, defaulting to **enabled**, and wire it to gate the AI monster generator end-to-end:
- Client: hide the "Generate from description" entry point in the New Monster dialog (`EditNpc.vue`) when the flag is off.
- Server: reject `POST /ai/generate-monster` (in `src-ssr/api/index.js`) with a clear error when the flag is off, so the kill switch is authoritative even if a request bypasses the UI.

## Capabilities

### New Capabilities
- `feature-flags`: a fixed registry of named boolean flags; Firebase-backed storage; a Vuex module that fetches the current values once per app load (client boot / each SSR render, not live-reactive); an admin page to toggle them; a server-side helper to check a flag's state from Express API routes. Includes the `monster_generator` flag and its enforcement on both the client entry point and the `/ai/generate-monster` endpoint as the first concrete use of the system.

### Modified Capabilities
<!-- None — no existing OpenSpec capability covers admin tooling or the monster generator today. -->

## Impact

- **Code**:
- New: `src/utils/featureFlags.js` (flag registry + defaults, shared by client and server), `src/services/featureFlags.js` (Firebase read/write), `src/store/modules/featureFlags.js` (Vuex module), `src/views/Admin/FeatureFlags.vue` (admin UI).
- Modified: `src/store/index.js` (register module), `src/store/modules/general.js` (fetch flags during `initialize`), `src/router/routes.js` (new admin route), `src/views/Admin/index.vue` (nav entry), `src/views/UserContent/Npcs/EditNpc.vue` (hide entry point when disabled), `src-ssr/api/index.js` (enforce flag in `/ai/generate-monster`).
- **APIs**: `POST /ai/generate-monster` gains a new failure mode (403-style rejection) when the flag is off. No new public endpoints — the admin page writes to Firebase directly via the client SDK, same as `Promotions`/`Vouchers`.
- **Data**: new Firebase Realtime Database path `feature_flags/<flag_id>` (`{ enabled: boolean }`). Write access must be restricted to admins in the existing Firebase security rules (same restriction already relied on by `promotions`/`vouchers`, managed outside this repo).
- **Dependencies**: none added.
- **Risk**: low. Worst case for a stale/misread flag is the monster generator staying in its last-known state for one page load longer than expected; nothing destructive, easily reversible by re-toggling.
79 changes: 79 additions & 0 deletions openspec/changes/feature-flags-admin/specs/feature-flags/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
## ADDED Requirements

### Requirement: Flag registry
The system SHALL maintain a fixed, code-defined registry of feature flags. Each registry entry SHALL have a unique kebab_case-or-snake_case id, a human-readable label, and a default enabled state used when no stored value exists. Flags SHALL NOT be creatable or deletable from the admin UI — only their enabled state is togglable; new flags are added by developers via a code change.

#### Scenario: Registry includes the monster generator flag
- **WHEN** the flag registry is loaded
- **THEN** it includes an entry with id `monster_generator`, a human-readable label, and a default enabled state of `true`

#### Scenario: Unknown flag id is treated as enabled
- **WHEN** any part of the system asks whether a flag not present in the registry is enabled
- **THEN** the system treats it as enabled and does not throw, so a typo or a since-removed flag never becomes an accidental kill switch

### Requirement: Flag state storage
The system SHALL persist each registered flag's enabled state under `feature_flags/<flag_id>` in the Firebase Realtime Database, as `{ enabled: boolean }`. Write access to this path SHALL be restricted to admin users by Firebase security rules. When no value is stored for a registered flag, the flag's registry default SHALL apply.

#### Scenario: Stored value overrides the default
- **WHEN** `feature_flags/monster_generator/enabled` is stored as `false` in the database
- **THEN** any part of the system checking the `monster_generator` flag reads it as disabled, regardless of the registry default

#### Scenario: Missing stored value falls back to the registry default
- **WHEN** no value exists yet at `feature_flags/monster_generator`
- **THEN** the flag is treated as enabled (the registry default), not disabled

#### Scenario: Flag read failure falls back to the registry default
- **WHEN** reading a flag's value from the database fails (network error, timeout, permission error)
- **THEN** the system falls back to that flag's registry default rather than failing the request or defaulting to disabled

### Requirement: Admin toggle page
The system SHALL provide an admin-only page listing every registered flag with its current effective state, that lets an admin user toggle each flag's stored `enabled` value independently. The page SHALL be reachable only by users with `userInfo.admin` set, consistent with the rest of `/admin`.

#### Scenario: Admin disables a flag
- **WHEN** an admin user toggles the `monster_generator` flag off on the admin page
- **THEN** `feature_flags/monster_generator/enabled` is set to `false` in the database

#### Scenario: Admin re-enables a flag
- **WHEN** an admin user toggles a previously-disabled flag back on
- **THEN** the flag's stored value is set to `true` (or the stored override is removed, reverting to the registry default of `true`)

### Requirement: Non-reactive propagation
Flag state SHALL NOT be pushed live to already-open clients. A flag change SHALL take effect the next time a client loads or reloads the page (client-side fetch on app boot, and fresh on every server-side render) — no application redeploy SHALL be required for a toggle to take effect.

#### Scenario: Open tab does not react to a toggle
- **WHEN** a user has the New Monster dialog open in an existing tab and an admin disables `monster_generator` in another session
- **THEN** the open tab's UI does not change until that tab is reloaded or a new page load occurs

#### Scenario: Reload picks up the new value
- **WHEN** a user reloads any page after `monster_generator` has been disabled
- **THEN** the reloaded page reflects the flag as disabled, without any redeploy of the application

### Requirement: Monster generator flag gates the AI generator's entry point
When the `monster_generator` flag is disabled, the client SHALL NOT present any entry point into the AI monster generator (every usage of `GenerateMonster.vue`): the "Generate from description" option (and its preceding "OR" divider) in the New Monster dialog (`EditNpc.vue`), the "Generate" button/menu item on the NPC list page (`Npcs.vue`), and the "Generate" button on the generic content import page (`ImportContent/index.vue`).

#### Scenario: Entry point hidden in the New Monster dialog while disabled
- **WHEN** the `monster_generator` flag is disabled
- **THEN** the New Monster dialog shows only "Copy existing monster" and "Create from scratch", without a "Generate from description" option or the "OR" divider that would otherwise precede it

#### Scenario: Entry point hidden on the NPC list page while disabled
- **WHEN** the `monster_generator` flag is disabled
- **THEN** the NPC list page's toolbar button and overflow-menu item for "Generate" are both absent, regardless of NPC slot/AI credit state

#### Scenario: Entry point hidden on the content import page while disabled
- **WHEN** the `monster_generator` flag is disabled
- **THEN** the "Generate" button on `/content/import` is absent, regardless of which content type is being imported

#### Scenario: Entry points shown while enabled
- **WHEN** the `monster_generator` flag is enabled (including its default state)
- **THEN** all three entry points behave as they did before this change (still subject to their existing slot/credit/tier conditions)

### Requirement: Monster generator flag is enforced server-side
The `POST /ai/generate-monster` endpoint SHALL check the `monster_generator` flag before generating a monster or spending AI credits, independent of whether the request came from the gated client UI.

#### Scenario: Request rejected while disabled
- **WHEN** `POST /ai/generate-monster` is called while the `monster_generator` flag is disabled
- **THEN** the endpoint responds with an error indicating the feature is disabled, does not call the external monster generator API, and does not spend or deduct AI credits

#### Scenario: Request proceeds while enabled
- **WHEN** `POST /ai/generate-monster` is called while the `monster_generator` flag is enabled
- **THEN** the endpoint proceeds with its existing authentication, credit-check, and generation behavior unchanged
Loading
Loading