GraveMint SDK
@solanadeads/gravemint · version 1.3.0 · install with npm i @solanadeads/gravemint
Guide adapted from the published README for clarity. Code examples are preserved.
Every method, with its signature and types: method reference.
The GraveMint SDK provides collection reads and wallet-signed mint flows for your application. It uses native fetch, includes TypeScript declarations and supports ESM and CommonJS. Bring your existing wallet library; no chain SDK is bundled.
The versioned API is additive-only. See Versioning.
npm install @solanadeads/gravemintPartner keys are invite-only. Request access for your collections and origins; see Getting a key.
Transaction submission
prepare() builds the transaction. Your wallet signs it, and execute() returns the signed bytes to GraveMint for validation and submission.
GraveMint checks the prepared instructions, accounts and amounts before broadcasting. Use the SDK's execute methods rather than broadcasting through your wallet library.
Contents
- Quick start · Getting a key · Configuration
- Building a mint page — phases · price · eligibility · capabilities
- Minting — the signer · wallet-adapter example
- Errors and retries · Rate limits
- Versioning · Legacy read API · Security
Quick start
import { GraveMintClient } from "@solanadeads/gravemint";
const gm = new GraveMintClient({ apiKey: "gm_pub_..." });
// 1. Everything needed to render the drop, in one call.
const drop = await gm.v1.collection("deads");
console.log(drop.collection.name);
console.log(drop.stats.mintedCount, "/", drop.stats.totalSupply);
// 2. Which phase is live right now (server-decided, not client-computed).
const phase = drop.phases.active[0];
// 3. Can this wallet mint it?
const verdict = await gm.v1.eligibility(drop.collection.id, phase.id, wallet);
// 4. Mint. You supply the signer; GraveMint broadcasts.
const result = await gm.mint.mint({
collectionId: drop.collection.id,
phaseId: phase.id,
quantity: 1,
walletAddress: wallet,
signer,
});Getting a key
Request a partner key for the collections and origins your integration will use.
Choose from three key classes:
| Class | Prefix | Where it belongs | What bounds it |
|---|---|---|---|
| Publishable | gm_pub_ | your web page — it is meant to be visible | the origin allow-list on the key |
| Secret | gm_live_ | your server only | secrecy — it has no origin binding |
| Sandbox | gm_test_ | development against test-network drops only | refused on mainnet (SANDBOX_KEY_ON_MAINNET); production keys are refused on test networks (LIVE_KEY_ON_TESTNET) |
Publishable keys are intended for browser bundles. Register every requesting origin, including staging and previews, and keep the collection scope limited to your integration.
Keep gm_live_ keys on your server. They do not use browser-origin binding.
Use gm_live_ for server calls. Browser keys require a matching Origin header; a request without one returns ORIGIN_NOT_ALLOWED.
Configuration
new GraveMintClient({
apiKey: "gm_pub_...", // required
timeoutMs: 30_000,
maxRetries: 3, // 429 / 5xx / network
rateLimit: { requestsPerSecond: 5, burst: 10 }, // null to disable
fetch: globalThis.fetch,
});Base URLs
The versioned contract is a separate mount from the legacy public read API, with its own auth and its own middleware. The client keeps two base URLs:
| default | used by | |
|---|---|---|
baseUrl | https://api.deads.io/gravemint/public (api.solanadeads.com serves the same origin and keeps working) | the legacy read API |
v1BaseUrl | https://api.solanadeads.com/gravemint/v1 | gm.v1, gm.mint |
You normally set neither. If you set baseUrl to something ending in /public, v1BaseUrl is derived for you. If it ends in anything else — because you are routing through your own proxy — the constructor throws and asks you to pass v1BaseUrl explicitly.
Explicit proxy configuration lets the client validate its API routes at startup.
// Proxying through your own backend:
new GraveMintClient({ apiKey, v1BaseUrl: "https://yoursite.com/api/gravemint" });Node <18 needs a fetch polyfill: new GraveMintClient({ apiKey, fetch }).
Building a mint page
Read collection data, phases, eligibility and supported features before enabling minting.
gm.v1.collection(identifier) returns all of it in one call. identifier is a shortId, a collection UUID, or an on-chain address.
Which one to use:
- Anything a person sees (URLs, share links): the
shortIdor the on-chain address. - API calls:
drop.collection.id, the UUID. Every v1 route accepts it, so passing it everywhere is always safe. Until gravemint#4493, some read routes answered a UUID with 404; they now accept it. gm.mint.prepare()(andgm.mint.mint()) also take theshortIdor on-chain address ascollectionId(gravemint#4525). It is resolved within your key's own drops only; anything else is refused as not in scope. Pass an address exactly as v1 returned it: it is matched as-is, not case-folded. If two of your drops share an on-chain address, that address resolves to neither, so use theshortIdor UUID.
const drop = await gm.v1.collection("deads");
drop.collection // name, symbol, image, chain, socials, isVerified
drop.stats // totalSupply, mintedCount, availableCount, percentMinted
drop.phases // { all, active, upcoming }
drop.capabilities // can you render this drop faithfully?
drop.serverTime // our clock — see belowServer-resolved values
Use the returned pricing, eligibility and phase status values. GraveMint evaluates the collection and wallet settings on the server; refresh these reads when the wallet, selected phase or mint state changes.
Phases and countdowns
for (const phase of drop.phases.all) {
phase.status // "active" | "upcoming" | "ended"
phase.name
phase.startDate // ISO, or null
phase.endDate
phase.maxPerWallet // null = no cap
phase.maxPerTransaction
phase.phaseSupply
phase.isGated // does it have eligibility requirements?
phase.bogo // { buy, get } or null
phase.msUntilStart // ← render countdowns from these
phase.msUntilEnd
}phases.all includes ended phases, which you can display in the full schedule or filter from an active view.
Use msUntilStart and msUntilEnd for countdowns. Anchor a ticking display to drop.serverTime, then refresh the collection response when a countdown expires.
Price is a shape, not a number
type PriceDisplay =
| { kind: "amount"; amount: number; currency: string; isFree: boolean; approximate?: boolean }
| { kind: "range"; min: number; max: number; currency: string }
| { kind: "hidden" }
| { kind: "unknown" };Render it by branching, and treat all four as normal:
switch (phase.priceDisplay.kind) {
case "amount":
return phase.priceDisplay.isFree
? <>Free</>
: <>{phase.priceDisplay.approximate ? "~" : ""}{phase.priceDisplay.amount} {phase.priceDisplay.currency}</>;
case "range":
return <>{phase.priceDisplay.min}–{phase.priceDisplay.max} {phase.priceDisplay.currency}</>;
case "hidden":
return <>Price revealed when you qualify</>;
case "unknown":
return <>Price shown at checkout</>;
}priceDisplay represents the price available for display before a mint is prepared. It accounts for the phase's pricing settings. Hidden prices remain hidden until the wallet qualifies; unknown prices have no display amount for the request.
Show “Free” only for an amount with isFree: true. Do not replace hidden or unknown values with zero.
The final wallet-specific breakdown is returned by prepare() as pricing, including applicable benefits and fees.
Eligibility
const v = await gm.v1.eligibility(collectionId, phaseId, walletAddress);
v.gated // does this phase gate at all?
v.meetsRequirements // the verdict
v.onAllowlist
v.spotsAllocated
v.spotsRemaining
v.requirements // [{ type, met, description }] — render these
v.phase // { id, name, hasStarted, hasEnded }
v.messageThis is server-evaluated because it has to be: the dominant gate is an NFT-holding check needing an on-chain lookup, and an allowlist must never be published to a client.
meetsRequirements reports wallet requirements separately from the time window. Check phase.hasStarted and phase.hasEnded as well before enabling minting. For ungated phases, use gated: false as the requirements check.
const canMintNow = v.meetsRequirements && v.phase.hasStarted && !v.phase.hasEnded;The rest of the drop — art, feed, bounty, per-wallet counts
These are all scoped to your key the same way collection() is: a key issued for another drop gets 403 COLLECTION_NOT_IN_SCOPE, never an empty list.
await gm.v1.gallery("deads", { limit: 24 }); // NFTs still available to mint
await gm.v1.recentlyMinted("deads"); // the live "just minted" feed
await gm.v1.bounty("deads"); // bounty summary, when there is one
await gm.v1.bountyPrizes("deads"); // its public prize table
await gm.v1.walletMints("deads", wallet); // "you have minted 2 of 3", per phasewalletMints includes bonus mints such as BOGO and bounty mints. Eligibility uses separate allocation rules. Use the wallet-mint response when displaying minted or remaining counts.
Claim codes
Claim-code endpoints describe the collection’s code configuration and the benefits a wallet has already redeemed.
await gm.v1.claimCodes(collectionId); // does this drop use codes?
await gm.v1.claimCodeBenefits(collectionId, wallet); // what has this wallet earned?
await gm.v1.validateClaimCode(collectionId, 'CODE-123'); // { valid, grantType, ... }validateClaimCode checks a code without redeeming it or consuming a use. A code outside the collection returns the same response as an unknown code.
prepare applies benefits already redeemed by the minting wallet for this collection. It does not accept a claim code. The collector redeems codes on the hosted GraveMint page, where the required checks and signature are handled.
To display benefits before minting, call claimCodeBenefits(collectionId, wallet, { phaseId }). Include the selected phase and, for a pack drop, packTierId to restrict the calculation appropriately. The prepare response's pricing remains the final amount.
Pricing helpers
await gm.v1.tokenPrices(); // platform token prices, for an SPL-priced drop
await gm.v1.peggedPrice(phaseId); // resolved amount for a USD/native-pegged phase
await gm.v1.dutchPrice(phaseId); // live price of a dynamic Dutch phaseRender phase.priceDisplay — it already resolves Dutch and pegged prices (a pegged figure comes back approximate). v1 phases carry no raw price field; these two helpers are for refreshing a live number without re-reading the whole drop.
Both phase routes are scoped through the phase's own collection: a phase id is not a way around collection scope.
Supply, traits and social proof
await gm.v1.availability(collectionId); // approximate supply counts — a progress bar, never a sold-out decision
await gm.v1.traits(identifier); // trait names and values, for gallery filters
await gm.v1.allMinted(identifier, { limit: 100, offset: 0 }); // ONE page (default 20, max 100) — page with offset
await gm.v1.topHolders(identifier); // largest holders
await gm.v1.topMinters(identifier); // who minted the mostCapabilities — when you cannot render a drop
drop.capabilities // { requiresFeatures: string[], supportedBySurface: boolean, mintUrl: string | null }v1 covers core mint: supply, phases, fixed and pegged pricing (including BOGO and hidden prices), claim-code benefits already redeemed, eligibility, per-wallet limits, and minting on Solana. requiresFeatures names what a drop needs beyond that; any of these makes supportedBySurface false:
| Feature | Why the drop is not core-renderable |
|---|---|
packs | a phase sells pack tiers |
gallery_mode | the collector chooses which NFT to mint |
nft_selection | a phase restricts which NFTs can be minted (by rarity, trait, or a specific list) |
generative | a generative collection — traits are composed at mint and the price carries a surcharge |
dutch_auction, dynamic_dutch | the live price decays or surges, so priceDisplay is a range or unknown, not a price tag |
cross_chain | payment on another chain, which partner surfaces never support |
non_solana_chain | the drop is not on Solana — v1 mints Solana only |
claim_codes | the drop has active claim codes (a collection-level fact — codes are redeemed against the drop) |
This list only grows. Branch on supportedBySurface, not on the names.
if (!drop.capabilities.supportedBySurface) {
return <a href={drop.capabilities.mintUrl}>Mint on GraveMint</a>;
}Handle unsupported capabilities before enabling minting, so collectors can use the hosted page for features outside your integration.
Collections with unrecognized requirements are marked unsupported. Use mintUrl when provided to open the hosted collection page.
Minting
const prepared = await gm.mint.prepare({
collectionId, phaseId, quantity: 1, walletAddress,
affiliateCode, // optional; validated server-side, ignored if unused
walletProvider: wallet.adapter.name, // optional; fills your wallet breakdown, else "Unknown"
});
const signed = await signer.signTransaction(prepared.transactions[0].transaction);
const result = await gm.mint.execute({
sessionId: prepared.sessions[0].sessionId,
signedTransaction: signed,
});prepare returns transactions[] and sessions[], including for quantity one. Read sessions[0].sessionId for the first transaction. mint() handles the pairing for you.
Or in one call:
const result = await gm.mint.mint({
collectionId, phaseId, quantity: 1, walletAddress, signer,
});prepare() does the real work — eligibility, limits, pricing, supply reservation, transaction build — and returns pricing (the authoritative breakdown), sessions[] (one per transaction), expiresAt / expiresInMs, and quantityAdjusted — true when fewer NFTs were prepared than requested (a wallet or phase limit, or supply). Tell the collector before they sign; adjustmentReason says why.
execute waits up to 130 seconds by default — longer than the server's own budget, which can still be broadcasting after a shorter client gives up — unless you set a client-wide timeoutMs, which is respected. It is never retried automatically: re-sending signed transactions gets ALREADY_PROCESSING / ALREADY_COMPLETED back, which would hide whether the first attempt landed.
If gm.mint.mint() loses the answer AFTER the signed transaction left your client — a timeout, a dropped connection, any 5xx (including an edge timeout such as Cloudflare's 524), the server's own 408, or ALREADY_PROCESSING / ALREADY_COMPLETED / RECONCILE_PENDING / MINT_PAYMENT_UNVERIFIED — it throws MintOutcomeUnknownError (code: "OUTCOME_UNKNOWN", sessionIds). The mint may still land. Do not retry: a retry prepares again and can mint twice. Show its message ("check your wallet before trying again"). A 5xx refused before broadcast looks the same from here, so it is reported as unknown too — the safe direction.
mint() also returns (does not throw) when a batch confirmed only some, or none, of its transactions: { success: false, partial, message }. Anything but success: true is not a success.
Sessions expire. Do not hold a signature and submit it later; prepare again. A prepared session also reserves supply, so do not call prepare() just to read a price — that is what priceDisplay is for.
The signer
interface TransactionSigner {
signTransaction(base64Transaction: string): Promise<string>;
}The signer accepts a base64 transaction and returns the signed transaction in base64. You can also supply signAllTransactions to request batch approval.
Wallet-adapter example
import { VersionedTransaction } from "@solana/web3.js";
const signer = {
async signTransaction(base64: string) {
const tx = VersionedTransaction.deserialize(Buffer.from(base64, "base64"));
const signed = await wallet.signTransaction(tx);
return Buffer.from(signed.serialize()).toString("base64");
},
};For legacy transaction support and browser Buffer setup, see the signer adapter.
Chain support
The v1 mint flow supports Solana. Check capabilities.supportedBySurface before preparation and offer mintUrl for collections that require another flow.
Errors and retries
Every error carries a machine-readable code, a human message safe to show a collector, plus .status and .requestId for support.
import { GraveMintError, RateLimitError } from "@solanadeads/gravemint";
try {
await gm.mint.mint({ ... });
} catch (err) {
if (err instanceof GraveMintError) {
console.error(err.code, err.requestId);
showToast(err.message); // safe: no internals, no schema details
}
}Error handling
The API's stable v1 error catalog covers authentication, scope and collection lookups. Mint handlers and middleware can return additional codes, including NO_NFTS_AVAILABLE, PHASE_ENDED, WALLET_LIMIT_EXCEEDED, SESSION_NOT_FOUND, INSUFFICIENT_FUNDS and NOT_WHITELISTED.
Handle known codes explicitly and keep a message fallback for additional or code-less errors.
For batch outcomes, read each transaction's errorCode from the returned results array. A batch HTTP response can succeed even when individual transactions fail or remain pending.
import { isBatchMintResult } from "@solanadeads/gravemint";
const res = await gm.mint.mint({ ... });
if (isBatchMintResult(res) && res.success === false) {
// res.partial: did SOME land? res.minted of res.requested; res.message is safe to show.
for (const r of res.results) {
if (!r.success) console.warn(r.sessionId, r.errorCode); // e.g. TX_MODIFIED
}
}Branch on documented stable codes where your application needs specific recovery behavior. Display the returned message for other responses.
const code = err instanceof GraveMintError ? err.code : undefined;
if (code === "TX_MODIFIED") { /* never retry — see below */ }
else if (code === "ORIGIN_NOT_ALLOWED") { /* config: send us the origin */ }
else { showToast(err.message); } // covers the unfrozen and code-less casesStable v1 codes
| Code | Meaning | Retry? |
|---|---|---|
COLLECTION_NOT_FOUND | no such drop, or not publicly servable | no |
LOOKUP_FAILED | the collection lookup could not complete | yes, backoff |
COLLECTION_REQUIRED | the request named no collection | no — fix the call |
CODE_REQUIRED | a claim-code check arrived with no code | no — fix the call |
API_KEY_REQUIRED / API_KEY_INVALID | missing or wrong key | no — fix config |
API_KEY_INACTIVE / API_KEY_EXPIRED | key revoked or lapsed | no — talk to us |
ORIGIN_NOT_ALLOWED | valid key, unregistered origin (or a gm_pub_ key used server-side) | no |
COLLECTION_NOT_IN_SCOPE | valid key, not issued for this drop | no |
AUTH_UNAVAILABLE | the key check could not complete | yes, backoff |
Also frozen, and emitted by the shared mint handlers: COLLECTION_CLOSED · NFTS_LOCKED · SESSION_EXPIRED · TX_MODIFIED.
For single mints, a transaction failure is exposed as a thrown error code. Batch mint outcomes use results[].errorCode; inspect those returned results as well as thrown errors.
Use server submission for all v1 mints. The following codes require changing the request rather than resubmitting it:
CLIENT_BROADCAST_NOT_ALLOWED— the request carriedtransactionHash(ortxHash/transaction_hash). Sign the transactionprepare-mintreturned and send it back assignedTransaction; we submit it and confirm it.CHAIN_NOT_SUPPORTED_BY_SURFACE— the drop is on a chain this API does not serve. v1 is Solana-only. This is distinct fromCHAIN_DISABLED, which means a chain was retired platform-wide; your drop's chain is fine, it is just out of scope here. UsemintUrlfrom the collection response to send the collector to GraveMint instead.
Frozen but not emitted on the v1 path: CHAIN_DISABLED · UNSUPPORTED_BY_SURFACE (not emitted anywhere yet), and NOT_ELIGIBLE · WALLET_MISMATCH (emitted elsewhere in GraveMint, on the cross-chain and mint-page-token paths — neither of which v1 mounts). They will not be renamed.
The eligibility endpoint
gm.v1.eligibility() reuses the first-party handler, so it has its own two codes. Neither is frozen:
| Code | Meaning | Retry? |
|---|---|---|
INVALID_ADDRESS | the wallet address is not valid for this chain | no — fix the call |
CHECK_BUSY | the eligibility engine is at its global budget | yes, short backoff |
Rate limiting and suspension
| Code | Meaning | Retry? |
|---|---|---|
RATE_LIMITED | too many requests for this key | yes, honour Retry-After |
COLLECTION_RATE_LIMITED | too many prepares against this collection, from everyone | yes, honour retryAfter |
WALLET_BLOCKED | this wallet is suspended | no — on prepare-mint the body also carries reason / blockedUntil / permanent; on execute-mint it is the code and message only |
WALLET_HELD | a wallet this request is made by or for — the signed-in wallet, a minting or recipient wallet named in the body, or the wallet behind the mint session — is on a security hold (reported compromised) | no — show the message; the collector must contact SolanaDeads |
WALLET_HOLD_UNVERIFIED | holds are active and we could not confirm the wallet behind this request (typically: a mint session's wallet could not be read), so we refused rather than guess (HTTP 503) | yes, backoff |
ASSET_BANNED | this collection or token is on the platform ban list | no |
Bot protection and the server's time budget
Every v1 request passes the platform's bot protection and a per-request time budget. None of these is fixed by retrying the same request immediately:
| Code | Meaning | Retry? |
|---|---|---|
BOT_BLOCKED | this client was blocked by bot protection | no |
BOTNET_DETECTED | the request's signature (e.g. its User-Agent) matches known automation tooling | no — from a server, send a descriptive User-Agent |
WALLET_BOT_BLOCKED | the wallet in this request is restricted by bot protection | no |
BOT_DETECTED | the request scored as automated | no — back off |
REQUEST_TIMEOUT (HTTP 408) | the server's time budget ran out — the work may still finish | reads: yes, backoff. execute-mint: never — mint() reports it as OUTCOME_UNKNOWN |
Key class and scope
A key carries a class (live or sandbox) and a list of scopes. These are refusals of the key for this request, not of the drop, so none of them is fixed by retrying:
| Code | Meaning | Retry? |
|---|---|---|
SCOPE_NOT_GRANTED | the key is valid but was not issued the scope this call needs (e.g. mint) | no — ask us to add the scope |
SANDBOX_KEY_ON_MAINNET | a sandbox (gm_test_) key was used against a mainnet drop | no — use a production key: gm_pub_ in a browser, gm_live_ on a server |
LIVE_KEY_ON_TESTNET | a production key (gm_pub_ or gm_live_) was used against a test-network drop | no — use your sandbox (gm_test_) key |
These codes distinguish test-network and production credentials.
TX_MODIFIED means the signed transaction differs from the prepared transaction. Check the adapter for changed instructions or accounts, then prepare a new transaction. Do not resend the modified bytes.
ORIGIN_NOT_ALLOWED requires origin registration; API_KEY_REQUIRED indicates a missing credential.
The SDK retries 429/5xx/network automatically — except execute, which it never replays (see "Minting") — with exponential backoff, honouring Retry-After, up to maxRetries. It does not auto-retry the terminal codes above.
Rate limits
| Limit | Value | Scope |
|---|---|---|
Reads on /v1 (shared with /gravemint/public) | 600 / minute | per IP |
/v1/prepare-mint and /v1/execute-mint (shared bucket) | 60 / minute | per key |
prepare-mint, per COLLECTION | 30 / 10s | per collection, shared with first-party traffic |
validateClaimCode (claim-code check) | 20 / minute | per IP, shared with first-party code checks |
| SDK client-side limiter | 5 rps sustained, 10 burst | your process |
Account for each limit when sizing request queues. A shared limit can apply before a per-key limit is reached.
Two sharing details that matter in practice:
prepareandexecuteshare the same 60/min bucket, and a mint needs one of each — so the sustained ceiling is roughly 30 mints per minute per key, not 60.- The read limiter is the same instance
/gravemint/publicuses, so v1 reads and first-party public reads from one IP draw on one 600/min bucket. - The per-collection prepare cap is shared with gravemint.io. It is keyed on the collection alone, so on a hot drop your prepares compete with every first-party collector's — and on a busy launch it can bind well below the 60/min-per-key figure. Treat 60/min as a ceiling you may not reach, not a reservation.
The mint limit is keyed on the API key, not the IP, so a partner proxying every collector through one server is not sharing a bucket with the rest of the internet — but it does mean all of that partner's collectors share it.
- The claim-code check is per IP, not per key. Validate from the collector's browser. A partner that validates from its own server (a
gm_live_key) sends every collector's check from one address, so all of its users share 20 a minute.
On a 429 the SDK honours Retry-After — or, when a limiter sends it only in the body, retryAfter — and retries up to maxRetries. The server body is on the thrown error as err.details. Disable the client-side limiter with rateLimit: null only if you have your own queue. Contact us if your integration needs a higher limit.
Versioning
v1 is additive-only. Within the major we will not remove a field, change a field's type, rename an error code, or tighten validation. New behaviour arrives as new fields or a new version. Unversioned legacy paths keep working.
Pin the package version in your application and use capability checks to handle collections that require newer or additional features.
await gm.v1.version(); // { version: "v1", stability: "additive-only", docs }x-gm-client identifies the SDK version on each request, including browser requests.
Deprecations will be announced with a migration path and a real timeline, and old majors keep working while both are live.
Legacy read API
The original read-only surface is unchanged and still supported:
| Resource | Methods |
|---|---|
gm.discovery | getFeatured, getLiveMints, getTrending, getUpcoming, getRecentSoldouts, listCollections, searchCollections |
gm.collections | get, getGallery, getRecentlyMinted |
gm.nfts | get |
gm.bounty | getSummary, getPrizes |
gm.platform | getCapabilities, getTokenPrices |
gm.codes | validate (read-only — does not redeem) |
These legacy resources use the first-party public API and are not available to partner keys. Use the scoped gm.v1 methods for partner collection reads and gm.mint for minting.
Cross-collection discovery remains first-party. The v1 equivalents for collection data include collection, eligibility, gallery, recentlyMinted, bounty, bountyPrizes and walletMints.
Use gm.v1.collection() for display prices and capability checks.
Security notes for partners
- Register the exact origins used by your browser application, including staging and previews.
- Keep
gm_live_keys on your server and request revocation if a key is exposed. - Use the API's resolved pricing and eligibility results.
- Return signed transactions to GraveMint for submission.
Development
npm test # unit tests, no network
npm run typecheck
npm run build # dual ESM + CJSLicense
MIT
