Skip to content

Latest commit

 

History

History
297 lines (227 loc) · 22.3 KB

AGENTS.md

File metadata and controls

297 lines (227 loc) · 22.3 KB

AuthForge SDK - AI Agent Reference

This file is optimized for AI coding agents (Cursor, Copilot, Claude Code, etc.). It contains everything needed to correctly integrate AuthForge licensing into a project.

What AuthForge does

AuthForge is a license key validation service. Your app activates online: it sends a license key + hardware ID to POST /auth/validate, and the server checks revocation, expiry, HWID binding, and credits, then returns an Ed25519-signed session with a TTL.

After activation the client follows one of two policies:

  • Grace period (default): the app keeps running on the signed session without contacting AuthForge. The SDK re-verifies the signature and expiry locally in the background and fails once the session TTL expires. The grace period equals the session TTL: default 24h, server clamps requests to 1h to 7d (ttlSeconds).
  • Online check-ins (opt-in, onlineHeartbeat: true): the SDK periodically calls POST /auth/heartbeat for fast revocation and concurrent-use detection.

If the license is revoked or expired, the background check fails and you handle it (typically exit the app).

There is also a separate mode for machines that can never reach the internet: offline license files (.authforge). The operator mints a signed file in the AuthForge cloud; loginFromFile() verifies it locally with the app public key and the machine HWID, with zero network calls. Do not ship the App Secret in those builds (omit appSecret). Only use it when the user explicitly asks for air-gapped / offline-file licensing. The default integration is always online login() + grace period. To collect the HWID for a bound file, write an activation request (.authforge-request) with createActivationRequest / writeActivationRequest. It is not a license, is not signed, and does not mint anything. Prefer it over printing the raw HWID.

A third, also separate mode is self-serve offline activation (activateOffline()), for apps sold as a one-time purchase: on first run the end user enters their key, one online call (POST /auth/offline/activate) returns a lifetime .authforge file bound to that machine, and from then on the app runs from the file with loginFromFile() and no network. Unlike air-gapped files it needs the App Secret in the build (it is an online call); air-gapped builds still ship with no app secret. The app owner must opt in on the app, and only perpetual, seat-limited licenses qualify. Only use it when the user asks for one-time-purchase / activate-once offline licensing.

Billing model (so you can pick sensible intervals)

  • 1 login() or validateLicense() = 1 credit (one /auth/validate debit).
  • 10 online check-ins = 1 credit (billed on every 10th successful check-in per license). The grace period costs nothing after the initial activate.
  • 1 offline file mint = 1 credit (charged to the operator when the file is minted). loginFromFile() / verifyLicenseFile() cost nothing.
  • 1 new offline activation = 2 credits; re-activating the same machine is free (activateOffline(), charged to the app owner; a refused activation costs nothing).
  • Keep heartbeatInterval at >= 10 seconds (900 / 15 min is the common desktop default). /auth/heartbeat is limited to 6 requests/minute per license key, and revocations still take effect on the next check-in.

Installation

Install from npm as @authforgecc/sdk:

npm install @authforgecc/sdk

Or copy authforge.mjs into your project (single file, Node.js built-ins only). Requires Node.js 18+.

Minimal working integration

import process from "node:process";
import { AuthForgeClient, AuthForgeError } from "@authforgecc/sdk";

// Aborted when the license is lost; the app listens and shuts down cleanly.
const licenseLost = new AbortController();

const onFailure = (reason, error) => {
  if (reason === "heartbeat_failed" && error instanceof AuthForgeError && error.transient) {
    return; // network blip / rate_limited: the SDK checks in again next interval
  }
  console.error(`AuthForge: ${reason}`);
  if (error) {
    console.error(error);
  }
  licenseLost.abort(error); // do not process.exit() here: let the app save first
};

const client = new AuthForgeClient({
  appId: "YOUR_APP_ID",
  appSecret: "YOUR_APP_SECRET",
  publicKey: "YOUR_PUBLIC_KEY",
  onFailure,
});

