Skip to content

Commit a62e318

Browse files
committed
feat(billing): register AWS Marketplace licenses to an account and sign Marketplace calls as the task role
- POST /api/marketplace/aws/claim/register-account: a session-only application operation for a buyer who administers no organization (or prefers not to pick one). Same guards as linking: an open claim for this license, rechecked under the row lock, and a fresh entitlement read that must be current. Refuses a license already linked to an organization. Records the buyer as linkedBy with no organization, consumes the claim, clears the cookie, audits aws_marketplace_license.registered, and emails the enterprise inbox to create the buyer's organization. Such a license can still be linked later. - Onboarding page: registering is the primary action with no administered organization and a secondary one beside the organization picker; a registered success state. - Marketplace and event-queue clients use the ECS container credential provider when the container credential endpoint is present, since the app env also carries static user keys the default chain would prefer; elsewhere they keep the default chain.
1 parent 2887894 commit a62e318

14 files changed

Lines changed: 457 additions & 112 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
25+
}),
26+
present: () => ({ success: true as const }),
27+
/** The claim is spent once the registration commits; the cookie that carried it goes with it. */
28+
finalizeResponse: () => ({ headers: { 'set-cookie': serializeClaimCookie(null) } }),
29+
})

‎apps/sim/app/marketplace/aws/aws-marketplace-onboarding.tsx‎

