Skip to content

Commit 392dc44

Browse files
authored
feat(aws-marketplace): sell several listings from one integration through a product catalog (#9920)
* feat(aws-marketplace): sell several listings from one integration through a product catalog * improvement(aws-marketplace): resolve each run's products once, keep env free of billing code, and drop derivable state * fix(aws-marketplace): treat a usage-only read as no answer about access, and refuse to boot on a malformed product catalog * fix(aws-marketplace): discover a license from any entitlement and decide access from the plan's dimension
1 parent 56f7ed7 commit 392dc44

18 files changed

Lines changed: 406 additions & 140 deletions

File tree

‎apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { generateShortId } from '@sim/utils/id'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { verifyCronAuth } from '@/lib/auth/internal'
5-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
5+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
66
import { reconcileAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/reconcile'
77
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
88
import { runDetached } from '@/lib/core/utils/background'
@@ -20,15 +20,15 @@ export const dynamic = 'force-dynamic'
2020
* Applies AWS Marketplace agreement events from the SQS queue and re-verifies licenses no event
2121
* touched within a day. Acknowledges the cron call at once and works in the background; a Redis
2222
* lock keeps runs from overlapping, and every write is idempotent, so a replayed run is harmless.
23-
* A no-op on deployments without `AWS_MARKETPLACE_PRODUCT_CODE`.
23+
* A no-op on deployments without `AWS_MARKETPLACE_PRODUCTS`.
2424
*/
2525
export const GET = withRouteHandler(async (request: NextRequest) => {
2626
const authError = verifyCronAuth(request, 'AWS Marketplace reconcile')
2727
if (authError) {
2828
return authError
2929
}
3030

31-
if (!getAwsMarketplaceProductCode()) {
31+
if (!isAwsMarketplaceEnabled()) {
3232
return NextResponse.json({
3333
success: true,
3434
message: 'AWS Marketplace is not configured – skipped',

‎apps/sim/app/api/marketplace/aws/register/route.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { awsMarketplaceRegistrationFormSchema } from '@/lib/api/contracts/aws-marketplace'
55
import { serializeClaimCookie } from '@/lib/billing/aws-marketplace/claim'
6-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
6+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
77
import {
88
AWS_MARKETPLACE_ONBOARDING_PATH,
99
type AwsMarketplaceOnboardingError,
@@ -59,8 +59,7 @@ async function readRegistrationToken(request: NextRequest): Promise<string | nul
5959
* the claim cookie unless the license lapsed. The token is never logged.
6060
*/
6161
export const POST = withRouteHandler(async (request: NextRequest) => {
62-
const productCode = getAwsMarketplaceProductCode()
63-
if (!productCode) return NextResponse.json({ error: 'Not found' }, { status: 404 })
62+
if (!isAwsMarketplaceEnabled()) return NextResponse.json({ error: 'Not found' }, { status: 404 })
6463

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

79-
const outcome = await registerAwsMarketplaceBuyer(productCode, token)
78+
const outcome = await registerAwsMarketplaceBuyer(token)
8079
if (outcome.kind !== 'claimed') return redirectToOnboarding(outcome.kind)
8180

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

9291
/** A refresh, back button, or bookmark of the fulfillment URL lands on the onboarding page. */
9392
export const GET = withRouteHandler(async () => {
94-
if (!getAwsMarketplaceProductCode())
95-
return NextResponse.json({ error: 'Not found' }, { status: 404 })
93+
if (!isAwsMarketplaceEnabled()) return NextResponse.json({ error: 'Not found' }, { status: 404 })
9694
return redirectToOnboarding()
9795
})

‎apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
linkAwsMarketplaceLicenseAsAdmin,
1010
listUnlinkedAwsMarketplaceLicenses,
1111
} from '@/lib/billing/aws-marketplace/application/admin'
12-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
12+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
1313
import { asOrchestrationError } from '@/lib/core/orchestration/types'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1515
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
@@ -29,7 +29,7 @@ const logger = createLogger('AdminAwsMarketplaceLicensesAPI')
2929
/** The Marketplace licenses no organization holds yet. */
3030
export const GET = withRouteHandler(
3131
withAdminAuth(async (request) => {
32-
if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace')
32+
if (!isAwsMarketplaceEnabled()) return adminNotFoundResponse('AWS Marketplace')
3333
const parsed = await parseRequest(
3434
adminDashboardListAwsMarketplaceLicensesContract,
3535
request,
@@ -56,7 +56,7 @@ export const GET = withRouteHandler(
5656
/** Links a Marketplace license to an organization once the team has provisioned it. */
5757
export const POST = withRouteHandler(
5858
withAdminAuth(async (request) => {
59-
if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace')
59+
if (!isAwsMarketplaceEnabled()) return adminNotFoundResponse('AWS Marketplace')
6060
const parsed = await parseRequest(
6161
adminDashboardLinkAwsMarketplaceLicenseContract,
6262
request,

‎apps/sim/app/marketplace/aws/page.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Metadata } from 'next'
22
import { notFound } from 'next/navigation'
3-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
3+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
44
import { AWS_MARKETPLACE_ONBOARDING_ERRORS } from '@/lib/billing/aws-marketplace/onboarding'
55
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
66
import { AwsMarketplaceOnboarding } from '@/app/marketplace/aws/aws-marketplace-onboarding'
@@ -18,7 +18,7 @@ interface AwsMarketplacePageProps {
1818

1919
/** Where AWS Marketplace buyers land after registration, to link their subscription. */
2020
export default async function AwsMarketplacePage({ searchParams }: AwsMarketplacePageProps) {
21-
if (!getAwsMarketplaceProductCode()) notFound()
21+
if (!isAwsMarketplaceEnabled()) notFound()
2222
const { error } = await searchParams
2323
const registrationError = AWS_MARKETPLACE_ONBOARDING_ERRORS.find((code) => code === error) ?? null
2424
return (

‎apps/sim/bootstrap.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
/**
2-
* Container entrypoint. Hydrates `process.env` from the runtime secret, resolves an AWS
3-
* Marketplace Enterprise license into `ENTERPRISE_ENABLED` on Marketplace installs, and drops
2+
* Container entrypoint. Hydrates `process.env` from the runtime secret, refuses to start on a
3+
* malformed `AWS_MARKETPLACE_PRODUCTS` (so a bad deploy rolls back instead of failing buyers and
4+
* checkouts later), resolves an AWS Marketplace Enterprise license into `ENTERPRISE_ENABLED` on
5+
* Marketplace installs, and drops
46
* Next's route cache when an earlier build wrote it, before loading the Next.js standalone server,
57
* so application modules that read env at import time see the full configuration. See
68
* `@sim/runtime-secrets`.
79
*/
810
import { fileURLToPath } from 'node:url'
911
import { loadRuntimeSecrets } from '@sim/runtime-secrets'
1012
import { applyAwsMarketplaceContainerLicense } from '@/lib/billing/aws-marketplace/container-license'
13+
import { parseAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/products'
1114
import { clearRouteCacheFromOtherBuild } from '@/lib/core/utils/route-cache'
1215

1316
await loadRuntimeSecrets()
17+
if (process.env.AWS_MARKETPLACE_PRODUCTS) {
18+
parseAwsMarketplaceProducts(process.env.AWS_MARKETPLACE_PRODUCTS)
19+
}
1420
await applyAwsMarketplaceContainerLicense()
1521
await clearRouteCacheFromOtherBuild(fileURLToPath(new URL('./.next', import.meta.url)))
1622
// `server.js` is the Next standalone build artifact, a sibling of this file in

‎apps/sim/lib/billing/authorization.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { subscription } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { APIError } from 'better-auth/api'
55
import { and, eq, isNotNull } from 'drizzle-orm'
6-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
6+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
77
import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository'
88
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
99
import {
@@ -64,7 +64,7 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise
6464
* license lookup fails.
6565
*/
6666
async function assertNotBilledThroughMarketplace(referenceId: string): Promise<void> {
67-
if (!getAwsMarketplaceProductCode()) return
67+
if (!isAwsMarketplaceEnabled()) return
6868
if (!(await holdsMarketplaceLicense(referenceId))) return
6969

7070
logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId })

‎apps/sim/lib/billing/aws-marketplace/application/use-cases.ts‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { db } from '@sim/db'
33
import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace'
44
import { awsMarketplaceOperations } from '@/lib/billing/aws-marketplace/application/operations'
55
import { hashClaimToken } from '@/lib/billing/aws-marketplace/claim'
6-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
6+
import {
7+
findAwsMarketplaceProduct,
8+
isAwsMarketplaceEnabled,
9+
} from '@/lib/billing/aws-marketplace/config'
710
import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements'
811
import {
912
type AwsMarketplaceLicenseRecord,
@@ -27,10 +30,15 @@ import { requireOrganizationSubjectMembership } from '@/lib/core/application/org
2730
import { OrchestrationError } from '@/lib/core/orchestration/types'
2831

2932
/** The same answer as an unknown route, so a deployment that does not sell on AWS shows nothing. */
30-
function requireProductCode(): string {
31-
const productCode = getAwsMarketplaceProductCode()
32-
if (!productCode) throw new OrchestrationError('not_found', 'Not found')
33-
return productCode
33+
function requireMarketplaceEnabled(): void {
34+
if (!isAwsMarketplaceEnabled()) throw new OrchestrationError('not_found', 'Not found')
35+
}
36+
37+
/** The listing a license was sold under; one this deployment no longer sells reads as unknown. */
38+
function requireLicenseProduct(license: AwsMarketplaceLicenseRecord) {
39+
const product = findAwsMarketplaceProduct(license.productCode)
40+
if (!product) throw new OrchestrationError('not_found', 'Not found')
41+
return product
3442
}
3543

3644
function isClaimOpen(license: AwsMarketplaceLicenseRecord, now: Date): boolean {
@@ -115,7 +123,7 @@ export const readAwsMarketplaceClaim: OperationUseCase<
115123
operation: awsMarketplaceOperations.readClaim,
116124
async execute({ principal, input }) {
117125
assertOperationPrincipal(principal, awsMarketplaceOperations.readClaim)
118-
requireProductCode()
126+
requireMarketplaceEnabled()
119127
if (!input.claimToken) return { status: 'missing' }
120128
const license = await findLicenseByClaimHash(hashClaimToken(input.claimToken))
121129
if (!license || !isClaimOpen(license, new Date())) return { status: 'missing' }
@@ -169,7 +177,7 @@ function contactOf(input: BuyerContact) {
169177
* rechecks the claim under the row lock, so two tabs racing one claim take it at most once.
170178
*/
171179
async function verifyClaimedLicense(input: ClaimInput, organizationId: string | null) {
172-
const productCode = requireProductCode()
180+
requireMarketplaceEnabled()
173181
const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
174182
const claimed = requireOpenClaim(
175183
claimHash ? await findLicenseByClaimHash(claimHash) : null,
@@ -179,12 +187,12 @@ async function verifyClaimedLicense(input: ClaimInput, organizationId: string |
179187
requireLinkableTo(claimed, organizationId)
180188

181189
const verifiedAt = new Date()
182-
const summary = await verifyLicense(productCode, claimed.licenseArn)
190+
const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn)
183191
const verification = { summary, verifiedAt }
184192
if (!summary.active) {
185193
const outcome = await recordLicenseVerification({
186194
licenseArn: claimed.licenseArn,
187-
productCode,
195+
productCode: claimed.productCode,
188196
verification,
189197
})
190198
throw new OrchestrationError(
@@ -321,15 +329,15 @@ export const saveAwsMarketplaceClaimContact: OperationUseCase<
321329
operation: awsMarketplaceOperations.saveClaimContact,
322330
async execute({ principal, input }) {
323331
assertOperationPrincipal(principal, awsMarketplaceOperations.saveClaimContact)
324-
const productCode = requireProductCode()
332+
requireMarketplaceEnabled()
325333
const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
326334
const claimed = requireOpenClaim(
327335
claimHash ? await findLicenseByClaimHash(claimHash) : null,
328336
input.licenseId,
329337
claimHash
330338
)
331339
const verifiedAt = new Date()
332-
const summary = await verifyLicense(productCode, claimed.licenseArn)
340+
const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn)
333341
return db.transaction(async (tx) => {
334342
const locked = requireOpenClaim(
335343
await lockLicense(tx, input.licenseId),
@@ -360,7 +368,7 @@ function toLicenseStatusList(rows: Awaited<ReturnType<typeof listOrganizationLic
360368
export const listOrganizationAwsMarketplaceLicenses = defineAuthorizedOrganizationUseCase({
361369
operation: awsMarketplaceOperations.listOrganizationLicenses,
362370
async execute({ context }: OrganizationUseCaseContext<{ organizationId: string }>) {
363-
if (!getAwsMarketplaceProductCode()) return []
371+
if (!isAwsMarketplaceEnabled()) return []
364372
return toLicenseStatusList(await listOrganizationLicenses(context.organizationId))
365373
},
366374
})
@@ -374,7 +382,7 @@ export const listAccountAwsMarketplaceLicenses: OperationUseCase<
374382
operation: awsMarketplaceOperations.listAccountLicenses,
375383
async execute({ principal }) {
376384
assertOperationPrincipal(principal, awsMarketplaceOperations.listAccountLicenses)
377-
if (!getAwsMarketplaceProductCode()) return []
385+
if (!isAwsMarketplaceEnabled()) return []
378386
return toLicenseStatusList(await listAccountLicenses(principal.userId))
379387
},
380388
}

‎apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ const PRODUCT = 'product-placeholder'
88
const ACCOUNT = '000000000000'
99
const TEAM_PLAN = 'team_6000'
1010

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

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

1717
import {
@@ -84,7 +84,7 @@ async function organizationWithLicense(status: LicenseStatus | null) {
8484
}
8585

8686
afterEach(() => {
87-
productCode.current = PRODUCT
87+
marketplace.enabled = true
8888
})
8989

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

152152
await expect(
153153
authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN)
Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
1+
import {
2+
type AwsMarketplaceProduct,
3+
parseAwsMarketplaceProducts,
4+
} from '@/lib/billing/aws-marketplace/products'
15
import { env } from '@/lib/core/config/env'
26

7+
let products: ReadonlyMap<string, AwsMarketplaceProduct> | undefined
8+
39
/**
4-
* The SaaS product code, or null when this deployment does not sell through AWS Marketplace:
5-
* self-hosted installs and every environment but production. Null turns the registration,
6-
* claim, and reconcile surfaces off.
10+
* The products this deployment sells through AWS Marketplace, by product code. Empty on
11+
* self-hosted installs and every environment but production, which turns the Marketplace surfaces
12+
* off. The container entrypoint refuses to start on a malformed setting.
713
*/
8-
export function getAwsMarketplaceProductCode(): string | null {
9-
return env.AWS_MARKETPLACE_PRODUCT_CODE ?? null
14+
export function getAwsMarketplaceProducts(): ReadonlyMap<string, AwsMarketplaceProduct> {
15+
products ??= env.AWS_MARKETPLACE_PRODUCTS
16+
? parseAwsMarketplaceProducts(env.AWS_MARKETPLACE_PRODUCTS)
17+
: new Map()
18+
return products
19+
}
20+
21+
export function isAwsMarketplaceEnabled(): boolean {
22+
return getAwsMarketplaceProducts().size > 0
23+
}
24+
25+
/** The configured product for a code AWS returned or a license row stored; null for any other. */
26+
export function findAwsMarketplaceProduct(
27+
productCode: string | null | undefined
28+
): AwsMarketplaceProduct | null {
29+
return productCode ? (getAwsMarketplaceProducts().get(productCode) ?? null) : null
1030
}

0 commit comments

Comments
 (0)