const licenseKey = process.argv[2]?.trim();
if (!licenseKey) {
  console.error("Provide a license key.");
  process.exit(1);
}

const ok = await client.login(licenseKey);
if (!ok) {
  console.error("Login failed.");
  process.exit(1);
}

licenseLost.signal.addEventListener(
  "abort",
  () => {
    // Save the user's work and close servers/timers here; Node then exits on its own.
    client.logout();
    process.exitCode = 1;
  },
  { once: true },
);

// --- Your application code starts here ---
console.log("Running with a valid license.");
// Long-running work should watch licenseLost.signal (pass it to fetch(), or check
// licenseLost.signal.aborted between steps) and stop cleanly when it fires.
// --- Your application code ends here ---

client.logout();

This default configuration activates online once and then runs through the grace period (no further network calls until the session TTL expires). To enable online check-ins, add onlineHeartbeat: true (and optionally tune heartbeatInterval).

Constructor parameters

Parameter Type Required Default Description
appId string yes - Application ID
appSecret string for online APIs - Application secret. Required for login / validateLicense / selfBan / activateOffline. Omit or pass "" for loginFromFile only; do not ship it in air-gapped binaries.
publicKey string | readonly string[] yes - Base64 Ed25519 public key from the dashboard (3rd positional arg, or publicKey in the options object). Accepts one key, an array, or a comma-separated string; the SDK trusts a signature matching any entry (key rotation)
onlineHeartbeat boolean no false Enable online check-ins (periodic /auth/heartbeat). When false, the app runs through the grace period without contacting AuthForge
heartbeatMode string no - Deprecated. "SERVER" or "LOCAL" (case-insensitive); still the 4th positional arg for compatibility. See Migrating from heartbeatMode
heartbeatInterval number no 900 Seconds between background checks (minimum 10)
apiBaseUrl string no https://auth.authforge.cc API base URL
onFailure (reason: string, error: Error | null) => void | null no null Called on login/check-in/network failure. If omitted, a transient background check failure prints a one-line warning to stderr and check-ins continue; any other failure exits via process.exit(1)
requestTimeout number no 15 HTTP timeout (seconds)
ttlSeconds number | null no null (server default: 86400) Requested grace period duration (session TTL) in seconds. Server clamps to [3600, 604800] (1h to 7d) and preserves the lifetime across check-in refreshes.
hwidOverride string | null no null Optional custom HWID/subject string. When set to a non-empty value (for example tg:123456789), the SDK sends it instead of generating a machine fingerprint.

For Telegram/Discord bot flows, prefer immutable IDs (tg:<user_id>, discord:<user_id>) instead of usernames.

Migrating from heartbeatMode

heartbeatMode is deprecated but still accepted (options object or 4th positional arg). Passing it emits a DeprecationWarning; an invalid value still throws heartbeatMode must be LOCAL or SERVER.

  • "LOCAL" maps to the default grace period behavior: remove the option.
  • "SERVER" maps to onlineHeartbeat: true.
// Before
new AuthForgeClient({ appId, appSecret, publicKey, heartbeatMode: "SERVER" });

// After
new AuthForgeClient({ appId, appSecret, publicKey, onlineHeartbeat: true });

The client exposes onlineHeartbeat (boolean, the effective policy) and keeps a back-compat heartbeatMode property ("SERVER" when check-ins are enabled, "LOCAL" otherwise). Prefer onlineHeartbeat.

Methods