Lines changed: 85 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useState } from 'react'
44
import { AuthField, AuthFormMessage, AuthSelect, AuthTag } from '@sim/auth-ui'
55
import { useRouter } from 'next/navigation'
66
import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace'
7-
import { client, useSession } from '@/lib/auth/client'
7+
import { useSession } from '@/lib/auth/client'
88
import {
99
AWS_MARKETPLACE_ONBOARDING_PATH,
1010
type AwsMarketplaceOnboardingError,
@@ -15,6 +15,7 @@ import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
1515
import {
1616
useAwsMarketplaceClaim,
1717
useLinkAwsMarketplaceLicense,
18+
useRegisterAwsMarketplaceLicenseToAccount,
1819
} from '@/hooks/queries/aws-marketplace'
1920

2021
const RESTART_FROM_AWS =
@@ -64,6 +65,7 @@ export function AwsMarketplaceOnboarding({
6465
const viewerId = session?.user?.id ?? null
6566
const claimQuery = useAwsMarketplaceClaim(registrationError ? null : viewerId)
6667
const linkLicense = useLinkAwsMarketplaceLicense()
68+
const registerToAccount = useRegisterAwsMarketplaceLicenseToAccount()
6769
const returnHome = { label: 'Return to Home', onClick: () => router.push('/') }
6870

6971
if (registrationError) {
@@ -122,6 +124,19 @@ export function AwsMarketplaceOnboarding({
122124
)
123125
}
124126

127+
if (registerToAccount.isSuccess) {
128+
return (
129+
<InviteLayout>
130+
<InviteStatusCard
131+
type='success'
132+
title='Subscription registered'
133+
description='Our team will set up your Enterprise workspace and email you when it is ready.'
134+
actions={[{ label: 'Go to Sim', onClick: () => router.push(APP_ENTRY_PATH) }]}
135+
/>
136+
</InviteLayout>
137+
)
138+
}
139+
125140
if (claimQuery.isPending) {
126141
return (
127142
<InviteLayout>
@@ -157,50 +172,52 @@ export function AwsMarketplaceOnboarding({
157172
)
158173
}
159174

160-
if (claim.organizations.length === 0) {
161-
return (
162-
<InviteLayout>
163-
<InviteStatusCard
164-
type='warning'
165-
title='No organization to link'
166-
description='Only an owner or admin of a Sim organization can link this subscription. Sign in with that account in this browser.'
167-
actions={[
168-
{
169-
label: 'Sign in with a different account',
170-
onClick: async () => {
171-
await client.signOut()
172-
router.push(authLink('/login'))
173-
},
174-
},
175-
returnHome,
176-
]}
177-
/>
178-
</InviteLayout>
179-
)
180-
}
181-
175+
const pending = linkLicense.isPending || registerToAccount.isPending
182176
return (
183177
<LinkLicenseCard
184178
claim={claim}
179+
pending={pending}
185180
linking={linkLicense.isPending}
186-
error={linkLicense.error?.message ?? null}
187-
onLink={(organizationId) =>
181+
registering={registerToAccount.isPending}
182+
error={(linkLicense.error ?? registerToAccount.error)?.message ?? null}
183+
onLink={(organizationId) => {
184+
registerToAccount.reset()
188185
linkLicense.mutate({ licenseId: claim.license.id, organizationId })
189-
}
186+
}}
187+
onRegister={() => {
188+
linkLicense.reset()
189+
registerToAccount.mutate({ licenseId: claim.license.id })
190+
}}
190191
onCancel={returnHome.onClick}
191192
/>
192193
)
193194
}
194195

195196
interface LinkLicenseCardProps {
196197
claim: Extract<AwsMarketplaceClaim, { status: 'ready' }>
198+
pending: boolean
197199
linking: boolean
200+
registering: boolean
198201
error: string | null
199202
onLink: (organizationId: string) => void
203+
onRegister: () => void
200204
onCancel: () => void
201205
}
202206

203-
function LinkLicenseCard({ claim, linking, error, onLink, onCancel }: LinkLicenseCardProps) {
207+
/**
208+
* Links the license to an organization the viewer administers, or registers it to their
209+
* account when they administer none or would rather not pick one.
210+
*/
211+
function LinkLicenseCard({
212+
claim,
213+
pending,
214+
linking,
215+
registering,
216+
error,
217+
onLink,
218+
onRegister,
219+
onCancel,
220+
}: LinkLicenseCardProps) {
204221
const { license, organizations } = claim
205222
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string>()
206223
const linkedOrganization = organizations.find((org) => org.id === license.organizationId)
@@ -218,12 +235,28 @@ function LinkLicenseCard({ claim, linking, error, onLink, onCancel }: LinkLicens
218235
},
219236
]
220237

238+
const hasOrganizations = organizations.length > 0
239+
const registerAction = {
240+
label: hasOrganizations ? 'Register to my account instead' : 'Register to my account',
241+
onClick: onRegister,
242+
disabled: pending,
243+
loading: registering,
244+
}
245+
221246
return (
222247
<InviteLayout>
223248
<InviteStatusCard
224249
type='invitation'
225-
title='Link your AWS Marketplace subscription'
226-
description='Choose the organization this subscription is for. Our team will finish setting up your plan after you link it.'
250+
title={
251+
hasOrganizations
252+
? 'Link your AWS Marketplace subscription'
253+
: 'Set up your AWS Marketplace subscription'
254+
}
255+
description={
256+
hasOrganizations
257+
? 'Choose the organization this subscription is for, or register it to your account. Our team will finish setting up your plan.'
258+
: 'Register this subscription to your account. Our team will set up your Enterprise workspace and email you.'
259+
}
227260
details={
228261
<div className='au:w-full au:space-y-4'>
229262
<dl className='au:w-full au:space-y-2 au:text-sm au:tabular-nums'>
@@ -240,26 +273,33 @@ function LinkLicenseCard({ claim, linking, error, onLink, onCancel }: LinkLicens
240273
</dd>
241274
</div>
242275
</dl>
243-
<AuthField htmlFor='aws-marketplace-organization' label='Organization'>
244-
<AuthSelect
245-
id='aws-marketplace-organization'
246-
options={organizations.map((org) => ({ label: org.name, value: org.id }))}
247-
value={organizationId}
248-
onChange={setSelectedOrganizationId}
249-
placeholder='Select an organization'
250-
fullWidth
251-
/>
252-
</AuthField>
276+
{hasOrganizations && (
277+
<AuthField htmlFor='aws-marketplace-organization' label='Organization'>
278+
<AuthSelect
279+
id='aws-marketplace-organization'
280+
options={organizations.map((org) => ({ label: org.name, value: org.id }))}
281+
value={organizationId}
282+
onChange={setSelectedOrganizationId}
283+
placeholder='Select an organization'
284+
fullWidth
285+
/>
286+
</AuthField>
287+
)}
253288
{error && <AuthFormMessage type='error'>{error}</AuthFormMessage>}
254289
</div>
255290
}
256291
actions={[
257-
{
258-
label: 'Link subscription',
259-
onClick: () => organizationId && onLink(organizationId),
260-
disabled: !organizationId || linking,
261-
loading: linking,
262-
},
292+
...(hasOrganizations
293+
? [
294+
{
295+
label: 'Link subscription',
296+
onClick: () => organizationId && onLink(organizationId),
297+
disabled: !organizationId || pending,
298+
loading: linking,
299+
},
300+
]
301+
: []),
302+
registerAction,
263303
{ label: 'Not now', onClick: onCancel },
264304
]}
265305
/>

‎apps/sim/hooks/queries/aws-marketplace.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ContractBodyInput } from '@/lib/api/contracts'
44
import {
55
getAwsMarketplaceClaimContract,
66
linkAwsMarketplaceLicenseContract,
7+
registerAwsMarketplaceLicenseToAccountContract,
78
} from '@/lib/api/contracts/aws-marketplace'
89

910
const AWS_MARKETPLACE_CLAIM_STALE_TIME = 30 * 1000
@@ -35,3 +36,14 @@ export function useLinkAwsMarketplaceLicense() {
3536
},
3637
})
3738
}
39+
40+
export function useRegisterAwsMarketplaceLicenseToAccount() {
41+
const queryClient = useQueryClient()
42+
return useMutation({
43+
mutationFn: (body: ContractBodyInput<typeof registerAwsMarketplaceLicenseToAccountContract>) =>
44+
requestJson(registerAwsMarketplaceLicenseToAccountContract, { body }),
45+
onSuccess: () => {
46+
queryClient.invalidateQueries({ queryKey: awsMarketplaceKeys.claims() })
47+
},
48+
})
49+
}

‎apps/sim/lib/api/contracts/aws-marketplace.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,11 @@ export const linkAwsMarketplaceLicenseContract = defineRouteContract({
5858
schema: z.object({ success: z.literal(true), organizationId: z.string() }),
5959
},
6060
})
61+
62+
/** Registers the claimed license to the signed-in account when no organization is chosen. */
63+
export const registerAwsMarketplaceLicenseToAccountContract = defineRouteContract({
64+
method: 'POST',
65+
path: '/api/marketplace/aws/claim/register-account',
66+
body: z.object({ licenseId: z.string().min(1, 'License ID is required').max(255) }),
67+
response: { mode: 'json', schema: z.object({ success: z.literal(true) }) },
68+
})

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ export const awsMarketplaceOperations = {
1212
principalKinds: ['session'],
1313
capability: 'none',
1414
}),
15+
// permission-group-exempt: a buyer with no organization takes their own purchase; nothing a group governs is touched.
16+
registerToAccount: defineOperation({
17+
id: 'aws_marketplace.license.register_account',
18+
principalKinds: ['session'],
19+
capability: 'none',
20+
}),
1521
// permission-group-exempt: linking a purchased license is organization administration, not a governed capability.
1622
linkLicense: defineOrganizationOperation({
1723
id: 'organization.aws_marketplace_license.link',

0 commit comments

Comments
 (0)