Files
triggerdotdev--trigger.dev/apps/webapp/app/v3/featureFlags.ts
Chris Arderne 9409ddf9bc feat(webapp): add multiple environment API key management (#4390)
## Summary

Projects can create, inspect, expire, and revoke multiple API keys for
each environment. Plaintext values are shown only at creation; stored
credentials are hashed and the API keys page displays only an obfuscated
suffix afterward.

Self-hosted installations support full-access additional keys by
default. Authorization extensions can provide additional access presets
and optional task selection. Additional keys can also mint scoped public
access tokens through the Trigger.dev API without receiving the
environment signing key.

## Feature notes
- Only admin+ can create API keys (Developer can make in Development
branch).
- JWT self-signing will be a server call when used with new `_ak_` keys.
- JWTs with long expiry can keep working even with api key deleted (gets
priveleges from api key, signed with root key)
- Unfiltered session listings intentionally preserve the existing broad
task-read behavior. Filtered listings enforce task-level scopes for
every requested task.
- Buffered runs without a task identifier are not safely authorizable,
so cancel/replay requests fail closed rather than resolving an unscoped
run.
- Batch and waitpoint endpoints intentionally return server-minted,
narrowly scoped public tokens to all callers. These tokens have bounded
lifetimes and may remain valid until expiry after API-key revocation.

## Deployment notes

Deploy the management UI and public-token endpoint with new key creation
disabled. Enable creation for selected organizations after the
authentication path and released SDK have been verified, then expand
availability gradually.

Revoking an API key prevents new bearer requests and new token minting.
Public tokens already minted by that key remain valid until their own
expiration because they are signed by the environment signing key.

## TODO
- [x] Add "Created by" to the key table
- [x] Document that streamed batch ingestion is non-atomic and may
 partially accept items before a validation or authorization error.

## Follow-ups

- [x] Add an organization-level feature flag for the API key management
UI and creation action.
- [x] Document rollout ordering: enable additional-key lookup before
enabling issuance.
- [x] Add a system-wide gate that can stop new key issuance without
disabling authentication for existing keys.
- [x] Replace the generic SDK compatibility warning with the first
published compatible version. Old SDK will mint an unusable token if
given an `_ak_` key.
- [x] Add public documentation covering creation, storage, expiration,
revocation, SDK compatibility, and public-token lifetime behavior.
- [x] Add observability for key creation, revocation, policy preparation
failures, and public-token mint failures.
- [ ] Exercise create, copy-once display, authenticate, mint, expire,
and revoke flows end to end before broad enablement.
2026-08-06 15:27:10 +01:00

201 lines
9.0 KiB
TypeScript

import { z } from "zod";
export const FEATURE_FLAG = {
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
taskEventRepository: "taskEventRepository",
hasQueryAccess: "hasQueryAccess",
hasLogsPageAccess: "hasLogsPageAccess",
hasAiAccess: "hasAiAccess",
hasDashboardAgentAccess: "hasDashboardAgentAccess",
hasComputeAccess: "hasComputeAccess",
hasPrivateConnections: "hasPrivateConnections",
hasSso: "hasSso",
hasThemeSwitcher: "hasThemeSwitcher",
mollifierEnabled: "mollifierEnabled",
workerQueueScheduledSplitEnabled: "workerQueueScheduledSplitEnabled",
internalApiOriginEnabled: "internalApiOriginEnabled",
realtimeBackend: "realtimeBackend",
computeMigrationEnabled: "computeMigrationEnabled",
computeMigrationFreePercentage: "computeMigrationFreePercentage",
computeMigrationPaidPercentage: "computeMigrationPaidPercentage",
computeMigrationRequireTemplate: "computeMigrationRequireTemplate",
devBranchesEnabled: "devBranchesEnabled",
runOpsMintKind: "runOpsMintKind",
// Grace-linger stamp carried alongside runOpsMintKind on flip. See mintFlipGrace.ts.
runOpsMintKindPrev: "runOpsMintKindPrev",
runOpsMintKindFlippedAt: "runOpsMintKindFlippedAt",
queueMetricsUiEnabled: "queueMetricsUiEnabled",
// Per-organization rollout for creating additional environment API keys.
additionalApiKeysEnabled: "additionalApiKeysEnabled",
// System-wide kill switch for issuing additional environment API keys.
additionalApiKeyIssuanceEnabled: "additionalApiKeyIssuanceEnabled",
// System-wide kill switch for additional (scoped) environment API-key lookup.
// Defaults off; enable during rollout once the new lookup path is trusted.
additionalApiKeyLookupEnabled: "additionalApiKeyLookupEnabled",
} as const;
export const FeatureFlagCatalog = {
[FEATURE_FLAG.defaultWorkerInstanceGroupId]: z.string(),
[FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "clickhouse_v2", "postgres"]),
[FEATURE_FLAG.hasQueryAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasLogsPageAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(),
// Gates the in-dashboard AI agent panel. Controllable globally and per-org
// (org wins). Defaults off via DASHBOARD_AGENT_ENABLED.
[FEATURE_FLAG.hasDashboardAgentAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(),
[FEATURE_FLAG.hasSso]: z.coerce.boolean(),
// Gates the Interface theme setting in /account. Off by default.
[FEATURE_FLAG.hasThemeSwitcher]: z.coerce.boolean(),
[FEATURE_FLAG.mollifierEnabled]: z.coerce.boolean(),
[FEATURE_FLAG.workerQueueScheduledSplitEnabled]: z.coerce.boolean(),
// Routes deployed runs' TRIGGER_API_URL to INTERNAL_API_ORIGIN. Per-org, with
// INTERNAL_API_ORIGIN_ENABLED as the global default (org wins). No-op unless
// INTERNAL_API_ORIGIN is set.
// Strict z.boolean(): coercion turns the string "false" into true, which
// would silently enable the wrong orgs if written as a string.
[FEATURE_FLAG.internalApiOriginEnabled]: z.boolean(),
// Which backend serves the realtime run feed. Controllable
// globally and per-org (org wins). Defaults to "electric" when unset.
// "shadow" serves Electric but diffs the native path in the background.
[FEATURE_FLAG.realtimeBackend]: z.enum(["electric", "native", "shadow"]),
// Strict z.boolean() (not z.coerce.boolean()): coercion turns the string "false"
// into true, which would silently flip this kill switch / per-org exclude the wrong
// way if written as a string via the admin PAT route. The admin toggle sends a real
// boolean, so this only rejects the dangerous stringified case.
[FEATURE_FLAG.computeMigrationEnabled]: z.boolean(),
[FEATURE_FLAG.computeMigrationFreePercentage]: z.coerce.number().int().min(0).max(100),
[FEATURE_FLAG.computeMigrationPaidPercentage]: z.coerce.number().int().min(0).max(100),
// When on, migrated orgs build their compute template in required mode at deploy
// (fails the deploy on error) instead of shadow. Strict boolean (see above).
[FEATURE_FLAG.computeMigrationRequireTemplate]: z.boolean(),
// Per-org access to development branches. Off unless enabled for the org.
[FEATURE_FLAG.devBranchesEnabled]: z.coerce.boolean(),
// Per-org run-ops-id mint cutover. Defaults to "cuid"; only honored when
// RUN_OPS_MINT_ENABLED is on AND isSplitEnabled() is true.
[FEATURE_FLAG.runOpsMintKind]: z.enum(["cuid", "runOpsId"]),
// Grace-linger stamp: the previously-effective kind and the flip timestamp, written
// by stampMintKindFlip on a genuine flip. Display-only (see ORG_LOCKED_FLAGS).
[FEATURE_FLAG.runOpsMintKindPrev]: z.enum(["cuid", "runOpsId"]),
[FEATURE_FLAG.runOpsMintKindFlippedAt]: z.string().datetime(),
// Per-org access to the Queue Metrics dashboard UI (view only; emission is global and
// separate). Off unless enabled for the org.
[FEATURE_FLAG.queueMetricsUiEnabled]: z.coerce.boolean(),
// Strict booleans prevent a stringified "false" from silently enabling API-key
// creation or lookup. Cold/absent values resolve to the safe `false`.
[FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(),
[FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: z.boolean(),
[FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(),
};
export type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
// Infrastructure flags that are read-only on the global flags page.
// Shown with current/resolved value but no controls.
export const GLOBAL_LOCKED_FLAGS: FeatureFlagKey[] = [
FEATURE_FLAG.defaultWorkerInstanceGroupId,
FEATURE_FLAG.taskEventRepository,
];
// Flags that are read-only on the org-level dialog.
// Shown with global value but no controls (org can't override these).
export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [
FEATURE_FLAG.defaultWorkerInstanceGroupId,
FEATURE_FLAG.taskEventRepository,
FEATURE_FLAG.runOpsMintKindPrev,
FEATURE_FLAG.runOpsMintKindFlippedAt,
// System-wide only — orgs must not be able to override these kill switches.
FEATURE_FLAG.additionalApiKeyIssuanceEnabled,
FEATURE_FLAG.additionalApiKeyLookupEnabled,
];
// Create a Zod schema from the existing catalog
export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog);
export type FeatureFlagCatalog = z.infer<typeof FeatureFlagCatalogSchema>;
// Utility function to validate a feature flag value
export function validateFeatureFlagValue<T extends FeatureFlagKey>(
key: T,
value: unknown
): z.SafeParseReturnType<unknown, z.infer<(typeof FeatureFlagCatalog)[T]>> {
return FeatureFlagCatalog[key].safeParse(value);
}
// Utility function to validate all feature flags at once
export function validateAllFeatureFlags(values: Record<string, unknown>) {
return FeatureFlagCatalogSchema.safeParse(values);
}
// Utility function to validate partial feature flags (all keys optional)
export function validatePartialFeatureFlags(values: Record<string, unknown>) {
return FeatureFlagCatalogSchema.partial().safeParse(values);
}
// Utility types for catalog-driven UI rendering
/**
* Resolve whether deployed runs should use the internal API origin, from the
* org's feature-flags JSON. Precedence: a per-org override wins in BOTH
* directions; the global default applies only when the org has not set the
* flag (or set it to something invalid).
*/
export function resolveInternalApiOriginEnabled({
orgFeatureFlags,
globalDefault,
}: {
orgFeatureFlags: unknown;
globalDefault: boolean;
}): boolean {
const override =
orgFeatureFlags && typeof orgFeatureFlags === "object" && !Array.isArray(orgFeatureFlags)
? (orgFeatureFlags as Record<string, unknown>)[FEATURE_FLAG.internalApiOriginEnabled]
: undefined;
if (override !== undefined) {
const parsed = FeatureFlagCatalog[FEATURE_FLAG.internalApiOriginEnabled].safeParse(override);
if (parsed.success) {
return parsed.data;
}
}
return globalDefault;
}
export type FlagControlType =
| { type: "boolean" }
| { type: "enum"; options: string[] }
| { type: "number"; min?: number; max?: number }
| { type: "string" };
export function getFlagControlType(schema: z.ZodTypeAny): FlagControlType {
const typeName = schema._def.typeName;
if (typeName === "ZodBoolean") {
return { type: "boolean" };
}
if (typeName === "ZodEnum") {
return { type: "enum", options: schema._def.values as string[] };
}
// z.coerce.number() reports as ZodNumber; pull min/max out of its checks
// so the UI can render a constrained number input instead of free text.
if (typeName === "ZodNumber") {
const checks = (schema._def.checks ?? []) as Array<{ kind: string; value?: number }>;
const min = checks.find((c) => c.kind === "min")?.value;
const max = checks.find((c) => c.kind === "max")?.value;
return { type: "number", min, max };
}
return { type: "string" };
}
export function getAllFlagControlTypes(): Record<string, FlagControlType> {
const result: Record<string, FlagControlType> = {};
for (const [key, schema] of Object.entries(FeatureFlagCatalog)) {
result[key] = getFlagControlType(schema);
}
return result;
}