Method Returns Description
login(licenseKey) Promise<boolean> Activates online: validates license, verifies signatures, starts the background check interval
validateLicense(licenseKey) Promise<ValidateLicenseResult> Same validate + signatures as login; does not mutate session or start background checks; never calls onFailure or process.exit: use the result object
loginFromFile(pathOrText) boolean Offline mode: verifies a .authforge file locally (no network), authenticates the client, never starts background checks. Failures -> onFailure("offline_login_failed", error) + false; never process.exit
activateOffline(licenseKey, { path }?) Promise<boolean> Self-serve offline activation: one POST /auth/offline/activate returns a lifetime file bound to this HWID; verified locally, written atomically to path, then an offline session starts (as loginFromFile). Needs the app secret. Failures reject with AuthForgeError; never onFailure / process.exit
verifyLicenseFile(pathOrText, options?) VerifyLicenseFileResult Same offline checks without changing client state
getOfflineLicense() OfflineLicenseSummary | null jti, expiresAt, hwidPolicy, … of the offline file in use
getSessionKind() "online" | "offline" | null Kind of session the client holds; null when logged out
getHwid() string HWID this client sends; the customer reports it so the operator can mint a bound file
createActivationRequest(options?) string Unsigned .authforge-request for this machine. No network, no secret, callable before login(). Hostname omitted unless includeMachineName: true
writeActivationRequest(path, options?) void Writes that file as UTF-8
logout() void Stops background checks and clears session state
isAuthenticated() boolean Whether a session token is present and marked authenticated
getSessionData() Record<string, unknown> | null Decoded signed payload map
getAppVariables() Record<string, unknown> | null App-scoped variables
getLicenseVariables() Record<string, unknown> | null License-scoped variables

Error codes the server can return

Full set (knownServerErrors): invalid_app, invalid_key, expired, revoked, hwid_mismatch, no_credits, app_burn_cap_reached, blocked, rate_limited, replay_detected, app_disabled, session_expired, revoke_requires_session, bad_request, malformed_request, demo_quota_exceeded, system_error, offline_activation_disabled, offline_activation_requires_perpetual, offline_activation_requires_seats, offline_activation_limit_reached, offline_file_rejected (SDK code). Unrecognized codes are passed through verbatim.

Notes:

  • replay_detected is validate-only. rate_limited can be returned by /auth/validate and /auth/heartbeat (heartbeat is license-limited at 6/min and has no app-layer IP limit).
  • /auth/heartbeat returns hwid_mismatch when the HWID is no longer bound to the license (for example after an HWID reset) and blocked when the HWID/IP is blacklisted or not whitelisted.
  • app_burn_cap_reached means the app's configured credit burn cap is hit; revoke_requires_session means a pre-session self-ban tried to revoke a license (only session-authenticated self-ban can revoke).
  • session_expired is also raised locally when the grace period (session TTL) runs out.

Heartbeat failure classification (AuthForgeError.transient / isTransientError()):

  • Definitive (allowlist, exported as definitiveErrorCodes): revoked, expired, hwid_mismatch, blocked, session_expired, malformed_request, app_disabled, invalid_app, signature_mismatch, plus the self-serve activation codes offline_activation_disabled, offline_activation_requires_perpetual, offline_activation_requires_seats, offline_activation_limit_reached, offline_file_rejected. The SDK clears the stored session and stops check-ins before calling onFailure.
  • Transient: everything else, including network_error, timeout, rate_limited, system_error, no_credits, demo_quota_exceeded, app_burn_cap_reached, bad_request, invalid_key, unexpected_response, every http_error_<status> and unknown codes. The session is kept and the SDK checks in again next interval; after the session TTL passes, the next transient failure becomes a definitive session_expired.
  • A failed check-in is a verdict only when its body is {"status":"failed","error":"<code>"}; any other failure body is unexpected_response (raw status/error in the message).
  • Only rate_limited (or a 429 with no error code) is retried inside the request (2s, then 5s). no_credits, demo_quota_exceeded and app_burn_cap_reached are never retried immediately.
  • onFailure may call logout(), isAuthenticated() or login(). A check-in still in flight when logout()/login() runs is discarded.

Common patterns

Reading license variables (feature gating)

const varsMap = client.getLicenseVariables() ?? {};
const tier = varsMap.tier;

Graceful shutdown

client.logout();

Offline license file (air-gapped machine, only when asked)

// Step 1 (customer machine): print the HWID so the operator can bind the file to it.
console.log(client.getHwid());

