@vss-software/lumen-account-sdk
v1.1.2
Published
Official TypeScript SDK for Lumen Accounting — central auth, organization management, Stripe checkout, license gating, and usage reporting for the Lumen product suite.
Maintainers
Readme
@vss-software/lumen-account-sdk
Official TypeScript SDK for the Lumen Accounting central API — the single source of truth for auth, licensing, and billing across the Lumen product suite (Lumen HR, Lumen CRM, …).
What's new in this version (Phase 0)
The client now exposes a namespaced API alongside the existing flat methods, plus a few new building blocks that the upcoming billing/paywall phases depend on:
client.auth.*—login,register,logout,requestPasswordReset,resetPassword,acceptInvitation,sessions.list(),sessions.revoke()client.me.*—get()(fetches/auth/me),switchOrg(orgId)client.service.*—verifyToken,checkLicense,reportUsage- Auto token refresh — a 401 on any authenticated request triggers one
silent
POST /auth/refresh+ retry. Concurrent 401s share a single refresh call (single-flight). TokenStorageadapter —MemoryTokenStorage(default) orBrowserLocalStorageTokenStoragefor browser apps. Implement your own for cookie-backed persistence.- Typed error hierarchy — every failing HTTP call now throws a subclass
of
LumenApiError(ValidationError,UnauthorizedError,PaywallError,NotFoundError,ConflictError,LimitExceededError,ServerError,NetworkError) withstatus,code,requestId, andbodyfields. - Active organization context —
client.setActiveOrg(orgId)attachesx-organization-idto every subsequent request. - New events —
auth:tokens-refreshed,auth:logout.
Migration notes (non-breaking for existing callers):
- The flat methods (
client.login,client.checkLicense, …) still exist as@deprecatedthin wrappers that delegate to the namespaces. Existing code keeps working without changes. setTokensandgetRefreshTokenare nowasync(return a Promise). Existing synchronous callers that don'tawaitstill work with the defaultMemoryTokenStorage; addawaitto be forward-compatible.
The SDK is a two-in-one package:
- HTTP client (
LumenAccountClient) — service-to-service and user-facing calls to the Lumen Accounting API, with built-in exponential-backoff retry and a lifecycle event system. - Route-protection middleware (
requiresLicenseExpress/requiresLicenseFastify) — plug-and-play license gating for Express and Fastify apps, with in-memory caching (or a pluggable Redis-backed cache) and tier-based access control.
Table of contents
- Installation
- Concepts
- Quick start
- Client API
- requireLicense middleware
- Retry & timeout behaviour
- Types reference
- Testing
- License
Installation
pnpm add @vss-software/lumen-account-sdk
# or
npm install @vss-software/lumen-account-sdkRequirements: Node.js ≥ 18 (uses the native fetch API and
AbortSignal.timeout). No build-time dependencies — the package ships
compiled ESM with .d.ts type declarations.
Concepts
Service mode vs user mode
LumenAccountClient supports two mutually exclusive authentication modes:
| Mode | Credential | Header sent | Use case |
| ------------- | ------------------- | --------------------------- | --------------------------------------------------------------------- |
| Service | serviceApiKey | X-Service-API-Key | Lumen product backends calling checkLicense, reportUsage, … |
| User | accessToken | Authorization: Bearer ... | Frontends / SSRs calling getSessions, revokeSession, and other user endpoints |
Auth-flow endpoints (login, register, requestPasswordReset,
resetPassword, acceptInvitation) never send the service API key —
they are always called unauthenticated.
Authentication headers
The client's internal request helper applies headers in this priority order:
- Explicit
authTokenpassed for a single call (used byverifyToken) - Stored
accessTokenon the client (user mode) serviceApiKey(service mode), unlessomitServiceKey: trueis set
If none of these apply the request is sent without an auth header — which is correct for the public auth endpoints listed above.
Quick start
Service mode — gate a Lumen HR route on a valid license
import {
LumenAccountClient,
requiresLicenseFastify,
} from "@vss-software/lumen-account-sdk";
import Fastify from "fastify";
const client = new LumenAccountClient({
baseUrl: process.env.LUMEN_ACCOUNT_BASE_URL!, // https://api.lumen.example.com
serviceApiKey: process.env.LUMEN_ACCOUNT_SERVICE_KEY!, // lumen_sk_...
});
const app = Fastify();
app.get(
"/employees",
{ preHandler: requiresLicenseFastify(client, "lumen-hr") },
async () => {
return { employees: [/* … */] };
}
);
// Later, report current headcount back for billing reconciliation:
setInterval(async () => {
const headcount = await countActiveEmployees();
await client.reportUsage("org_abc123", "lumen-hr", headcount);
}, 1000 * 60 * 60);User mode — log a user in from a frontend
import { LumenAccountClient } from "@vss-software/lumen-account-sdk";
const client = new LumenAccountClient({
baseUrl: "https://api.lumen.example.com",
accessToken: sessionStorage.getItem("accessToken") ?? "",
});
const { accessToken, refreshToken, user } = await client.login(email, password);
client.setTokens(accessToken, refreshToken);
sessionStorage.setItem("accessToken", accessToken);
const { sessions } = await client.getSessions();
console.log(`${user.firstName} has ${sessions.length} active session(s)`);Client API
Constructor
new LumenAccountClient(options: LumenAccountClientOptions)LumenAccountClientOptions is a discriminated union — exactly one of
serviceApiKey or accessToken must be provided:
type LumenAccountClientOptions =
| {
baseUrl: string;
serviceApiKey: string;
timeout?: number; // default: 10 000 ms
}
| {
baseUrl: string;
accessToken: string;
refreshToken?: string;
timeout?: number;
};Trailing slashes on baseUrl are stripped automatically.
Service helpers
These endpoints require service mode (serviceApiKey) and are intended
to be called from Lumen product backends.
verifyToken(token)
Verify a user JWT issued by Lumen Accounting and retrieve the full user
profile, org memberships, and license summaries. Sends
Authorization: Bearer <token> — does NOT use the service API key.
const { user, organizations, licenses } = await client.verifyToken(
req.headers.authorization?.replace("Bearer ", "") ?? ""
);
console.log(`${user.firstName} belongs to ${organizations.length} org(s)`);checkLicense(organizationId, productSlug)
Check whether an organization holds a valid license for a product. Used for access control — see the middleware section below for a plug-and-play wrapper.
const check = await client.checkLicense("org_abc123", "lumen-hr");
if (!check.valid) {
throw new ForbiddenError("No active Lumen HR license for this org");
}
console.log(`Tier: ${check.tier}`);
console.log(`Seats: ${check.unitCount}`);
console.log(`Features: ${JSON.stringify(check.features)}`);Response shape (CheckLicenseResponse):
interface CheckLicenseResponse {
valid: boolean;
status?: "active" | "trial";
tier: string | null;
features: Record<string, boolean | number | string> | null;
limits: Record<string, number | string> | null;
unitCount: number | null;
remainingDays?: number; // only set on trial licenses
}When
validisfalsethe server explicitly returnsnullfortier,features,limits, andunitCount— notundefined. This lets you destructure the response without null-guards when you know it is valid, and lets you reliably checkresult.tier !== nullwhen you don't.
The client also emits license:valid, license:invalid, and
license:expiring events — see Events.
reportUsage(organizationId, productSlug, unitCount)
Report current usage metrics (headcount, active users, storage used, …)
for a product license. Only increases are accepted — shrinking the
count is a no-op that returns accepted: false with
warning: "limit_exceeded".
const result = await client.reportUsage("org_abc123", "lumen-hr", 42);
if (!result.accepted) {
console.warn(`Usage rejected — warning: ${result.warning}`);
}Emits usage:accepted or usage:rejected depending on the result.
Auth helpers
These endpoints are called unauthenticated — the client skips its service key and Bearer token for these calls.
login(email, password)
const { accessToken, refreshToken, user } = await client.login(
"[email protected]",
"s3cr3t"
);
client.setTokens(accessToken, refreshToken);register(email, password, firstName, lastName, organizationName?)
Creates a new user account and optionally a new organization owned by that user.
const result = await client.register(
"[email protected]",
"password123",
"Bob",
"Builder",
"Builder Inc" // optional
);
client.setTokens(result.accessToken, result.refreshToken);requestPasswordReset(email)
Always returns a generic success message to prevent email enumeration.
await client.requestPasswordReset("[email protected]");resetPassword(token, newPassword)
Exchange a reset token from the email link for a new password.
await client.resetPassword(resetToken, "n3w_s3cr3t");acceptInvitation(token, firstName, lastName, password)
Accept an organization invitation and create the invited user's account in one step.
const { accessToken, refreshToken, user } = await client.acceptInvitation(
invitationToken,
"Carol",
"Danvers",
"str0ng!"
);
client.setTokens(accessToken, refreshToken);Session management
These endpoints require a stored access token (user mode).
getSessions()
List all active sessions for the current user — useful for a "devices and sessions" settings page.
const { sessions } = await client.getSessions();
sessions.forEach((s) => {
console.log(`${s.id}: ${s.userAgent} — last active ${s.lastActiveAt}`);
});revokeSession(sessionId)
Revoke a specific session — logs that device out.
await client.revokeSession("sess_abc123");Token management
When a user logs in successfully, the server returns both an access token
and a refresh token. Store them on the client with setTokens so
subsequent user-mode requests are authenticated automatically:
client.setTokens(accessToken, refreshToken);
const currentRefresh = client.getRefreshToken(); // for persisting e.g. in sessionStorageEvents
The client exposes a lightweight event system so you can observe license
and usage lifecycle transitions without wrapping every call in a
try/catch:
client.on("license:valid", (data) => track("license.valid", data));
client.on("license:invalid", (data) => showPaywall(data));
client.on("license:expiring", (data) => {
console.warn(`Trial ends in ${data.remainingDays} days`);
});
client.on("usage:accepted", (data) => log("usage reported", data));
client.on("usage:rejected", (data) => alertOncall("usage rejected", data));| Event | Fired when |
| ------------------ | ------------------------------------------------------ |
| license:valid | checkLicense() returns valid: true |
| license:invalid | checkLicense() returns valid: false |
| license:expiring | checkLicense() response contains remainingDays ≤ 7 |
| usage:accepted | reportUsage() returns accepted: true |
| usage:rejected | reportUsage() returns accepted: false |
Handler errors are swallowed so a buggy listener cannot poison the calling
code. Call client.off("license:valid") to remove handlers for a single
event, or client.off() to clear all listeners at once.
requireLicense middleware
The middleware is a thin wrapper around client.checkLicense() that
plugs directly into Express or Fastify. It caches results, enforces a
minimum tier if you set one, and throws structured error objects with
statusCode / error / message fields on failure.
Express
import express from "express";
import {
LumenAccountClient,
requiresLicenseExpress,
NoActiveLicenseError,
InsufficientTierError,
} from "@vss-software/lumen-account-sdk";
const client = new LumenAccountClient({
baseUrl: process.env.LUMEN_ACCOUNT_BASE_URL!,
serviceApiKey: process.env.LUMEN_ACCOUNT_SERVICE_KEY!,
});
const app = express();
app.get(
"/employees",
requiresLicenseExpress(client, "lumen-hr"),
(req, res) => res.json({ employees: [] })
);
app.get(
"/advanced-reports",
requiresLicenseExpress(client, "lumen-hr", {
minTier: "pro",
tierSortOrder: { starter: 1, pro: 2, enterprise: 3 },
}),
(req, res) => res.json({ reports: [] })
);The Express adapter converts thrown errors into JSON responses automatically:
| Error | Status |
| -------------------------- | ------ |
| NoOrganizationIdError | 401 |
| NoActiveLicenseError | 403 |
| InsufficientTierError | 403 |
| UsageLimitExceededError | 403 |
| anything else | 500 |
Fastify
import Fastify from "fastify";
import {
LumenAccountClient,
requiresLicenseFastify,
} from "@vss-software/lumen-account-sdk";
const client = new LumenAccountClient({
baseUrl: process.env.LUMEN_ACCOUNT_BASE_URL!,
serviceApiKey: process.env.LUMEN_ACCOUNT_SERVICE_KEY!,
});
const app = Fastify();
app.get(
"/employees",
{ preHandler: requiresLicenseFastify(client, "lumen-hr") },
async () => ({ employees: [] })
);Unlike the Express adapter, the Fastify preHandler re-throws on error — let
your Fastify error handler translate it. The error classes expose
toJSON() and a statusCode field so a single default handler is enough:
app.setErrorHandler((err, req, reply) => {
if (typeof (err as { statusCode?: unknown }).statusCode === "number") {
void reply.code((err as { statusCode: number }).statusCode).send(err);
} else {
void reply.code(500).send({ error: "INTERNAL_ERROR" });
}
});Options
Both adapters accept the same RequireLicenseOptions:
interface RequireLicenseOptions {
/** Minimum required tier slug. */
minTier?: string;
/**
* Tier sort-order mapping. Higher = more feature-rich.
* Example: { starter: 1, pro: 2, enterprise: 3 }
*
* Required when `minTier` is set and the current tier is not an exact
* match — otherwise the middleware falls back to slug equality.
*/
tierSortOrder?: TierSortOrder;
/**
* Optional external cache. Defaults to an in-memory map scoped to the
* middleware instance — swap in a Redis-backed implementation for
* multi-instance deployments.
*/
cache?: MiddlewareCache;
}Organization ID resolution
The middleware needs an organization ID on every request. It looks in two places, in order:
- The
x-organization-idheader (case-insensitive) req.user.orgId— populated by your JWT middleware upstream
If neither is present, the middleware throws NoOrganizationIdError (HTTP
401). Mount your JWT/auth middleware before requiresLicense* so the
fallback can kick in.
Error classes
All error classes extend Error, carry a stable statusCode + error
string, and implement toJSON() so they serialise cleanly.
import {
NoOrganizationIdError,
NoActiveLicenseError,
InsufficientTierError,
UsageLimitExceededError,
} from "@vss-software/lumen-account-sdk";
try {
await someLicenseCheck();
} catch (err) {
if (err instanceof NoActiveLicenseError) {
console.log(err.product); // e.g. "lumen-hr"
console.log(err.statusCode); // 403
}
if (err instanceof InsufficientTierError) {
console.log(err.product, err.requiredTier, err.currentTier);
}
if (err instanceof UsageLimitExceededError) {
console.log(err.usageType, err.currentUsage, err.limit);
}
}| Class | statusCode | error code | Extra fields |
| -------------------------- | ------------ | ---------------------- | ---------------------------------------- |
| NoOrganizationIdError | 401 | UNAUTHORIZED | — |
| NoActiveLicenseError | 403 | NO_ACTIVE_LICENSE | product |
| InsufficientTierError | 403 | INSUFFICIENT_TIER | product, requiredTier, currentTier |
| UsageLimitExceededError | 403 | USAGE_LIMIT_EXCEEDED | usageType, currentUsage, limit |
Custom caches (Redis)
The default cache is an in-memory Map scoped to the middleware instance
— fine for a single-process server. For multi-instance deployments pass
a cache that implements the MiddlewareCache interface:
interface MiddlewareCache {
get<T>(key: string): Promise<T | null>;
set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
}Example: an ioredis-backed cache.
import Redis from "ioredis";
import {
LumenAccountClient,
requiresLicenseFastify,
type MiddlewareCache,
} from "@vss-software/lumen-account-sdk";
const redis = new Redis(process.env.REDIS_URL!);
const redisCache: MiddlewareCache = {
async get<T>(key) {
const raw = await redis.get(key);
return raw ? (JSON.parse(raw) as T) : null;
},
async set(key, value, ttlSeconds = 60) {
await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
},
};
const client = new LumenAccountClient({
baseUrl: process.env.LUMEN_ACCOUNT_BASE_URL!,
serviceApiKey: process.env.LUMEN_ACCOUNT_SERVICE_KEY!,
});
app.get(
"/employees",
{
preHandler: requiresLicenseFastify(client, "lumen-hr", {
cache: redisCache,
}),
},
async () => ({ employees: [] })
);The package also exports a standalone InMemoryCache class you can reuse
outside the middleware if you need its TTL semantics.
Cache keys are namespaced as middleware:license:<orgId>:<productSlug>
(see the cacheKey helper if you need to invalidate entries from outside).
Usage limit checks
For usage-based limits that aren't covered by the license check itself
(e.g. "no more than 500 API calls per minute"), the SDK ships a
checkUsageLimit helper you can call directly after checkLicense or
inside a request handler:
import {
checkUsageLimit,
UsageLimitExceededError,
} from "@vss-software/lumen-account-sdk";
const license = await client.checkLicense(orgId, "lumen-hr");
try {
checkUsageLimit(license, "api_calls", currentCount);
} catch (err) {
if (err instanceof UsageLimitExceededError) {
return reply.code(429).send(err.toJSON());
}
throw err;
}String limits (e.g. "50") are parsed as numbers; null or missing
limits are treated as unlimited.
Retry & timeout behaviour
Every request is routed through a fetchWithRetry helper that retries
5xx responses and network errors with exponential backoff + jitter:
| Attempt | Base delay | | ------- | ---------- | | 1 | immediate | | 2 | ~1 s | | 3 | ~2 s | | 4 | ~4 s |
- 4xx responses are never retried (they indicate a client-side bug).
- Each attempt is wrapped in
AbortSignal.timeout(timeout)— the default is 10 000 ms. Passtimeoutin the constructor to override. - On final failure the client throws an
Errorwhose message includes the HTTP status and response body — safe to surface in logs.
Types reference
All types are exported directly from the package:
import type {
// Client
LumenAccountClient,
LumenAccountClientOptions,
LumenAccountEvent,
LumenAccountEventHandler,
// Service responses
VerifyTokenResponse,
CheckLicenseResponse,
ReportUsageResponse,
// Auth responses
LoginResponse,
RegisterResponse,
AcceptInvitationResponse,
PasswordResetRequest,
PasswordResetResponse,
SessionListResponse,
Session,
// Summaries returned by /auth/me
OrganizationSummary,
LicenseSummary,
// Middleware
RequireLicenseOptions,
MiddlewareCache,
TierSortOrder,
} from "@vss-software/lumen-account-sdk";The SDK also re-exports the most commonly used domain types from
@lumen/shared, so you don't need a second dependency just to type your
variables:
import type {
User,
Organization,
Product,
Tier,
License,
LicenseStatus,
OrganizationRole,
BillingInterval,
PricingModel,
ProductStatus,
Invoice,
PaymentMethod,
Address,
PaginatedResult,
} from "@vss-software/lumen-account-sdk";Testing
Every exported helper is covered by unit tests. Run them from the monorepo root:
pnpm --filter @vss-software/lumen-account-sdk testThe suite is split into two files under src/__tests__/:
client.test.ts— covers the HTTP client: every endpoint, auth-header priority, retry behaviour, and the event system (39 tests).requireLicense.test.ts— covers the middleware core:extractOrgId,enforceMinTier,checkUsageLimit,checkLicenseCore, the error classes, andcreateRequireLicenseend-to-end with mocked clients (42 tests).
Tests use vi.spyOn(globalThis, "fetch") rather than module mocks to
avoid vitest hoisting quirks — follow the existing patterns when adding
new ones.
License
MIT © VSS Software
