Skip to content
Merged
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
15 changes: 15 additions & 0 deletions apps/sdp-api/src/lib/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
"",
Expand All @@ -13,6 +25,7 @@ describe("isAssetProfilesEnabled", () => {
isAssetProfilesEnabled({
ENVIRONMENT: "development",
ASSET_PROFILES_ENABLED: flag,
SDP_DEPLOYMENT_MODE: "self_hosted",
})
).toBe(true);
});
Expand All @@ -28,6 +41,7 @@ describe("isAssetProfilesEnabled", () => {
isAssetProfilesEnabled({
ENVIRONMENT: "production",
ASSET_PROFILES_ENABLED: flag,
SDP_DEPLOYMENT_MODE: "self_hosted",
})
).toBe(false);
});
Expand All @@ -37,6 +51,7 @@ describe("isAssetProfilesEnabled", () => {
isAssetProfilesEnabled({
ENVIRONMENT: "production",
ASSET_PROFILES_ENABLED: flag,
SDP_DEPLOYMENT_MODE: "self_hosted",
})
).toBe(true);
});
Expand Down
11 changes: 10 additions & 1 deletion apps/sdp-api/src/lib/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,7 +13,15 @@ export function isRecurringPaymentCollectionEnabled(
}

export function isAssetProfilesEnabled(
env: Pick<Env, "ASSET_PROFILES_ENABLED" | "ENVIRONMENT">
env: Pick<Env, "ASSET_PROFILES_ENABLED" | "ENVIRONMENT" | "SDP_DEPLOYMENT_MODE">
): 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);
}
4 changes: 2 additions & 2 deletions apps/sdp-api/src/routes/asset-profiles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion apps/sdp-api/src/types/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 0 additions & 7 deletions apps/sdp-web/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
2 changes: 0 additions & 2 deletions apps/sdp-web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand All @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion apps/sdp-web/playwright/tests/issuance.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions apps/sdp-web/src/app/.well-known/vercel/flags/route.ts
Original file line number Diff line number Diff line change
@@ -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));
9 changes: 4 additions & 5 deletions apps/sdp-web/src/app/dashboard/issuance/(overview)/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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());
Expand All @@ -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,
Expand Down Expand Up @@ -323,6 +321,7 @@ export default async function IssuancePage({ searchParams }: IssuancePageProps)

return (
<IssuanceWorkspace
assetProfilesEnabled={assetProfilesEnabled}
tokens={tokens}
templates={templatesResult.data ?? []}
apiKeys={apiKeys}
Expand Down
6 changes: 3 additions & 3 deletions apps/sdp-web/src/app/dashboard/issuance/[tokenId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { auth } from "@clerk/nextjs/server";
import type { AssetProfile, Token } 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 { createTimedTrace } from "@/lib/request-tracing";
import { createSdpApiClient, type SdpApiClient } from "@/lib/sdp-api";
Expand Down Expand Up @@ -102,10 +102,11 @@ function mapAssetProfile(payload: unknown): AssetProfile | null {
}

export default async function IssuanceTokenManagementPage({ params }: TokenManagementPageProps) {
const [t, { userId, orgId }, { tokenId }] = await Promise.all([
const [t, { userId, orgId }, { tokenId }, assetProfilesEnabled] = await Promise.all([
getTranslations(),
auth(),
params,
assetProfiles(),
]);
if (!userId) {
redirect(await getAuthEntryPath());
Expand All @@ -121,7 +122,6 @@ export default async function IssuanceTokenManagementPage({ params }: TokenManag
createSdpApiClient(trace.childContext("dashboard.issuance.token.api"))
);

const assetProfilesEnabled = isAssetProfilesUiEnabled();
const profileResultPromise = assetProfilesEnabled
? trace.step("fetch_asset_profile", () =>
fetchData<AssetProfile | null>(
Expand Down
4 changes: 2 additions & 2 deletions apps/sdp-web/src/app/dashboard/issuance/create/page.tsx
Original file line number Diff line number Diff line change
@@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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 (
<DashboardWorkspaceOverviewPanel
className="space-y-6"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context";
import { useLocale, useTranslations } from "@/i18n/provider";
import { isAssetProfilesUiEnabled } from "@/lib/asset-profiles-feature";
import { getStoredApiKeySecret } from "@/lib/playground-api-keys";
import { useDashboardRouter } from "@/lib/use-dashboard-router";
import { CreateIssuanceTokenModal } from "./create-token-modal";
Expand Down Expand Up @@ -58,6 +57,7 @@ interface IssuanceTemplateOption {
}

interface IssuanceWorkspaceProps {
assetProfilesEnabled: boolean;
tokens: IssuanceTokenView[];
templates: IssuanceTemplateOption[];
apiKeys: IssuanceApiKeyOption[];
Expand All @@ -72,6 +72,7 @@ interface IssuanceWorkspaceProps {
const VIEW_STORAGE_KEY = "sdp.issuance.tokenView";

export function IssuanceWorkspace({
assetProfilesEnabled,
tokens,
templates,
apiKeys,
Expand Down Expand Up @@ -114,9 +115,8 @@ export function IssuanceWorkspace({
};

// Asset Profiles UI flag: on → full-page wizard; off → legacy modal.
const assetProfilesUiEnabled = isAssetProfilesUiEnabled();
const startTokenCreation = () => {
if (assetProfilesUiEnabled) {
if (assetProfilesEnabled) {
router.push(CREATE_DRAFT_PATH);
return;
}
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 13 additions & 3 deletions apps/sdp-web/src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -34,7 +35,11 @@ async function loadOnboardingStatus(): Promise<OrganizationOnboardingStatus | nu
}

export default async function DashboardLayout({ children }: { children: ReactNode }) {
const { orgRole, orgId, userId } = await getSdpAuth();
const [{ orgRole, orgId, userId }, onboardingEnabled, assetProfilesEnabled] = await Promise.all([
getSdpAuth(),
organizationOnboarding(),
assetProfiles(),
]);

if (!userId || !orgId) {
redirect(await getAuthEntryPath());
Expand All @@ -48,7 +53,7 @@ export default async function DashboardLayout({ children }: { children: ReactNod

const [loadedProjects, onboardingStatus, cookieStore] = await Promise.all([
loadProjects(),
loadOnboardingStatus(),
onboardingEnabled ? loadOnboardingStatus() : Promise.resolve(null),
cookies(),
]);
const projects = loadedProjects ?? [];
Expand All @@ -67,7 +72,12 @@ export default async function DashboardLayout({ children }: { children: ReactNod
shouldRepairInitialProjectCookie={projectSelection.shouldRepairCookie}
>
<NetworkDebugProvider>
<DashboardShell onboardingStatus={onboardingStatus}>{children}</DashboardShell>
<DashboardShell
assetProfilesEnabled={assetProfilesEnabled}
onboardingStatus={onboardingStatus}
>
{children}
</DashboardShell>
</NetworkDebugProvider>
</DashboardWorkspaceProvider>
);
Expand Down
9 changes: 7 additions & 2 deletions apps/sdp-web/src/app/dashboard/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<OnboardingStatusResponse>("/v1/onboarding/status");
Expand Down
Loading
Loading