// Step 2 (operator): mint the .authforge file in the dashboard or via
// POST /v1/licenses/{licenseKey}/offline-files and deliver it out-of-band.

// Step 3 (customer machine): authorize with the file. No network, no check-ins.
if (!client.loginFromFile("./license.authforge")) {
  // onFailure already received ("offline_login_failed", Error(code)) where code is one of
  // bad_armor | bad_signature | unsupported_version | malformed_payload | wrong_app | expired | hwid_mismatch
  process.exit(1);
}

Offline file error codes (in check order): bad_armor, bad_signature, unsupported_version, malformed_payload, wrong_app, expired, hwid_mismatch.

Self-serve offline activation (one-time purchase, only when asked)

Separate from the air-gapped flow above: this build ships the App Secret (activation is an online call), the app owner has enabled self-serve offline activation on the app, and the licenses are perpetual and seat-limited. 1 new offline activation = 2 credits (charged to the app owner); re-activating the same machine is free and returns the identical file.

import { existsSync } from "node:fs";
import { AuthForgeClient, AuthForgeError } from "@authforgecc/sdk";

const LICENSE_PATH = "./license.authforge"; // a writable per-user location

const client = new AuthForgeClient({
  appId: "YOUR_APP_ID",
  appSecret: process.env.AUTHFORGE_APP_SECRET, // required for activateOffline()
  publicKey: "YOUR_PUBLIC_KEY",
  onFailure: () => {}, // loginFromFile() reports here; activateOffline() throws instead
});

if (!(existsSync(LICENSE_PATH) && client.loginFromFile(LICENSE_PATH))) {
  const key = await promptForKey(); // app UI
  try {
    await client.activateOffline(key, { path: LICENSE_PATH }); // verifies, then writes atomically
  } catch (error) {
    if (!(error instanceof AuthForgeError)) throw error;
    if (error.code === "offline_activation_limit_reached") {
      showError("This key is activated on its maximum number of computers. Please contact the vendor.");
    } else if (error.code === "invalid_key") {
      showError("That license key is not valid."); // classified transient, but a retry needs a different key
    } else if (error.transient) {
      showError("Activation failed, please try again later."); // network, rate_limited, no_credits, ...
    } else {
      showError(`Activation refused: ${error.code}`); // revoked, hwid_mismatch (no free seat), ...
    }
  }
}
// On success: getSessionKind() === "offline", no background checks; next launch takes the loginFromFile() branch.

