@tamga/sdk
v0.4.5
Published
Official JavaScript/TypeScript SDK for Tamga. Integrate license activation, offline verification, and machine management into your JavaScript/TypeScript applications.
Maintainers
Readme
@tamga/sdk
Official JavaScript/TypeScript SDK for Tamga. Integrate license activation, offline verification, and machine management into your JavaScript/TypeScript applications.
Install
npm install @tamga/sdkAlso available via pnpm add @tamga/sdk or yarn add @tamga/sdk. Published on
the npm registry under the @tamga scope; the bare tamga name on npm belongs
to an unrelated package.
Node.js ≥18, Deno, Bun, and browsers are all supported from a single dual
ESM/CJS build. CI runs lint, typecheck and tests on Node 18/20/22, then executes
the built dist/ output on Deno and Bun in a separate job
(.github/workflows/ci.yml).
- Node.js / Bun — install as above.
- Deno — import via an
npm:specifier, no install step:docs/examples/deno-quickstart.ts. - Browser — import the built ESM bundle in a
<script type="module">:docs/examples/browser-quickstart.html.
Quickstart
import { TamgaClient } from "@tamga/sdk";
const licenseKey = "YOUR-LICENSE-KEY";
const client = new TamgaClient({
accountId: "your-account-id",
baseUrl: "https://api.tamga.sh",
auth: { kind: "license", key: licenseKey },
});
const { license, meta } = await client.validateByKey(licenseKey);
if (meta.valid) {
console.log(`License ${license.id} is valid.`);
} else {
// Match on `meta.code` — it is the stable, machine-readable outcome.
// `meta.detail` is human text whose wording changes between server versions.
console.log(`License ${license.id} is not valid: ${meta.code}`);
}Every networked operation is a method on TamgaClient. licenseId /
machineId / processId / entitlementId are the resource's UUID, and every
method sends whatever auth transport was configured.
| Method | Endpoint |
|---|---|
| validateByKey(key) | POST /licenses/actions/validate-key |
| validateById(licenseId, { scope?, skipTouch? }) | POST /licenses/{id}/actions/validate |
| quickValidate(licenseId) | GET /licenses/{id}/actions/validate |
| checkIn(licenseId) | POST /licenses/{id}/actions/check-in |
| checkOutLicense(licenseId, { encrypt?, ttl? }) | GET /licenses/{id}/actions/check-out (raw PEM) |
| checkOutLicenseJson(licenseId, { encrypt?, ttl? }) | POST /licenses/{id}/actions/check-out (JSON:API resource) |
| checkOutMachine(machineId, { encrypt?, ttl? }) | GET /machines/{id}/actions/check-out (raw PEM) |
| checkOutMachineJson(machineId, { encrypt?, ttl? }) | POST /machines/{id}/actions/check-out (JSON:API resource) |
| createMachine(licenseId, fingerprint, opts?) | POST /machines |
| activateMachine(licenseId, fingerprint, opts?, scope?, autoDeleteOnOverage?, reuseExistingMachine?) | composed: create + validate |
| getLicense(licenseId) | GET /licenses/{id} |
| getLicensePolicy(licenseId) | GET /licenses/{id}/policy |
| getPolicy(policyId) | GET /policies/{id} |
| getMachine(machineId) | GET /machines/{id} |
| listMachines(opts?) | GET /machines (offset-paginated) |
| findMachineByFingerprint(licenseId, fingerprint, opts?) | composed: search + exact re-check |
| updateMachine(machineId, attrs) | PATCH /machines/{id} |
| pingHeartbeat(machineId) | POST /machines/{id}/actions/ping-heartbeat |
| resetHeartbeat(machineId) | POST /machines/{id}/actions/reset-heartbeat |
| deleteMachine(machineId) | DELETE /machines/{id} |
| startHeartbeat(machineId, intervalMs) | convenience scheduler around pingHeartbeat |
| startHeartbeatFromPolicy(machineId, licenseId, opts?) | startHeartbeat, interval read off the policy |
| resolveHeartbeatWindowMs(licenseId) | the policy's real heartbeat window, in ms |
| generateOfflineProof(machineId, dataset?) | POST /machines/{id}/actions/generate-offline-proof |
| createComponent(machineId, fingerprint, name, metadata?) | POST /components |
| listComponents(machineId, { limit?, after? }) | GET /machines/{id}/components |
| createProcess(machineId, pid, metadata?) | POST /processes |
| pingProcess(processId) | POST /processes/{id}/actions/ping |
| deleteProcess(processId) | DELETE /processes/{id} |
| listMachineProcesses(machineId, { limit?, after? }) | GET /machines/{id}/processes |
| startProcessHeartbeat(processId, intervalMs?) | convenience scheduler around pingProcess |
| listEntitlements(licenseId, { limit?, after? }) | GET /licenses/{id}/entitlements |
| getEntitlement(licenseId, entitlementId) | GET /licenses/{id}/entitlements/{entitlementId} |
| hasEntitlement(licenseId, code, limit?) | convenience wrapper around listEntitlements |
| incrementEntitlementUsage(licenseId, entitlementId, increment?) | POST /licenses/{id}/entitlements/{entitlementId}/actions/increment |
| decrementEntitlementUsage(licenseId, entitlementId, decrement?) | POST /licenses/{id}/entitlements/{entitlementId}/actions/decrement |
| resetEntitlementUsage(licenseId, entitlementId) | POST /licenses/{id}/entitlements/{entitlementId}/actions/reset |
| checkForUpgrade(opts) | GET /releases/actions/upgrade |
| listReleaseArtifacts(releaseId, { limit?, after? }) | GET /releases/{id}/artifacts |
| getArtifact(artifactId) | GET /artifacts/{id} |
| getArtifactDownloadUrl(artifactId, { ttlSeconds? }) | GET /artifacts/{id}/actions/download (presigned URL, never followed) |
| health() | GET /v1/health (not account-scoped) |
| dispose() | stops every timer this client started |
Several of these need a caveat before you wire them in:
quickValidatedoes not record the validation when the request carries anOriginheader — which a browser always adds. See Known gaps.resetHeartbeatandgenerateOfflineProofare role-gated and always403for a license-key credential. So isgetPolicy, which needspolicy.read; usegetLicensePolicyinstead, which needs onlylicense.read. See Auth transports.listEntitlementsignoresafter: that route is not paginable server-side.incrementEntitlementUsage/decrementEntitlementUsage/resetEntitlementUsageonly work on an entitlement directly attached to the license — one only inherited through the license's policy has no counter row and these404. See Entitlements and meters below.listMachinesis offset-paginated and every other list here is not. It takespage/sizeand returns{ items, page: { number, size, total, totalPages } };listComponentsandlistMachineProcessestakelimit/afterand return a bare array. Sending the wrong one is silent in both directions.createMachine'smemory/diskare megabytes, and so areupdateMachine's — which also cannot clear a field back tonull.startHeartbeatnever stops on aheartbeat_statusvalue — and a ping cannot report"DEAD"in the first place (a machine read can). See Known gaps.checkForUpgradereturningundefineddoes not mean you are up to date. See Known gaps.deleteProcessis not optional housekeeping: nothing server-side reaps a stale process row. See Known gaps.
Errors are typed subclasses of TamgaError (NotFoundError,
FingerprintTakenError, MachineLimitExceededError, MeterLimitExceededError,
LicenseNotAllowedError, CheckInNotRequiredError, …). Match on the stable
.code, never on .message / .detail.
More runnable examples — scoped validation, machine heartbeats, offline .lic /
.mach verification, offline proof tokens, Deno and browser quickstarts — live
in docs/examples/.
Entitlements and meters
Every entitlement is either a kind: "flag" (a plain boolean grant —
the only kind that existed before entitlement metering) or a kind: "meter" —
a named, per-license counter with its own cap. kind is always present on
every response; a license-scoped listing also carries max_value (the
effective cap — null means unlimited) and current_value (the running
count, 0 if never incremented or if this entitlement is only inherited from
the license's policy and has never been directly attached).
const [entitlement] = await client.listEntitlements(licenseId);
if (entitlement.attributes.kind === "meter") {
console.log(`${entitlement.attributes.current_value}/${entitlement.attributes.max_value ?? "∞"}`);
}
// Bump a directly-attached meter's usage by 1 (or pass a custom amount) and
// read back the fresh current_value from the same response.
const updated = await client.incrementEntitlementUsage(licenseId, entitlement.id);
console.log(updated.attributes.current_value);
// Give some usage back.
await client.decrementEntitlementUsage(licenseId, entitlement.id, 3);
// Start a billing period over.
await client.resetEntitlementUsage(licenseId, entitlement.id);import { MeterLimitExceededError } from "@tamga/sdk";
try {
await client.incrementEntitlementUsage(licenseId, entitlement.id, 100);
} catch (error) {
if (error instanceof MeterLimitExceededError) {
// error.entitlementId names which meter hit its cap (meta.entitlement_id).
console.log(`entitlement ${error.entitlementId} is at its limit`);
} else {
throw error;
}
}Auth transports
Five transports are modelled; pass one as auth in TamgaClientConfig
(src/transport.ts::authHeaders, src/transport.ts::authQueryParam).
import type { AuthCredentials } from "@tamga/sdk";
// The primary transport for embedded/client SDKs.
const license: AuthCredentials = { kind: "license", key: "YOUR-LICENSE-KEY" };
const bearer: AuthCredentials = { kind: "bearer", token: "tok-..." };
// Basic has three sub-forms: "email-password", "token", "license-key".
const basic: AuthCredentials = { kind: "basic", form: "token", token: "tok-..." };
// Browser/portal-only — requires a matching Origin header.
const cookie: AuthCredentials = {
kind: "cookie",
sessionId: "00000000-0000-0000-0000-000000000000",
origin: "https://app.example.com",
};
// Sent as ?token=… instead of a header.
const query: AuthCredentials = { kind: "query", token: "tok-..." };Tokens are treated as opaque strings throughout — there is no prefix-based type
detection. A raw license key embedded in browser-shipped code is inherently
visible to the end user; that is expected for the license transport. Never
embed a Bearer or Basic account credential in client-side code.
[!IMPORTANT] Auth is enforced server-side, and a license key is not automatically a valid credential. The server accepts
Authorization: License <key>only when the license's policy setsauthentication_strategyto"LICENSE"or"MIXED". That column defaults to"TOKEN", and"NONE"rejects the key the same way — so against a default policy every call returns401 LICENSE_NOT_ALLOWED(LicenseNotAllowedError). It is a configuration precondition, not a retryable auth failure: no retry, key rotation, or re-prompt can fix it, only a policy change.Two further limits on a license-key credential:
resetHeartbeatandgenerateOfflineProofare role-gated, not permission-gated, and always answer403for it. Proofs have to be minted by a backend holding an account-level token;verifyOfflineProofneeds no credential and is the half a client can run.- An expired license whose policy uses
expiration_strategy: "REVOKE_ACCESS"is rejected at the auth gate with401 LICENSE_EXPIRED, so validate is not reachable to report the expiry. Under the other three strategies it authenticates and comes back as anEXPIREDvalidation code.
Offline verification
These functions never touch the network once the relevant public key is embedded in your application, so they work in air-gapped environments.
| Function | Purpose |
|---|---|
| verifyAndDecryptLicenseFile(pem, ed25519PublicKey, licenseKey?, now?) | Verify, decrypt and expiry-check a .lic file |
| verifyLicenseFileWithClaims(pem, ed25519PublicKey, licenseKey?, now?) | The same, also returning the signed iat/exp/jti/kid |
| verifyAndDecryptMachineFile(pem, scheme, publicKey, keyMaterial?, now?) | Verify, decrypt and expiry-check a .mach file (multi-scheme) |
| verifyMachineFileWithClaims(pem, scheme, publicKey, keyMaterial?, now?) | The same, also returning the signed iat/exp/jti/kid |
| verifyLicenseFileWithKeySet(pem, keySet, licenseKey?, now?) | Verify a .lic file against the signing key its kid names — survives a key rotation |
| verifyMachineFileWithKeySet(pem, keySet, keyMaterial?, now?) | The same for an Ed25519-signed .mach file |
| verifyOfflineProof(proof, accountId, machineId, fingerprint, dataset, rsaPublicKey) | Verify a "v1x0." offline proof token |
| computeFingerprint(components) | Canonicalise labelled components into a stable machine fingerprint |
| canonicalFingerprintString(components) | The string computeFingerprint hashes — for debugging a cross-SDK disagreement |
import { TamgaClient, CheckoutError, verifyAndDecryptLicenseFile } from "@tamga/sdk";
declare const client: TamgaClient;
declare const licenseId: string;
// Your account's Ed25519 public key: 32 raw bytes, embedded at build time.
// Fetching it at verify time would defeat the point of offline verification.
declare const ed25519PublicKey: Uint8Array;
const pem = await client.checkOutLicense(licenseId, { encrypt: true, ttl: 24 * 3600 });
try {
const license = await verifyAndDecryptLicenseFile(pem, ed25519PublicKey, "YOUR-LICENSE-KEY");
console.log(`Verified offline: ${license.id} (${license.attributes.status})`);
} catch (error) {
if (error instanceof CheckoutError && error.kind === "expired") {
console.log("Authentic, but past its signed expiry — check out a fresh file.");
} else {
throw error;
}
}Pass a trusted timestamp as the fourth argument (now, in Unix seconds) when
you are defending against a user winding the local clock back.
Machine files are multi-scheme: the signing algorithm comes from the governing
license's own scheme field, which you pass in — never from the file's
self-declared alg string.
import { verifyAndDecryptMachineFile, type LicenseScheme } from "@tamga/sdk";
declare const machPem: string;
declare const publicKey: Uint8Array;
// If the license has no scheme set, the server signs with Ed25519 by default.
const scheme: LicenseScheme = "ED25519_SIGN";
const machine = await verifyAndDecryptMachineFile(machPem, scheme, publicKey, {
licenseKey: "YOUR-LICENSE-KEY",
fingerprint: "fp-abc123",
});publicKey is your account's public key for scheme: 32 raw bytes for Ed25519,
a 65-byte uncompressed P-256 point for ECDSA, or — for either RSA variant — the
RSA public key in DER. Both DER encodings are accepted: the PKCS#1
RSAPublicKey blob the API publishes, and SubjectPublicKeyInfo.
A machine file carries the same signed meta.exp a license file does, and it is
enforced here too, so pass a trusted timestamp as the fifth argument (now)
when the local clock cannot be trusted.
Surviving a signing-key rotation
Verifying against a single embedded key has one failure mode worth designing around: when the account rotates its Ed25519 signing key, every file signed before the rotation stops verifying — and it fails with exactly the error a forged file produces. The file is authentic and the license may well still be valid, so a paying customer gets locked out and the error points support at tampering rather than at a stale key.
Both file formats have always carried a kid claim naming the key that signed
them. Verify through a SigningKeySet and the two outcomes separate:
import {
SigningKeySet,
SigningKeyError,
CheckoutError,
verifyLicenseFileWithKeySet,
} from "@tamga/sdk";
declare const pem: string;
// Every public key your account has ever signed with, standard base64 of the
// raw 32 bytes — the newest first, the retired ones after it. Pinned in the
// binary, so this stays fully offline.
const keySet = SigningKeySet.fromPublicKeys([
"<current key>",
"<key retired at the last rotation>",
]);
try {
const { license, claims } = await verifyLicenseFileWithKeySet(pem, keySet);
console.log(`Verified offline: ${license.id}, signed by key ${claims.kid}`);
} catch (error) {
if (error instanceof SigningKeyError) {
// The file names a key you do not have. Almost always a key set that
// predates a rotation — refresh it or ship an update. Do NOT accuse the
// file, and do not lock the customer out over it.
console.warn(`${error.message} (set holds: ${keySet.keyIds.join(", ")})`);
} else if (error instanceof CheckoutError && error.kind === "crypto") {
// The file names a key you DO have, and the signature still failed.
// This one is a forgery.
throw error;
} else {
throw error;
}
}If the application can reach the API with an account-level credential, fetch the set instead of pinning it — one call, cacheable for the life of the process, since a rotation adds a key rather than invalidating the ones already there:
const keySet = await client.getSigningKeySet();⚠️ GET /signing-keys is gated on account.read, which the license-key role
does not hold, so listSigningKeys()/getSigningKeySet() answer 403 for
an Authorization: License <key> client no matter how the account is
configured. Fetch the set with a back-office token and ship the public keys with
your application, or have your own backend proxy the call.
Three things about this are deliberate and worth knowing before you build on it:
- Retired keys belong in the set. Filtering down to the active key reintroduces the very problem this solves.
- Every held key is tried against the signature before a byte of the file is
decoded. The
kidis read afterwards, only to label a failure: not held →SigningKeyError; held →CheckoutError"crypto"withreason: "signature". Once a signature has verified,reason: "decryption"usually means the wrong license key — except thatalgis not covered by the signature, so a verified license file whosealgwas flipped from the plain to the encrypted variant produces the samereason: "decryption"even with the correct key. Trustalgonly when it comes from a source you control, not merely because the file verified. SigningKeyErrorhas a third kind,"no-published-signing-key". The server signs withed25519_public_key.unwrap_or_default(), so an account whose key was never published signs every file with thekidof the empty string. No client-side action fixes that one, which is why it is not reported as a merely stale key set. Post-patch servers publish a key with every account and repair the public half at startup, so only pre-patch files carry thatkid.
signingKeyId(publicKeyBase64) computes the kid for a key you hold. ⚠️ It
hashes the base64 string the server publishes, not the 32 decoded bytes —
pass the string exactly as it appears in attributes.publicKey.
verifyMachineFileWithKeySet covers Ed25519-signed machine files only and
refuses the other three schemes outright. GET /signing-keys publishes Ed25519
keys and nothing else, so no set built from it could hold a key that verifies an
RSA or ECDSA signature. Verify those with verifyAndDecryptMachineFile and the
license's own scheme, and accept that a rotation is not a distinguishable
outcome for them.
Compatibility warning — offline license and machine files must be format v2.
algmust end in+v2and the signed payload must carry itsmetaclaims; a file issued under v1 is rejected outright, with no fallback path (src/checkout/licenseFile.ts::verifyLicenseFileWithClaims,src/checkout/machineFile.ts::verifyMachineFileWithClaims). This is a real behavioural break for any caller still holding a v1-issued file: re-run checkout to obtain a v2 file.
Fingerprint canonicalisation
A machine's fingerprint is what a seat is counted against, and the server
stores it as fingerprint TEXT NOT NULL — no length limit, no CHECK, no
normalisation, unique per (license_id, fingerprint). Every Tamga SDK sent
whatever string the caller supplied, byte for byte. So "ABC-123", "abc-123"
and " ABC-123 " were three machines holding three seats, and the third is the
common case: a value read from a file, a command's stdout or an environment
variable, carrying a trailing newline nobody sees.
computeFingerprint is a pure function that turns caller-chosen labelled
components into one stable string.
import { computeFingerprint } from "@tamga/sdk";
const fingerprint = computeFingerprint([
{ label: "machine-id", value: machineId },
{ label: "disk", value: diskSerial },
]);
await client.activateMachine(licenseId, fingerprint);It reads no hardware identifiers, and that is deliberate. What identifies a machine is a product decision, not a library's: a cloned VM template shares its identifiers, a container has none, a replaced motherboard changes them, and in a browser there is nothing sane to read at all. No default is right for both a desktop application and a Kubernetes sidecar, and a wrong default here spends your customers' seats. You choose the components; this makes the choice stable.
Three properties, each pinned by a pair of shared cross-SDK vectors:
- Order does not matter. The components are sorted, so the order you pass them in is your convenience, not part of the machine's identity.
- Surrounding ASCII whitespace does not matter. It is trimmed from values before hashing — the trailing-newline footgun above.
- Case does matter. Case folding is absent on purpose: lowercasing a base64 or hex identifier corrupts it.
Values are not Unicode-normalised. JavaScript has
String.prototype.normalize, so this is the one SDK where adding NFC would look
free — which is exactly why it is not there. NFC needs a new dependency in the
Rust and Go SDKs and either ICU or hand-rolled Unicode tables in the C11 one. A
rule the eight ports cannot implement identically would produce two fingerprints
for one machine depending on which SDK an application was written with, and
quietly consume two seats. If your values can arrive in more than one normal
form, normalise them yourself before calling.
Invalid input throws FingerprintError rather than being repaired: an empty
component list, an empty or non-ASCII label, a label containing =, a duplicate
label, or a value that still holds an ASCII control character after trimming.
Repairing any of these would map two genuinely different inputs onto one
fingerprint — that is, onto one seat — which is the bug this exists to close.
Match on error.kind.
Pick your components once and keep them stable for the life of an installation: changing the set changes the fingerprint, and a changed fingerprint is a new machine holding a new seat.
Security notes
This SDK reimplements Tamga's offline file cryptography from scratch, on audited
primitives (@noble/curves, @noble/hashes) and native crypto.subtle. Every
claim below names the code that implements it.
- License-file and machine-file AES keys are both HKDF-SHA256 (RFC 5869).
License files bind
salt = "tamga:license-file-key-v1",ikm = <license key>,info = "license-file"(src/crypto/hkdf.ts::deriveLicenseFileKey). Machine files bindsalt = "tamga:machine-file-key-v1",ikm = <license key>,info = <fingerprint>(src/checkout/machineFile.ts::verifyAndDecryptMachineFile, viasrc/crypto/hkdf.ts::deriveHkdfKey), so a machine file cannot be decrypted anywhere but on the machine it was issued for. The pre-v2 transform that zero-padded the raw license key to 32 bytes was deleted, not deprecated — no code path can produce that key any more. - Both formats' expiry is inside the signature and is enforced for you.
meta.expis checked with a 60-second clock-skew tolerance and an expired file throwsCheckoutErrorof kind"expired"(src/checkout/licenseFile.ts::verifyLicenseFileWithClaims,src/checkout/machineFile.ts::verifyMachineFileWithClaims— one sharedCLOCK_SKEW_TOLERANCE_SECONDS, so the two cannot drift apart).expis absent when checkout was made without attl, which is a file that genuinely never expires, not an error. The tolerance is deliberately small: the client's clock belongs to the attacker, so a generous allowance would be a free extension on every expired file. Thettl/expiryfields on the checkout envelope remain unsigned metadata and must not be trusted. - The signature covers
enc's base64 string bytes, not its decoded bytes. A non-obvious wire-format detail, and the highest-risk interop bug in any from-scratch reimplementation — verifying against decoded bytes accepts some forgeries and rejects some valid files (src/checkout/licenseFile.ts::verifyLicenseFileWithClaims, regression testtest/license-file-signing-gotcha.spec.ts). - Ed25519 verification is strict.
zip215: false, i.e. RFC 8032 / FIPS 186-5 semantics, rejecting malleable signatures and non-canonicalS(src/crypto/ed25519.ts::verifyEd25519). - Algorithm confusion is guarded three ways. The scheme is chosen by the
caller, never parsed from the file; the file's declared
algsuffix must match what that scheme implies; andRSA_2048_JWT_RS256is rejected up front, before any parsing (src/checkout/machineFile.ts::verifyMachineFileWithClaims). The suffix cannot stand in for the scheme even in principle — the server emits the samersa-sha256forRSA_2048_PKCS1_SIGNandRSA_2048_JWT_RS256. - Machine-file verification is tested against certificates the server
produced, not ones this SDK built: 12 fixtures in
test/fixtures/machine-file-v2/, four signing schemes by three variants, driven off their manifest bytest/machine-file-server-fixtures.spec.tsand re-run against the built output on Node, Deno and Bun byscripts/smoke.mjs. A self-generated fixture can only ever encode this SDK's belief about the wire format, and when that belief was wrong it hid the defects for two years. - Offline-proof payloads are canonicalised before verification, sorted by
UTF-8 byte order and rebuilt on a null-prototype accumulator so a
"__proto__"-keyed dataset cannot silently drop a field from the signed bytes (src/internal/canonicalJson.ts::canonicalJsonStringify, exercised bytest/canonical-json-utf8-sort.spec.tsandtest/proof-field-order.spec.ts). - HTTP 429 is retried with backoff.
Retry-Afteris parsed as delta-seconds and capped at 60s (src/transport.ts::parseRetryAfter,src/transport.ts::retryDelayMs); without it, exponential backoff with jitter so a fleet does not reconverge into the spike it was backing off from. Retries are scoped toGETplus seven effectively-idempotentPOSTactions —validate,validate-key,check-in,check-out,ping,ping-heartbeat,reset-heartbeat(src/transport.ts::isRetryable). Creates are deliberately excluded: retryingPOST /machinescan burn a second seat, and only you know whether that is acceptable. The budget is three retries (src/transport.ts::doFetch). - Requests have a deadline. Each attempt is capped at
DEFAULT_TIMEOUT_MS(45s), overridable per client viaTamgaClientConfig.timeoutMs, or disabled with0. It sits deliberately past the API's own 30s gateway timeout so the server wins that race and you get its504— the response that carries theX-Request-Idsupport needs — instead of an opaque local abort. The deadline covers the whole attempt including the response-body read:fetchresolves as soon as headers arrive, so a deadline released at that point would leave a peer that stalls the body (a wedged proxy, a connection held open) able to hang the call indefinitely (src/transport.ts::doFetch).
Reporting a vulnerability: see SECURITY.md.
Known gaps
Things this SDK deliberately does not do, or cannot do yet.
Key rotation is not handled. Both formats'
meta.kididentifies the signing key and is returned byverifyLicenseFileWithClaims/verifyMachineFileWithClaims, but nothing here selects a key by it — you still embed exactly one public key per scheme and a rotation invalidates files issued under the previous one.The 429 retry budget is not configurable. It is fixed at three attempts and is not plumbed through
TamgaClientConfig.X-RateLimit-*response headers are unavailable — no server handler sets them, soRetry-Afteron a 429 is the only rate-limit signal to read.checkForUpgradeansweringundefineddoes not mean "you are up to date".GET /releases/actions/upgradereturns204 No Contentin two different situations and will not distinguish them: no release newer than the version you sent exists, and a newer release exists that this license is not entitled to (an expired license under a policy that stops delivering new builds at expiry). The collapse is deliberate — a distinct refusal would leak "there is a newer version and you cannot have it", which is exactly what the expiry gate withholds. Word it to users as no update is available to you, never as you are on the latest version: the second is a claim this endpoint cannot support, and it is wrong precisely for the customers whose licence lapsed. A suspended licence is the one case that is not collapsed — it answers403, which surfaces asForbiddenError.Two more traps on this route. Leaving
constraintunset does not mean "no constraint": the server substitutes a pessimistic~{major}.{minor}.{patch}built from the version you sent, so an updater on1.2.0will never be offered1.3.0and will look indistinguishable from a current client. Pass"^1.2.0"for minor upgrades. Andchannelis optional server-side but required by this SDK, because omitting it drops the channel predicate entirely and lets alpha and dev builds answer a production updater.The artifact bytes behind a release are now modelled —
listReleaseArtifacts,getArtifactandgetArtifactDownloadUrl. They were excluded whileartifact.downloadsat in no role's permission set: the license-key role has always heldartifact.read, so listing and showing an artifact were reachable all along, but metadata you cannot act on is not worth a surface. The server now grants that roleartifact.downloadtoo, so an embedded updater can resolve its own build and the read routes finally have a point. Create, update, delete and upload are still out — those verbs are not in that role's set, so nothing this SDK could send would be authorized.getArtifactDownloadUrlhands back a short-lived presigned URL rather than the bytes, and that is deliberate. Fetch it yourself with a plainfetch(url)and no credentials — it points at an object store, not at the Tamga API. The route's default answer is a303at that URL, andfetchfollows redirects; the Fetch standard only stripsAuthorizationacross an origin boundary, so a deployment serving object storage from the API's own origin would otherwise receive your licence key. This SDK sends?redirect=falseand pins the request toredirect: "manual"so neither can happen.A
403from the download action is not necessarily a permission problem: the handler enforces the owning release's read gate too — distribution strategy, suspension, expiry, entitlement — so aCLOSEDrelease refuses its binary even to a caller that does holdartifact.download.A license key is not confined to its own license on the read routes. The server's
require_license_scopeguard — which stops a license-key credential naming a different license — is called byvalidate,validate-keyandcheck-out, but not byGET /licenses/{id}orGET /licenses/{id}/policy. The license-key role holdslicense.readby default, so a key can read any license in the account throughgetLicense, andattributes.keycomes back in plaintext. Reported upstream; there is no client-side fix. Do not treat possession of a license key as evidence that its holder can only see that license.getPolicyis unreachable with a license key.GET /policies/{id}requires thepolicy.readpermission, which the license-key role's default set does not include, so it answers403regardless of the policy'sauthentication_strategy.getLicensePolicyreaches the same resource throughGET /licenses/{id}/policy, which needs onlylicense.read— use that from an embedded client.Nothing server-side reaps a stale process row. The reaper exists (
find_and_claim_dead_processes/process_process_heartbeat) but the job scheduler never dispatches it — itsdispatchhandlescull_dead_machinesand has no process arm. A process that stops pinging is therefore not eventually cleaned up: the row persists indefinitely and keeps holding a seat againstpolicy.max_processes, which only an explicitdeleteProcessdecrements. An application that registers a process per launch and never deletes one eventually gets422 TOO_MANY_PROCESSESon every start, with no client-side recovery beyond enumeratinglistMachineProcessesand deleting. CalldeleteProcesson shutdown.policy.max_memoryandpolicy.max_diskare omitted from theGETresponse even though both are enforced during validation, sogetPolicy/getLicensePolicycannot introspect those two limits — you only observeTOO_MUCH_MEMORY/TOO_MUCH_DISKon a failed validation, orMEMORY_LIMIT_EXCEEDED/DISK_LIMIT_EXCEEDEDon a refused machine create.PolicyAttributestherefore does not declare them; it dropped the two never-populated properties in 0.4.0.policy.check_in_intervalisdaily/weekly/monthly/yearly, and the server does not act on any of them. The column's ownCHECKconstraint admits nothing else, so those four are what a policy read carries —CheckInIntervalwas corrected to them in 0.4.0, from the noun spellings (day/week/ …) it previously and wrongly declared. Separately and not fixable here, the server's overdue calculation matches on those same noun spellings, so every configured cadence falls through its 30-day default branch andcheck_in_interval_countis discarded with it. Readrequire_check_into decide whether to callcheckInat all; treat the cadence as configuration rather than as a deadline.GET /licenses/{id}/entitlementscannot be paginated. The listing is a union of the license's direct entitlements and the ones inherited from its policy, so no single keyset cursor describes it and the server ignorespage[after].listEntitlementssends the server maximum (limit=100) when you give no explicit limit, and does not send the cursor;ListOptions.afteris accepted on the shared type but has no effect on this route. A license with more than 100 effective entitlements cannot be fully enumerated, so afalsefromhasEntitlementis only authoritative below that ceiling.listComponentsis unaffected — its cursor works.quickValidatedoes not always record the validation. The server skips thelast_validated_atwrite whenever the request carries anOriginheader, and the response is byte-identical either way, so there is nothing to branch on. In a browser this is unavoidable: the browser addsOriginto a cross-originfetchitself and script cannot suppress it, so quick-validate from a browser never records a validation. That leaves a license with no machines reading asINACTIVEand keeps the check-in-overdue worker firing. UsevalidateByIdwhen the write matters — thePOSTroute has noOriginbranch.No RFC 9421 response-signature verification. No API response is signed, so there is nothing to verify.
No
Tamga-Environmentrequest header. No server code path reads it yet.scope.versionandscope.checksumare dead. The server answers422 SCOPE_NOT_SUPPORTEDto a scope carrying either and never runs the validation, sovalidateByIdstrips both before sending rather than letting them fail the whole call. They are deprecated and will be removed in the next minor release. The other six scope fields — includingentitlements(matched on entitlement codes, case-insensitively, across direct and inherited rows) andfingerprint(matched against any machine on the license) — are genuinely enforced.Machine
memoryanddiskare megabytes, not bytes. They feed the license's memory/disk tallies and the activation limit check, so reporting 16 GB as17179869184instead of16384inflates the account tally by roughly a million and gets the next activation on that license refused withMEMORY_LIMIT_EXCEEDED.heartbeat_status: "DEAD"never comes back from a ping, and does not mean the machine was culled where it does. The rule is about what the request did tolast_heartbeat_at, not about whether it wrote anything: a write that sets the column cannot reportDEAD, because the status is then derived from the timestamp it just wrote.pingHeartbeatwriteslast_heartbeat_at = NOW(), so it answersALIVEorRESURRECTED;resetHeartbeatnulls the column (NOT_STARTED);createMachinenever sets it (NOT_STARTED); and validate never emitsHEARTBEAT_DEAD.updateMachineis the exception —PATCH /machines/{id}leaveslast_heartbeat_atuntouched and still derives a status from it, and itsUPDATE … RETURNINGjoins no policy, so it judges against the 600s fallback and can disagree with a read in either direction. Do not read heartbeat state off a patch response. Read-backed responses carry a real verdict, and this SDK has two: machine checkout resolves the machine through a lookup that joins the policy, so theMachinereturned byverifyAndDecryptMachineFilecarries a genuine staleness verdict that may beDEAD, andgenerateOfflineProof'smachinehalf is built the same way.getMachineandlistMachinesare a third and fourth: both resolve through the same policy-joining lookup, which is what makesgetMachinethe direct way to observe a machine's real staleness. So branch onDEADfrom a read if it is useful — just never from a ping, where it cannot appear. Even from a file it does not mean the row was culled: the cull job runs exclusively for policies withrequire_heartbeat = true, which defaults tofalse, so under a default policy no row is ever culled and a machine staysDEADindefinitely with its row and its seat intact — and a ping revives it regardless (barelast_heartbeat_at = now, no resurrection check). The practical rule: a heartbeat scheduler must not stop on any status. The one terminal signal is a404 NOT_FOUND(NotFoundError) from the ping, meaning the row is gone; hang re-activation off that.startHeartbeatswallows every ping failure, that 404 included, so a client that must react to deletion should drivepingHeartbeaton its own timer and catchNotFoundError.The heartbeat window is policy-driven, and
startHeartbeatdoes not adapt to it — but you can read it off a machine file. The server usespolicy.heartbeat_durationseconds when that column is set, and falls back to 600s (10 min) only when it is null (Policy::effective_heartbeat_duration_secs; the cull job's claim query usesCOALESCE(p.heartbeat_duration, 600)).MACHINE_HEARTBEAT_WINDOW_MSis that 600s fallback, not a reading of your policy, so dividing it is only safe whileheartbeat_durationis unset — under a policy that sets it lower, an interval sized against 600s leaves the machine outside its window between pings, which is what makes it cullable underrequire_heartbeat.To get the real value, subtract on a read-backed machine —
verifyAndDecryptMachineFile's return value, orgenerateOfflineProof'smachine, whose queries join the policy:const { last_heartbeat_at: last, next_heartbeat_at: next } = machine.attributes; const windowMs = last && next ? Date.parse(next) - Date.parse(last) : undefined;heartbeatWindowMsFromMachine(machine)does exactly this, so you need not re-derive it. ⚠️ It returnsnumber | undefined, andundefinedis the common case, not an edge one — it is what any machine that has not been pinged yet gives you, which includes every freshly activated one. So do not writeheartbeatWindowMsFromMachine(m)! / 3: that isNaNexactly when a scheduler is starting up, andNaNis a delaysetIntervalturns into a 1 ms tick rather than refusing. Branch on theundefined.Three caveats: a
pingHeartbeatresponse does not work for this (that query carries no policy join, sonext_heartbeat_atcomes back aslast_heartbeat_at + 600swhatever the policy says); both fields arenulluntil the machine has been pinged once; and the value is a snapshot from the file's issue time.getMachineis read-backed too and has neither the second nor the third problem beyond the moment you read it.When no machine is at hand, ask the policy:
resolveHeartbeatWindowMs(licenseId)readsGET /licenses/{id}/policyand applies the same fallback the server does, andstartHeartbeatFromPolicy(machineId, licenseId)does that and starts the timer at a third of the result. One extra request at startup, and the scheduler stops guessing 600s at a policy that asked for 60.Both of those report the window verbatim, including a
heartbeat_durationof0or a negative one — the column carries noCHECKconstraint andeffective_heartbeat_duration_secsreturns whatever it holds; onlyNULLtakes the 600s fallback. That is deliberate: rounding a misconfigured policy up to something friendlier in the accessor would hide it. The guard lives in the scheduler instead.startHeartbeatclampsintervalMsto[1000, 2147483647]and truncates it to an integer; a non-finite value becomes1000. Worked through:20000stays20000,500becomes1000,1becomes1000,0/-1/NaNbecome1000,2**31becomes2147483647. Nothing throws.startProcessHeartbeatapplies the same clamp;startHeartbeatFromPolicyinherits it.The floor is flat rather than a guard on just the values
setIntervalrefuses to honour, and the reason is that the rewrite is not what does the damage — the rate is.setIntervalhonours1exactly, and1is the same ~740 pings a second as0(measured:0→ 1.4 ms/tick,1→ 1.35,2→ 2.55,3→ 3.75,500→ 501). A rule clamping0but passing1would give two inputs with identical behaviour opposite treatment. The floor costs nothing a policy can ask for:heartbeat_durationis an integer-seconds column, so the shortest expressible window is 1s and a once-a-second ping is inside every policy that exists.⚠️ The server judges liveness on truncated whole seconds, which is easy to get wrong in the pessimistic direction.
heartbeat_status_withincompares(now - last_heartbeat_at).num_seconds() <= window_secs, and chrono'snum_seconds()truncates — so a machine readsDEADonly once its age reacheswindow_secs + 1seconds. Every window carries one free second on top of its nominal value. A 1s window is therefore served comfortably by a 1s ping (2s of slack, not zero), which is what makes the flat floor safe on short windows. What the floor does cost is theMACHINE_HEARTBEAT_INTERVAL_DIVISORpromise of two tolerable consecutive losses:heartbeat_durationof 3 is the first window where floor and divisor agree, 2 keeps one spare ping, 1 keeps none.⚠️ A non-positive
heartbeat_durationis scheduled at the 600s default rate — 200s — rather than divided.0and negatives are storable and are unsatisfiable at any ping rate: the cull job claims rows withlast_heartbeat_at < NOW() - make_interval(secs => COALESCE(p.heartbeat_duration, 600)), andCOALESCEreplaces onlyNULL, so a stored0reduces that tolast_heartbeat_at < NOW()— true for every machine that has ever pinged, at every instant. Note the cull job andheartbeat_statusdisagree here: the status comparison truncates, so a sub-second ping keeps a0window reportingALIVEwhile the SQL comparison still claims the row. Survival follows the cull job. Since no rate helps, the only thing left to choose is what the futility costs — 18 requests an hour instead of the 3600 that dividing the raw0produced. This substitutes a rate, not a window:resolveHeartbeatWindowMsstill reports0verbatim. Hand-composing the primitives yields the 1s floor instead, becausestartHeartbeatreceives a bare number and cannot know where it came from. The interaction table is pinned intest/policy-read.spec.ts. The 30s process window is genuinely hardcoded server-side and needs no such care.Five of the 24
ValidationCodevalues are not reachable today —NOT_FOUND,BANNED,COMPONENTS_SCOPE_MISMATCH,CHECKSUM_SCOPE_MISMATCH,VERSION_SCOPE_MISMATCH. They are modelled for forward-compatibility (src/models/validation.ts); do not write logic that depends on receiving one.TOO_MANY_USERS,HEARTBEAT_DEADandHEARTBEAT_NOT_STARTEDjoined the reachable set with the API patch, andENTITLEMENTS_MISSING/FINGERPRINT_SCOPE_MISMATCHhave been live since the scope fields behind them were enforced.
Documentation
docs/examples/— runnable end-to-end examples.- https://tamga.sh — product and API documentation.
CONTRIBUTING.md— dev setup, commands, and the security-review requirement for crypto-touching changes.SECURITY.md— vulnerability reporting and what counts as a security issue here.
License
MIT © Tamga
