1import * as LicenseManager from "@/AWS/LicenseManager";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schedule from "effect/Schedule";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import { randomUUID } from "node:crypto";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "seller-handler.ts");
13
14// Deterministic fixture identifiers — the lifecycle route sweeps stale
15// licenses left by a crashed prior run before issuing a fresh one.
16const FIXTURE_LICENSE_NAME = "alchemy-lm-seller-e2e";
17const FIXTURE_PRODUCT_SKU = "alchemy-lm-seller-sku";
18const FIXTURE_PRODUCT_NAME = "Alchemy LicenseManager Fixture";
19
20// Well-formed-but-nonexistent grant — drives the typed error path of
21// CreateGrantVersion (a genuine IAM gap would 500 with AccessDenied).
22const BOGUS_GRANT_ARN =
23 "arn:aws:license-manager::111111111111:grant:g-00000000000000000000000000000000";
24
25export class LicenseManagerSellerFunction extends Lambda.Function<Lambda.Function>()(
26 "LicenseManagerSellerFunction",
27) {}
28
29export default LicenseManagerSellerFunction.make(
30 {
31 main,
32 functionUrl: true,
33 timeout: Duration.seconds(60),
34 },
35 Effect.gen(function* () {
36 // --- seller-issued license / grant / token data plane (account-level) ---
37 const createLicense = yield* LicenseManager.CreateLicense();
38 const createLicenseVersion = yield* LicenseManager.CreateLicenseVersion();
39 const deleteLicense = yield* LicenseManager.DeleteLicense();
40 const createGrant = yield* LicenseManager.CreateGrant();
41 const createGrantVersion = yield* LicenseManager.CreateGrantVersion();
42 const deleteGrant = yield* LicenseManager.DeleteGrant();
43 const createToken = yield* LicenseManager.CreateToken();
44 const deleteToken = yield* LicenseManager.DeleteToken();
45 const listTokens = yield* LicenseManager.ListTokens();
46
47 // --- consumer half of the loop (already-covered bindings, re-used here
48 // to drive the seller-issued license end-to-end) ---
49 const getLicense = yield* LicenseManager.GetLicense();
50 const listLicenses = yield* LicenseManager.ListLicenses();
51 const getAccessToken = yield* LicenseManager.GetAccessToken();
52 const checkoutLicense = yield* LicenseManager.CheckoutLicense();
53 const extendLicenseConsumption =
54 yield* LicenseManager.ExtendLicenseConsumption();
55 const checkInLicense = yield* LicenseManager.CheckInLicense();
56
57 const bound = {
58 createLicense,
59 createLicenseVersion,
60 deleteLicense,
61 createGrant,
62 createGrantVersion,
63 deleteGrant,
64 createToken,
65 deleteToken,
66 listTokens,
67 };
68
69 const uuid = Effect.sync(() => randomUUID());
70
71 // Delete a seller license, tolerating the typed rejections a stale or
72 // already-deleted ARN surfaces.
73 const deleteLicenseQuietly = (arn: string, version: string) =>
74 deleteLicense({ LicenseArn: arn, SourceVersion: version }).pipe(
75 Effect.map(() => undefined),
76 Effect.catchTag(
77 ["InvalidParameterValueException", "ConflictException"],
78 () => Effect.succeed(undefined),
79 ),
80 );
81
82 return {
83 fetch: Effect.gen(function* () {
84 const request = yield* HttpServerRequest;
85 const url = new URL(request.originalUrl);
86 const pathname = url.pathname;
87
88 if (request.method === "GET" && pathname === "/bindings") {
89 return yield* HttpServerResponse.json({
90 bound: Object.keys(bound),
91 });
92 }
93
94 // Full seller data plane: issue -> version -> token mint/exchange ->
95 // checkout/extend/checkin -> grant create/delete -> delete.
96 if (request.method === "POST" && pathname === "/lifecycle") {
97 const beneficiary = url.searchParams.get("account");
98 if (beneficiary === null) {
99 return yield* HttpServerResponse.json(
100 { error: "missing ?account=" },
101 { status: 400 },
102 );
103 }
104 const region = yield* Effect.sync(() => process.env.AWS_REGION!);
105 const now = yield* Effect.sync(() => Date.now());
106 const begin = new Date(now - 24 * 3600 * 1000).toISOString();
107 const end = new Date(now + 365 * 24 * 3600 * 1000).toISOString();
108 const bumpedEnd = new Date(
109 now + 2 * 365 * 24 * 3600 * 1000,
110 ).toISOString();
111 const entitlements = [
112 {
113 Name: "seats",
114 MaxCount: 10,
115 Unit: "Count",
116 AllowCheckIn: true,
117 Overage: false,
118 },
119 ];
120 const consumption = {
121 RenewType: "None",
122 ProvisionalConfiguration: { MaxTimeToLiveInMinutes: 60 },
123 };
124
125 // Zero-orphan: sweep stale fixture licenses from crashed runs.
126 const stale = yield* listLicenses();
127 yield* Effect.forEach(
128 (stale.Licenses ?? []).filter(
129 (l): boolean =>
130 l.LicenseName === FIXTURE_LICENSE_NAME &&
131 l.Status === "AVAILABLE" &&
132 l.LicenseArn !== undefined &&
133 l.Version !== undefined,
134 ),
135 (l) => deleteLicenseQuietly(l.LicenseArn!, l.Version!),
136 );
137
138 // 1. Issue the license.
139 const created = yield* createLicense({
140 LicenseName: FIXTURE_LICENSE_NAME,
141 ProductName: FIXTURE_PRODUCT_NAME,
142 ProductSKU: FIXTURE_PRODUCT_SKU,
143 Issuer: { Name: "alchemy" },
144 HomeRegion: region,
145 Validity: { Begin: begin, End: end },
146 Entitlements: entitlements,
147 Beneficiary: beneficiary,
148 ConsumptionConfiguration: consumption,
149 ClientToken: yield* uuid,
150 });
151 const licenseArn = created.LicenseArn!;
152
153 // 2. Observe until AVAILABLE (bounded), capture issuer fingerprint.
154 const { License } = yield* getLicense({
155 LicenseArn: licenseArn,
156 }).pipe(
157 Effect.repeat({
158 schedule: Schedule.spaced("2 seconds"),
159 until: (r): boolean => r.License?.Status === "AVAILABLE",
160 times: 8,
161 }),
162 );
163 const fingerprint = License!.Issuer!.KeyFingerprint!;
164
165 // 3. Publish a new version (extended validity).
166 const bumped = yield* createLicenseVersion({
167 LicenseArn: licenseArn,
168 LicenseName: FIXTURE_LICENSE_NAME,
169 ProductName: FIXTURE_PRODUCT_NAME,
170 Issuer: { Name: "alchemy" },
171 HomeRegion: region,
172 Validity: { Begin: begin, End: bumpedEnd },
173 Entitlements: entitlements,
174 ConsumptionConfiguration: consumption,
175 Status: "AVAILABLE",
176 SourceVersion: License!.Version!,
177 ClientToken: yield* uuid,
178 });
179 const version = bumped.Version ?? License!.Version!;
180
181 // 4. Mint an activation token, exchange + revoke it. The exchange
182 // result stays Redacted; only its presence is reported.
183 const token = yield* createToken({
184 LicenseArn: licenseArn,
185 ClientToken: yield* uuid,
186 });
187 const tokens = yield* listTokens({ TokenIds: [token.TokenId!] });
188 const accessToken = yield* getAccessToken({
189 Token: token.Token!,
190 }).pipe(
191 Effect.map((r) => (r.AccessToken !== undefined ? "Ok" : "Empty")),
192 Effect.catchTag(
193 [
194 "AuthorizationException",
195 "ValidationException",
196 "AccessDeniedException",
197 "InvalidParameterValueException",
198 ],
199 (e) => Effect.succeed(e._tag),
200 ),
201 );
202 yield* deleteToken({ TokenId: token.TokenId! });
203
204 // 5. Check out the license as the beneficiary account, extend the
205 // provisional consumption, and check it back in. Retry through
206 // issuance propagation with a bounded typed retry.
207 const checkout = yield* checkoutLicense({
208 ProductSKU: FIXTURE_PRODUCT_SKU,
209 CheckoutType: "PROVISIONAL",
210 KeyFingerprint: fingerprint,
211 Entitlements: [{ Name: "seats", Value: "1", Unit: "Count" }],
212 ClientToken: yield* uuid,
213 Beneficiary: beneficiary,
214 }).pipe(
215 Effect.retry({
216 while: (e): boolean =>
217 e._tag === "ResourceNotFoundException" ||
218 e._tag === "NoEntitlementsAllowedException",
219 schedule: Schedule.exponential("1 second"),
220 times: 5,
221 }),
222 );
223 const consumptionToken = checkout.LicenseConsumptionToken!;
224 const extended = yield* extendLicenseConsumption({
225 LicenseConsumptionToken: consumptionToken,
226 });
227 yield* checkInLicense({
228 LicenseConsumptionToken: consumptionToken,
229 });
230
231 // 6. Grant lifecycle — a self-account grant may be rejected with a
232 // typed validation error; either outcome proves grant + wiring.
233 const grant = yield* createGrant({
234 GrantName: "alchemy-lm-seller-grant",
235 LicenseArn: licenseArn,
236 HomeRegion: region,
237 Principals: [`arn:aws:iam::${beneficiary}:root`],
238 AllowedOperations: [
239 "CheckoutLicense",
240 "CheckInLicense",
241 "ExtendConsumptionLicense",
242 "ListPurchasedLicenses",
243 ],
244 ClientToken: yield* uuid,
245 }).pipe(
246 Effect.flatMap((g) =>
247 deleteGrant({ GrantArn: g.GrantArn!, Version: g.Version! }).pipe(
248 Effect.map(() => "CreatedAndDeleted"),
249 ),
250 ),
251 Effect.catchTag(
252 [
253 "InvalidParameterValueException",
254 "ValidationException",
255 "AuthorizationException",
256 "ResourceLimitExceededException",
257 ],
258 (e) => Effect.succeed(e._tag),
259 ),
260 );
261
262 // 7. Delete the license (retry through a lingering consumption).
263 const deletion = yield* deleteLicense({
264 LicenseArn: licenseArn,
265 SourceVersion: version,
266 }).pipe(
267 Effect.retry({
268 while: (e): boolean => e._tag === "ConflictException",
269 schedule: Schedule.exponential("1 second"),
270 times: 4,
271 }),
272 );
273
274 return yield* HttpServerResponse.json({
275 licenseArn,
276 licenseStatus: License?.Status ?? null,
277 bumpedVersion: bumped.Version ?? null,
278 tokenListed: (tokens.Tokens ?? []).length,
279 accessToken,
280 checkedOut: checkout.LicenseConsumptionToken !== undefined,
281 extended: extended.LicenseConsumptionToken !== undefined,
282 grant,
283 deletionStatus: deletion.Status ?? null,
284 });
285 }
286
287 // Typed probe: CreateGrantVersion on a nonexistent grant surfaces a
288 // typed rejection (an IAM gap would 500 with AccessDenied instead).
289 if (request.method === "GET" && pathname === "/grant-version-invalid") {
290 const result = yield* createGrantVersion({
291 GrantArn: BOGUS_GRANT_ARN,
292 ClientToken: "00000000-0000-4000-8000-000000000001",
293 }).pipe(
294 Effect.map(() => "Ok"),
295 Effect.catchTag(
296 [
297 "InvalidParameterValueException",
298 "ValidationException",
299 "AuthorizationException",
300 ],
301 (e) => Effect.succeed(e._tag),
302 ),
303 );
304 return yield* HttpServerResponse.json({ tag: result });
305 }
306
307 return yield* HttpServerResponse.json(
308 { error: "Not found", method: request.method, pathname },
309 { status: 404 },
310 );
311 }).pipe(Effect.orDie),
312 };
313 }).pipe(
314 Effect.provide(
315 Layer.mergeAll(
316 Lambda.EventSource,
317 LicenseManager.CreateLicenseHttp,
318 LicenseManager.CreateLicenseVersionHttp,
319 LicenseManager.DeleteLicenseHttp,
320 LicenseManager.CreateGrantHttp,
321 LicenseManager.CreateGrantVersionHttp,
322 LicenseManager.DeleteGrantHttp,
323 LicenseManager.CreateTokenHttp,
324 LicenseManager.DeleteTokenHttp,
325 LicenseManager.ListTokensHttp,
326 LicenseManager.GetLicenseHttp,
327 LicenseManager.ListLicensesHttp,
328 LicenseManager.GetAccessTokenHttp,
329 LicenseManager.CheckoutLicenseHttp,
330 LicenseManager.ExtendLicenseConsumptionHttp,
331 LicenseManager.CheckInLicenseHttp,
332 ),
333 ),
334 ),
335);