Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions apps/api/src/app/db/__tests__/feature-flags.db.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const prismaMock = vi.hoisted(() => ({
featureFlagOverride: {
findMany: vi.fn(),
},
teamMember: {
findFirst: vi.fn(),
},
}));

vi.mock('@jetstream/api-config', () => ({
logger: { error: vi.fn(), warn: vi.fn() },
prisma: prismaMock,
}));

import { ALL_FEATURE_FLAG_KEYS, DEFAULT_FEATURE_FLAGS } from '@jetstream/types';

// Drive fixtures off the real registry so renaming/retiring the example flag never breaks these tests.
const FLAG = ALL_FEATURE_FLAG_KEYS[0];

describe('resolveFeatureFlagsForUser', () => {
let resolveFeatureFlagsForUser: typeof import('../feature-flags.db').resolveFeatureFlagsForUser;

beforeEach(async () => {
vi.clearAllMocks();
({ resolveFeatureFlagsForUser } = await import('../feature-flags.db'));
});

it('requires at least one flag in the registry to exercise these tests', () => {
expect(ALL_FEATURE_FLAG_KEYS.length).toBeGreaterThan(0);
});

it('returns code defaults when there are no overrides', async () => {
prismaMock.featureFlagOverride.findMany.mockResolvedValue([]);
const flags = await resolveFeatureFlagsForUser({ userId: 'user-1', teamId: null });
expect(flags[FLAG]).toBe(DEFAULT_FEATURE_FLAGS[FLAG]);
});

it('enables a flag from a user override', async () => {
prismaMock.featureFlagOverride.findMany.mockResolvedValue([{ key: FLAG, enabled: true }]);
const flags = await resolveFeatureFlagsForUser({ userId: 'user-1', teamId: null });
expect(flags[FLAG]).toBe(true);
});

it('enables a flag from a team override', async () => {
prismaMock.featureFlagOverride.findMany.mockResolvedValue([{ key: FLAG, enabled: true }]);
const flags = await resolveFeatureFlagsForUser({ userId: 'user-1', teamId: 'team-1' });
expect(flags[FLAG]).toBe(true);
});

it('is most-permissive when user and team overrides conflict (any true wins)', async () => {
prismaMock.featureFlagOverride.findMany.mockResolvedValue([
{ key: FLAG, enabled: false },
{ key: FLAG, enabled: true },
]);
const flags = await resolveFeatureFlagsForUser({ userId: 'user-1', teamId: 'team-1' });
expect(flags[FLAG]).toBe(true);
});

it('ignores override rows for unknown/removed flag keys', async () => {
prismaMock.featureFlagOverride.findMany.mockResolvedValue([{ key: 'flag-removed-from-code', enabled: true }]);
const flags = await resolveFeatureFlagsForUser({ userId: 'user-1', teamId: null });
expect(flags).not.toHaveProperty('flag-removed-from-code');
expect(flags[FLAG]).toBe(DEFAULT_FEATURE_FLAGS[FLAG]);
});

it('only filters by userId when the user has no team', async () => {
prismaMock.featureFlagOverride.findMany.mockResolvedValue([]);
await resolveFeatureFlagsForUser({ userId: 'user-1', teamId: null });
expect(prismaMock.featureFlagOverride.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { OR: [{ userId: 'user-1' }] } }),
);
});

it('filters by userId and teamId when the user has a team', async () => {
prismaMock.featureFlagOverride.findMany.mockResolvedValue([]);
await resolveFeatureFlagsForUser({ userId: 'user-1', teamId: 'team-1' });
expect(prismaMock.featureFlagOverride.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { OR: [{ userId: 'user-1' }, { teamId: 'team-1' }] } }),
);
});
});

describe('resolveActiveTeamIdForUser', () => {
let resolveActiveTeamIdForUser: typeof import('../feature-flags.db').resolveActiveTeamIdForUser;

beforeEach(async () => {
vi.clearAllMocks();
({ resolveActiveTeamIdForUser } = await import('../feature-flags.db'));
});

it('resolves the team only for an ACTIVE membership in an ACTIVE team', async () => {
prismaMock.teamMember.findFirst.mockResolvedValue({ teamId: 'team-1' });
const teamId = await resolveActiveTeamIdForUser('user-1');
expect(teamId).toBe('team-1');
expect(prismaMock.teamMember.findFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: 'user-1', status: 'ACTIVE', team: { status: 'ACTIVE' } } }),
);
});