Codes (all fatal): offline_activation_disabled (app has not opted in), offline_activation_requires_perpetual, offline_activation_requires_seats (shared keys cannot self-serve), offline_activation_limit_reached (every machine slot used; HWID resets do not free them; tell the user to contact the vendor), offline_file_rejected (SDK code: the returned file failed local verification and was not written; the verifier's reason is in error.message). The server's hwid_mismatch here means no free seat. file_write_failed (transient) means the verified file could not be saved; activating again is free. Existing codes keep their meaning and classification.

Custom error handling

Server error codes appear as AuthForgeError in the error passed to onFailure: error.code is the code (for example invalid_key, revoked, hwid_mismatch), error.message the message. Reasons are login_failed, heartbeat_failed, or offline_login_failed. A login() network failure is reported once, as login_failed with error.code network_error or timeout. For heartbeat_failed, error is always an AuthForgeError; use error.transient to tolerate outages. selfBan() never calls onFailure: every failure, including a network one (error.code network_error or timeout), rejects the returned promise.

import { AuthForgeError } from "@authforgecc/sdk";

const licenseLost = new AbortController(); // the app listens for "abort", saves work, then exits

const onFailure = (reason, error) => {
  if (reason === "heartbeat_failed" && error instanceof AuthForgeError && error.transient) {
    return; // no verdict (network, rate_limited, no_credits, ...): SDK retries next interval
  }
  // Definitive heartbeat failures have already cleared the session.
  const code = error?.code ?? error?.message;
  if (new Set(["invalid_key", "expired", "revoked", "hwid_mismatch", "blocked"]).has(code)) {
    console.error(`License issue: ${code}`);
  }
  licenseLost.abort(error);
};

process.exit(1) inside onFailure is a last resort: it does not wait for pending writes, so save the user's work first.

Do NOT

  • Do not hardcode the app secret as a plain string literal in source - use environment variables or encrypted config
  • Do not embed the App Secret in air-gapped / loginFromFile builds - omit it or pass ""; verification only needs app id + public key
  • Do not skip the onFailure callback - without it, transient check-in failures only print a stderr warning, but definitive ones (and a failed login()) terminate the process via process.exit(1) without your cleanup
  • Do not call process.exit() from onFailure as the normal shutdown path - signal the app (for example an AbortController) so it can save work and exit cleanly
  • Do not treat every heartbeat_failed as a network blip or every one as fatal - check error.transient (only the definitiveErrorCodes allowlist is fatal). Fatal failures have already cleared the session, so do not keep the app running on it
  • Do not call login() on every app action - call it once at startup; the background checks handle the rest
  • Do not use heartbeatMode in new code - it is deprecated; use onlineHeartbeat: true when you need online check-ins, or nothing at all for the default grace period
  • Do not treat the grace period as persistent offline licensing - it is session continuation after one successful online activation, and revocations are only picked up at the next online validate or check-in
  • Do not reach for loginFromFile() unless the user explicitly needs air-gapped / offline-file licensing - the default is online login() + grace period
  • Do not expect an online revoke to disable an offline file that is already on a customer machine - the file stays valid until its own expiresAt; prefer short expiries and HWID-bound files
  • Do not use activateOffline() for air-gapped builds or confuse it with them - it needs the App Secret and one online call; air-gapped builds use operator-minted files and ship no secret. Files from activateOffline() are lifetime: a refund, chargeback or revoke cannot reach them
  • Do not call activateOffline() on every launch - try loginFromFile(path) first and activate only when there is no file or it fails; each new machine costs 2 credits (same machine is free)
  • Do not treat the server's hwid_mismatch from activateOffline() as a local file problem - it means no free seat; a file that fails local checks is offline_file_rejected
  • Do not mint or accept hwid.mode: "any" files casually - anyone who copies an unbound file has a working license
  • Do not call loginFromFile() with another app's public key or app id - the file is rejected with bad_signature / wrong_app by design
  • Do not try to build .authforge files client-side - only the AuthForge cloud holds the signing key; there is no BYO issuer
  • Do not call selfBan() or any other online method after loginFromFile() - an offline session has no server session (getSessionKind() is "offline"), so selfBan() rejects with offline_session without contacting the server and online check-ins never start; machines that can reach AuthForge should use online login()
  • Do not bind an offline file to an HWID collected before upgrading to 2.0 - HWID v2 replaced the old fingerprint, so pre-2.0 HWIDs no longer match; collect it again with createActivationRequest() (since 2.0 any AuthForge SDK gives the same HWID for the same app id and machine)

HWID v2

The HWID is sha256("authforge-hwid-v2|" + appId + "|" + machineId) (lowercase hex), where machineId is the trimmed, lowercased OS machine id: macOS IOPlatformUUID, Linux /etc/machine-id (then /var/lib/dbus/machine-id), Windows MachineGuid (64-bit registry view). Without a valid OS id the SDK falls back to legacy:<pre-2.0 fingerprint>. For containers, use hwidOverride.

hwid_vectors.json is generated by authforge-node/generate_hwid_vectors.mjs. Regenerating it means copying the file unmodified into all six SDK repos in the same change.

Activation request vectors

activation_request_vectors.json is generated by authforge-node/generate_activation_request_vectors.mjs. Regenerating it means copying the file unmodified into all six SDK repos and platform/frontend/src/test/fixtures/activation_request_vectors.json in the same change. The vector sdk value is a frozen encoding fixture, not the live SDK tag.