@terreno/feature-flags 57.20.0 → 58.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -20,6 +20,99 @@ Upgrade notes for consumer action live in [`mcp-server/src/docs/upgrades/`](mcp-
20
20
 
21
21
  Unreleased changes live in [`changelog/unreleased/`](changelog/unreleased/). Add one Markdown file per feature (see that directory's README) instead of editing this section.
22
22
 
23
+ ## [58.0.0-beta.1] - 2026-11-02
24
+
25
+ Upgrade note: [`mcp-server/src/docs/upgrades/58.0.0-beta.1.md`](mcp-server/src/docs/upgrades/58.0.0-beta.1.md).
26
+
27
+ ### Breaking
28
+
29
+ - Terreno 58 targets Expo SDK 58 (`~58.0.14`), React Native `0.88.0`, and React `19.3.0`. `@terreno/ui` peers `react-native` `0.88.0`. Native catalog moves with the SDK: `react-native-gesture-handler` `~3.3.0`, `react-native-reanimated` `4.7.1` + `react-native-worklets` `0.13.0`, `react-native-screens` `~4.30.0`, `react-native-webview` `14.0.1`, `@sentry/react-native` `~8.28.0`, `@shopify/flash-list` `2.3.2`, `@shopify/react-native-skia` `2.14.0`. Rebuild dev clients and store binaries.
30
+ - Expo Router 58: set `export const unstable_settings = {initialRouteName}` on the layout instead of passing `initialRouteName` to `<Slot>` / navigators. `SidebarNavigation` ignores the prop.
31
+ - Expo Router 58 static rendering: a custom `app/+html.tsx` must render `headNodes` and `bodyNodes` from `useServerDocumentContext()` (`expo-router/html`), or exported pages ship without their JavaScript. `example-frontend` is updated.
32
+ - React Native 0.88 generated types: type refs as `ViewInstance`, `TextInstance`, `ScrollViewInstance`, and `TextInputInstance` (`Box.scrollRef`, `TextField.inputRef`, `Text` `ref`).
33
+ - `create-terreno-app` scaffolds Expo `~58.0.14`, React Native `0.88.0`, and React `19.3.0`.
34
+ - The Terreno 58 native module baseline lands in `example-frontend` and `demo`: `@stripe/stripe-react-native` 0.81.0, `react-native-purchases` and `react-native-purchases-ui` 10.12.2, `expo-device`, `expo-crypto`, `expo-local-authentication`, `expo-system-ui`, and `expo-build-properties` (Android Kotlin 2.2.21 and `compileSdkVersion` 37 pins). Rebuild every dev client and store binary. `react-native-otp-verify` is left out because it does not support the New Architecture. The demo has a hidden **Native Modules** page that shows each module's version and whether the running binary links it. See the 58.0.0 upgrade note.
35
+ - `TerrenoApp` turns HTTP rate limiting on by default. Omitting `rateLimit` now mounts the limiter with the memory store (auth 20 / 15 min, api 600 / 15 min). Pass `rateLimit: false` to turn it off. On Cloud Run pass `rateLimit: {trustProxy: 1}` so unauthenticated traffic keys by client IP. See the 58.0.0 upgrade note.
36
+ - `@terreno/admin-frontend` no longer takes an RTK `api`. The `api` prop is removed from every admin component (`AdminProvider`, `AdminShellLayout`, `AdminHome`, `AdminModelList`, `AdminModelTable`, `AdminModelForm`, `AdminScreenRouter`, `ConfigurationScreen`, `DocumentStorageBrowser`, `ConsentHistory`, comms, jobs, org, consent, and announcement screens, and widget props), and admin hooks drop their `api` argument (`useAdminConfig(apiBase)`, `useAdminScripts(apiBase)`, `useAdminRoles(apiBase)`, `useConfigurationApi({basePath})`, `useDocumentStorageApi(basePath)`, `useConsentHistory(baseUrl)`, `useOrganizationsApi(basePath)`, `useCommsDashboardApi()`, `useJobsDashboardApi()`, and the `useAiObservability*Api()` hooks).
37
+ - `useAdminApi(api, routePath, modelName)` is removed. Use `useAdminModelApi(routePath, modelName)`, which keeps the same hook names and calls the same `/admin/{routePath}` URLs through the admin fetch client. `AdminApi`, `EndpointBuilder`, and admin `injectEndpoints` are gone; `@reduxjs/toolkit` and `react-redux` are no longer peers of admin-frontend.
38
+ - `AdminProvider` is now the required auth binding for every admin request, including ObjectId and API-only model CRUD. Cookie SPAs pass `credentials="same-origin"`; embedded apps pass `apiOrigin` and `getAuthHeaders`. Product screens that reuse admin components wrap them in `AdminProvider`. See the 58.0.0 upgrade guide.
39
+ - Removed the legacy RTK realtime path. Use `sync` on `modelRouter` with `@terreno/syncdb` for live collection updates. `RealtimeApp`, `SyncApp`, socket auth, `sync:delta`, and admin `admin:model.changed` events are unchanged. See the 58.0.0 upgrade note.
40
+ - `@terreno/api`: `modelRouter` no longer accepts `realtime` (now a TypeScript error), and the change stream no longer emits the `sync` websocket event. Removed `RealtimeConfig`, `RealtimeEvent`, `DocumentSubscription`, `QuerySubscription`, `registerRealtime`, `getRealtimeRegistry`, `findRegistryEntryByCollection`, `findRegistryEntryByRoutePath`, `updateRealtimeRegistryOptions`, `clearRealtimeRegistry`, `RealtimeRegistryEntry`, the query-subscription store (`addQuerySubscription`, `removeQuerySubscription`, `removeAllSocketQueries`, `getQuerySubscriptionsForCollection`, `computeQueryId`, `clearQueryStore`), and `MAX_MODEL_SUBSCRIPTIONS` / `MAX_DOCUMENT_SUBSCRIPTIONS` / `MAX_QUERY_SUBSCRIPTIONS`. Sockets no longer handle `subscribe:model`, `subscribe:document`, or `subscribe:query`.
41
+ - `@terreno/rtk`: removed `realtimeList`, `realtimeDocument`, `setRealtimeSocket`, `getRealtimeSocket`, `useSyncConnection`, and the `RealtimeEvent` type. The rest of `@terreno/rtk` (including `useSocketConnection` and `useRealtimeDebug`) is removed in the same release; see `remove-rtk.md`.
42
+ - Removed `@terreno/rtk` and every Redux dependency (`@reduxjs/toolkit`, `react-redux`, `redux-persist`, `@rtk-query/codegen-openapi`). `@terreno/rtk` is not published for 58. `@terreno/syncdb` is the only frontend data, request, and session layer. Follow `docs/how-to/migrate-rtk-to-syncdb.md`, which maps every removed export to its replacement, and the 58.0.0 upgrade note.
43
+ - `@terreno/syncdb` adds `@terreno/syncdb/api` (`createApiClient`, `ApiError`), request hooks in `@terreno/syncdb/react` (`ApiClientProvider`, `useApiQuery`, `useLazyApiQuery`, `useApiMutation`, `useSocketEvent`), `@terreno/syncdb/config` (`baseUrl`, `baseWebsocketsUrl`, `appHeaders`), `@terreno/syncdb/auth` (`createBetterAuthClient`, `SessionProvider`, `useSession`, `jwtAuthAdapter`, `useJwtAuth`, token-expiry helpers), `@terreno/syncdb/flags` (`useFeatureFlags`, `useTerrenoFeatureFlags` on the sync socket), `@terreno/syncdb/app` (`useUpgradeCheck`, `hasPermission`, `canOpenAdminPage`, `installDevConsoleLogger`, `useMCPTools`), and `@terreno/syncdb/buildNumber`.
44
+ - `terreno-syncdb-codegen --api-out` generates typed hooks for every non-synced OpenAPI operation (same names as the RTK codegen), replacing `@rtk-query/codegen-openapi`. `bun run sdk` writes `store/apiSdk.ts`.
45
+ - `@terreno/ui` `ConsentNavigator`, `AnnouncementNavigator`, `useConsentForms`, `useSubmitConsent`, `useAnnouncements`, and `useAcknowledgeAnnouncement` take a request `client` instead of an RTK `api`.
46
+ - `GptHistory` (`gpt/histories`) and `McpServiceToken` (`mcp/service-tokens`, read-only) are synced collections with String ids. Run the `gptHistoryStringIdsMigration` (`@terreno/ai`) and `mcpServiceTokenStringIdsMigration` (`@terreno/api`) before deploying 58 frontends.
47
+ - `create-terreno-app`, the `terreno_bootstrap` prompt, and the `terreno_generate_screen` tool scaffold apps without Redux or `@terreno/rtk`. The terreno-mcp-local `get_rtk_state` tool, the MCP resource `terreno://docs/rtk`, and `terreno state --slice rtk` are removed; use `get_syncdb_state` and `terreno state`.
48
+
49
+ ### Added
50
+
51
+ - `DataTable` columns size themselves when `width` is omitted. Each auto column starts at the wider of its header and its content (sampled from at most 50 rows, measured with a canvas on web and estimated on native), clamped to 80–360px, and auto columns grow to fill the table's container. Columns that set `width` keep it. `DataTableColumn.width` is now optional.
52
+
53
+ ### Changed
54
+
55
+ - The repository's agent rules now generate with rulesync 17 (up from 9). The root rule `.rulesync/rules/00-root.md` now generates `AGENTS.md`, which Devin and other AGENTS.md readers load, in place of `.devin/rules/00-root.md`. Never edit `AGENTS.md` by hand: edit the root rule and run `bun run rules`. The examples that existed only in the old hand-edited `AGENTS.md` (syncdb and RTK SDK usage) moved into the root rule, so `CLAUDE.md`, the Cursor root rule, and Copilot instructions get them too. Links in the root rule now resolve from the repository root. Generated Copilot hooks use the portable `command` field. Generated Copilot subagents no longer list `agent/runSubagent` as their only tool, so they get every default tool. Generated skills carry their `license`, `disable-model-invocation`, and Devin `triggers` frontmatter.
56
+ - `@terreno/syncdb/auth` `SessionProvider`: `logout()` no longer clears the session before the server answers. The session stays in the state until `authClient.signOut()` finishes (`signOutTimeoutMs`, default 10 seconds), and then it is cleared. Meanwhile the new `isSigningOut` state (`useSession().isSigningOut`) is true; `isLoading` is left alone, so a navigator gated on it keeps its screen. Before, a login screen that opened at once could find the still-live session and sign the user straight back in. A sign-out that fails or times out checks the session first. A session that is still live, or a cookie or native session the server cannot be asked about, stays signed in, and `error` is set to `SIGN_OUT_FAILED_MESSAGE` ("Couldn't sign out. Check your connection and try again."). A web bearer client, which drops its token before the request leaves, ends signed out. The global `auth/logout` action (a refused session) now ends signed out even when the sign-out request fails. With `apisToReset` (57.17.0), `logout()` purges the cache when the session clears, not when it is dispatched: a purge at dispatch refetched every mounted screen mid-sign-out, and a refetch that could not reach the server answered 401 and signed the user out even though the sign-out failed. A failed sign-out keeps both the session and the cache. The global `auth/logout` action still purges at once (only the first of several in flight) and again when the session clears. A sign-in for another user that lands while a sign-out is in flight is kept; a sign-out that started signed out asks `getSession` first, so a restore of the session it revoked is cleared. Show `selectBetterAuthIsSigningOut` on the sign-out control and `selectBetterAuthError` after a failed sign-out.
57
+
58
+ ### Removed
59
+
60
+ - `@terreno/ui` no longer exports the `Filter` and `FilterProps` aliases deprecated in 57.7.0. Import `DropdownPanel` and `DropdownPanelProps` instead. `FilterSelectMenu`, `FilterBoolean`, `FilterAccordion`, and `FilterChangesBadge` keep their names.
61
+
62
+ ### Fixed
63
+
64
+ - `@terreno/syncdb`: logging out while `syncDb.start()` is still running no longer loses local writes or logs errors. `start()` now resolves the web encryption key from `GET /sync/key` before it loads the store, so the flush in `stop()` never needs the network after the session has ended. A `stop()` or `signOut()` call now cancels a `start()` that is running or queued ahead of it, so the transport never connects without a session. A failed key fetch is now retried on the next save instead of failing every save for the rest of the session.
65
+
66
+ ## [57.21.0] - 2026-11-01
67
+
68
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.21.0.md`](mcp-server/src/docs/upgrades/57.21.0.md).
69
+
70
+ ### Breaking
71
+
72
+ - The `terreno-scan` plugin is now `terreno-loop`, and its skills are renamed from `terreno-scan-*` to `terreno-loop-*`. The resident loop `terreno-scan-loop` is now `terreno-loop-run`: `/terreno-loop:run` in Claude Code, `/terreno-loop-run` in Cursor, `$terreno-loop-run` in Codex. Claude Code moves existing installs over through the marketplace `renames` entry. In Cursor and Codex, uninstall `terreno-scan` and install `terreno-loop`. Campaign state stays at `.terreno/scan/<goal>.json`, so campaigns already in progress resume.
73
+
74
+ ### Added
75
+
76
+ - `@terreno/admin-spa` serves an MCP OAuth consent screen at `/console/oauth/consent`, so a backend with no product frontend can set `mcpOAuth.consentUrl` to `${issuer}/console/oauth/consent`. Any signed-in user can approve or deny a request with their Better Auth session; signed-out users sign in and return through the login screen's new `returnTo` parameter, which accepts only in-app paths.
77
+ - Claude Code plugins now publish to npm with every release, as `@terreno/agent-plugin` and `@terreno/agent-plugin-loop`. A new `terreno-releases` marketplace installs them at the latest stable release. To pin one release, add `TerrenoLabs/terreno#<version>` instead. See [Install agent skills](docs/how-to/install-agent-skills.md#pick-a-channel).
78
+ - The generated Claude plugin manifest no longer pins `version`. Before this change, plugins installed from the `TerrenoLabs/terreno` marketplace stayed on the `2.12.0` copy, because their version never changed. They now update on every commit. Run `/plugin update terreno@terreno-plugins` once to leave `2.12.0`.
79
+ - The `terreno-planning` lifecycle plugin (2.13.0) plans e2e tests and manual regression cases in every IP:
80
+
81
+ - Grow requires `## E2E tests` and `## Regression cases` sections (either may be `None — <reason>`), read from the e2e guide at `docs/testing/e2e.md` or a `## Testing conventions` override in AGENTS.md or CLAUDE.md.
82
+ - Regression cases stay minimal: most features add none. A case is only for what a developer or QA must check by hand, such as push notifications, and Roast fails one that repeats an existing case.
83
+ - Pick writes each planned spec in the task that delivers it, and Roast runs it; a missing or failing row is `FAIL`.
84
+ - Brew gates on every planned spec and merges regression cases into `docs/regression/<area>.md` with the next free ID.
85
+ - Taste re-checks both sections after behaviour-changing fixes and renumbers regression ID collisions with `master`.
86
+ - `bun run check:lifecycle-skills` enforces these phrases in every stage.
87
+ - New `backfill-regression` skill proposes manual regression cases one area at a time and writes only the sets the human approves.
88
+ - A consumer opts out of manual regression suites with `Regression suites | none` in its `## Testing conventions` table.
89
+ - The `terreno-planning` lifecycle plugin (2.14.0) grows an end-user guide with every feature:
90
+
91
+ - Every IP has a `## User guide` section listing the guide pages it adds, updates, or retires, or `None — <reason>`. Pages live in `docs/user-guide/<area>/<feature>.md`, one feature per page, with `title`, `audience`, `platforms`, and `questions` frontmatter that chat agents use to answer "how do I…" questions.
92
+ - Pick writes each page in the task that ships the feature, Roast checks it against the shipped screens, Brew gates on it and the index, and Taste keeps it current after fixes.
93
+ - A repo opts out with `| User guide | none — <reason> |` in a `## User guide conventions` table in AGENTS.md or CLAUDE.md.
94
+ - New `backfill-user-guide` skill drafts pages for features that shipped without one, one area at a time, and writes only the pages the human approves.
95
+ - `bun run check:user-guide` validates the pages and index (CI `repo-policies`); `--uncovered <app dir>` lists screens no page names.
96
+ - Terreno's own guide covers the example app's main features.
97
+ - `@terreno/api`: opt-in `mcpRequestLog` on `TerrenoApp` records one metadata-only `McpRequestLog` row per MCP tool call (tool, user, credential, service token or OAuth client, duration, outcome, error class; never arguments, results, or error text), expiring after `retentionDays` (default 30).
98
+ - `@terreno/api`: opt-in `mcpToolSettings` on `TerrenoApp` registers every MCP tool in `McpToolSetting` at startup; tools an admin switches off are hidden from `tools/list` and rejected by `tools/call`.
99
+ - `@terreno/api`: deleting an `McpOAuthToken` row revokes its whole connection (token family) and keeps the rows. New `McpOAuthToken.revokeAllForUser` and `McpServiceToken.revokeAllForUser`.
100
+ - `@terreno/admin-backend`: `mcpAdminModels()` adds MCP admin screens (service tokens, OAuth clients, OAuth connections, request log, tool switches), `mcpAdminStatements` for RBAC, and `mcpAdminScripts()` adds `mcp-sign-out-everywhere`. See [Manage MCP from the admin panel](docs/how-to/manage-mcp-in-admin.md).
101
+ - Docs: [Expose an MCP server safely](docs/how-to/expose-mcp-server-safely.md) (OAuth, service tokens, Cloudflare Tunnel, Access) and the `bootstrap-mcp-server` skill for a single-binary MCP server with the admin console embedded.
102
+ - Every workspace package has a real-process smoke test and an e2e page:
103
+
104
+ - Each package's `e2e/smoke.ts` (or `smoke.spec.ts` for browser packages) runs its built output through its public entry: a server on a real port, the compiled binary, or a page in Chromium. `bun run --cwd <package> smoke` runs one; `bun run smoke` runs them all and prints each package's time. The CircleCI `smoke` job runs them on every package change.
105
+ - `@terreno/test` adds the smoke helpers `smokeMongoUri()`, `spawnUntilReady()`, `spawnExampleBackend()`, `runBin()`, and `freePort()`.
106
+ - The [E2E testing guide](docs/testing/e2e.md) explains the test tiers and links one page per package under `docs/testing/e2e/`.
107
+ - Manual regression cases live in `docs/regression/<area>.md`, one file per area, and `bun run check:regression-suites` validates them.
108
+ - example-frontend's smoke serves a web export that `bun run --cwd example-frontend export:smoke` builds once with a placeholder API URL; the smoke swaps in a `freePort()` API URL as it serves the bundle, so the 30 s smoke no longer rebuilds the export. `spawnUntilReady()` takes `stopGraceMs`, and `spawnExampleBackend()` stops example-backend after 0.5 s instead of 5 s.
109
+ - Every workspace package has a `typecheck` script that runs `tsc` over source and test files through a new `tsconfig.typecheck.json`. Root `bun run lint` ends with `bun run typecheck`, and CircleCI runs it in a new `typecheck` job. Test files that had type errors were fixed.
110
+
111
+ ### Changed
112
+
113
+ - `@terreno/api`: deleting an `McpOAuthToken` row with document or query `deleteOne` now revokes that row's whole token family (the connection) and keeps the rows instead of removing one pair. `deleteMany` is unchanged.
114
+ - `@terreno/ui`: new `theme.field` tokens (`background`, `backgroundDisabled`, `border`, `borderFocus`, `borderError`, `borderDisabled`) style form inputs. `TextField`, `SelectField`, `DateTimeField`, `HeightField`, and `OtpField` read them instead of `surface.base` and `border.dark`. Defaults equal the old colors, but overriding `surface` or `border` no longer recolors those fields; use `setTheme({field: ...})`.
115
+
23
116
  ## [57.20.0] - 2026-10-30
24
117
 
25
118
  Upgrade note: [`mcp-server/src/docs/upgrades/57.20.0.md`](mcp-server/src/docs/upgrades/57.20.0.md).
package/biome.jsonc CHANGED
@@ -6,6 +6,7 @@
6
6
  "package.json",
7
7
  "src/**/*.ts",
8
8
  "src/**/*.tsx",
9
+ "e2e/**/*.ts",
9
10
  "!!**/node_modules",
10
11
  "!!node_modules",
11
12
  "!!**/dist",
@@ -83,7 +83,35 @@ class FeatureFlagsApp {
83
83
  });
84
84
  server_sdk_1.OpenFeature.setProvider(openFeatureDomain, this.mongoProvider);
85
85
  app.use(`${basePath}/flags`, (0, api_1.modelRouter)(featureFlagModel_1.FeatureFlag, routerOptions));
86
- app.get(`${basePath}/flagConfiguration`, (0, api_1.authenticateMiddleware)(), (0, api_1.asyncHandler)(async (req, res) => {
86
+ app.get(`${basePath}/flagConfiguration`, (0, api_1.authenticateMiddleware)(),
87
+ // Documentation only: request validation stays off so the handler behaves as before.
88
+ (0, api_1.createOpenApiBuilder)(openApi ? { openApi: openApi } : {})
89
+ .withTags(["feature-flags"])
90
+ .withSummary("Get every flag's configuration resolved for the current user")
91
+ .withDescription("Keyed by flag key. Disabled boolean flags are included with disabled: true; disabled variant flags are omitted.")
92
+ .withResponse(200, {
93
+ data: {
94
+ additionalProperties: {
95
+ properties: {
96
+ defaultVariant: {
97
+ description: "The variant to use for the current user",
98
+ type: "string",
99
+ },
100
+ disabled: { type: "boolean" },
101
+ variants: {
102
+ additionalProperties: { oneOf: [{ type: "boolean" }, { type: "string" }] },
103
+ description: "Variant name to its value",
104
+ type: "object",
105
+ },
106
+ },
107
+ required: ["defaultVariant", "disabled", "variants"],
108
+ type: "object",
109
+ },
110
+ type: "object",
111
+ },
112
+ }, { required: ["data"] })
113
+ .withValidation({ enabled: false })
114
+ .build(), (0, api_1.asyncHandler)(async (req, res) => {
87
115
  const user = req.user;
88
116
  if (!user) {
89
117
  throw new api_1.APIError({ status: 401, title: "Authentication required" });
@@ -105,7 +133,7 @@ class FeatureFlagsApp {
105
133
  app.get(`${basePath}/evaluate`, (0, api_1.authenticateMiddleware)(), (0, api_1.asyncHandler)(async (req, res) => {
106
134
  if (!evaluateDeprecationWarned) {
107
135
  evaluateDeprecationWarned = true;
108
- api_1.logger.warn("[feature-flags] GET /evaluate is deprecated; migrate clients to GET /flagConfiguration or use @terreno/rtk useFeatureFlags / useTerrenoFeatureFlags.");
136
+ api_1.logger.warn("[feature-flags] GET /evaluate is deprecated; migrate clients to GET /flagConfiguration or use useFeatureFlags / useTerrenoFeatureFlags from @terreno/syncdb/flags.");
109
137
  }
110
138
  const sunset = new Date(Date.now() + 90 * 86400000).toUTCString();
111
139
  res.setHeader("Deprecation", "true");
package/e2e/smoke.ts ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Real-process smoke for @terreno/feature-flags. Run `bun e2e/smoke.ts` after `bun run compile`.
3
+ *
4
+ * The smoke re-runs this file with `serve` as a child process: a TerrenoApp built from the
5
+ * compiled `@terreno/api` and `@terreno/feature-flags` packages, with a seeded admin user, on a
6
+ * free port, against `smokeMongoUri()`. The parent then checks over real HTTP that a signed-up
7
+ * user cannot create a flag, the admin can, and the user's flag configuration resolves it to `on`. Exits non-zero on the first failed check.
8
+ */
9
+ import {
10
+ freePort,
11
+ type SpawnedProcess,
12
+ smokeMongoUri,
13
+ spawnUntilReady,
14
+ stopSmokeMongo,
15
+ } from "@terreno/test";
16
+ import type express from "express";
17
+ import {DateTime} from "luxon";
18
+ import type {Schema} from "mongoose";
19
+
20
+ const SERVE_ARG = "serve";
21
+
22
+ /** The passport-local-mongoose static the child uses to seed the admin with a password. */
23
+ interface PassportLocalStatics {
24
+ register: (user: unknown, password: string) => Promise<unknown>;
25
+ }
26
+
27
+ interface FlagDefinition {
28
+ defaultVariant: string;
29
+ disabled: boolean;
30
+ variants: Record<string, boolean | string>;
31
+ }
32
+
33
+ /**
34
+ * Child: the compiled API with auth, the compiled FeatureFlagsApp with its default admin-only
35
+ * permissions, a health route, and one admin user seeded from `SMOKE_ADMIN_EMAIL` and
36
+ * `SMOKE_ADMIN_PASSWORD`. The packages are imported here, not at the top, so the parent process
37
+ * never loads them.
38
+ */
39
+ const serve = async (): Promise<void> => {
40
+ const {createdUpdatedPlugin, logger, TerrenoApp} = await import("@terreno/api");
41
+ const {FeatureFlagsApp} = await import("@terreno/feature-flags");
42
+ const {default: mongoose} = await import("mongoose");
43
+ const {default: passportLocalMongoose} = await import("passport-local-mongoose");
44
+
45
+ const userSchema = new mongoose.Schema({
46
+ admin: {default: false, description: "Whether the user is an admin", type: Boolean},
47
+ email: {description: "Sign-in email", type: String},
48
+ name: {description: "Display name", type: String},
49
+ });
50
+ userSchema.plugin(passportLocalMongoose as unknown as (schema: Schema, options: object) => void, {
51
+ usernameCaseInsensitive: true,
52
+ usernameField: "email",
53
+ });
54
+ userSchema.plugin(createdUpdatedPlugin);
55
+ const User = mongoose.model("User", userSchema);
56
+ const registerUser = (User as unknown as PassportLocalStatics).register.bind(User);
57
+
58
+ const healthPlugin = {
59
+ register: (app: express.Application): void => {
60
+ app.get("/health", (_req, res) => {
61
+ res.json({healthy: mongoose.connection.readyState === 1});
62
+ });
63
+ },
64
+ };
65
+
66
+ await mongoose.connect(process.env.MONGO_URI as string);
67
+ await registerUser(
68
+ new User({admin: true, email: process.env.SMOKE_ADMIN_EMAIL, name: "Smoke admin"}),
69
+ process.env.SMOKE_ADMIN_PASSWORD as string
70
+ );
71
+
72
+ const app = new TerrenoApp({
73
+ loggingOptions: {logRequests: false},
74
+ userModel: User as unknown as ConstructorParameters<typeof TerrenoApp>[0]["userModel"],
75
+ })
76
+ .register(new FeatureFlagsApp())
77
+ .register(healthPlugin);
78
+ app.start();
79
+ await app.whenReady();
80
+ logger.info(`[featureFlagsSmoke] serving on port ${process.env.PORT}`);
81
+ };
82
+
83
+ const expectStatus = async (res: Response, status: number, step: string): Promise<unknown> => {
84
+ const body = await res.text();
85
+ if (res.status !== status) {
86
+ throw new Error(`${step}: expected ${status}, got ${res.status}: ${body}`);
87
+ }
88
+ return body ? JSON.parse(body) : undefined;
89
+ };
90
+
91
+ const authHeaders = (token: string): Record<string, string> => ({
92
+ Authorization: `Bearer ${token}`,
93
+ "Content-Type": "application/json",
94
+ });
95
+
96
+ const signIn = async ({
97
+ baseUrl,
98
+ email,
99
+ password,
100
+ route,
101
+ }: {
102
+ baseUrl: string;
103
+ email: string;
104
+ password: string;
105
+ route: "login" | "signup";
106
+ }): Promise<string> => {
107
+ const step = `POST /auth/${route}`;
108
+ const res = (await expectStatus(
109
+ await fetch(`${baseUrl}/auth/${route}`, {
110
+ body: JSON.stringify({email, password}),
111
+ headers: {"Content-Type": "application/json"},
112
+ method: "POST",
113
+ }),
114
+ 200,
115
+ step
116
+ )) as {data: {token?: string}};
117
+ if (!res.data.token) {
118
+ throw new Error(`${step}: no token in the response`);
119
+ }
120
+ return res.data.token;
121
+ };
122
+
123
+ const fetchFlagConfiguration = async ({
124
+ baseUrl,
125
+ token,
126
+ }: {
127
+ baseUrl: string;
128
+ token: string;
129
+ }): Promise<Record<string, FlagDefinition>> => {
130
+ const res = (await expectStatus(
131
+ await fetch(`${baseUrl}/feature-flags/flagConfiguration`, {headers: authHeaders(token)}),
132
+ 200,
133
+ "GET /feature-flags/flagConfiguration"
134
+ )) as {data: Record<string, FlagDefinition>};
135
+ return res.data;
136
+ };
137
+
138
+ /** Parent: boots the child and walks the happy path over HTTP. */
139
+ const run = async (): Promise<void> => {
140
+ const startedAt = DateTime.now();
141
+ const runId = startedAt.toMillis();
142
+ const port = await freePort();
143
+ const baseUrl = `http://127.0.0.1:${port}`;
144
+ const adminEmail = `feature-flags-smoke-admin-${runId}@example.com`;
145
+ const adminPassword = "feature-flags-smoke-admin-password-1";
146
+ const flagKey = `feature-flags-smoke-${runId}`;
147
+ let server: SpawnedProcess | undefined;
148
+
149
+ try {
150
+ server = await spawnUntilReady({
151
+ cmd: [process.execPath, __filename, SERVE_ARG],
152
+ env: {
153
+ MONGO_URI: await smokeMongoUri({databaseName: "featureFlagsSmoke"}),
154
+ PORT: String(port),
155
+ REFRESH_TOKEN_SECRET: "feature-flags-smoke-refresh-secret",
156
+ SESSION_SECRET: "feature-flags-smoke-session-secret",
157
+ SMOKE_ADMIN_EMAIL: adminEmail,
158
+ SMOKE_ADMIN_PASSWORD: adminPassword,
159
+ TOKEN_ISSUER: "feature-flags-smoke",
160
+ TOKEN_SECRET: "feature-flags-smoke-token-secret",
161
+ },
162
+ timeoutMs: 20000,
163
+ url: `${baseUrl}/health`,
164
+ });
165
+
166
+ const userToken = await signIn({
167
+ baseUrl,
168
+ email: `feature-flags-smoke-user-${runId}@example.com`,
169
+ password: "feature-flags-smoke-user-password-1",
170
+ route: "signup",
171
+ });
172
+ const flagBody = JSON.stringify({
173
+ enabled: true,
174
+ key: flagKey,
175
+ name: "Feature flags smoke",
176
+ rolloutPercentage: 100,
177
+ type: "boolean",
178
+ });
179
+ await expectStatus(
180
+ await fetch(`${baseUrl}/feature-flags/flags`, {
181
+ body: flagBody,
182
+ headers: authHeaders(userToken),
183
+ method: "POST",
184
+ }),
185
+ 405,
186
+ "POST /feature-flags/flags as a non-admin"
187
+ );
188
+ console.info("✅ POST /feature-flags/flags refuses a signed-up non-admin user");
189
+
190
+ const adminToken = await signIn({
191
+ baseUrl,
192
+ email: adminEmail,
193
+ password: adminPassword,
194
+ route: "login",
195
+ });
196
+ const created = (await expectStatus(
197
+ await fetch(`${baseUrl}/feature-flags/flags`, {
198
+ body: flagBody,
199
+ headers: authHeaders(adminToken),
200
+ method: "POST",
201
+ }),
202
+ 201,
203
+ "POST /feature-flags/flags"
204
+ )) as {data: {key: string}};
205
+ if (created.data.key !== flagKey) {
206
+ throw new Error(
207
+ `POST /feature-flags/flags: created key ${created.data.key} is not ${flagKey}`
208
+ );
209
+ }
210
+ console.info("✅ POST /feature-flags/flags creates an enabled boolean flag as the admin");
211
+
212
+ const after = await fetchFlagConfiguration({baseUrl, token: userToken});
213
+ const flag = after[flagKey];
214
+ if (!flag) {
215
+ throw new Error(`GET /feature-flags/flagConfiguration: ${flagKey} is missing`);
216
+ }
217
+ if (flag.disabled || flag.defaultVariant !== "on" || flag.variants.on !== true) {
218
+ throw new Error(
219
+ `GET /feature-flags/flagConfiguration: ${flagKey} is not on: ${JSON.stringify(flag)}`
220
+ );
221
+ }
222
+ console.info("✅ GET /feature-flags/flagConfiguration resolves the flag to on for the user");
223
+ } catch (error: unknown) {
224
+ if (server) {
225
+ console.error(`--- server output ---\n${server.output()}`);
226
+ }
227
+ throw error;
228
+ } finally {
229
+ await server?.stop();
230
+ await stopSmokeMongo();
231
+ }
232
+
233
+ console.info(`feature-flags smoke passed in ${DateTime.now().diff(startedAt).toMillis()} ms`);
234
+ };
235
+
236
+ const main = process.argv[2] === SERVE_ARG ? serve : run;
237
+ main().then(
238
+ () => {
239
+ if (main === run) {
240
+ process.exit(0);
241
+ }
242
+ },
243
+ (error: unknown) => {
244
+ console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
245
+ process.exit(1);
246
+ }
247
+ );
package/package.json CHANGED
@@ -5,18 +5,22 @@
5
5
  },
6
6
  "dependencies": {
7
7
  "@openfeature/server-sdk": "^1.22.0",
8
- "@terreno/api": "57.20.0",
8
+ "@terreno/api": "58.0.0-beta.1",
9
9
  "express": "^5.2.1",
10
10
  "lodash": "^4.18.1"
11
11
  },
12
12
  "description": "Feature flags and A/B testing plugin for @terreno/api — part of Terreno, the batteries-included TypeScript framework for universal apps.",
13
13
  "devDependencies": {
14
14
  "@biomejs/biome": "^2.3.6",
15
- "@terreno/test": "57.20.0",
15
+ "@terreno/test": "58.0.0-beta.1",
16
16
  "@types/bun": "^1.4.0",
17
17
  "@types/express": "^5.0.6",
18
18
  "@types/lodash": "^4.17.17",
19
+ "@types/luxon": "^3.7.1",
19
20
  "@types/supertest": "^7.2.0",
21
+ "luxon": "^3.7.2",
22
+ "mongoose": "9.7.4",
23
+ "passport-local-mongoose": "^9.0.1",
20
24
  "supertest": "^7.2.2",
21
25
  "typescript": "~6.0.3"
22
26
  },
@@ -45,13 +49,15 @@
45
49
  "compile": "bun tsc",
46
50
  "compile:watch": "bun tsc -w",
47
51
  "dev": "bun tsc -w",
48
- "lint": "biome check ./src",
49
- "lint:fix": "biome check --write ./src",
52
+ "lint": "biome check ./src ./e2e",
53
+ "lint:fix": "biome check --write ./src ./e2e",
54
+ "smoke": "bun e2e/smoke.ts",
50
55
  "test": "bun test",
51
56
  "test:agent": "AGENT=1 bun test --only-failures",
52
57
  "test:ci": "bun test",
53
- "test:coverage": "bun run ../scripts/check-coverage.ts"
58
+ "test:coverage": "bun run ../scripts/check-coverage.ts",
59
+ "typecheck": "tsc -p tsconfig.typecheck.json"
54
60
  },
55
61
  "types": "dist/index.d.ts",
56
- "version": "57.20.0"
62
+ "version": "58.0.0-beta.1"
57
63
  }
@@ -4,6 +4,7 @@ import {
4
4
  APIError,
5
5
  asyncHandler,
6
6
  authenticateMiddleware,
7
+ createOpenApiBuilder,
7
8
  logger,
8
9
  type ModelRouterOptions,
9
10
  modelRouter,
@@ -116,6 +117,40 @@ export class FeatureFlagsApp implements TerrenoPlugin {
116
117
  app.get(
117
118
  `${basePath}/flagConfiguration`,
118
119
  authenticateMiddleware(),
120
+ // Documentation only: request validation stays off so the handler behaves as before.
121
+ createOpenApiBuilder(openApi ? {openApi: openApi as OpenApiMiddleware} : {})
122
+ .withTags(["feature-flags"])
123
+ .withSummary("Get every flag's configuration resolved for the current user")
124
+ .withDescription(
125
+ "Keyed by flag key. Disabled boolean flags are included with disabled: true; disabled variant flags are omitted."
126
+ )
127
+ .withResponse(
128
+ 200,
129
+ {
130
+ data: {
131
+ additionalProperties: {
132
+ properties: {
133
+ defaultVariant: {
134
+ description: "The variant to use for the current user",
135
+ type: "string",
136
+ },
137
+ disabled: {type: "boolean"},
138
+ variants: {
139
+ additionalProperties: {oneOf: [{type: "boolean"}, {type: "string"}]},
140
+ description: "Variant name to its value",
141
+ type: "object",
142
+ },
143
+ },
144
+ required: ["defaultVariant", "disabled", "variants"],
145
+ type: "object",
146
+ },
147
+ type: "object",
148
+ },
149
+ },
150
+ {required: ["data"]}
151
+ )
152
+ .withValidation({enabled: false})
153
+ .build(),
119
154
  asyncHandler(async (req: express.Request, res: express.Response) => {
120
155
  const user = req.user as {_id?: unknown; id?: string} | undefined;
121
156
  if (!user) {
@@ -147,7 +182,7 @@ export class FeatureFlagsApp implements TerrenoPlugin {
147
182
  if (!evaluateDeprecationWarned) {
148
183
  evaluateDeprecationWarned = true;
149
184
  logger.warn(
150
- "[feature-flags] GET /evaluate is deprecated; migrate clients to GET /flagConfiguration or use @terreno/rtk useFeatureFlags / useTerrenoFeatureFlags."
185
+ "[feature-flags] GET /evaluate is deprecated; migrate clients to GET /flagConfiguration or use useFeatureFlags / useTerrenoFeatureFlags from @terreno/syncdb/flags."
151
186
  );
152
187
  }
153
188
 
@@ -1,5 +1,6 @@
1
1
  import {afterEach, describe, expect, it} from "bun:test";
2
2
  import {FeatureFlag, featureFlagAdminConfig} from "../featureFlagModel";
3
+ import type {FeatureFlagRule} from "../types";
3
4
 
4
5
  describe("FeatureFlag model", () => {
5
6
  afterEach(async () => {
@@ -151,7 +152,15 @@ describe("FeatureFlag model", () => {
151
152
  FeatureFlag.create({
152
153
  key: "bad-op",
153
154
  name: "Bad Operator",
154
- rules: [{enabled: true, field: "plan", operator: "bogus", value: "pro"}],
155
+ // An operator outside the enum, to prove the schema rejects it.
156
+ rules: [
157
+ {
158
+ enabled: true,
159
+ field: "plan",
160
+ operator: "bogus" as unknown as FeatureFlagRule["operator"],
161
+ value: "pro",
162
+ },
163
+ ],
155
164
  })
156
165
  ).rejects.toBeDefined();
157
166
  });
@@ -5,9 +5,16 @@ import {
5
5
  apiErrorMiddleware,
6
6
  apiUnauthorizedMiddleware,
7
7
  setupAuth,
8
+ TerrenoApp,
8
9
  type UserModel as UserModelType,
9
10
  } from "@terreno/api";
10
- import {authAsUser, getBaseServer, setupDb, UserModel} from "@terreno/api/testing";
11
+ import {
12
+ authAsUser,
13
+ expectDocumentedResponse,
14
+ getBaseServer,
15
+ setupDb,
16
+ UserModel,
17
+ } from "@terreno/api/testing";
11
18
  import type express from "express";
12
19
  import supertest from "supertest";
13
20
  import type TestAgent from "supertest/lib/agent";
@@ -306,6 +313,74 @@ describe("FeatureFlagsApp", () => {
306
313
  });
307
314
  });
308
315
 
316
+ describe("OpenAPI", () => {
317
+ it("documents GET /feature-flags/flagConfiguration as a map of flag definitions", async () => {
318
+ const terrenoApp = new TerrenoApp({
319
+ rateLimit: false,
320
+ skipListen: true,
321
+ userModel: UserModel as unknown as UserModelType,
322
+ })
323
+ .register(new FeatureFlagsApp())
324
+ .build();
325
+
326
+ const res = await supertest(terrenoApp).get("/openapi.json").expect(200);
327
+ const operation = res.body.paths["/feature-flags/flagConfiguration"]?.get;
328
+ expect(operation?.tags).toEqual(["feature-flags"]);
329
+ const schema = operation.responses["200"].content["application/json"].schema;
330
+ expect(schema.required).toEqual(["data"]);
331
+ const definition = schema.properties.data.additionalProperties;
332
+ expect(definition.required).toEqual(["defaultVariant", "disabled", "variants"]);
333
+ expect(definition.properties.disabled.type).toBe("boolean");
334
+ expect(definition.properties.variants.additionalProperties.oneOf).toEqual([
335
+ {type: "boolean"},
336
+ {type: "string"},
337
+ ]);
338
+
339
+ await FeatureFlag.create({
340
+ enabled: true,
341
+ key: "documented-flag",
342
+ name: "Documented",
343
+ rolloutPercentage: 100,
344
+ type: "boolean",
345
+ });
346
+ await FeatureFlag.create({
347
+ defaultVariant: "compact",
348
+ enabled: true,
349
+ key: "documented-variant",
350
+ name: "Documented variant",
351
+ rules: [],
352
+ type: "variant",
353
+ variants: [
354
+ {key: "compact", weight: 50},
355
+ {key: "detailed", weight: 50},
356
+ ],
357
+ });
358
+ await FeatureFlag.create({
359
+ enabled: false,
360
+ key: "documented-off",
361
+ name: "Documented off",
362
+ rolloutPercentage: 100,
363
+ type: "boolean",
364
+ });
365
+ const agent = await authAsUser(terrenoApp, "notAdmin");
366
+ const configRes = await agent.get("/feature-flags/flagConfiguration").expect(200);
367
+ expect(Object.keys(configRes.body.data).sort()).toEqual([
368
+ "documented-flag",
369
+ "documented-off",
370
+ "documented-variant",
371
+ ]);
372
+ expectDocumentedResponse({
373
+ method: "get",
374
+ path: "/feature-flags/flagConfiguration",
375
+ response: configRes,
376
+ spec: res.body,
377
+ });
378
+ expect(Object.keys(definition.properties)).toEqual(
379
+ expect.arrayContaining(Object.keys(configRes.body.data["documented-flag"]))
380
+ );
381
+ });
382
+ });
383
+
309
384
  describe("GET /feature-flags/segments", () => {
310
385
  beforeEach(async () => {
311
386
  app = buildApp({
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "composite": false,
6
+ "incremental": false,
7
+ "declaration": false,
8
+ "declarationMap": false,
9
+ "types": [
10
+ "node",
11
+ "bun"
12
+ ]
13
+ },
14
+ "exclude": [
15
+ "node_modules",
16
+ "dist"
17
+ ]
18
+ }