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
1 change: 1 addition & 0 deletions apps/sim/app/_shell/providers/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const NON_LANDING_LIGHT_SEGMENTS = [
'unsubscribe',
'cli',
'credential-groups',
'marketplace',
] as const

/**
Expand Down
67 changes: 67 additions & 0 deletions apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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 { reconcileAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/reconcile'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { runDetached } from '@/lib/core/utils/background'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('AwsMarketplaceReconcileCron')

const LOCK_KEY = 'aws-marketplace-reconcile-lock'
/** Outlasts the reconcile's 6-minute run budget; the schedule runs every 15 minutes. */
const LOCK_TTL_SECONDS = 10 * 60
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved

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`.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
const authError = verifyCronAuth(request, 'AWS Marketplace reconcile')
if (authError) {
return authError
}

if (!getAwsMarketplaceProductCode()) {
return NextResponse.json({
success: true,
message: 'AWS Marketplace is not configured – skipped',
status: 'skip',
})
}

const lockValue = generateShortId()
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, {
reclaimOnFailure: true,
})
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'AWS Marketplace reconcile already in progress – skipped',
status: 'skip',
},
{ status: 202 }
)
}

runDetached('aws-marketplace-reconcile', async () => {
try {
const summary = await reconcileAwsMarketplaceLicenses()
logger.info('AWS Marketplace reconcile completed', { ...summary })
} finally {
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
}
})

return NextResponse.json(
{ success: true, message: 'AWS Marketplace reconcile started', status: 'started' },
{ status: 202 }
)
})
25 changes: 25 additions & 0 deletions apps/sim/app/api/marketplace/aws/claim/contact/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { saveAwsMarketplaceClaimContactContract } from '@/lib/api/contracts/aws-marketplace'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { saveAwsMarketplaceClaimContact } from '@/lib/billing/aws-marketplace/application/use-cases'
import { AWS_MARKETPLACE_CLAIM_COOKIE } from '@/lib/billing/aws-marketplace/claim'

export const POST = defineInternalJsonRoute({
contract: saveAwsMarketplaceClaimContactContract,
operation: saveAwsMarketplaceClaimContact.operation,
useCase: saveAwsMarketplaceClaimContact,
auth: internalSessionAuth,
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-link' }),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
mapInput: ({ body }, { request }) => ({
licenseId: body.licenseId,
contactEmail: body.contactEmail,
companyName: body.companyName || null,
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
}),
})
32 changes: 32 additions & 0 deletions apps/sim/app/api/marketplace/aws/claim/link/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { linkAwsMarketplaceLicenseContract } from '@/lib/api/contracts/aws-marketplace'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { linkAwsMarketplaceLicense } from '@/lib/billing/aws-marketplace/application/use-cases'
import {
AWS_MARKETPLACE_CLAIM_COOKIE,
serializeClaimCookie,
} from '@/lib/billing/aws-marketplace/claim'

export const POST = defineInternalJsonRoute({
contract: linkAwsMarketplaceLicenseContract,
operation: linkAwsMarketplaceLicense.operation,
useCase: linkAwsMarketplaceLicense,
auth: internalSessionAuth,
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-link' }),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
mapInput: ({ body }, { request }) => ({
organizationId: body.organizationId,
licenseId: body.licenseId,
contactEmail: body.contactEmail,
companyName: body.companyName || null,
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
}),
present: () => ({ success: true as const }),
/** The claim is spent once the link commits; the cookie that carried it goes with it. */
finalizeResponse: () => ({ headers: { 'set-cookie': serializeClaimCookie(null) } }),
})
31 changes: 31 additions & 0 deletions apps/sim/app/api/marketplace/aws/claim/register-account/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { registerAwsMarketplaceLicenseToAccountContract } from '@/lib/api/contracts/aws-marketplace'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { registerAwsMarketplaceLicenseToAccount } from '@/lib/billing/aws-marketplace/application/use-cases'
import {
AWS_MARKETPLACE_CLAIM_COOKIE,
serializeClaimCookie,
} from '@/lib/billing/aws-marketplace/claim'

export const POST = defineInternalJsonRoute({
contract: registerAwsMarketplaceLicenseToAccountContract,
operation: registerAwsMarketplaceLicenseToAccount.operation,
useCase: registerAwsMarketplaceLicenseToAccount,
auth: internalSessionAuth,
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-link' }),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
mapInput: ({ body }, { request }) => ({
licenseId: body.licenseId,
contactEmail: body.contactEmail,
companyName: body.companyName || null,
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
}),
present: () => ({ success: true as const }),
/** The claim is spent once the registration commits; the cookie that carried it goes with it. */
finalizeResponse: () => ({ headers: { 'set-cookie': serializeClaimCookie(null) } }),
})
22 changes: 22 additions & 0 deletions apps/sim/app/api/marketplace/aws/claim/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getAwsMarketplaceClaimContract } from '@/lib/api/contracts/aws-marketplace'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { readAwsMarketplaceClaim } from '@/lib/billing/aws-marketplace/application/use-cases'
import { AWS_MARKETPLACE_CLAIM_COOKIE } from '@/lib/billing/aws-marketplace/claim'

export const GET = defineInternalJsonRoute({
contract: getAwsMarketplaceClaimContract,
operation: readAwsMarketplaceClaim.operation,
useCase: readAwsMarketplaceClaim,
auth: internalSessionAuth,
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-claim' }),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
mapInput: (_parsed, { request }) => ({
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
}),
})
20 changes: 20 additions & 0 deletions apps/sim/app/api/marketplace/aws/licenses/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { listAccountAwsMarketplaceLicensesContract } from '@/lib/api/contracts/aws-marketplace'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { listAccountAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/application/use-cases'

export const GET = defineInternalJsonRoute({
contract: listAccountAwsMarketplaceLicensesContract,
operation: listAccountAwsMarketplaceLicenses.operation,
useCase: listAccountAwsMarketplaceLicenses,
auth: internalSessionAuth,
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-licenses' }),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
mapInput: () => ({}),
present: (licenses) => ({ licenses }),
})
97 changes: 97 additions & 0 deletions apps/sim/app/api/marketplace/aws/register/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createLogger } from '@sim/logger'
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 {
AWS_MARKETPLACE_ONBOARDING_PATH,
type AwsMarketplaceOnboardingError,
} from '@/lib/billing/aws-marketplace/onboarding'
import { registerAwsMarketplaceBuyer } from '@/lib/billing/aws-marketplace/registration'
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter'
import { readFormDataWithLimit } from '@/lib/core/utils/stream-limits'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

export const dynamic = 'force-dynamic'

const logger = createLogger('AwsMarketplaceRegisterAPI')

/** The form carries one opaque token; anything larger is not from AWS. */
const MAX_FORM_BYTES = 16 * 1024

const REGISTER_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 20,
refillRate: 10,
refillIntervalMs: 60_000,
}

function redirectToOnboarding(error?: AwsMarketplaceOnboardingError): NextResponse {
const url = new URL(AWS_MARKETPLACE_ONBOARDING_PATH, getBaseUrl())
if (error) url.searchParams.set('error', error)
return NextResponse.redirect(url, 303)
}

async function readRegistrationToken(request: NextRequest): Promise<string | null> {
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (mediaType !== 'application/x-www-form-urlencoded') return null
let form: FormData
try {
form = await readFormDataWithLimit(request, {
maxBytes: MAX_FORM_BYTES,
label: 'AWS Marketplace registration',
})
} catch {
return null
}
const token = form.get('x-amzn-marketplace-token')
const parsed = awsMarketplaceRegistrationFormSchema.safeParse({
'x-amzn-marketplace-token': typeof token === 'string' ? token : undefined,
})
return parsed.success ? parsed.data['x-amzn-marketplace-token'] : null
}

/**
* The listing's fulfillment URL. AWS sends the buyer's browser here with a cross-site form POST,
* so the request carries no Sim session and is authenticated only by the registration token,
* which ResolveCustomer verifies. The response is always a 303 to the onboarding page, carrying
* 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 })

try {
const rateLimited = await enforceIpRateLimit(
'aws-marketplace-register',
request,
REGISTER_RATE_LIMIT
)
if (rateLimited) return redirectToOnboarding('unavailable')

const token = await readRegistrationToken(request)
if (!token) {
logger.warn('AWS Marketplace registration arrived without a usable token')
return redirectToOnboarding('invalid')
}

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

const response = redirectToOnboarding()
response.headers.append('set-cookie', serializeClaimCookie(outcome.claimToken))
response.headers.set('Cache-Control', 'no-store')
return response
} catch (error) {
logger.error('AWS Marketplace registration failed', { error: getErrorMessage(error) })
return redirectToOnboarding('unavailable')
}
})

/** 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 })
return redirectToOnboarding()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { listOrganizationAwsMarketplaceLicensesContract } from '@/lib/api/contracts/aws-marketplace'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { listOrganizationAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/application/use-cases'

export const GET = defineInternalJsonRoute({
contract: listOrganizationAwsMarketplaceLicensesContract,
operation: listOrganizationAwsMarketplaceLicenses.operation,
useCase: listOrganizationAwsMarketplaceLicenses,
auth: internalSessionAuth,
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-licenses' }),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
mapInput: ({ params }) => ({ organizationId: params.id }),
present: (licenses) => ({ licenses }),
})
Loading
Loading