it('returns null when there is no active membership/team', async () => {
prismaMock.teamMember.findFirst.mockResolvedValue(null);
const teamId = await resolveActiveTeamIdForUser('user-1');
expect(teamId).toBeNull();
});
});

describe('checkFeatureFlag', () => {
let checkFeatureFlag: typeof import('../feature-flags.db').checkFeatureFlag;

beforeEach(async () => {
vi.clearAllMocks();
({ checkFeatureFlag } = await import('../feature-flags.db'));
});

it('scopes to the active team and returns the flag value', async () => {
prismaMock.teamMember.findFirst.mockResolvedValue({ teamId: 'team-1' });
prismaMock.featureFlagOverride.findMany.mockResolvedValue([{ key: FLAG, enabled: true }]);
const enabled = await checkFeatureFlag({ userId: 'user-1', key: FLAG });
expect(enabled).toBe(true);
expect(prismaMock.featureFlagOverride.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { OR: [{ userId: 'user-1' }, { teamId: 'team-1' }] } }),
);
});

it('falls back to user-only scope when the membership/team is not active', async () => {
prismaMock.teamMember.findFirst.mockResolvedValue(null);
prismaMock.featureFlagOverride.findMany.mockResolvedValue([]);
await checkFeatureFlag({ userId: 'user-1', key: FLAG });
expect(prismaMock.featureFlagOverride.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { OR: [{ userId: 'user-1' }] } }),
);
});
});
51 changes: 51 additions & 0 deletions apps/api/src/app/db/feature-flags.db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { prisma } from '@jetstream/api-config';
import { DEFAULT_FEATURE_FLAGS, FeatureFlagKey, FeatureFlags, isFeatureFlagKey } from '@jetstream/types';

/**
* Resolve the effective feature flags for a user.
*
* Starts from the code-defined defaults, then applies any DB overrides scoped to the user or their
* team. Resolution is "most permissive wins": a flag is enabled if the default OR any matching
* override is enabled. Override rows whose key is no longer defined in code are ignored (drift-safe).
* Always returns the full set of known flags.
*/
export async function resolveFeatureFlagsForUser({ userId, teamId }: { userId: string; teamId?: string | null }): Promise<FeatureFlags> {
const result: FeatureFlags = { ...DEFAULT_FEATURE_FLAGS };

const overrides = await prisma.featureFlagOverride.findMany({
where: { OR: [{ userId }, ...(teamId ? [{ teamId }] : [])] },
select: { key: true, enabled: true },
});

for (const { key, enabled } of overrides) {
if (isFeatureFlagKey(key)) {
result[key] = result[key] || enabled === true;
}
}

return result;
}

/**
* Authoritative server-side check for a single flag. Use this to enforce a gate on a dedicated API
* endpoint (mirrors `checkUserEntitlement`). Most of the UI hits generic endpoints, so this is only
* useful for the rare feature with its own server route.
*/
/**
* Canonical "active team" used for flag scoping: an ACTIVE membership in an ACTIVE team. The /api/me
* profile path (`findIdByUserIdUserFacing`) applies the same rule in-memory off its already-loaded
* membership, so the flags shown in the UI and the server-side gate always agree on the team.
*/
export async function resolveActiveTeamIdForUser(userId: string): Promise<string | null> {
const membership = await prisma.teamMember.findFirst({
select: { teamId: true },
where: { userId, status: 'ACTIVE', team: { status: 'ACTIVE' } },
});
return membership?.teamId ?? null;
}

export async function checkFeatureFlag({ userId, key }: { userId: string; key: FeatureFlagKey }): Promise<boolean> {
const teamId = await resolveActiveTeamIdForUser(userId);
const flags = await resolveFeatureFlagsForUser({ userId, teamId });
return flags[key];
}
Comment on lines +29 to +51
24 changes: 16 additions & 8 deletions apps/api/src/app/db/user.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import {
SoqlQueryFormatOptions,
SoqlQueryFormatOptionsSchema,
TeamMemberRole,
TeamMemberStatusSchema,
UserProfileUi,
UserProfileUiSchema,
} from '@jetstream/types';
import { signFeatureFlags } from '../services/feature-flag-signing.service';
import { resolveFeatureFlagsForUser } from './feature-flags.db';

