Skip to content

Commit fbf01e9

Browse files
committed
Merge commit 'fb9163dd0b^' into wip/merge-staging-9740
2 parents dcabc6c + 99a029d commit fbf01e9

57 files changed

Lines changed: 39382 additions & 7 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/_shell/providers/theme-provider.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const NON_LANDING_LIGHT_SEGMENTS = [
2828
'unsubscribe',
2929
'cli',
3030
'credential-groups',
31+
'marketplace',
3132
] as const
3233

3334
/**
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { createLogger } from '@sim/logger'
2+
import { generateShortId } from '@sim/utils/id'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { verifyCronAuth } from '@/lib/auth/internal'
5+
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
6+
import { reconcileAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/reconcile'
7+
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
8+
import { runDetached } from '@/lib/core/utils/background'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
11+
const logger = createLogger('AwsMarketplaceReconcileCron')
12+
13+
const LOCK_KEY = 'aws-marketplace-reconcile-lock'
14+
/** Outlasts the reconcile's 6-minute run budget; the schedule runs every 15 minutes. */
15+
const LOCK_TTL_SECONDS = 10 * 60
16+
17+
export const dynamic = 'force-dynamic'
18+
19+
/**
20+
* Applies AWS Marketplace agreement events from the SQS queue and re-verifies licenses no event
21+
* touched within a day. Acknowledges the cron call at once and works in the background; a Redis
22+
* 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`.
24+
*/
25+
export const GET = withRouteHandler(async (request: NextRequest) => {
26+
const authError = verifyCronAuth(request, 'AWS Marketplace reconcile')
27+
if (authError) {
28+
return authError
29+
}
30+
31+
if (!getAwsMarketplaceProductCode()) {
32+
return NextResponse.json({
33+
success: true,
34+
message: 'AWS Marketplace is not configured – skipped',
35+
status: 'skip',
36+
})
37+
}
38+
39+
const lockValue = generateShortId()
40+
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, {
41+
reclaimOnFailure: true,
42+
})
43+
if (!locked) {
44+
return NextResponse.json(
45+
{
46+
success: true,
47+
message: 'AWS Marketplace reconcile already in progress – skipped',
48+
status: 'skip',
49+
},
50+
{ status: 202 }
51+
)
52+
}
53+
54+
runDetached('aws-marketplace-reconcile', async () => {
55+
try {
56+
const summary = await reconcileAwsMarketplaceLicenses()
57+
logger.info('AWS Marketplace reconcile completed', { ...summary })
58+
} finally {
59+
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
60+
}
61+
})
62+
63+
return NextResponse.json(
64+
{ success: true, message: 'AWS Marketplace reconcile started', status: 'started' },
65+
{ status: 202 }
66+
)
67+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { saveAwsMarketplaceClaimContactContract } from '@/lib/api/contracts/aws-marketplace'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { saveAwsMarketplaceClaimContact } from '@/lib/billing/aws-marketplace/application/use-cases'
9+
import { AWS_MARKETPLACE_CLAIM_COOKIE } from '@/lib/billing/aws-marketplace/claim'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: saveAwsMarketplaceClaimContactContract,
13+
operation: saveAwsMarketplaceClaimContact.operation,
14+
useCase: saveAwsMarketplaceClaimContact,
15+
auth: internalSessionAuth,
16+
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-link' }),
17+
errorPolicy: internalOrchestrationErrorPolicy,
18+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
19+
mapInput: ({ body }, { request }) => ({
20+
licenseId: body.licenseId,
21+
contactEmail: body.contactEmail,
22+
companyName: body.companyName || null,
23+
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
24+
}),
25+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { linkAwsMarketplaceLicenseContract } from '@/lib/api/contracts/aws-marketplace'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { linkAwsMarketplaceLicense } from '@/lib/billing/aws-marketplace/application/use-cases'
9+
import {
10+
AWS_MARKETPLACE_CLAIM_COOKIE,
11+
serializeClaimCookie,
12+
} from '@/lib/billing/aws-marketplace/claim'
13+
14+
export const POST = defineInternalJsonRoute({
15+
contract: linkAwsMarketplaceLicenseContract,
16+
operation: linkAwsMarketplaceLicense.operation,
17+
useCase: linkAwsMarketplaceLicense,
18+
auth: internalSessionAuth,
19+
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-link' }),
20+
errorPolicy: internalOrchestrationErrorPolicy,
21+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
22+
mapInput: ({ body }, { request }) => ({
23+
organizationId: body.organizationId,
24+
licenseId: body.licenseId,
25+
contactEmail: body.contactEmail,
26+
companyName: body.companyName || null,
27+
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
28+
}),
29+
present: () => ({ success: true as const }),
30+
/** The claim is spent once the link commits; the cookie that carried it goes with it. */
31+
finalizeResponse: () => ({ headers: { 'set-cookie': serializeClaimCookie(null) } }),
32+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { registerAwsMarketplaceLicenseToAccountContract } from '@/lib/api/contracts/aws-marketplace'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { registerAwsMarketplaceLicenseToAccount } from '@/lib/billing/aws-marketplace/application/use-cases'
9+
import {
10+
AWS_MARKETPLACE_CLAIM_COOKIE,
11+
serializeClaimCookie,
12+
} from '@/lib/billing/aws-marketplace/claim'
13+
14+
export const POST = defineInternalJsonRoute({
15+
contract: registerAwsMarketplaceLicenseToAccountContract,
16+
operation: registerAwsMarketplaceLicenseToAccount.operation,
17+
useCase: registerAwsMarketplaceLicenseToAccount,
18+
auth: internalSessionAuth,
19+
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-link' }),
20+
errorPolicy: internalOrchestrationErrorPolicy,
21+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
22+
mapInput: ({ body }, { request }) => ({
23+
licenseId: body.licenseId,
24+
contactEmail: body.contactEmail,
25+
companyName: body.companyName || null,
26+
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
27+
}),
28+
present: () => ({ success: true as const }),
29+
/** The claim is spent once the registration commits; the cookie that carried it goes with it. */
30+
finalizeResponse: () => ({ headers: { 'set-cookie': serializeClaimCookie(null) } }),
31+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getAwsMarketplaceClaimContract } from '@/lib/api/contracts/aws-marketplace'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { readAwsMarketplaceClaim } from '@/lib/billing/aws-marketplace/application/use-cases'
9+
import { AWS_MARKETPLACE_CLAIM_COOKIE } from '@/lib/billing/aws-marketplace/claim'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: getAwsMarketplaceClaimContract,
13+
operation: readAwsMarketplaceClaim.operation,
14+
useCase: readAwsMarketplaceClaim,
15+
auth: internalSessionAuth,
16+
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-claim' }),
17+
errorPolicy: internalOrchestrationErrorPolicy,
18+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
19+
mapInput: (_parsed, { request }) => ({
20+
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
21+
}),
22+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { listAccountAwsMarketplaceLicensesContract } from '@/lib/api/contracts/aws-marketplace'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { listAccountAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/application/use-cases'
9+
10+
export const GET = defineInternalJsonRoute({
11+
contract: listAccountAwsMarketplaceLicensesContract,
12+
operation: listAccountAwsMarketplaceLicenses.operation,
13+
useCase: listAccountAwsMarketplaceLicenses,
14+
auth: internalSessionAuth,
15+
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-licenses' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
18+
mapInput: () => ({}),
19+
present: (licenses) => ({ licenses }),
20+
})
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { awsMarketplaceRegistrationFormSchema } from '@/lib/api/contracts/aws-marketplace'
5+
import { serializeClaimCookie } from '@/lib/billing/aws-marketplace/claim'
6+
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
7+
import {
8+
AWS_MARKETPLACE_ONBOARDING_PATH,
9+
type AwsMarketplaceOnboardingError,
10+
} from '@/lib/billing/aws-marketplace/onboarding'
11+
import { registerAwsMarketplaceBuyer } from '@/lib/billing/aws-marketplace/registration'
12+
import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter'
13+
import { readFormDataWithLimit } from '@/lib/core/utils/stream-limits'
14+
import { getBaseUrl } from '@/lib/core/utils/urls'
15+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
16+
17+
export const dynamic = 'force-dynamic'
18+
19+
const logger = createLogger('AwsMarketplaceRegisterAPI')
20+
21+
/** The form carries one opaque token; anything larger is not from AWS. */
22+
const MAX_FORM_BYTES = 16 * 1024
23+
24+
const REGISTER_RATE_LIMIT: TokenBucketConfig = {
25+
maxTokens: 20,
26+
refillRate: 10,
27+
refillIntervalMs: 60_000,
28+
}
29+
30+
function redirectToOnboarding(error?: AwsMarketplaceOnboardingError): NextResponse {
31+
const url = new URL(AWS_MARKETPLACE_ONBOARDING_PATH, getBaseUrl())
32+
if (error) url.searchParams.set('error', error)
33+
return NextResponse.redirect(url, 303)
34+
}
35+
36+
async function readRegistrationToken(request: NextRequest): Promise<string | null> {
37+
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
38+
if (mediaType !== 'application/x-www-form-urlencoded') return null
39+
let form: FormData
40+
try {
41+
form = await readFormDataWithLimit(request, {
42+
maxBytes: MAX_FORM_BYTES,
43+
label: 'AWS Marketplace registration',
44+
})
45+
} catch {
46+
return null
47+
}
48+
const token = form.get('x-amzn-marketplace-token')
49+
const parsed = awsMarketplaceRegistrationFormSchema.safeParse({
50+
'x-amzn-marketplace-token': typeof token === 'string' ? token : undefined,
51+
})
52+
return parsed.success ? parsed.data['x-amzn-marketplace-token'] : null
53+
}
54+
55+
/**
56+
* The listing's fulfillment URL. AWS sends the buyer's browser here with a cross-site form POST,
57+
* so the request carries no Sim session and is authenticated only by the registration token,
58+
* which ResolveCustomer verifies. The response is always a 303 to the onboarding page, carrying
59+
* the claim cookie unless the license lapsed. The token is never logged.
60+
*/
61+
export const POST = withRouteHandler(async (request: NextRequest) => {
62+
const productCode = getAwsMarketplaceProductCode()
63+
if (!productCode) return NextResponse.json({ error: 'Not found' }, { status: 404 })
64+
65+
try {
66+
const rateLimited = await enforceIpRateLimit(
67+
'aws-marketplace-register',
68+
request,
69+
REGISTER_RATE_LIMIT
70+
)
71+
if (rateLimited) return redirectToOnboarding('unavailable')
72+
73+
const token = await readRegistrationToken(request)
74+
if (!token) {
75+
logger.warn('AWS Marketplace registration arrived without a usable token')
76+
return redirectToOnboarding('invalid')
77+
}
78+
79+
const outcome = await registerAwsMarketplaceBuyer(productCode, token)
80+
if (outcome.kind !== 'claimed') return redirectToOnboarding(outcome.kind)
81+
82+
const response = redirectToOnboarding()
83+
response.headers.append('set-cookie', serializeClaimCookie(outcome.claimToken))
84+
response.headers.set('Cache-Control', 'no-store')
85+
return response
86+
} catch (error) {
87+
logger.error('AWS Marketplace registration failed', { error: getErrorMessage(error) })
88+
return redirectToOnboarding('unavailable')
89+
}
90+
})
91+
92+
/** A refresh, back button, or bookmark of the fulfillment URL lands on the onboarding page. */
93+
export const GET = withRouteHandler(async () => {
94+
if (!getAwsMarketplaceProductCode())
95+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
96+
return redirectToOnboarding()
97+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { listOrganizationAwsMarketplaceLicensesContract } from '@/lib/api/contracts/aws-marketplace'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { listOrganizationAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/application/use-cases'
9+
10+
export const GET = defineInternalJsonRoute({
11+
contract: listOrganizationAwsMarketplaceLicensesContract,
12+
operation: listOrganizationAwsMarketplaceLicenses.operation,
13+
useCase: listOrganizationAwsMarketplaceLicenses,
14+
auth: internalSessionAuth,
15+
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-licenses' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
18+
mapInput: ({ params }) => ({ organizationId: params.id }),
19+
present: (licenses) => ({ licenses }),
20+
})

0 commit comments

Comments
 (0)