c0b84595a3
## Summary The server half of hosted webhooks: the public ingress endpoint, signature verification, the delivery pipeline (Postgres partitioned storage + ClickHouse for ordering), the in-app partition manager, the HTTP API, and the dashboard (Deliveries, Endpoints, and the in-app test console). The public SDK and docs half is #4537. That PR carries the user-facing API (`webhook()`, `chat.event` / `chat.channels`, the `@trigger.dev/slack` connector) and builds on the shared `@trigger.dev/core` schemas that ship here. ## Shipping behind a flag A `WEBHOOK_ENABLED` env var (default off) gates the public ingress route and the engine worker plus partition cron, so merging and deploying this changes nothing in production until it is flipped on per environment. The dashboard is separately gated per org by the `hasWebhooksAccess` feature flag. ## Note on packages This PR includes the `@trigger.dev/core` schema additions the server compiles against, but carries no changeset. Core is not consumed independently of the SDK, so it is released together with the SDK via #4537. Keeping its changeset off `main` means no release cut from `main` publishes it early.
234 lines
11 KiB
TypeScript
234 lines
11 KiB
TypeScript
import { z } from "zod";
|
|
|
|
export const FEATURE_FLAG = {
|
|
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
|
|
taskEventRepository: "taskEventRepository",
|
|
hasQueryAccess: "hasQueryAccess",
|
|
hasLogsPageAccess: "hasLogsPageAccess",
|
|
hasWebhooksAccess: "hasWebhooksAccess",
|
|
hasAiAccess: "hasAiAccess",
|
|
hasDashboardAgentAccess: "hasDashboardAgentAccess",
|
|
dashboardAgentTurnEvalsEnabled: "dashboardAgentTurnEvalsEnabled",
|
|
promotedDashboardAgentPrompt: "promotedDashboardAgentPrompt",
|
|
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.hasWebhooksAccess]: 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(),
|
|
// Whether this org's agent turns may be sampled for the quality judge. A data-handling
|
|
// switch, not an entitlement: an org that turns it off has its turns judged never, and a
|
|
// setting that can't be read is treated as off. Per-org override wins; on by default.
|
|
// Strict z.boolean(): coercion reads the string "false" as true, which would keep judging
|
|
// an org that asked us to stop.
|
|
[FEATURE_FLAG.dashboardAgentTurnEvalsEnabled]: z.boolean(),
|
|
// A JSON string because this catalog is scalar-only. Validated where it's read, in
|
|
// `suggested-prompts/promotedPrompt.server.ts`.
|
|
[FEATURE_FLAG.promotedDashboardAgentPrompt]: z.string(),
|
|
[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;
|
|
}
|
|
|
|
/**
|
|
* Whether the org set `dashboardAgentTurnEvalsEnabled` to something the schema rejects.
|
|
* That flag is a consent switch, not an entitlement, so an unreadable override must not fall
|
|
* through to the global default the way `resolveInternalApiOriginEnabled` does: the org that
|
|
* wrote it was trying to say something, and the only safe reading of an unknown answer is no.
|
|
*/
|
|
export function hasUnreadableTurnEvalsOverride(orgFeatureFlags: unknown): boolean {
|
|
if (!orgFeatureFlags || typeof orgFeatureFlags !== "object" || Array.isArray(orgFeatureFlags)) {
|
|
return false;
|
|
}
|
|
|
|
const override = (orgFeatureFlags as Record<string, unknown>)[
|
|
FEATURE_FLAG.dashboardAgentTurnEvalsEnabled
|
|
];
|
|
if (override === undefined) return false;
|
|
|
|
return !FeatureFlagCatalog[FEATURE_FLAG.dashboardAgentTurnEvalsEnabled].safeParse(override)
|
|
.success;
|
|
}
|
|
|
|
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;
|
|
}
|