const FullUserFacingProfileSelect = {
id: true,
Expand Down Expand Up @@ -171,20 +174,25 @@ export const findByIdWithSubscriptions = (id: string) => {
});
};

export const findIdByUserIdUserFacing = ({
export const findIdByUserIdUserFacing = async ({
userId,
omitSubscriptions = false,
}: {
userId: string;
omitSubscriptions?: boolean;
}): Promise<UserProfileUi> => {
return prisma.user.findFirstOrThrow({ where: { id: userId }, select: UserFacingProfileSelect }).then((user) => {
// prefer team entitlements if exists, otherwise user entitlements
return UserProfileUiSchema.parse({
...user,
entitlements: user.teamMembership?.team.entitlements ?? user.entitlements,
subscriptions: omitSubscriptions ? [] : user.subscriptions,
});
const user = await prisma.user.findFirstOrThrow({ where: { id: userId }, select: UserFacingProfileSelect });
// teamMembership is already filtered to an ACTIVE team by UserFacingProfileSelect; require an ACTIVE
// membership too so team-scoped flags match the rule in resolveActiveTeamIdForUser used by checkFeatureFlag.
const teamId = user.teamMembership?.status === TeamMemberStatusSchema.enum.ACTIVE ? (user.teamMembership.team.id ?? null) : null;
const featureFlags = await resolveFeatureFlagsForUser({ userId: user.id, teamId });
// prefer team entitlements if exists, otherwise user entitlements
return UserProfileUiSchema.parse({
...user,
entitlements: user.teamMembership?.team.entitlements ?? user.entitlements,
subscriptions: omitSubscriptions ? [] : user.subscriptions,
featureFlags,
featureFlagsSignature: signFeatureFlags(user.id, featureFlags),
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ALL_FEATURE_FLAG_KEYS, FeatureFlags, serializeFeatureFlagsForSigning } from '@jetstream/types';
import { createPublicKey, verify } from 'crypto';
import { describe, expect, it, vi } from 'vitest';

vi.mock('@jetstream/api-config', () => ({
logger: { warn: vi.fn() },
ENV: { JETSTREAM_FEATURE_FLAG_PRIVATE_KEY: null, ENVIRONMENT: 'test' },
}));

// Public key matching the signing service's dev fallback private key (base64 SPKI DER).
const DEV_PUBLIC_KEY_DER_B64 =
'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEpntLvuS+SW/qo/ugLOyE+8pOqr4FHIHRYVsky3c7pw2KRzqYjyrKG8YoeA3vzHvbutKLn33ANIrVQasoxfKnGw==';

// Driven off the real registry so renaming/retiring the example flag never breaks these tests.
const FLAG = ALL_FEATURE_FLAG_KEYS[0];

function verifySignature(userId: string, flags: FeatureFlags, signature: string): boolean {
const publicKey = createPublicKey({ key: Buffer.from(DEV_PUBLIC_KEY_DER_B64, 'base64'), format: 'der', type: 'spki' });
return verify(
'sha256',
Buffer.from(serializeFeatureFlagsForSigning(userId, flags)),
{ key: publicKey, dsaEncoding: 'ieee-p1363' },
Buffer.from(signature, 'base64url'),
);
}

describe('signFeatureFlags', () => {
const userId = 'user-1';
const flags = { [FLAG]: true } as FeatureFlags;

it('produces a signature that verifies with the matching public key', async () => {
const { signFeatureFlags } = await import('../feature-flag-signing.service');
const signature = signFeatureFlags(userId, flags);
expect(verifySignature(userId, flags, signature)).toBe(true);
});

it('fails verification when the flags are tampered with', async () => {
const { signFeatureFlags } = await import('../feature-flag-signing.service');
const signature = signFeatureFlags(userId, flags);
const tamperedFlags = { [FLAG]: false } as FeatureFlags;
expect(verifySignature(userId, tamperedFlags, signature)).toBe(false);
});

it('fails verification when the userId is tampered with', async () => {
const { signFeatureFlags } = await import('../feature-flag-signing.service');
const signature = signFeatureFlags(userId, flags);
expect(verifySignature('different-user', flags, signature)).toBe(false);
});
});
44 changes: 44 additions & 0 deletions apps/api/src/app/services/feature-flag-signing.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ENV, logger } from '@jetstream/api-config';
import { FeatureFlags, serializeFeatureFlagsForSigning } from '@jetstream/types';
import { createPrivateKey, KeyObject, sign } from 'crypto';

/**
* Asymmetric (ECDSA P-256) signing of resolved feature flags.
*
* The server signs with a private key; the browser verifies with a pinned public key (it cannot
* forge a new signature). This is tamper-evidence, not tamper-proofing — a determined user can still
* edit the JS bundle — so it is intentionally lightweight.
*
* Keys are base64-encoded DER (no PEM newlines to wrangle in env). Signatures use IEEE-P1363
* (raw r||s, 64 bytes) so they verify directly with the browser WebCrypto `crypto.subtle.verify`.
*/

// Dev-only fallback. The matching public key is pinned in the client (DEV fallback for
// NX_PUBLIC_FEATURE_FLAG_PUBLIC_KEY). Set JETSTREAM_FEATURE_FLAG_PRIVATE_KEY in any deployed
// environment so signatures are produced by a key that isn't published in the repo.
const DEV_FALLBACK_PRIVATE_KEY_DER_B64 =
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgj95ded+RkY4QkmRbRsZeTmeUthsat/akgFvk52wV8pGhRANCAASme0u+5L5Jb+qj+6As7IT7yk6qvgUcgdFhWyTLdzunDYpHOpiPKsobxih4De/Me9u60ouffcA0itVBqyjF8qcb';

let cachedPrivateKey: KeyObject | null = null;

function getPrivateKey(): KeyObject {
if (cachedPrivateKey) {
return cachedPrivateKey;
}
const configured = ENV.JETSTREAM_FEATURE_FLAG_PRIVATE_KEY;
if (!configured && ENV.ENVIRONMENT === 'production') {
logger.warn('JETSTREAM_FEATURE_FLAG_PRIVATE_KEY is not set — feature flag signatures use an insecure dev fallback key');
}
cachedPrivateKey = createPrivateKey({
key: Buffer.from(configured || DEV_FALLBACK_PRIVATE_KEY_DER_B64, 'base64'),
format: 'der',
type: 'pkcs8',
});
return cachedPrivateKey;
}

/** Returns a base64url ECDSA P-256 signature over the canonical flag payload. */
export function signFeatureFlags(userId: string, flags: FeatureFlags): string {
const payload = serializeFeatureFlagsForSigning(userId, flags);
return sign('sha256', Buffer.from(payload), { key: getPrivateKey(), dsaEncoding: 'ieee-p1363' }).toString('base64url');
}
7 changes: 7 additions & 0 deletions libs/api-config/src/lib/env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ const envSchema = z.object({
.default('DEVELOPMENT_SECRET'),
),
JETSTREAM_SESSION_SECRET: z.string().min(32, { message: 'JETSTREAM_SESSION_SECRET must be at least 32 characters' }),
// Base64-encoded DER (PKCS8) ECDSA P-256 private key used to sign feature flags returned from /api/me.
// Optional: when unset the signing service falls back to an insecure dev key (warns in production).
// Generate a pair with: node -e "const c=require('crypto');const {publicKey,privateKey}=c.generateKeyPairSync('ec',{namedCurve:'P-256'});console.log('JETSTREAM_FEATURE_FLAG_PRIVATE_KEY',privateKey.export({type:'pkcs8',format:'der'}).toString('base64'));console.log('NX_PUBLIC_FEATURE_FLAG_PUBLIC_KEY',publicKey.export({type:'spki',format:'der'}).toString('base64'))"
JETSTREAM_FEATURE_FLAG_PRIVATE_KEY: z
.string()
.optional()
.transform((val) => val || null),
// SSO Configuration
JETSTREAM_SAML_SP_ENTITY_ID_PREFIX: z.string(),
JETSTREAM_SAML_ACS_PATH_PREFIX: z.string().default('/api/auth/sso/saml'),
Expand Down
Loading
Loading