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
6 changes: 3 additions & 3 deletions apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
import { reconcileAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/reconcile'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { runDetached } from '@/lib/core/utils/background'
Expand All @@ -20,15 +20,15 @@ export const dynamic = 'force-dynamic'
* Applies AWS Marketplace agreement events from the SQS queue and re-verifies licenses no event
* touched within a day. Acknowledges the cron call at once and works in the background; a Redis
* lock keeps runs from overlapping, and every write is idempotent, so a replayed run is harmless.
* A no-op on deployments without `AWS_MARKETPLACE_PRODUCT_CODE`.
* A no-op on deployments without `AWS_MARKETPLACE_PRODUCTS`.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const authError = verifyCronAuth(request, 'AWS Marketplace reconcile')
if (authError) {
return authError
}

if (!getAwsMarketplaceProductCode()) {
if (!isAwsMarketplaceEnabled()) {
return NextResponse.json({
success: true,
message: 'AWS Marketplace is not configured – skipped',
Expand Down
10 changes: 4 additions & 6 deletions apps/sim/app/api/marketplace/aws/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { awsMarketplaceRegistrationFormSchema } from '@/lib/api/contracts/aws-marketplace'
import { serializeClaimCookie } from '@/lib/billing/aws-marketplace/claim'
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
import {
AWS_MARKETPLACE_ONBOARDING_PATH,
type AwsMarketplaceOnboardingError,
Expand Down Expand Up @@ -59,8 +59,7 @@ async function readRegistrationToken(request: NextRequest): Promise<string | nul
* the claim cookie unless the license lapsed. The token is never logged.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const productCode = getAwsMarketplaceProductCode()
if (!productCode) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (!isAwsMarketplaceEnabled()) return NextResponse.json({ error: 'Not found' }, { status: 404 })

try {
const rateLimited = await enforceIpRateLimit(
Expand All @@ -76,7 +75,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return redirectToOnboarding('invalid')
}

const outcome = await registerAwsMarketplaceBuyer(productCode, token)
const outcome = await registerAwsMarketplaceBuyer(token)
if (outcome.kind !== 'claimed') return redirectToOnboarding(outcome.kind)

const response = redirectToOnboarding()
Expand All @@ -91,7 +90,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

/** A refresh, back button, or bookmark of the fulfillment URL lands on the onboarding page. */
export const GET = withRouteHandler(async () => {
if (!getAwsMarketplaceProductCode())
return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (!isAwsMarketplaceEnabled()) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return redirectToOnboarding()
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
linkAwsMarketplaceLicenseAsAdmin,
listUnlinkedAwsMarketplaceLicenses,
} from '@/lib/billing/aws-marketplace/application/admin'
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
Expand All @@ -29,7 +29,7 @@ const logger = createLogger('AdminAwsMarketplaceLicensesAPI')
/** The Marketplace licenses no organization holds yet. */
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace')
if (!isAwsMarketplaceEnabled()) return adminNotFoundResponse('AWS Marketplace')
const parsed = await parseRequest(
adminDashboardListAwsMarketplaceLicensesContract,
request,
Expand All @@ -56,7 +56,7 @@ export const GET = withRouteHandler(
/** Links a Marketplace license to an organization once the team has provisioned it. */
export const POST = withRouteHandler(
withAdminAuth(async (request) => {
if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace')
if (!isAwsMarketplaceEnabled()) return adminNotFoundResponse('AWS Marketplace')
const parsed = await parseRequest(
adminDashboardLinkAwsMarketplaceLicenseContract,
request,
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/marketplace/aws/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
import { AWS_MARKETPLACE_ONBOARDING_ERRORS } from '@/lib/billing/aws-marketplace/onboarding'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { AwsMarketplaceOnboarding } from '@/app/marketplace/aws/aws-marketplace-onboarding'
Expand All @@ -18,7 +18,7 @@ interface AwsMarketplacePageProps {

/** Where AWS Marketplace buyers land after registration, to link their subscription. */
export default async function AwsMarketplacePage({ searchParams }: AwsMarketplacePageProps) {
if (!getAwsMarketplaceProductCode()) notFound()
if (!isAwsMarketplaceEnabled()) notFound()
const { error } = await searchParams
const registrationError = AWS_MARKETPLACE_ONBOARDING_ERRORS.find((code) => code === error) ?? null
return (
Expand Down
10 changes: 8 additions & 2 deletions apps/sim/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
/**
* Container entrypoint. Hydrates `process.env` from the runtime secret, resolves an AWS
* Marketplace Enterprise license into `ENTERPRISE_ENABLED` on Marketplace installs, and drops
* Container entrypoint. Hydrates `process.env` from the runtime secret, refuses to start on a
* malformed `AWS_MARKETPLACE_PRODUCTS` (so a bad deploy rolls back instead of failing buyers and
* checkouts later), resolves an AWS Marketplace Enterprise license into `ENTERPRISE_ENABLED` on
* Marketplace installs, and drops
* Next's route cache when an earlier build wrote it, before loading the Next.js standalone server,
* so application modules that read env at import time see the full configuration. See
* `@sim/runtime-secrets`.
*/
import { fileURLToPath } from 'node:url'
import { loadRuntimeSecrets } from '@sim/runtime-secrets'
import { applyAwsMarketplaceContainerLicense } from '@/lib/billing/aws-marketplace/container-license'
import { parseAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/products'
import { clearRouteCacheFromOtherBuild } from '@/lib/core/utils/route-cache'

await loadRuntimeSecrets()
if (process.env.AWS_MARKETPLACE_PRODUCTS) {
parseAwsMarketplaceProducts(process.env.AWS_MARKETPLACE_PRODUCTS)
}
await applyAwsMarketplaceContainerLicense()
await clearRouteCacheFromOtherBuild(fileURLToPath(new URL('./.next', import.meta.url)))
// `server.js` is the Next standalone build artifact, a sibling of this file in
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/billing/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { APIError } from 'better-auth/api'
import { and, eq, isNotNull } from 'drizzle-orm'
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import {
Expand Down Expand Up @@ -64,7 +64,7 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise
* license lookup fails.
*/
async function assertNotBilledThroughMarketplace(referenceId: string): Promise<void> {
if (!getAwsMarketplaceProductCode()) return
if (!isAwsMarketplaceEnabled()) return
if (!(await holdsMarketplaceLicense(referenceId))) return

logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId })
Expand Down
34 changes: 21 additions & 13 deletions apps/sim/lib/billing/aws-marketplace/application/use-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { db } from '@sim/db'
import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace'
import { awsMarketplaceOperations } from '@/lib/billing/aws-marketplace/application/operations'
import { hashClaimToken } from '@/lib/billing/aws-marketplace/claim'
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
import {
findAwsMarketplaceProduct,
isAwsMarketplaceEnabled,
} from '@/lib/billing/aws-marketplace/config'
import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements'
import {
type AwsMarketplaceLicenseRecord,
Expand All @@ -27,10 +30,15 @@ import { requireOrganizationSubjectMembership } from '@/lib/core/application/org
import { OrchestrationError } from '@/lib/core/orchestration/types'

/** The same answer as an unknown route, so a deployment that does not sell on AWS shows nothing. */
function requireProductCode(): string {
const productCode = getAwsMarketplaceProductCode()
if (!productCode) throw new OrchestrationError('not_found', 'Not found')
return productCode
function requireMarketplaceEnabled(): void {
if (!isAwsMarketplaceEnabled()) throw new OrchestrationError('not_found', 'Not found')
}

/** The listing a license was sold under; one this deployment no longer sells reads as unknown. */
function requireLicenseProduct(license: AwsMarketplaceLicenseRecord) {
const product = findAwsMarketplaceProduct(license.productCode)
if (!product) throw new OrchestrationError('not_found', 'Not found')
return product
}

function isClaimOpen(license: AwsMarketplaceLicenseRecord, now: Date): boolean {
Expand Down Expand Up @@ -115,7 +123,7 @@ export const readAwsMarketplaceClaim: OperationUseCase<
operation: awsMarketplaceOperations.readClaim,
async execute({ principal, input }) {
assertOperationPrincipal(principal, awsMarketplaceOperations.readClaim)
requireProductCode()
requireMarketplaceEnabled()
if (!input.claimToken) return { status: 'missing' }
const license = await findLicenseByClaimHash(hashClaimToken(input.claimToken))
if (!license || !isClaimOpen(license, new Date())) return { status: 'missing' }
Expand Down Expand Up @@ -169,7 +177,7 @@ function contactOf(input: BuyerContact) {
* rechecks the claim under the row lock, so two tabs racing one claim take it at most once.
*/
async function verifyClaimedLicense(input: ClaimInput, organizationId: string | null) {
const productCode = requireProductCode()
requireMarketplaceEnabled()
const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
const claimed = requireOpenClaim(
claimHash ? await findLicenseByClaimHash(claimHash) : null,
Expand All @@ -179,12 +187,12 @@ async function verifyClaimedLicense(input: ClaimInput, organizationId: string |
requireLinkableTo(claimed, organizationId)

const verifiedAt = new Date()
const summary = await verifyLicense(productCode, claimed.licenseArn)
const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn)
const verification = { summary, verifiedAt }
if (!summary.active) {
const outcome = await recordLicenseVerification({
licenseArn: claimed.licenseArn,
productCode,
productCode: claimed.productCode,
verification,
})
throw new OrchestrationError(
Expand Down Expand Up @@ -321,15 +329,15 @@ export const saveAwsMarketplaceClaimContact: OperationUseCase<
operation: awsMarketplaceOperations.saveClaimContact,
async execute({ principal, input }) {
assertOperationPrincipal(principal, awsMarketplaceOperations.saveClaimContact)
const productCode = requireProductCode()
requireMarketplaceEnabled()
const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
const claimed = requireOpenClaim(
claimHash ? await findLicenseByClaimHash(claimHash) : null,
input.licenseId,
claimHash
)
const verifiedAt = new Date()
const summary = await verifyLicense(productCode, claimed.licenseArn)
const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn)
return db.transaction(async (tx) => {
const locked = requireOpenClaim(
await lockLicense(tx, input.licenseId),
Expand Down Expand Up @@ -360,7 +368,7 @@ function toLicenseStatusList(rows: Awaited<ReturnType<typeof listOrganizationLic
export const listOrganizationAwsMarketplaceLicenses = defineAuthorizedOrganizationUseCase({
operation: awsMarketplaceOperations.listOrganizationLicenses,
async execute({ context }: OrganizationUseCaseContext<{ organizationId: string }>) {
if (!getAwsMarketplaceProductCode()) return []
if (!isAwsMarketplaceEnabled()) return []
return toLicenseStatusList(await listOrganizationLicenses(context.organizationId))
},
})
Expand All @@ -374,7 +382,7 @@ export const listAccountAwsMarketplaceLicenses: OperationUseCase<
operation: awsMarketplaceOperations.listAccountLicenses,
async execute({ principal }) {
assertOperationPrincipal(principal, awsMarketplaceOperations.listAccountLicenses)
if (!getAwsMarketplaceProductCode()) return []
if (!isAwsMarketplaceEnabled()) return []
return toLicenseStatusList(await listAccountLicenses(principal.userId))
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ const PRODUCT = 'product-placeholder'
const ACCOUNT = '000000000000'
const TEAM_PLAN = 'team_6000'

const productCode = vi.hoisted(() => ({ current: 'product-placeholder' as string | null }))
const marketplace = vi.hoisted(() => ({ enabled: true }))

vi.mock('@/lib/billing/aws-marketplace/config', () => ({
getAwsMarketplaceProductCode: () => productCode.current,
isAwsMarketplaceEnabled: () => marketplace.enabled,
}))

import {
Expand Down Expand Up @@ -84,7 +84,7 @@ async function organizationWithLicense(status: LicenseStatus | null) {
}

afterEach(() => {
productCode.current = PRODUCT
marketplace.enabled = true
})

afterAll(async () => {
Expand Down Expand Up @@ -147,7 +147,7 @@ describe('Stripe checkout for an AWS Marketplace-billed reference', () => {
const { ownerId, organizationId } = await organizationWithLicense('active')
const userId = await createUser()
await insertLicense({ organizationId: null, linkedByUserId: userId, status: 'active' })
productCode.current = null
marketplace.enabled = false

await expect(
authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN)
Expand Down
30 changes: 25 additions & 5 deletions apps/sim/lib/billing/aws-marketplace/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import {
type AwsMarketplaceProduct,
parseAwsMarketplaceProducts,
} from '@/lib/billing/aws-marketplace/products'
import { env } from '@/lib/core/config/env'

let products: ReadonlyMap<string, AwsMarketplaceProduct> | undefined

/**
* The SaaS product code, or null when this deployment does not sell through AWS Marketplace:
* self-hosted installs and every environment but production. Null turns the registration,
* claim, and reconcile surfaces off.
* The products this deployment sells through AWS Marketplace, by product code. Empty on
* self-hosted installs and every environment but production, which turns the Marketplace surfaces
* off. The container entrypoint refuses to start on a malformed setting.
*/
export function getAwsMarketplaceProductCode(): string | null {
return env.AWS_MARKETPLACE_PRODUCT_CODE ?? null
export function getAwsMarketplaceProducts(): ReadonlyMap<string, AwsMarketplaceProduct> {
products ??= env.AWS_MARKETPLACE_PRODUCTS
? parseAwsMarketplaceProducts(env.AWS_MARKETPLACE_PRODUCTS)
: new Map()
return products
}

export function isAwsMarketplaceEnabled(): boolean {
return getAwsMarketplaceProducts().size > 0
}

/** The configured product for a code AWS returned or a license row stored; null for any other. */
export function findAwsMarketplaceProduct(
productCode: string | null | undefined
): AwsMarketplaceProduct | null {
return productCode ? (getAwsMarketplaceProducts().get(productCode) ?? null) : null
}
Loading
Loading