| 1 | import * as LicenseManager from "@/AWS/LicenseManager"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 8 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 9 | import { randomUUID } from "node:crypto"; |
| 10 | import path from "pathe"; |
| 11 | |
| 12 | const 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. |
| 16 | const FIXTURE_LICENSE_NAME = "alchemy-lm-seller-e2e"; |
| 17 | const FIXTURE_PRODUCT_SKU = "alchemy-lm-seller-sku"; |
| 18 | const 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). |
| 22 | const BOGUS_GRANT_ARN = |
| 23 | "arn:aws:license-manager::111111111111:grant:g-00000000000000000000000000000000"; |
| 24 | |
| 25 | export class LicenseManagerSellerFunction extends Lambda.Function<Lambda.Function>()( |
| 26 | "LicenseManagerSellerFunction", |
| 27 | ) {} |
| 28 | |
| 29 | export 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 | ); |