Compare commits

...

2 Commits

Author SHA1 Message Date
Tom Moor e821856fa0 PR feedback 2026-04-14 11:07:52 -04:00
Tom Moor 967f9a5537 First pass 2026-04-14 10:46:59 -04:00
14 changed files with 497 additions and 82 deletions
+1
View File
@@ -94,6 +94,7 @@ yarn install
## Database & ORM
- Use Sequelize models in `server/models/`.
- Add class-level validations on model columns wherever possible (e.g. `@IsIn` for enum fields, `validate: { min, max }` for numeric ranges, `@Length` for string bounds). Prefer model validations over relying solely on route-level or migration-level constraints.
- Generate migrations with Sequelize CLI:
```bash
+19 -18
View File
@@ -8,12 +8,12 @@ import {
TrashIcon,
UserIcon,
} from "outline-icons";
import { FeatureFlag } from "@shared/types";
import { toast } from "sonner";
import { createAction, createActionWithChildren } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import Logger from "~/utils/Logger";
import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
@@ -206,23 +206,24 @@ export const toggleFeatureFlag = createActionWithChildren({
icon: <BeakerIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
children: Object.values(Feature).map((flag) =>
createAction({
id: `flag-${flag}`,
name: flag,
selected: () => FeatureFlags.isEnabled(flag),
section: DeveloperSection,
perform: () => {
if (FeatureFlags.isEnabled(flag)) {
FeatureFlags.disable(flag);
toast.success(`Disabled feature flag: ${flag}`);
} else {
FeatureFlags.enable(flag);
toast.success(`Enabled feature flag: ${flag}`);
}
},
})
),
children: ({ stores }) =>
(Object.values(FeatureFlag) as unknown as FeatureFlag[]).map((flag) =>
createAction({
id: `flag-${flag}`,
name: flag,
selected: () => stores.auth.getFeatureFlag(flag),
section: DeveloperSection,
perform: () => {
stores.auth.toggleFeatureFlagOverride(flag);
const enabled = stores.auth.getFeatureFlag(flag);
toast.success(
enabled
? `Enabled feature flag: ${flag}`
: `Disabled feature flag: ${flag}`
);
},
})
),
});
export const developer = createActionWithChildren({
+13
View File
@@ -0,0 +1,13 @@
import type { FeatureFlag } from "@shared/types";
import useStores from "~/hooks/useStores";
/**
* Hook to check if a feature flag is enabled for the current team.
*
* @param flag The feature flag to check.
* @returns Whether the flag is enabled.
*/
export default function useFeatureFlag(flag: FeatureFlag): boolean {
const { auth } = useStores();
return auth.getFeatureFlag(flag);
}
+57 -2
View File
@@ -3,7 +3,8 @@ import invariant from "invariant";
import isNil from "lodash/isNil";
import { observable, action, computed, autorun, runInAction } from "mobx";
import { getCookie, setCookie } from "tiny-cookie";
import type { CustomTheme } from "@shared/types";
import { FeatureFlagDefaults } from "@shared/constants";
import type { CustomTheme, FeatureFlag, FeatureFlags } from "@shared/types";
import Storage from "@shared/utils/Storage";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import type RootStore from "~/stores/RootStore";
@@ -19,7 +20,12 @@ import Store from "./base/Store";
type PersistedData = Pick<
AuthStore,
"user" | "team" | "collaborationToken" | "availableTeams" | "policies"
| "user"
| "team"
| "collaborationToken"
| "availableTeams"
| "policies"
| "featureFlags"
>;
type Provider = {
@@ -55,6 +61,14 @@ export default class AuthStore extends Store<Team> {
@observable
public logoutRedirectUri?: string;
/* The resolved feature flags for the current team. */
@observable
public featureFlags: Required<FeatureFlags> = { ...FeatureFlagDefaults };
/* Local overrides for feature flags, used for development testing. */
@observable
private featureFlagOverrides: Partial<FeatureFlags> = {};
/* A list of teams that the current user has access to. */
@observable
public availableTeams?: {
@@ -143,6 +157,7 @@ export default class AuthStore extends Store<Team> {
this.currentTeamId = data.team?.id;
this.currentUserId = data.user?.id;
this.collaborationToken = data.collaborationToken;
this.featureFlags = data.featureFlags ?? { ...FeatureFlagDefaults };
this.lastSignedIn = getCookie("lastSignedIn");
}
@@ -169,6 +184,44 @@ export default class AuthStore extends Store<Team> {
return policy ? [policy] : [];
}
/**
* Returns whether a feature flag is enabled for the current team.
* Local overrides take precedence over server-resolved values.
*
* @param flag The feature flag to check.
* @returns Whether the flag is enabled.
*/
getFeatureFlag(flag: FeatureFlag): boolean {
const overrides = this.featureFlagOverrides as Record<string, boolean>;
if (flag in overrides) {
return overrides[flag];
}
const flags = this.featureFlags as Record<string, boolean>;
const defaults = FeatureFlagDefaults as Record<string, boolean>;
return flags[flag] ?? defaults[flag] ?? false;
}
/**
* Toggle a local override for a feature flag. Used for development testing.
*
* @param flag The feature flag to toggle.
*/
@action
toggleFeatureFlagOverride(flag: FeatureFlag) {
const current = this.getFeatureFlag(flag);
(this.featureFlagOverrides as Record<string, boolean>)[flag] = !current;
}
/**
* Returns whether a feature flag has a local override set.
*
* @param flag The feature flag to check.
* @returns Whether a local override exists.
*/
hasFeatureFlagOverride(flag: FeatureFlag): boolean {
return flag in (this.featureFlagOverrides as Record<string, boolean>);
}
/** Whether the user is signed in */
@computed
get authenticated(): boolean {
@@ -182,6 +235,7 @@ export default class AuthStore extends Store<Team> {
team: this.team,
collaborationToken: this.collaborationToken,
availableTeams: this.availableTeams,
featureFlags: this.featureFlags,
policies: this.policies,
};
}
@@ -215,6 +269,7 @@ export default class AuthStore extends Store<Team> {
this.availableTeams = res.data.availableTeams;
this.collaborationToken = res.data.collaborationToken;
this.featureFlags = data.featureFlags ?? { ...FeatureFlagDefaults };
if (env.SENTRY_DSN) {
Sentry.configureScope((scope) => {
-48
View File
@@ -1,48 +0,0 @@
import { observable } from "mobx";
import Storage from "@shared/utils/Storage";
export enum Feature {
/** New collection permissions UI */
newCollectionSharing = "newCollectionSharing",
}
/** Default values for feature flags */
const FeatureDefaults: Record<Feature, boolean> = {
[Feature.newCollectionSharing]: true,
};
/**
* A simple feature flagging system that stores flags in browser storage.
*/
export class FeatureFlags {
public static isEnabled(flag: Feature) {
// init on first read
if (this.initalized === false) {
this.cache = new Set();
for (const key of Object.values(Feature)) {
const value = Storage.get(key);
if (value === true) {
this.cache.add(key);
}
}
this.initalized = true;
}
return this.cache.has(flag) ? true : (FeatureDefaults[flag] ?? false);
}
public static enable(flag: Feature) {
this.cache.add(flag);
Storage.set(flag, true);
}
public static disable(flag: Feature) {
this.cache.delete(flag);
Storage.set(flag, false);
}
@observable
private static cache: Set<Feature> = new Set();
private static initalized = false;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Next } from "koa";
import type { FeatureFlag } from "@shared/types";
import { ValidationError } from "@server/errors";
import { FeatureFlag as FeatureFlagModel } from "@server/models";
import type { APIContext } from "@server/types";
/**
* Middleware to check if a feature flag is enabled for the team.
*
* @param flag The feature flag to check.
* @returns The middleware function.
*/
export function featureFlag(flag: FeatureFlag) {
return async function featureFlagMiddleware(ctx: APIContext, next: Next) {
const { user } = ctx.state.auth;
const enabled = await FeatureFlagModel.isEnabled(flag, user.teamId);
if (!enabled) {
throw ValidationError("This feature is not currently available");
}
return next();
};
}
@@ -0,0 +1,39 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("feature_flags", {
id: {
type: Sequelize.UUID,
allowNull: false,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
name: {
type: Sequelize.STRING,
allowNull: false,
unique: true,
},
teamIds: {
type: Sequelize.ARRAY(Sequelize.UUID),
allowNull: false,
defaultValue: [],
},
percentage: {
type: Sequelize.INTEGER,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
},
down: async (queryInterface) => {
await queryInterface.dropTable("feature_flags");
},
};
+156
View File
@@ -0,0 +1,156 @@
import { randomUUID } from "node:crypto";
import { FeatureFlagDefaults } from "@shared/constants";
import { FeatureFlag as FeatureFlagEnum } from "@shared/types";
import { FeatureFlag } from "@server/models";
import { buildTeam } from "@server/test/factories";
describe("FeatureFlag", () => {
afterEach(async () => {
await FeatureFlag.destroy({ where: {}, force: true });
await FeatureFlag.invalidateCache();
});
describe("isInPercentage", () => {
it("should return false when percentage is 0", () => {
const teamId = randomUUID();
expect(FeatureFlag.isInPercentage(teamId, "testFlag", 0)).toBe(false);
});
it("should return true when percentage is 100", () => {
const teamId = randomUUID();
expect(FeatureFlag.isInPercentage(teamId, "testFlag", 100)).toBe(true);
});
it("should be deterministic for the same inputs", () => {
const teamId = randomUUID();
const a = FeatureFlag.isInPercentage(teamId, "testFlag", 50);
const b = FeatureFlag.isInPercentage(teamId, "testFlag", 50);
expect(a).toBe(b);
});
it("should vary by teamId", () => {
const results = new Set<boolean>();
for (let i = 0; i < 50; i++) {
results.add(FeatureFlag.isInPercentage(randomUUID(), "testFlag", 50));
}
expect(results.size).toBe(2);
});
it("should vary by flag name", () => {
const teamId = randomUUID();
const results = new Set<boolean>();
for (let i = 0; i < 50; i++) {
results.add(FeatureFlag.isInPercentage(teamId, `flag-${i}`, 50));
}
expect(results.size).toBe(2);
});
it("should be monotonic — increasing percentage never removes a team", () => {
const teamId = randomUUID();
let becameTrue = false;
for (let pct = 0; pct <= 100; pct++) {
const result = FeatureFlag.isInPercentage(teamId, "testFlag", pct);
if (result) {
becameTrue = true;
}
if (becameTrue) {
expect(result).toBe(true);
}
}
});
it("should approximate the target percentage across many teams", () => {
const total = 1000;
let enabled = 0;
for (let i = 0; i < total; i++) {
if (FeatureFlag.isInPercentage(randomUUID(), "testFlag", 30)) {
enabled++;
}
}
const ratio = enabled / total;
expect(ratio).toBeGreaterThan(0.2);
expect(ratio).toBeLessThan(0.4);
});
});
describe("resolveAll", () => {
it("should return defaults when no rows exist", async () => {
const team = await buildTeam();
const result = await FeatureFlag.resolveAll(team.id);
expect(result).toEqual(FeatureFlagDefaults);
});
it("should return true when team is in teamIds", async () => {
const team = await buildTeam();
await FeatureFlag.create({
name: FeatureFlagEnum.GroupSync,
teamIds: [team.id],
percentage: null,
});
const result = await FeatureFlag.resolveAll(team.id);
expect(result[FeatureFlagEnum.GroupSync]).toBe(true);
});
it("should return false when row exists but team not included", async () => {
const team = await buildTeam();
const otherTeam = await buildTeam();
await FeatureFlag.create({
name: FeatureFlagEnum.GroupSync,
teamIds: [otherTeam.id],
percentage: null,
});
const result = await FeatureFlag.resolveAll(team.id);
expect(result[FeatureFlagEnum.GroupSync]).toBe(false);
});
it("should use percentage when team is not in teamIds", async () => {
const team = await buildTeam();
await FeatureFlag.create({
name: FeatureFlagEnum.GroupSync,
teamIds: [],
percentage: 100,
});
const result = await FeatureFlag.resolveAll(team.id);
expect(result[FeatureFlagEnum.GroupSync]).toBe(true);
});
it("should enable for team in teamIds regardless of percentage", async () => {
const team = await buildTeam();
await FeatureFlag.create({
name: FeatureFlagEnum.GroupSync,
teamIds: [team.id],
percentage: 0,
});
const result = await FeatureFlag.resolveAll(team.id);
expect(result[FeatureFlagEnum.GroupSync]).toBe(true);
});
});
describe("isEnabled", () => {
it("should return default when no row exists", async () => {
const team = await buildTeam();
const result = await FeatureFlag.isEnabled(
FeatureFlagEnum.GroupSync,
team.id
);
expect(result).toBe(false);
});
it("should return true when team is explicitly enabled", async () => {
const team = await buildTeam();
await FeatureFlag.create({
name: FeatureFlagEnum.GroupSync,
teamIds: [team.id],
percentage: null,
});
const result = await FeatureFlag.isEnabled(
FeatureFlagEnum.GroupSync,
team.id
);
expect(result).toBe(true);
});
});
});
+148
View File
@@ -0,0 +1,148 @@
import crypto from "node:crypto";
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import {
Table,
Column,
DataType,
AllowNull,
Default,
IsIn,
Unique,
AfterCreate,
AfterUpdate,
AfterDestroy,
AfterBulkCreate,
AfterBulkUpdate,
AfterBulkDestroy,
} from "sequelize-typescript";
import { FeatureFlagDefaults } from "@shared/constants";
import {
FeatureFlag as FeatureFlagEnum,
type FeatureFlags,
} from "@shared/types";
import { Hour } from "@shared/utils/time";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
@Table({ tableName: "feature_flags", modelName: "feature_flag" })
@Fix
class FeatureFlag extends IdModel<
InferAttributes<FeatureFlag>,
Partial<InferCreationAttributes<FeatureFlag>>
> {
/** The flag name from the FeatureFlag enum. */
@IsIn([Object.values(FeatureFlagEnum)])
@Unique
@Column(DataType.STRING)
name: FeatureFlagEnum;
/** Team IDs that have this flag explicitly enabled. */
@Default([])
@Column(DataType.ARRAY(DataType.UUID))
teamIds: string[];
/** Percentage of teams to enable for (0-100). Applies to teams not in teamIds. */
@AllowNull
@Column({
type: DataType.INTEGER,
validate: {
min: 0,
max: 100,
},
})
percentage: number | null;
// Hooks
@AfterCreate
@AfterUpdate
@AfterDestroy
@AfterBulkCreate
@AfterBulkUpdate
@AfterBulkDestroy
static async invalidateCache() {
await CacheHelper.clearData(RedisPrefixHelper.getFeatureFlagsKey());
}
// Static methods
/**
* Resolve all feature flags for a team.
*
* @param teamId The team to resolve flags for.
* @returns A complete map of all flags with resolved boolean values.
*/
static async resolveAll(teamId: string): Promise<Required<FeatureFlags>> {
const rows = await CacheHelper.getDataOrSet(
RedisPrefixHelper.getFeatureFlagsKey(),
async () => this.findAll(),
12 * Hour.seconds
);
const rowsByName = new Map((rows ?? []).map((r) => [r.name, r]));
const resolved = Object.assign(
{} as Record<string, boolean>,
FeatureFlagDefaults
);
for (const flag of Object.values(FeatureFlagEnum) as string[]) {
const row = rowsByName.get(flag as unknown as FeatureFlagEnum);
if (!row) {
continue;
}
if (row.teamIds.includes(teamId)) {
resolved[flag] = true;
continue;
}
if (row.percentage !== null) {
resolved[flag] = this.isInPercentage(teamId, flag, row.percentage);
continue;
}
resolved[flag] = false;
}
return resolved as Required<FeatureFlags>;
}
/**
* Check if a single flag is enabled for a team.
*
* @param flag The feature flag to check.
* @param teamId The team to check for.
* @returns Whether the flag is enabled.
*/
static async isEnabled(
flag: FeatureFlagEnum,
teamId: string
): Promise<boolean> {
const resolved = await this.resolveAll(teamId);
return (resolved as Record<string, boolean>)[flag] ?? false;
}
/**
* Deterministic percentage check. Uses MD5 hash of teamId + flag name
* so the same team always gets the same result for a given percentage.
* As percentage increases, previously-included teams stay included.
*
* @param teamId The team ID.
* @param flag The flag name.
* @param percentage The rollout percentage (0-100).
* @returns Whether this team falls within the percentage.
*/
static isInPercentage(
teamId: string,
flag: string,
percentage: number
): boolean {
const hash = crypto.createHash("md5").update(`${teamId}:${flag}`).digest();
const value = hash.readUInt32BE(0) % 100;
return value < percentage;
}
}
export default FeatureFlag;
+2
View File
@@ -18,6 +18,8 @@ export { default as Document } from "./Document";
export { default as Event } from "./Event";
export { default as FeatureFlag } from "./FeatureFlag";
export { default as ExternalGroup } from "./ExternalGroup";
export { default as FileOperation } from "./FileOperation";
+16 -13
View File
@@ -6,7 +6,7 @@ import { parseDomain } from "@shared/utils/domains";
import env from "@server/env";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import { Event, Team } from "@server/models";
import { Event, FeatureFlag, Team } from "@server/models";
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
import {
presentUser,
@@ -122,18 +122,20 @@ router.post("auth.info", auth(), async (ctx: APIContext<T.AuthInfoReq>) => {
const sessions = getSessionsInCookie(ctx);
const signedInTeamIds = Object.keys(sessions);
const [team, groups, signedInTeams, availableTeams] = await Promise.all([
Team.scope("withDomains").findByPk(user.teamId, {
rejectOnEmpty: true,
}),
user.groups(),
Team.findAll({
where: {
id: signedInTeamIds,
},
}),
user.availableTeams(),
]);
const [team, groups, featureFlags, signedInTeams, availableTeams] =
await Promise.all([
Team.scope("withDomains").findByPk(user.teamId, {
rejectOnEmpty: true,
}),
user.groups(),
FeatureFlag.resolveAll(user.teamId),
Team.findAll({
where: {
id: signedInTeamIds,
},
}),
user.availableTeams(),
]);
// If the user did not _just_ sign in then we need to check if they continue
// to have access to the workspace they are signed into. This only applies
@@ -165,6 +167,7 @@ router.post("auth.info", auth(), async (ctx: APIContext<T.AuthInfoReq>) => {
includeDetails: true,
}),
team: presentTeam(team),
featureFlags,
groups: await Promise.all(groups.map(presentGroup)),
groupUsers: groups.map((group) => presentGroupUser(group.groupUsers[0])),
collaborationToken: user.getCollaborationToken(),
+9
View File
@@ -42,4 +42,13 @@ export class RedisPrefixHelper {
public static getUserCollectionIdsKey(userId: string) {
return `uc:${userId}`;
}
/**
* Gets key for caching all feature flag rows.
*
* @returns the cache key string.
*/
public static getFeatureFlagsKey() {
return "featureFlags:all";
}
}
+6 -1
View File
@@ -1,5 +1,6 @@
import type { TeamPreferences, UserPreferences } from "./types";
import type { FeatureFlags, TeamPreferences, UserPreferences } from "./types";
import {
FeatureFlag,
TOCPosition,
TeamPreference,
UserPreference,
@@ -22,6 +23,10 @@ export const CSRF = {
fieldName: "_csrf",
};
export const FeatureFlagDefaults: Required<FeatureFlags> = {
[FeatureFlag.GroupSync]: false,
};
export const TeamPreferenceDefaults: TeamPreferences = {
[TeamPreference.SeamlessEdit]: true,
[TeamPreference.ViewersCanExport]: true,
+9
View File
@@ -431,6 +431,15 @@ export type TeamPreferences = {
[TeamPreference.DisabledEmbeds]?: string[];
};
export enum FeatureFlag {
/** Whether group sync from external providers is enabled. */
GroupSync = "groupSync",
}
export type FeatureFlags = {
[K in FeatureFlag]?: boolean;
};
export enum NavigationNodeType {
Collection = "collection",
Document = "document",