From 304f35c38fcd6f35dba859ea5c6467863d01944a Mon Sep 17 00:00:00 2001 From: Gui Bibeau Date: Thu, 23 Jul 2026 13:39:44 -0400 Subject: [PATCH 1/2] feat(web): move product rollouts to Vercel Flags --- apps/sdp-api/src/lib/feature-flags.test.ts | 15 ++++ apps/sdp-api/src/lib/feature-flags.ts | 11 ++- .../src/routes/asset-profiles/index.ts | 4 +- apps/sdp-api/src/types/env.d.ts | 2 +- apps/sdp-web/.env.local.example | 7 -- apps/sdp-web/Dockerfile | 2 - .../playwright/tests/issuance.e2e.spec.ts | 2 +- .../src/app/.well-known/vercel/flags/route.ts | 5 ++ .../dashboard/issuance/(overview)/page.tsx | 9 +-- .../app/dashboard/issuance/[tokenId]/page.tsx | 6 +- .../app/dashboard/issuance/create/page.tsx | 4 +- .../issuance/issuance-page-skeleton.tsx | 9 ++- .../dashboard/issuance/issuance-workspace.tsx | 8 +- apps/sdp-web/src/app/dashboard/layout.tsx | 16 +++- .../src/app/dashboard/onboarding/page.tsx | 9 ++- .../src/components/dashboard-shell.tsx | 21 ++++-- apps/sdp-web/src/flags.ts | 61 +++++++++++++++- .../sdp-web/src/lib/asset-profiles-feature.ts | 29 -------- .../lib/asset-profiles-feature.unit.test.ts | 73 ------------------- apps/sdp-web/src/lib/feature-flag-defaults.ts | 36 +++++++++ .../lib/feature-flag-defaults.unit.test.ts | 66 +++++++++++++++++ docker-compose.yml | 1 - infra/self-hosted/compose.yml | 1 - turbo.json | 3 +- 24 files changed, 250 insertions(+), 150 deletions(-) create mode 100644 apps/sdp-web/src/app/.well-known/vercel/flags/route.ts delete mode 100644 apps/sdp-web/src/lib/asset-profiles-feature.ts delete mode 100644 apps/sdp-web/src/lib/asset-profiles-feature.unit.test.ts create mode 100644 apps/sdp-web/src/lib/feature-flag-defaults.ts create mode 100644 apps/sdp-web/src/lib/feature-flag-defaults.unit.test.ts diff --git a/apps/sdp-api/src/lib/feature-flags.test.ts b/apps/sdp-api/src/lib/feature-flags.test.ts index bec843b74..85bebd68a 100644 --- a/apps/sdp-api/src/lib/feature-flags.test.ts +++ b/apps/sdp-api/src/lib/feature-flags.test.ts @@ -2,6 +2,18 @@ import { describe, expect, it } from "vitest"; import { isAssetProfilesEnabled } from "./feature-flags"; describe("isAssetProfilesEnabled", () => { + it.each([ + undefined, + "managed", + ] as const)("keeps the managed API capability available when deployment mode is %s", (deploymentMode) => { + expect( + isAssetProfilesEnabled({ + ENVIRONMENT: "production", + SDP_DEPLOYMENT_MODE: deploymentMode, + }) + ).toBe(true); + }); + it.each([ undefined, "", @@ -13,6 +25,7 @@ describe("isAssetProfilesEnabled", () => { isAssetProfilesEnabled({ ENVIRONMENT: "development", ASSET_PROFILES_ENABLED: flag, + SDP_DEPLOYMENT_MODE: "self_hosted", }) ).toBe(true); }); @@ -28,6 +41,7 @@ describe("isAssetProfilesEnabled", () => { isAssetProfilesEnabled({ ENVIRONMENT: "production", ASSET_PROFILES_ENABLED: flag, + SDP_DEPLOYMENT_MODE: "self_hosted", }) ).toBe(false); }); @@ -37,6 +51,7 @@ describe("isAssetProfilesEnabled", () => { isAssetProfilesEnabled({ ENVIRONMENT: "production", ASSET_PROFILES_ENABLED: flag, + SDP_DEPLOYMENT_MODE: "self_hosted", }) ).toBe(true); }); diff --git a/apps/sdp-api/src/lib/feature-flags.ts b/apps/sdp-api/src/lib/feature-flags.ts index d52591899..95fff15ae 100644 --- a/apps/sdp-api/src/lib/feature-flags.ts +++ b/apps/sdp-api/src/lib/feature-flags.ts @@ -1,4 +1,5 @@ import type { Env } from "@/types/env"; +import { isSelfHostedDeployment } from "./runtime-env"; function isTruthyFlag(value: string | undefined): boolean { if (!value) return false; @@ -12,7 +13,15 @@ export function isRecurringPaymentCollectionEnabled( } export function isAssetProfilesEnabled( - env: Pick + env: Pick ): boolean { + // Managed SDP rolls out the UI through Vercel's `asset-profiles` flag. Keep + // the authenticated API capability available so Cloud Run configuration + // cannot drift from the web rollout. Self-hosted operators retain their + // explicit environment opt-in because they do not depend on Vercel. + if (!isSelfHostedDeployment(env)) { + return true; + } + return env.ENVIRONMENT === "development" || isTruthyFlag(env.ASSET_PROFILES_ENABLED); } diff --git a/apps/sdp-api/src/routes/asset-profiles/index.ts b/apps/sdp-api/src/routes/asset-profiles/index.ts index 4f9cbce67..70571d532 100644 --- a/apps/sdp-api/src/routes/asset-profiles/index.ts +++ b/apps/sdp-api/src/routes/asset-profiles/index.ts @@ -16,8 +16,8 @@ import { const assetProfiles = new Hono<{ Bindings: Env }>(); -// Non-production environments always expose Asset Profiles. Production keeps -// the explicit feature flag so rollout remains independently controlled. +// Managed SDP exposes the authenticated API capability while Vercel controls +// the UI rollout. Self-hosted production retains an explicit environment opt-in. async function requireAssetProfilesFeature(c: Context<{ Bindings: Env }>, next: Next) { if (!isAssetProfilesEnabled(c.env)) { throw new AppError("FORBIDDEN", "Asset Profiles are not enabled for this environment"); diff --git a/apps/sdp-api/src/types/env.d.ts b/apps/sdp-api/src/types/env.d.ts index a28783b0a..ecefa14a0 100644 --- a/apps/sdp-api/src/types/env.d.ts +++ b/apps/sdp-api/src/types/env.d.ts @@ -187,7 +187,7 @@ export interface Env { PAYMENTS_RECURRING_COLLECTION_BATCH_SIZE?: string; PAYMENTS_RECURRING_COLLECTION_RETRY_AFTER_MINUTES?: string; - // Asset Profiles production opt-in; development is always enabled. + // Self-hosted Asset Profiles production opt-in; managed rollout uses Vercel. ASSET_PROFILES_ENABLED?: string; // Compliance providers diff --git a/apps/sdp-web/.env.local.example b/apps/sdp-web/.env.local.example index cc65f86ee..46392aac5 100644 --- a/apps/sdp-web/.env.local.example +++ b/apps/sdp-web/.env.local.example @@ -22,13 +22,6 @@ NEXT_PUBLIC_SDP_API_BASE_URL=http://127.0.0.1:8787 # Docs site URL for in-dashboard documentation links (optional) # NEXT_PUBLIC_SDP_DOCS_URL=http://localhost:3001 -# Feature flags (optional). -# Asset Profiles is enabled automatically in local development and Vercel -# previews. Keep this explicit for production-mode local/Docker builds. -NEXT_PUBLIC_ASSET_PROFILES_ENABLED=true -# Recurring collection UI in the payments wizards. -NEXT_PUBLIC_PAYMENTS_RECURRING_COLLECTION_ENABLED= - # Error tracking (optional) NEXT_PUBLIC_SENTRY_DSN= SENTRY_AUTH_TOKEN= diff --git a/apps/sdp-web/Dockerfile b/apps/sdp-web/Dockerfile index a71b21699..f2888d698 100644 --- a/apps/sdp-web/Dockerfile +++ b/apps/sdp-web/Dockerfile @@ -67,7 +67,6 @@ ARG NEXT_PUBLIC_SDP_API_BASE_URL=__SDP_RT_NEXT_PUBLIC_SDP_API_BASE_URL__ ARG NEXT_PUBLIC_SDP_DOCS_URL=__SDP_RT_NEXT_PUBLIC_SDP_DOCS_URL__ ARG NEXT_PUBLIC_SOLANA_NETWORK=__SDP_RT_NEXT_PUBLIC_SOLANA_NETWORK__ ARG NEXT_PUBLIC_SDP_ENVIRONMENT=__SDP_RT_NEXT_PUBLIC_SDP_ENVIRONMENT__ -ARG NEXT_PUBLIC_ASSET_PROFILES_ENABLED=__SDP_RT_NEXT_PUBLIC_ASSET_PROFILES_ENABLED__ ARG NEXT_PUBLIC_SENTRY_DSN ARG NEXT_PUBLIC_ENABLE_NETWORK_DEBUG ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=__SDP_RT_NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY__ @@ -81,7 +80,6 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ NEXT_PUBLIC_SDP_DOCS_URL=$NEXT_PUBLIC_SDP_DOCS_URL \ NEXT_PUBLIC_SOLANA_NETWORK=$NEXT_PUBLIC_SOLANA_NETWORK \ NEXT_PUBLIC_SDP_ENVIRONMENT=$NEXT_PUBLIC_SDP_ENVIRONMENT \ - NEXT_PUBLIC_ASSET_PROFILES_ENABLED=$NEXT_PUBLIC_ASSET_PROFILES_ENABLED \ NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN \ NEXT_PUBLIC_ENABLE_NETWORK_DEBUG=$NEXT_PUBLIC_ENABLE_NETWORK_DEBUG \ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ diff --git a/apps/sdp-web/playwright/tests/issuance.e2e.spec.ts b/apps/sdp-web/playwright/tests/issuance.e2e.spec.ts index 3a714d4c0..f9fda009b 100644 --- a/apps/sdp-web/playwright/tests/issuance.e2e.spec.ts +++ b/apps/sdp-web/playwright/tests/issuance.e2e.spec.ts @@ -221,7 +221,7 @@ interface CreateDraftOptions { } // Creating a draft goes through one of two UIs depending on the -// NEXT_PUBLIC_ASSET_PROFILES_ENABLED flag: the full-page wizard (flag on) or the +// `asset-profiles` Vercel flag: the full-page wizard (flag on) or the // legacy modal (flag off). Detect which one the "Create draft" button opened — // the wizard navigates to its own route, the modal stays on the overview — and // drive whichever renders, so this passes under either flag value. diff --git a/apps/sdp-web/src/app/.well-known/vercel/flags/route.ts b/apps/sdp-web/src/app/.well-known/vercel/flags/route.ts new file mode 100644 index 000000000..53c96d4d3 --- /dev/null +++ b/apps/sdp-web/src/app/.well-known/vercel/flags/route.ts @@ -0,0 +1,5 @@ +import { getProviderData } from "@flags-sdk/vercel"; +import { createFlagsDiscoveryEndpoint } from "flags/next"; +import * as flags from "@/flags"; + +export const GET = createFlagsDiscoveryEndpoint(async () => getProviderData(flags)); diff --git a/apps/sdp-web/src/app/dashboard/issuance/(overview)/page.tsx b/apps/sdp-web/src/app/dashboard/issuance/(overview)/page.tsx index f7469b724..fa9d28087 100644 --- a/apps/sdp-web/src/app/dashboard/issuance/(overview)/page.tsx +++ b/apps/sdp-web/src/app/dashboard/issuance/(overview)/page.tsx @@ -1,8 +1,8 @@ import { auth } from "@clerk/nextjs/server"; import type { AssetCategory, IssuanceMetadata } from "@sdp/types"; import { redirect } from "next/navigation"; +import { assetProfiles } from "@/flags"; import { getTranslations } from "@/i18n/server"; -import { isAssetProfilesUiEnabled } from "@/lib/asset-profiles-feature"; import { getAuthEntryPath } from "@/lib/auth-entry"; import { createTimedTrace } from "@/lib/request-tracing"; import { createSdpApiClient, type SdpApiClient } from "@/lib/sdp-api"; @@ -254,10 +254,11 @@ interface IssuancePageProps { } export default async function IssuancePage({ searchParams }: IssuancePageProps) { - const [t, { userId, orgId }, resolvedSearchParams] = await Promise.all([ + const [t, { userId, orgId }, resolvedSearchParams, assetProfilesEnabled] = await Promise.all([ getTranslations(), auth(), searchParams ?? Promise.resolve(undefined), + assetProfiles(), ]); if (!userId) { redirect(await getAuthEntryPath()); @@ -278,9 +279,6 @@ export default async function IssuancePage({ searchParams }: IssuancePageProps) const apiClient = await trace.step("create_sdp_api_client", () => createSdpApiClient(trace.childContext("dashboard.issuance.api")) ); - // Asset profiles only drive the flag-on workspace; skip the fetch entirely - // when it's off so the legacy list renders from token fields alone. - const assetProfilesEnabled = isAssetProfilesUiEnabled(); const [ templatesResult, tokensResult, @@ -323,6 +321,7 @@ export default async function IssuancePage({ searchParams }: IssuancePageProps) return ( fetchData( diff --git a/apps/sdp-web/src/app/dashboard/issuance/create/page.tsx b/apps/sdp-web/src/app/dashboard/issuance/create/page.tsx index afa9890d0..93df908b1 100644 --- a/apps/sdp-web/src/app/dashboard/issuance/create/page.tsx +++ b/apps/sdp-web/src/app/dashboard/issuance/create/page.tsx @@ -1,15 +1,15 @@ import { auth } from "@clerk/nextjs/server"; import type { PaymentsDashboardWallet } from "@sdp/types"; import { notFound, redirect } from "next/navigation"; +import { assetProfiles } from "@/flags"; import { getTranslations } from "@/i18n/server"; -import { isAssetProfilesUiEnabled } from "@/lib/asset-profiles-feature"; import { getAuthEntryPath } from "@/lib/auth-entry"; import { createSdpApiClient } from "@/lib/sdp-api"; import { fetchPaymentsWallets } from "../../payments/payments-page.data"; import { IssuanceDraftWizard } from "./issuance-draft-wizard"; export default async function CreateAssetPage() { - if (!isAssetProfilesUiEnabled()) { + if (!(await assetProfiles())) { notFound(); } diff --git a/apps/sdp-web/src/app/dashboard/issuance/issuance-page-skeleton.tsx b/apps/sdp-web/src/app/dashboard/issuance/issuance-page-skeleton.tsx index 81eaa9557..110461f07 100644 --- a/apps/sdp-web/src/app/dashboard/issuance/issuance-page-skeleton.tsx +++ b/apps/sdp-web/src/app/dashboard/issuance/issuance-page-skeleton.tsx @@ -1,6 +1,5 @@ import { DashboardWorkspaceOverviewPanel } from "@/components/dashboard-workspace-panel"; import { SkeletonBlock } from "@/components/ui/skeleton-block"; -import { isAssetProfilesUiEnabled } from "@/lib/asset-profiles-feature"; const ISSUANCE_SKELETON_IDS = [ "issuance-skeleton-1", @@ -109,10 +108,14 @@ function LegacyIssuanceTokenCardSkeleton() { ); } -export function IssuancePageSkeleton() { +export function IssuancePageSkeleton({ + assetProfilesEnabled = false, +}: { + assetProfilesEnabled?: boolean; +}) { // Legacy list skeleton when the Asset Profiles UI flag is off, so the loading // state matches the old grid instead of flashing the new one. - if (!isAssetProfilesUiEnabled()) { + if (!assetProfilesEnabled) { return ( { - if (assetProfilesUiEnabled) { + if (assetProfilesEnabled) { router.push(CREATE_DRAFT_PATH); return; } @@ -211,7 +211,7 @@ export function IssuanceWorkspace({ // Legacy overview when the Asset Profiles UI flag is off: the old card grid // with no classification chips, filters, view toggle, or kebab — just search, // a Type/Supply/Created stat box, and a Manage link per token. - if (!assetProfilesUiEnabled) { + if (!assetProfilesEnabled) { const needle = search.trim().toLowerCase(); const legacyFilteredTokens = needle ? tokens.filter( diff --git a/apps/sdp-web/src/app/dashboard/layout.tsx b/apps/sdp-web/src/app/dashboard/layout.tsx index ce0b7f7d0..7f418a02f 100644 --- a/apps/sdp-web/src/app/dashboard/layout.tsx +++ b/apps/sdp-web/src/app/dashboard/layout.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { DashboardShell } from "@/components/dashboard-shell"; import { DashboardWorkspaceProvider } from "@/contexts/dashboard-workspace-context"; import { NetworkDebugProvider } from "@/contexts/network-debug-context"; +import { assetProfiles, organizationOnboarding } from "@/flags"; import { getAuthEntryPath } from "@/lib/auth-entry"; import { resolveDashboardAccess } from "@/lib/dashboard-access"; import { type DashboardCacheScope, getDashboardCacheScopeKey } from "@/lib/dashboard-cache-scope"; @@ -34,7 +35,11 @@ async function loadOnboardingStatus(): Promise - {children} + + {children} + ); diff --git a/apps/sdp-web/src/app/dashboard/onboarding/page.tsx b/apps/sdp-web/src/app/dashboard/onboarding/page.tsx index 64b3c2fe9..2d195bc66 100644 --- a/apps/sdp-web/src/app/dashboard/onboarding/page.tsx +++ b/apps/sdp-web/src/app/dashboard/onboarding/page.tsx @@ -6,6 +6,7 @@ import { type OrganizationRpcProvider, } from "@sdp/types"; import { redirect } from "next/navigation"; +import { organizationOnboarding } from "@/flags"; import { getTranslations } from "@/i18n/server"; import { getAuthEntryPath } from "@/lib/auth-entry"; import { fetchProviderAvailability } from "@/lib/provider-availability"; @@ -23,10 +24,14 @@ const GENERAL_RPC_PROVIDERS = ORGANIZATION_RPC_PROVIDERS.filter( ); export default async function OrganizationOnboardingPage() { - const t = await getTranslations(); - const { getToken, userId, orgId } = await auth(); + const [t, onboardingEnabled, { getToken, userId, orgId }] = await Promise.all([ + getTranslations(), + organizationOnboarding(), + auth(), + ]); if (!userId) redirect(await getAuthEntryPath()); if (!orgId) redirect("/dashboard"); + if (!onboardingEnabled) redirect("/dashboard"); const { organizationClient } = await createRequestScopedSdpApiClients({ getToken }); const status = await organizationClient.fetch("/v1/onboarding/status"); diff --git a/apps/sdp-web/src/components/dashboard-shell.tsx b/apps/sdp-web/src/components/dashboard-shell.tsx index 3f77f8d98..bda1cbca0 100644 --- a/apps/sdp-web/src/components/dashboard-shell.tsx +++ b/apps/sdp-web/src/components/dashboard-shell.tsx @@ -73,7 +73,6 @@ import { Badge } from "@/components/ui/badge"; import { WorkspaceSwitcher } from "@/components/workspace-switcher"; import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context"; import { useTranslations } from "@/i18n/provider"; -import { isAssetProfilesUiEnabled } from "@/lib/asset-profiles-feature"; import { DASHBOARD_NAVIGATION_RECOVERY_TIMEOUT_MS, DASHBOARD_NAVIGATION_START_EVENT, @@ -522,7 +521,8 @@ function getAccessControlPageConfig( function getIssuanceRoutePageConfig( pathname: string, - t: ReturnType + t: ReturnType, + assetProfilesEnabled: boolean ): DashboardPageConfig | null { if (pathname === "/dashboard/issuance") { return { @@ -545,7 +545,7 @@ function getIssuanceRoutePageConfig( // Gate the chrome on the same flag the page uses to pick the workspace. Flag // on → the create flow's centered title + capped column; off → the legacy // left-aligned, full-width layout, untouched. - if (isAssetProfilesUiEnabled()) { + if (assetProfilesEnabled) { return actionPageConfig({ centeredTitle: t("Shared.dashboardShell.assetManagement"), backHref: "/dashboard/issuance", @@ -565,7 +565,8 @@ function getIssuanceRoutePageConfig( function getDashboardPageConfig( pathname: string, - t: ReturnType + t: ReturnType, + assetProfilesEnabled: boolean ): DashboardPageConfig { const accessControlPageConfig = getAccessControlPageConfig(pathname, t); if (accessControlPageConfig) return accessControlPageConfig; @@ -612,7 +613,7 @@ function getDashboardPageConfig( contentWidthClass: "max-w-none", }); } - const issuanceRoutePageConfig = getIssuanceRoutePageConfig(pathname, t); + const issuanceRoutePageConfig = getIssuanceRoutePageConfig(pathname, t, assetProfilesEnabled); if (issuanceRoutePageConfig) return issuanceRoutePageConfig; if (pathname === "/dashboard/payments/counterparty") { return { @@ -710,6 +711,7 @@ function AllowlistLoading() { } interface PageLoadingProps { + assetProfilesEnabled?: boolean; targetSearch?: string; } @@ -1065,9 +1067,11 @@ function DashboardSidebarContent({ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: this shell intentionally coordinates route-specific dashboard layout behavior in one place. export function DashboardShell({ + assetProfilesEnabled, children, onboardingStatus, }: { + assetProfilesEnabled: boolean; children: ReactNode; onboardingStatus: OrganizationOnboardingStatus | null; }) { @@ -1099,7 +1103,7 @@ export function DashboardShell({ Boolean(pendingNavigationPathname) || isProjectSwitching || isOrganizationSwitching; const sidebarExpandedWidth = 296; const sidebarCollapsedWidth = 64; - const pageConfig = getDashboardPageConfig(shellPathname, t); + const pageConfig = getDashboardPageConfig(shellPathname, t, assetProfilesEnabled); const navSections = getNavSections(t, { canReadApprovals: dashboardAccess.capabilities.canReadApprovals, pendingApprovalCount, @@ -1491,7 +1495,10 @@ export function DashboardShell({ aria-live="polite" > {t("Shared.dashboardShell.loadingDashboard")} - + ) : ( children diff --git a/apps/sdp-web/src/flags.ts b/apps/sdp-web/src/flags.ts index b2c5585c3..7299bc398 100644 --- a/apps/sdp-web/src/flags.ts +++ b/apps/sdp-web/src/flags.ts @@ -1,13 +1,70 @@ +import { auth } from "@clerk/nextjs/server"; import { vercelAdapter } from "@flags-sdk/vercel"; -import { flag } from "flags/next"; +import { dedupe, flag } from "flags/next"; +import { getAssetProfilesDefault, getHomepageOpenSignupDefault } from "@/lib/feature-flag-defaults"; + +type DashboardFlagEntities = { + user?: { + id: string; + }; + team?: { + id: string; + role?: string; + }; +}; + +const identifyDashboardEntities = dedupe(async (): Promise => { + const { orgId, orgRole, userId } = await auth(); + + return { + user: userId ? { id: userId } : undefined, + team: orgId + ? { + id: orgId, + role: orgRole ?? undefined, + } + : undefined, + }; +}); export const homepageOpenSignup = flag({ key: "homepage-open-signup", adapter: vercelAdapter(), - defaultValue: process.env.VERCEL_ENV !== "production", + defaultValue: getHomepageOpenSignupDefault({ + vercelEnvironment: process.env.VERCEL_ENV, + }), description: "Show self-serve signup and contact CTAs instead of the homepage waitlist CTA.", options: [ { value: false, label: "Waitlist" }, { value: true, label: "Open signup" }, ], }); + +export const organizationOnboarding = flag({ + key: "organization-onboarding", + adapter: vercelAdapter(), + identify: identifyDashboardEntities, + defaultValue: true, + description: + "Require newly created organizations to choose RPC and custody providers before entering the dashboard.", + options: [ + { value: false, label: "Skip onboarding" }, + { value: true, label: "Require onboarding" }, + ], +}); + +export const assetProfiles = flag({ + key: "asset-profiles", + adapter: vercelAdapter(), + identify: identifyDashboardEntities, + defaultValue: getAssetProfilesDefault({ + nodeEnvironment: process.env.NODE_ENV, + sdpEnvironment: process.env.NEXT_PUBLIC_SDP_ENVIRONMENT, + vercelEnvironment: process.env.VERCEL_ENV, + }), + description: "Show the Asset Profiles issuance wizard and per-token asset management workspace.", + options: [ + { value: false, label: "Legacy issuance" }, + { value: true, label: "Asset Profiles" }, + ], +}); diff --git a/apps/sdp-web/src/lib/asset-profiles-feature.ts b/apps/sdp-web/src/lib/asset-profiles-feature.ts deleted file mode 100644 index de3c36203..000000000 --- a/apps/sdp-web/src/lib/asset-profiles-feature.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Frontend gate for the Asset Profiles issuance UI. Controls two surfaces: -// 1. The full-page create wizard at /dashboard/issuance/create. -// 2. The per-token AssetManagementWorkspace at /dashboard/issuance/[tokenId]. -// Independent of the backend ASSET_PROFILES_ENABLED flag on sdp-api: this only -// controls whether the UI is shown. Recognized non-production contexts are -// always enabled; production keeps the explicit flag for a controlled rollout. -export function isAssetProfilesUiEnabled(): boolean { - const sdpEnvironment = process.env.NEXT_PUBLIC_SDP_ENVIRONMENT?.trim().toLowerCase(); - if (sdpEnvironment === "development") { - return true; - } - - const vercelEnvironment = process.env.NEXT_PUBLIC_VERCEL_ENV?.trim().toLowerCase(); - if (vercelEnvironment === "preview" || vercelEnvironment === "development") { - return true; - } - - const nodeEnvironment = process.env.NODE_ENV?.trim().toLowerCase(); - if ( - !sdpEnvironment && - !vercelEnvironment && - (nodeEnvironment === "development" || nodeEnvironment === "test") - ) { - return true; - } - - const explicitFlag = process.env.NEXT_PUBLIC_ASSET_PROFILES_ENABLED?.trim().toLowerCase(); - return explicitFlag === "true"; -} diff --git a/apps/sdp-web/src/lib/asset-profiles-feature.unit.test.ts b/apps/sdp-web/src/lib/asset-profiles-feature.unit.test.ts deleted file mode 100644 index bc065baef..000000000 --- a/apps/sdp-web/src/lib/asset-profiles-feature.unit.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { isAssetProfilesUiEnabled } from "./asset-profiles-feature"; - -afterEach(() => { - vi.unstubAllEnvs(); -}); - -function stubEnvironment({ - flag = "", - node = "production", - sdp = "", - vercel = "", -}: { - flag?: string; - node?: string; - sdp?: string; - vercel?: string; -}) { - vi.stubEnv("NEXT_PUBLIC_ASSET_PROFILES_ENABLED", flag); - vi.stubEnv("NEXT_PUBLIC_SDP_ENVIRONMENT", sdp); - vi.stubEnv("NEXT_PUBLIC_VERCEL_ENV", vercel); - vi.stubEnv("NODE_ENV", node); -} - -describe("isAssetProfilesUiEnabled", () => { - it.each(["development", "test"])("enables local %s builds", (node) => { - stubEnvironment({ node }); - expect(isAssetProfilesUiEnabled()).toBe(true); - }); - - it.each([ - "preview", - "development", - " PREVIEW ", - ])("enables Vercel's %s environment even in a production-mode build", (vercel) => { - stubEnvironment({ node: "production", vercel }); - expect(isAssetProfilesUiEnabled()).toBe(true); - }); - - it("keeps Vercel production disabled without the explicit flag", () => { - stubEnvironment({ vercel: "production" }); - expect(isAssetProfilesUiEnabled()).toBe(false); - }); - - it.each(["true", " TRUE "])("honors the explicit production opt-in %s", (flag) => { - stubEnvironment({ flag, vercel: "production" }); - expect(isAssetProfilesUiEnabled()).toBe(true); - }); - - it("enables prebuilt self-hosted development images", () => { - stubEnvironment({ node: "production", sdp: "development" }); - expect(isAssetProfilesUiEnabled()).toBe(true); - }); - - it("keeps prebuilt self-hosted production images disabled without an opt-in", () => { - stubEnvironment({ node: "production", sdp: "production" }); - expect(isAssetProfilesUiEnabled()).toBe(false); - }); - - it("honors the prebuilt self-hosted production opt-in", () => { - stubEnvironment({ flag: "true", node: "production", sdp: "production" }); - expect(isAssetProfilesUiEnabled()).toBe(true); - }); - - it.each([ - "", - "staging", - "unexpected", - ])("fails closed for the unrecognized deployment marker %s in production-mode builds", (vercel) => { - stubEnvironment({ vercel }); - expect(isAssetProfilesUiEnabled()).toBe(false); - }); -}); diff --git a/apps/sdp-web/src/lib/feature-flag-defaults.ts b/apps/sdp-web/src/lib/feature-flag-defaults.ts new file mode 100644 index 000000000..50f985b6c --- /dev/null +++ b/apps/sdp-web/src/lib/feature-flag-defaults.ts @@ -0,0 +1,36 @@ +type RuntimeFlagEnvironment = { + nodeEnvironment?: string; + sdpEnvironment?: string; + vercelEnvironment?: string; +}; + +function normalize(value: string | undefined): string | undefined { + return value?.trim().toLowerCase() || undefined; +} + +export function getHomepageOpenSignupDefault({ + vercelEnvironment, +}: Pick): boolean { + // Preserve open signup for non-Vercel/self-hosted deployments. Vercel + // production is the only environment that should fail back to the waitlist. + return normalize(vercelEnvironment) !== "production"; +} + +export function getAssetProfilesDefault({ + nodeEnvironment, + sdpEnvironment, + vercelEnvironment, +}: RuntimeFlagEnvironment): boolean { + const vercel = normalize(vercelEnvironment); + if (vercel) { + return vercel === "preview" || vercel === "development"; + } + + const sdp = normalize(sdpEnvironment); + if (sdp) { + return sdp === "development"; + } + + const node = normalize(nodeEnvironment); + return node === "development" || node === "test"; +} diff --git a/apps/sdp-web/src/lib/feature-flag-defaults.unit.test.ts b/apps/sdp-web/src/lib/feature-flag-defaults.unit.test.ts new file mode 100644 index 000000000..5247d78f7 --- /dev/null +++ b/apps/sdp-web/src/lib/feature-flag-defaults.unit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { getAssetProfilesDefault, getHomepageOpenSignupDefault } from "./feature-flag-defaults"; + +describe("getHomepageOpenSignupDefault", () => { + it("defaults Vercel production to the waitlist", () => { + expect(getHomepageOpenSignupDefault({ vercelEnvironment: "production" })).toBe(false); + }); + + it.each([ + "preview", + "development", + undefined, + ])("defaults %s deployments to open signup", (vercelEnvironment) => { + expect(getHomepageOpenSignupDefault({ vercelEnvironment })).toBe(true); + }); +}); + +describe("getAssetProfilesDefault", () => { + it.each([ + "preview", + "development", + " PREVIEW ", + ])("enables the %s Vercel environment", (vercelEnvironment) => { + expect( + getAssetProfilesDefault({ + nodeEnvironment: "production", + vercelEnvironment, + }) + ).toBe(true); + }); + + it.each([ + "production", + "staging", + "unexpected", + ])("fails closed for the %s Vercel environment", (vercelEnvironment) => { + expect( + getAssetProfilesDefault({ + nodeEnvironment: "development", + vercelEnvironment, + }) + ).toBe(false); + }); + + it("enables self-hosted development", () => { + expect( + getAssetProfilesDefault({ + nodeEnvironment: "production", + sdpEnvironment: "development", + }) + ).toBe(true); + }); + + it("keeps self-hosted production disabled", () => { + expect( + getAssetProfilesDefault({ + nodeEnvironment: "production", + sdpEnvironment: "production", + }) + ).toBe(false); + }); + + it.each(["development", "test"])("enables local %s", (nodeEnvironment) => { + expect(getAssetProfilesDefault({ nodeEnvironment })).toBe(true); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index bf2097556..33781264c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,7 +93,6 @@ services: NEXT_PUBLIC_SDP_DOCS_URL: ${NEXT_PUBLIC_SDP_DOCS_URL:-http://localhost:3001} NEXT_PUBLIC_SOLANA_NETWORK: ${NEXT_PUBLIC_SOLANA_NETWORK:-devnet} NEXT_PUBLIC_SDP_ENVIRONMENT: ${ENVIRONMENT:-development} - NEXT_PUBLIC_ASSET_PROFILES_ENABLED: ${ASSET_PROFILES_ENABLED:-true} NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY} restart: unless-stopped ports: diff --git a/infra/self-hosted/compose.yml b/infra/self-hosted/compose.yml index 38096ce14..9f88d89a5 100644 --- a/infra/self-hosted/compose.yml +++ b/infra/self-hosted/compose.yml @@ -72,7 +72,6 @@ services: NEXT_PUBLIC_SDP_DOCS_URL: ${NEXT_PUBLIC_SDP_DOCS_URL:-http://localhost:3001} NEXT_PUBLIC_SOLANA_NETWORK: ${NEXT_PUBLIC_SOLANA_NETWORK:-devnet} NEXT_PUBLIC_SDP_ENVIRONMENT: ${ENVIRONMENT:-production} - NEXT_PUBLIC_ASSET_PROFILES_ENABLED: ${ASSET_PROFILES_ENABLED:-false} NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:?set NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY in .env} SDP_API_BASE_URL: ${SDP_API_BASE_URL:-http://sdp-api:8787} CLERK_SECRET_KEY: ${CLERK_SECRET_KEY:?set CLERK_SECRET_KEY in .env} diff --git a/turbo.json b/turbo.json index 72a6a2c73..b3337c1d9 100644 --- a/turbo.json +++ b/turbo.json @@ -42,6 +42,8 @@ "FIREBLOCKS_API_SECRET", "FIREBLOCKS_ASSET_ID", "FIREBLOCKS_VAULT_ID", + "FLAGS", + "FLAGS_SECRET", "CHAINALYSIS_API_BASE_URL", "CHAINALYSIS_API_KEY", "ELLIPTIC_API_BASE_URL", @@ -61,7 +63,6 @@ "MAGICBLOCK_PRIVATE_PAYMENTS_EPHEMERAL_RPC_URL", "DATABASE_URL", "NEXT_PUBLIC_API_BASE_URL", - "NEXT_PUBLIC_ASSET_PROFILES_ENABLED", "NEXT_PUBLIC_VERCEL_ENV", "NEXT_PUBLIC_CLERK_JWT_TEMPLATE", "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", From e2d528ca3432dc54aff36a1cafc31e44ce641cf6 Mon Sep 17 00:00:00 2001 From: Gui Bibeau Date: Thu, 23 Jul 2026 14:09:49 -0400 Subject: [PATCH 2/2] fix(web): preserve self-hosted asset profile opt-in --- apps/sdp-web/src/flags.ts | 1 + apps/sdp-web/src/lib/feature-flag-defaults.ts | 4 +++- .../sdp-web/src/lib/feature-flag-defaults.unit.test.ts | 10 ++++++++++ docker-compose.yml | 1 + infra/self-hosted/compose.yml | 1 + 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/sdp-web/src/flags.ts b/apps/sdp-web/src/flags.ts index 7299bc398..a6bafb2cd 100644 --- a/apps/sdp-web/src/flags.ts +++ b/apps/sdp-web/src/flags.ts @@ -58,6 +58,7 @@ export const assetProfiles = flag({ adapter: vercelAdapter(), identify: identifyDashboardEntities, defaultValue: getAssetProfilesDefault({ + assetProfilesEnabled: process.env.ASSET_PROFILES_ENABLED, nodeEnvironment: process.env.NODE_ENV, sdpEnvironment: process.env.NEXT_PUBLIC_SDP_ENVIRONMENT, vercelEnvironment: process.env.VERCEL_ENV, diff --git a/apps/sdp-web/src/lib/feature-flag-defaults.ts b/apps/sdp-web/src/lib/feature-flag-defaults.ts index 50f985b6c..4e04ed323 100644 --- a/apps/sdp-web/src/lib/feature-flag-defaults.ts +++ b/apps/sdp-web/src/lib/feature-flag-defaults.ts @@ -1,4 +1,5 @@ type RuntimeFlagEnvironment = { + assetProfilesEnabled?: string; nodeEnvironment?: string; sdpEnvironment?: string; vercelEnvironment?: string; @@ -17,6 +18,7 @@ export function getHomepageOpenSignupDefault({ } export function getAssetProfilesDefault({ + assetProfilesEnabled, nodeEnvironment, sdpEnvironment, vercelEnvironment, @@ -28,7 +30,7 @@ export function getAssetProfilesDefault({ const sdp = normalize(sdpEnvironment); if (sdp) { - return sdp === "development"; + return sdp === "development" || normalize(assetProfilesEnabled) === "true"; } const node = normalize(nodeEnvironment); diff --git a/apps/sdp-web/src/lib/feature-flag-defaults.unit.test.ts b/apps/sdp-web/src/lib/feature-flag-defaults.unit.test.ts index 5247d78f7..605aaf3aa 100644 --- a/apps/sdp-web/src/lib/feature-flag-defaults.unit.test.ts +++ b/apps/sdp-web/src/lib/feature-flag-defaults.unit.test.ts @@ -60,6 +60,16 @@ describe("getAssetProfilesDefault", () => { ).toBe(false); }); + it("honors the server-only self-hosted production opt-in", () => { + expect( + getAssetProfilesDefault({ + assetProfilesEnabled: " TRUE ", + nodeEnvironment: "production", + sdpEnvironment: "production", + }) + ).toBe(true); + }); + it.each(["development", "test"])("enables local %s", (nodeEnvironment) => { expect(getAssetProfilesDefault({ nodeEnvironment })).toBe(true); }); diff --git a/docker-compose.yml b/docker-compose.yml index 33781264c..825182c6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,6 +98,7 @@ services: ports: - "${SDP_WEB_PORT:-3000}:3000" environment: + ASSET_PROFILES_ENABLED: ${ASSET_PROFILES_ENABLED:-true} SDP_API_BASE_URL: http://sdp-api:8787 CLERK_SECRET_KEY: ${CLERK_SECRET_KEY} CLERK_JWT_TEMPLATE: ${CLERK_JWT_TEMPLATE:-sdp-api} diff --git a/infra/self-hosted/compose.yml b/infra/self-hosted/compose.yml index 9f88d89a5..642c136ec 100644 --- a/infra/self-hosted/compose.yml +++ b/infra/self-hosted/compose.yml @@ -73,6 +73,7 @@ services: NEXT_PUBLIC_SOLANA_NETWORK: ${NEXT_PUBLIC_SOLANA_NETWORK:-devnet} NEXT_PUBLIC_SDP_ENVIRONMENT: ${ENVIRONMENT:-production} NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:?set NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY in .env} + ASSET_PROFILES_ENABLED: ${ASSET_PROFILES_ENABLED:-false} SDP_API_BASE_URL: ${SDP_API_BASE_URL:-http://sdp-api:8787} CLERK_SECRET_KEY: ${CLERK_SECRET_KEY:?set CLERK_SECRET_KEY in .env} CLERK_JWT_TEMPLATE: ${CLERK_JWT_TEMPLATE:-sdp-api}