Skip to content
Draft
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
80 changes: 80 additions & 0 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,86 @@ export async function seed() {
}
}
},
featureFlags: {
// Realistic-ish flags so the dashboard has something to demo on first run of the
// internal project. The /feature-flags-demo page in examples/demo evaluates these.
flags: {
"demo-new-checkout": {
key: "new-checkout",
description: "Roll out the redesigned checkout to a slice of users",
type: "boolean",
enabled: true,
killSwitch: false,
defaultVariantKey: "off",
variants: {
on: { value: true },
off: { value: false },
},
rules: {
"ramp-25": {
priority: 10,
enabled: true,
rolloutPercentage: 25,
rolloutSeed: "new-checkout-2026-04",
variantKey: "on",
},
},
},
"demo-pricing-experiment": {
key: "pricing-experiment",
description: "A/B test for the pricing page hero",
type: "multivariate",
enabled: true,
killSwitch: false,
defaultVariantKey: "control",
variants: {
control: { value: "control" },
"treatment-a": { value: "treatment-a" },
"treatment-b": { value: "treatment-b" },
},
rules: {
"all-traffic": {
priority: 0,
enabled: true,
rolloutPercentage: 100,
variantWeights: {
control: 0.5,
"treatment-a": 0.25,
"treatment-b": 0.25,
},
},
},
},
"demo-internal-tools": {
key: "internal-tools",
description: "Show internal tooling to @stack-auth.com addresses only",
type: "boolean",
enabled: true,
killSwitch: false,
defaultVariantKey: "off",
variants: {
on: { value: true },
off: { value: false },
},
rules: {
"internal-emails": {
priority: 100,
enabled: true,
rolloutPercentage: 100,
variantKey: "on",
conditions: {
"is-employee": {
attribute: "user.email",
operator: "regex",
value: "@stack-auth\\.com$",
},
},
},
},
},
},
holdouts: {},
},
payments: {
productLines: {
plans: {
Expand Down
114 changes: 114 additions & 0 deletions apps/backend/src/app/api/latest/feature-flags/bootstrap/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import type { SmartResponse } from "@/route-handlers/smart-response";
import { _internal as hashingInternal } from "@stackframe/stack-shared/dist/feature-flags/hashing";
import type { FeatureFlagsConfig, FlagDef } from "@stackframe/stack-shared/dist/feature-flags/types";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
import type { Schema } from "yup";

function deepSortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(deepSortKeys);
if (value == null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value)
.sort(([a], [b]) => stringCompare(a, b))
.map(([key, nestedValue]) => [key, deepSortKeys(nestedValue)]),
);
}

export const GET = createSmartRouteHandler({
metadata: {
Comment thread
mantrakp04 marked this conversation as resolved.
hidden: true,
summary: "Bootstrap feature flag definitions for SDK local evaluation",
description: "Returns the full set of feature flag definitions for the resolved tenancy. Clients cache the payload and evaluate locally; the server's evaluator and the SDK's evaluator are byte-identical.",
tags: ["Feature Flags"],
},
request: yupObject({
auth: yupObject({
type: clientOrHigherAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
headers: yupObject({
"if-none-match": yupArray(yupString().defined()).optional(),
}).defined(),
method: yupString().oneOf(["GET"]).defined(),
}),
response: yupUnion(
yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["binary"]).defined(),
body: yupMixed<Uint8Array>().defined(),
headers: yupObject({
"content-type": yupArray(yupString().defined()).defined(),
etag: yupArray(yupString().defined()).defined(),
}).defined(),
}).defined(),
yupObject({
statusCode: yupNumber().oneOf([304]).defined(),
bodyType: yupString().oneOf(["empty"]).defined(),
headers: yupObject({
etag: yupArray(yupString().defined()).defined(),
}).defined(),
}).defined(),
) as unknown as Schema<SmartResponse>,
handler: async ({ auth, headers }) => {
const config: FeatureFlagsConfig = auth.tenancy.config.featureFlags;
const flagsById: Record<string, Omit<FlagDef, "ownerUserId">> = {};
const flagIdsByKey: Record<string, string> = {};
for (const [id, def] of Object.entries(config.flags ?? {})) {
if (!def?.key) continue;
// ownerUserId is operator-facing metadata, never needed for evaluation. Strip it from the
// bootstrap payload so we don't leak admin user ids to client SDKs.
const { ownerUserId: _ownerUserId, ...rest } = def;
flagsById[id] = rest;
flagIdsByKey[def.key] = id;
}

const holdouts = config.holdouts ?? {};
// Stable content-addressed version: SDKs hit this endpoint with `If-None-Match: <version>` and
// we (eventually) 304 when nothing changed. Using murmur3 keeps this fast enough to recompute
// per request without caching.
const versionPayload = deepSortKeys({ flags: flagsById, flagIdsByKey, holdouts });
const version = hashingInternal.murmur3_32(JSON.stringify(versionPayload)).toString(16);
const etag = `"${version}"`;

Comment thread
mantrakp04 marked this conversation as resolved.
const ifNoneMatchTags = parseIfNoneMatch(headers["if-none-match"] ?? []);
if (ifNoneMatchTags.has("*") || ifNoneMatchTags.has(etag) || ifNoneMatchTags.has(version)) {
const responseHeaders: Record<string, string[]> = {
etag: [etag],
};
return {
statusCode: 304,
bodyType: "empty",
headers: responseHeaders,
};
}

const body = {
flags: flagsById,
flag_ids_by_key: flagIdsByKey,
holdouts,
version,
};
const responseHeaders: Record<string, string[]> = {
"content-type": ["application/json; charset=utf-8"],
etag: [etag],
};
return {
statusCode: 200,
bodyType: "binary",
body: new TextEncoder().encode(JSON.stringify(body)),
headers: responseHeaders,
};
},
});

