Skip to content

Commit 1990941

Browse files
committed
feat(billing): AWS Marketplace SaaS contract registration, claim linking, and entitlement reconcile
- POST /api/marketplace/aws/register: the listing's fulfillment URL. Resolves the registration token with ResolveCustomer, rejects a foreign product, calls GetEntitlements for the license on every registration, upserts the license by ARN, and 303s to /marketplace/aws with a one-time httpOnly claim cookie when active. - /marketplace/aws onboarding page: sign in or sign up, review the license, and link it to an organization the viewer owns or administers. - GET /api/marketplace/aws/claim and POST /api/marketplace/aws/claim/link: contracted internal routes over application use cases. Linking requires the open claim and org admin, re-verifies the entitlement, refuses a license linked elsewhere, consumes the claim, audits the link, and emails the enterprise inbox. No subscription changes. - GET /api/cron/aws-marketplace-reconcile: drains agreement events from SQS in bounded batches and re-verifies licenses not checked within a day, under a Redis lock. - aws_marketplace_license table (migration 0454); AWS_MARKETPLACE_PRODUCT_CODE and AWS_MARKETPLACE_EVENTS_QUEUE_URL env vars. With no product code every surface is off.
1 parent 79dbf14 commit 1990941

31 files changed

Lines changed: 35000 additions & 0 deletions

File tree

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+
/** Covers a full drain plus the re-verification batch; 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
26+
}),
27+
present: (_linked, { input }) => ({
28+
success: true as const,
29+
organizationId: input.organizationId,
30+
}),
31+
/** The claim is spent once the link commits; the cookie that carried it goes with it. */
32+
finalizeResponse: () => ({ headers: { 'set-cookie': serializeClaimCookie(null) } }),
33+
})
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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { awsMarketplaceRegistrationFormSchema } from '@/lib/api/contracts/aws-marketplace'
4+
import { serializeClaimCookie } from '@/lib/billing/aws-marketplace/claim'
5+
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
6+
import {
7+
AWS_MARKETPLACE_ONBOARDING_PATH,
8+
type AwsMarketplaceOnboardingError,
9+
} from '@/lib/billing/aws-marketplace/onboarding'
10+
import { registerAwsMarketplaceBuyer } from '@/lib/billing/aws-marketplace/registration'
11+
import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter'
12+
import { readFormDataWithLimit } from '@/lib/core/utils/stream-limits'
13+
import { getBaseUrl } from '@/lib/core/utils/urls'
14+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
15+
16+
export const dynamic = 'force-dynamic'
17+
18+
const logger = createLogger('AwsMarketplaceRegisterAPI')
19+
20+
/** The form carries one opaque token; anything larger is not from AWS. */
21+
const MAX_FORM_BYTES = 16 * 1024
22+
23+
const REGISTER_RATE_LIMIT: TokenBucketConfig = {
24+
maxTokens: 20,
25+
refillRate: 10,
26+
refillIntervalMs: 60_000,
27+
}
28+
29+
function redirectToOnboarding(error?: AwsMarketplaceOnboardingError): NextResponse {
30+
const url = new URL(AWS_MARKETPLACE_ONBOARDING_PATH, getBaseUrl())
31+
if (error) url.searchParams.set('error', error)
32+
return NextResponse.redirect(url, 303)
33+
}
34+
35+
async function readRegistrationToken(request: NextRequest): Promise<string | null> {
36+
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
37+
if (mediaType !== 'application/x-www-form-urlencoded') return null
38+
let form: FormData
39+
try {
40+
form = await readFormDataWithLimit(request, {
41+
maxBytes: MAX_FORM_BYTES,
42+
label: 'AWS Marketplace registration',
43+
})
44+
} catch {
45+
return null
46+
}
47+
const parsed = awsMarketplaceRegistrationFormSchema.safeParse({
48+
'x-amzn-marketplace-token': form.get('x-amzn-marketplace-token'),
49+
})
50+
return parsed.success ? parsed.data['x-amzn-marketplace-token'] : null
51+
}
52+
53+
/**
54+
* The listing's fulfillment URL. AWS sends the buyer's browser here with a cross-site form POST,
55+
* so the request carries no Sim session and is authenticated only by the registration token,
56+
* which ResolveCustomer verifies. The response is always a 303 to the onboarding page, carrying
57+
* the claim cookie when the license is active. The token is never logged.
58+
*/
59+
export const POST = withRouteHandler(async (request: NextRequest) => {
60+
const productCode = getAwsMarketplaceProductCode()
61+
if (!productCode) return NextResponse.json({ error: 'Not found' }, { status: 404 })
62+
63+
const rateLimited = await enforceIpRateLimit(
64+
'aws-marketplace-register',
65+
request,
66+
REGISTER_RATE_LIMIT
67+
)
68+
if (rateLimited) return rateLimited
69+
70+
const token = await readRegistrationToken(request)
71+
if (!token) {
72+
logger.warn('AWS Marketplace registration arrived without a usable token')
73+
return redirectToOnboarding('invalid')
74+
}
75+
76+
const outcome = await registerAwsMarketplaceBuyer(productCode, token)
77+
if (outcome.kind !== 'claimed') return redirectToOnboarding(outcome.kind)
78+
79+
const response = redirectToOnboarding()
80+
response.headers.append('set-cookie', serializeClaimCookie(outcome.claimToken))
81+
response.headers.set('Cache-Control', 'no-store')
82+
return response
83+
})

0 commit comments

Comments
 (0)