Adds lifetime reminder alongside the pre-existing lifecycle reminder.
Only one banner at a time is rendered, and the lifecycle banner takes
precedence over the lifetime one.
### Expected lifetime reminder
- Rendered when the flag's expected lifetime has ended
(`expectedLifetimeEndsAt` is in the past) and the flag is not yet
completed or archived.
- Offers "Mark completed", which opens the existing mark completed
dialogue.
<img width="1497" height="610" alt="Screenshot 2026-10-09 at 18 02 21"
src="https://github.com/user-attachments/assets/bb82d77f-7af0-43f4-bed0-0119ed333b47"
/>
### Yearly check-in for permanent flags
- Rendered for permanent flags (`expectedLifetimeEndsAt` is `null`) once
a year has passed since flag creation, while the flag is not completed
or archived.
- Offers "Skip for now", which snoozes the reminder for a year in
local storage, and "Mark completed".
<img width="1541" height="672" alt="Screenshot 2026-10-09 at 17 14 15"
src="https://github.com/user-attachments/assets/a899d05b-ff40-46ef-81bc-7e2a8f83f16f"
/>
Also refactored so that banner actions no longer wrap awkwardly on
narrow screens.
Before:
<img width="1167" height="125" alt="Screenshot 2026-10-09 at 16 54 25"
src="https://github.com/user-attachments/assets/598a26f5-06e1-4b82-be4c-3f0b41feb3c6"
/>
After:
<img width="1020" height="116" alt="Screenshot 2026-10-09 at 18 01 06"
src="https://github.com/user-attachments/assets/3dad9389-f67c-4104-8408-edc44edd5000"
/>
## About the changes
Removes the unused `queryMissingTokens` experimental flag from
`IFlagKey` and defaults (`UNLEASH_EXPERIMENTAL_QUERY_MISSING_TOKENS`).
No code, tests, frontend, or docs referenced it and implementation was
already gone, only the definition remained. No behavior change.
Closes #
### Important files
## Discussion points
## OSS PR checklist
- [x] I have read and agree to the [Unleash Contributor License
Agreement](https://github.com/Unleash/unleash/blob/main/CLA.md).
- [ ] I have added tests or explained why tests are not needed.
- [ ] I have updated documentation where relevant.
Adds "Pick a date" to the expected lifetime row of the new create flag
dialog.
- Clicking the chip shows a date input defaulting to tomorrow, limited
to dates after today. The picked date becomes `lifetimeDays` in the
payload, counted as calendar days from today.
- The form's lifetime state is now a typed choice instead of a day
count:
```ts
type Lifetime =
| { type: 'preset'; days: number }
| { type: 'custom'; endsAt: string }
| { type: 'permanent' };
Examples of what is saved in local storage:
User clicked the 30 days chip:
`"lifetime": { "type": "preset", "days": 30 }`
User picked 11 January 2027 in the calendar:
`"lifetime": { "type": "custom", "endsAt": "2027-01-11" }`
User clicked Permanent:
`"lifetime": { "type": "permanent" }`
https://github.com/user-attachments/assets/754a15b1-1ae4-4b4f-8615-233db732baa1
Adds `expectedLifetimeEndsAt` to the admin `GET
/api/admin/projects/:projectId/features/:featureName` payload, behind
the `perFlagLifetime` flag. The flag page will use it to show the
cleanup reminder based on the flag's lifetime.
- A new `getLifetime(featureName)` read on the feature toggle store
returns a `FlagLifetime`: `{ type: 'permanent' }` when the flag's
lifetime is `0` or, failing that, its type has none, otherwise e.g. `{
type: 'expiring', days: 30 }` with the flag's own lifetime or its type's
as fallback. It returns `undefined` for a flag that does not exist.
- `getFeature` turns that into the date in the service: `null` for a
permanent flag, otherwise `createdAt` plus the days.
- `expectedLifetimeEndsAt` is absent when the flag is off or no lifetime
is known; `null` for a permanent flag or permanent type; otherwise
`createdAt` plus the lifetime in days.
- On the wire the field is an ISO 8601 string, like every other date on
the payload: the service returns a `Date` and [the controller passes the
response through
`serializeDates`](https://github.com/bricks-software/unleash-enterprise/blob/8399e1548cf76f0bc0d58d957aedba21f3691d38/oss/src/lib/features/feature-toggle/feature-toggle-controller.ts#L776-L781).
Not declared in `featureSchema` on purpose: the response schema is open
while this is behind feature flag.
Example, a release flag created on 7 October 2026 with a 30-day
lifetime:
```
{
"name": "checkout-redesign",
"type": "release",
"createdAt": "2026-10-07T11:39:43.155Z",
"expectedLifetimeEndsAt": "2026-11-06T11:39:43.155Z",
...
}
```
For a permanent flag the field is null. With the flag off it is absent.
* moved `FeatureStrategyEnabledDisabled` directly to `StrategyFormBody`,
gated with new `allowEnableDisable` prop
* renamed legacy-UI-specific prop `generalTabExtras` to `statusContent`
as it now only holds feature flag status info (it will live separately
from `FeatureStrategyEnabledDisabled` so it needed to be split)
* copied `StrategyFormBody` to `LegacyStrategyFormBody` to be used once
the `singlePageStrategyForm` flag is disabled
* added `StrategyFormBodySelector` to route between (new)
`StrategyFormBody` and (current) `LegacyStrategyFormBody` based on
`singlePageStrategyForm` flag and updated callees not to choose the impl
on their own
* split out tests for old and new paths - identical by now, ready for
future divergence
* removed obsolete and no longer used `newStrategyConfiguration` flag
The split is shallow - both `StrategyFormBody` and
`LegacyStrategyFormBody` share lots of common dependencies. These would
need to be split out once we proceed with implementation of changes
within `StrategyFormBody`
[CXUX-449](https://linear.app/unleash/issue/CXUX-449/split-out-new-strategy-form-rename-existing-to-legacy)
Working on the [audit metrics for idle
timeouts](https://linear.app/unleash/project/configurable-idle-session-timeout-with-re-authentication-after-68a334e79506/issues)
met some obstacles on the way...
-`SessionTimeoutReason` codes would need a rename to be used as a metric
labe, move them to snake_case at the same time, so the value is usable
-`parse()` returns `undefined` for both a missing `authenticatedAt` and
one that will not parse, so both ended the session as `unknown-start`.
-a legacy session (_one started before timeouts get shipped_) is signed
out on its next request -- and during a rolling deploy the old pods keep
minting them => so it is a sustained login loop rather than a one-off
wave.
Now: If the session has a missing `start` is now backfilled to `now` and
the session carries on, only a `start` we cannot read ends it, under its
own reason
**Issue**
we have six modules (`logout,timeout mw,reset-password,oidc,saml x2`)
that end a session by hand and disagree on every step.
Working on idle timeouts, we spotted three of them clear the cookie with
no path, so under a non-empty `BASE_URI_PATH` they actually ...clear
nothing.
### This PR
As we did before with introducing `startSession` for all modules, we add
`endSession` besides it.
-destroys the row,
-clears the cookie with the full sessionCookieOptions, and
-sets Clear-Site-Data when the instance asks for it.
It's being called by all six modules so we stay consistent in all paths.
Why now before idle-timeouts ship:
`logout.ts:` redirects to `session.logoutUrl` before the destroy block,
so for SAML and OIDC the store wipe is never reached. The SSO providers
then clear the cookie with a bare `res.clearCookie(this.cookieName)`.
---------
Co-authored-by: irstavr <irstav@gmail.com>
New install type that looks similar to the one used by slack-app. This
one expects a path relative to the addon it belongs to and sends
non-sensitive params to the server in the URL.
This will be used to support the GitHub App Manifest flow, since we need
to pass on details like organization name etc. I started by enhancing
the current Install property, but its easier to _remove_ this if we
choose not to go down the app manifest path.
The environment toggle endpoints (`POST
/api/admin/projects/:projectId/features/:featureName/environments/:environment/on`
and `/off`) document a feature in their 200 response, but the handlers
end with `res.status(200).end()` and have sent an empty body since they
were added in Unleash/unleash#880 (2021). The feature response was
declared when the docs were written in Unleash/unleash#1563 (2022), so
the response body was documented but never sent. This declares the empty
200 the endpoints already return, the same way the bulk toggle routes
do, and nothing about the endpoints' behaviour changes.
Selecting popular schemas grouped by categories that are used in the
wild (we may reconsider the grouping in future)
<img width="806" height="707" alt="Screenshot 2026-10-08 at 12 31 17"
src="https://github.com/user-attachments/assets/cc7f5355-c7f0-4209-b7d5-da7b0ca337cd"
/>
One selected the field is populated with a sample schema you can edit
(ginger cake pattern)
<img width="815" height="842" alt="Screenshot 2026-10-08 at 12 31 23"
src="https://github.com/user-attachments/assets/8428173c-aab5-4115-9e59-e484feeb7f05"
/>
No need to review the actual schemas. They will probably be changed
anyway and they take the most of this PR line count
Also perf impact is negligible. New templates are 3kb after gzip/minify
and this part of app is already lazy loaded and not part of the initial
bundle
All fields have human readable title to use for UI labels later with a
fall back to property key
First step in adding a new create feature flag dialog (gated behind
`perFlagLifetime`), with the newly introduced lifetime selection. The
dialog has:
- flag type chips (Standard / Experiment / Kill switch) with a "Used
for" list for the standard types
- expected lifetime chips (7 / 30 / 90 days / Permanent) with a default
per type, kept when the user has picked one
- `lifetimeDays` in the create payload, posted only when set; `0` means
permanent, matching the backend
Some of the code is copied from the "old" create feature form dialog (I
left comments throughout this PR with the relevant LOCs).
Out of scope (for the sake of keeping the PR at a reasonable size):
- "Pick a date" as a custom lifetime
- tracking for the new dialog
- the "Other optional settings" section (tags and impression data)
https://github.com/user-attachments/assets/f3139f4b-17c0-4c7b-83c3-39828cd59464
A saved payload schema is now kept in memory and returned with the flag
as `payloadSchema`.
- In memory until the UI has shown what we need to store, so a restart
loses schemas and each instance has its own
- Added in the flag controller, not in the flag service where links are
read, because ten factories build that service and each would get its
own empty store
We've decided that we will no longer use Plausible, so its chart comes
out of impact metrics.
- Remove the `GET /impact-metrics/plausible` endpoint, its response
schema and the service call. It returned 404 unless the
`plausibleMetrics` flag was on and `PLAUSIBLE_TOKEN` was set, so no
customer could rely on it
- Remove the `plausibleMetrics` flag
(`UNLEASH_EXPERIMENTAL_PLAUSIBLE_METRICS`)
- Remove `PlausibleChart`, `PlausibleChartItem` and
`usePlausibleMetrics` from the impact metrics page
- Remove the generated `getPlausibleMetrics*` and
`plausibleMetricsResponseSchema*` frontend models
- `PLAUSIBLE_TOKEN` is no longer read, so the secret can be dropped from
deployments
The endpoint now refuses to set a payload schema on a flag that is not
in the project named in the URL, because the permission is only checked
against that project.
- The check lives in a new service, as in the feature links (#1837) and
lifecycle (#1944) fixes
- Missing and archived flags get the same 404
the endpoint now refuses a payload schema that is not a JSON Schema.
- Only draft 2020-12 is accepted
- Unknown keywords and formats are refused
- Ajv's type, tuple and required rules are off, because they refuse
valid and common schemas, such as a value of two types or a conditional
`required`. The tests name those cases.
When a new node hydrates an environment while an update commits,
separate feature/segment/revision queries can combine old feature state
with a newer cursor. That revision's actual update is then skipped
because the delta processor considers it processed.
This PR reads hydration features, referenced-segment revision state and
all segments through transaction-scoped readers inside a read-only
PostgreSQL `REPEATABLE READ` transaction. Inject the snapshot reader
through the OSS composition root and initialize the cache and watermark
from that consistent result. The transaction runs on environment
initialization/rehydration, not every delta poll.
### TDD commit structure
1. `349a83c2` — **test: expose inconsistent delta hydration during a
concurrent commit**. Adds only
`oss/src/lib/features/client-feature-toggles/delta/delta-hydration-snapshot.test.ts`;
production code is unchanged.
2. `21625a4c` — **fix: read delta hydration from a repeatable-read
database snapshot**. Adds the snapshot reader, wires it into hydration,
and adapts existing unit fixtures to the injected reader. The regression
file is unchanged from commit 1.
### Regression coverage
The test follows nearby OSS database tests.
A method spy delegates to the real feature query and pauses after it
returns. Another DB connection atomically commits a feature change, a
referenced segment change and both events. Hydration must retain the
earlier state, segment constraints and watermark. A subsequent refresh
must deliver the committed feature/segment changes to a client resuming
from that earlier watermark. This tests actual isolation.
Introduce "Show less" / "Show more" functionality into shared
`Truncator` component via new `expandable` property, off by default.
Also, fix `whiteSpace` style in `Truncator` so that it does not inherit
values that may break line-clamp functionality inadvertently.
[DX-4773](https://linear.app/unleash/issue/DX-4773/ux-expand-description-ff)
- Turns on Biome's `correctness/noNodejsModules` for
`oss/frontend/src/**`.
- #2019 imported `node:util` in a frontend file. Type check, unit tests
and the build passed, and the app broke in the browser (fixed in #2029).
This makes that import a lint error.
- Checked against the #2019 version of
`strategy-change-diff-calculation.ts`: lint fails on the import. Current
`main` passes with no hits, so no source changes are needed.
- Scoped to `src` so `vite.config.mts` and the Cypress config can keep
using Node builtins.
- Does not cover a dependency that imports a Node builtin. That still
needs a runtime check.
## Summary
Make signing in again through OIDC ask for credentials, instead of the
IdP quietly reusing its own session. Gated behind `sessionTimeouts`, off
by default; with it off, OIDC sign-in works as today. The logout changes
apply with the flag on or off.
https://linear.app/unleash/issue/EG-4756/use-force-auth-for-the-oidc
## Key points
- With `sessionTimeouts` on, the sign-in request asks the IdP for a
fresh login (`prompt=login`, `max_age=300`), and the callback refuses an
ID token whose `auth_time` is older than 5 minutes or missing.
- The sign-in keeps what it asked for on its session, next to `state`
and `nonce`, so the callback checks exactly that even if the flag is
flipped in between.
- OIDC logout ends the user's sessions in every browser, as password
logout already does, and a database error there still signs this browser
out.
- OIDC logout clears the session cookie on the base path it was set on.
## Testing
- Sign-in: the request to the IdP carries `prompt` and `max_age` only
with the flag on. On a signed ID token, openid-client's own check lets a
sign-in from a minute ago in and refuses one from 10 minutes ago or with
no `auth_time`; with the flag off, a token without `auth_time` still
signs in.
- Flag flips: a sign-in started with the flag on is still checked if
it's turned off before the callback, and one started with it off isn't
refused if it's turned on.
- Logout: ends the user's sessions and clears the cookie on a `/hosted`
base path, and still signs this browser out when ending the other
sessions fails.
- On `pnpm run dev:enterprise` against a local Keycloak with the flag
on, signing in again showed Keycloak's password prompt even with a live
IdP session, and logging out in the browser ended a second session of
the same user. An authorize request with `prompt` and `max_age`
stripped, sent after the IdP login was 5 minutes old, got a code from
Keycloak without a prompt and Unleash refused it. With the flag off,
that same IdP session signed straight in. With Keycloak's `auth_time`
left out of the ID token, a sign-in started with the flag on was refused
even after restarting the server with it off.
## Notes
- Entra ID sends `auth_time` only when the app adds it as an optional
claim, so with the flag on, an Entra app without it can't sign in.
- Replaces #1921. Siblings: #1951 (SAML), #1950 (hosted JWT).
Added configuration for GitHub Actions and updated schedule for npm
dependencies.
Since the rest of our repos are using dependabot, this configures
dependabot as our dependency updater.
## About the changes
Closes #
### Important files
## Discussion points
## OSS PR checklist
- [x] I have read and agree to the [Unleash Contributor License
Agreement](https://github.com/Unleash/unleash/blob/main/CLA.md).
- [x] I have added tests or explained why tests are not needed.
- [x] I have updated documentation where relevant.
Before this PR: every session test ran on `session.MemoryStore`, which
rewrites the whole record when a session is touched. But in prod
(`connect-session-knex`) updates only the `expired` column and leaves
the stored payload alone. So the store we actually ship on was never
tested.
Since we added the timeouts, current tests cannot actually assert on
session lifetime.
### What's new:
-added test helper`setupAppWithSessionStore`: now sessions live in
Postgres, with `sessionTimeouts` on by default (different test can
modify this). The rest helpers keep the in-memory store as before.
-added e2e `session-timeout` tests against a real DB
-- idle window: a session nobody touched expires: a tab that only polls
does not keep itself alive - a fourteen one-minute reads survive, the
fifteenth minute ends it. the keep-alive moves the deadline, a plain
read does not. The keep-alive cannot revive an already-expired session.
Signing in again issues a new session id.
-- max-age: ends a session however active the user it, still applies on
its own with the timeouts off, not enforced while the flag is off.
-tested that sessions before the timeouts feature carry no timestamps,
so there's no window to measure -> they logout on 1st request.
(introduced in
[PR](https://github.com/bricks-software/unleash-enterprise/pull/1991))
-added e2e session tests: for the [keep-alive
endpoint](https://github.com/bricks-software/unleash-enterprise/pull/1992):
what it answers with a window in force, with the flag off, with no
session, and the ui-config interval both ways. Runs with
`strictSchemaValidation` on, so responses are checked against the new
schema.
One file missed in
https://github.com/bricks-software/unleash-enterprise/pull/2019
```
/oss/frontend ❯ pnpm i
[ERROR] This project is configured to use 12.9.1 of pnpm. Your current pnpm is v11.15.1
Corepack invoked pnpm with this version, and pnpm does not switch versions when running under corepack.
Align the "packageManager" field in package.json with "devEngines.packageManager", or invoke pnpm directly (without corepack) so it can switch versions automatically.
If you want to bypass this version check, you can set the "pmOnFail" configuration to "warn" or "ignore" (e.g. via --pm-on-fail=ignore). If using "devEngines.packageManager", you can set its "onFail" to "warn" or "ignore"
```
## Summary
PR #12369 merged with a failing frontend E2E test because the required
`build` check only depended on the backend and frontend builds.
Call the existing frontend E2E workflow from PR Build and include its
result in the required `build` gate. E2E must succeed for the gate to
pass; backend and frontend builds can still be skipped by their existing
path filters. Remove the E2E workflow's standalone PR trigger to avoid
duplicate runs. E2E continues to run on every PR, and no branch
protection changes are needed.