function parseIfNoneMatch(values: string[]) {
return new Set(values.flatMap((value) => (
value
.split(",")
.map((tag) => tag.trim())
.map((tag) => tag.startsWith("W/") ? tag.slice(2).trim() : tag)
.map((tag) => tag.startsWith("\"") && tag.endsWith("\"") ? tag.slice(1, -1) : tag)
)));
}
106 changes: 106 additions & 0 deletions apps/backend/src/app/api/latest/feature-flags/evaluate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { evaluateFlag, evaluateFlags, findFlagIdByKey } from "@stackframe/stack-shared/dist/feature-flags/evaluator";
import type { EvalContext, EvalResult, FeatureFlagsConfig } from "@stackframe/stack-shared/dist/feature-flags/types";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";

const evalResultSchema = yupObject({
flag_key: yupString().defined(),
variant_key: yupString().nullable().defined(),
value: yupMixed(),
reason: yupString().defined(),
rule_id: yupString().nullable().defined(),
});

export const POST = createSmartRouteHandler({
metadata: {
summary: "Evaluate feature flags for a given context",
description: "Resolves feature flag variants for a user/team/anonymous context. Definitions are read from the project's branch+environment config; evaluation is deterministic and matches the SDK's local evaluator byte-for-byte.",
tags: ["Feature Flags"],
},
request: yupObject({
auth: yupObject({
type: clientOrHigherAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
user: adaptSchema.optional(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
body: yupObject({
// Caller-provided distinct identifier for sticky bucketing. If omitted we fall back to
// auth.user.id so authenticated callers get a stable bucket without any extra wiring.
distinct_id: yupString().optional(),
user_id: yupString().optional(),
team_id: yupString().optional(),
user: yupRecord(yupString(), yupMixed()).optional(),
team: yupRecord(yupString(), yupMixed()).optional(),
context: yupRecord(yupString(), yupMixed()).optional(),
cohorts: yupRecord(yupString(), yupBoolean().defined()).optional(),
// Subset of flag keys to evaluate. If omitted, every flag in the tenancy config is evaluated.
flag_keys: yupArray(yupString().defined()).optional(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
results: yupRecord(yupString(), evalResultSchema).defined(),
}).defined(),
}),
handler: async ({ auth, body }) => {
const config: FeatureFlagsConfig = auth.tenancy.config.featureFlags;

const callerCanSupplyTargetingContext = auth.type !== "client";
const verifiedPrimaryEmail = auth.user?.primary_email_verified ? auth.user.primary_email ?? undefined : undefined;
const evalContext: EvalContext = callerCanSupplyTargetingContext ? {
distinctId: body.distinct_id ?? body.user_id ?? auth.user?.id,
userId: body.user_id ?? auth.user?.id,
teamId: body.team_id,
user: body.user,
team: body.team,
context: body.context,
cohorts: body.cohorts,
} : {
distinctId: body.distinct_id ?? auth.user?.id,
userId: auth.user?.id,
user: auth.user ? {
id: auth.user.id,
primary_email: verifiedPrimaryEmail,
primary_email_verified: auth.user.primary_email_verified,
email: verifiedPrimaryEmail,
} : undefined,
};
Comment thread
mantrakp04 marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const results = new Map<string, ReturnType<typeof shape>>();
if (body.flag_keys) {
for (const requestedKey of body.flag_keys) {
const flagId = findFlagIdByKey(config, requestedKey);
const result = flagId === undefined
? { flagKey: requestedKey, variantKey: undefined, value: undefined, reason: "missing" } satisfies EvalResult
: evaluateFlag(flagId, config, evalContext);
results.set(requestedKey, shape(requestedKey, result));
}
} else {
const evaluated = evaluateFlags(config, evalContext);
for (const [id, result] of Object.entries(evaluated)) {
const flagDef = config.flags?.[id];
const userKey = flagDef?.key ?? id;
results.set(userKey, shape(userKey, result));
}
}

return {
statusCode: 200,
bodyType: "json",
body: { results: Object.fromEntries(results) },
};
},
});

function shape(flagKey: string, result: EvalResult) {
return {
flag_key: flagKey,
variant_key: result.variantKey ?? null,
value: result.value,
reason: result.reason,
rule_id: result.ruleId ?? null,
};
}
Loading