Package Exports
- @vss-software/lumen-account-sdk
- @vss-software/lumen-account-sdk/vue
- @vss-software/lumen-account-sdk/vue-ui
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, …).
New in 1.2.0 — Asset service & profile picture
client.assets.*— universal file CRUD against/api/v1/assets(upload,get,list,update,delete,contentUrl). No more hand-rolledFormData/fetchin consuming apps.client.me.uploadPhoto(file)/client.me.deletePhoto()— profile-picture convenience; keepsuser.photoUrlin sync. In Vue,useAuth()exposesuploadPhoto()/deletePhoto()too.- See Assets & profile picture below.
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),uploadPhoto(file),deletePhoto()client.assets.*—upload,get,list,update,delete,contentUrlclient.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(
"alice@example.com",
"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(
"bob@example.com",
"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("alice@example.com");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);Assets & profile picture
User-mode endpoints (require a stored access token). The asset store is a
universal file CRUD API — any Lumen app can store files org-/user-/app-scoped
without building FormData or hand-rolling multipart requests.
client.me.uploadPhoto(file) / client.me.deletePhoto()
The profile-picture special case. uploadPhoto stores the image as an asset
(kind=avatar, visibility=public), removes the previous avatar, points
user.photoUrl at the new content URL, and returns the refreshed /auth/me
payload. GET /auth/me keeps returning photoUrl unchanged.
// Browser — from an <input type="file">
const file = inputEl.files![0]; // a File
const me = await client.me.uploadPhoto(file);
console.log(me.user.photoUrl); // → https://account.lumen.example/api/v1/assets/<id>/content
// Node — raw bytes
await client.me.uploadPhoto({ data: pngBytes, filename: "avatar.png", contentType: "image/png" });
// Remove it again
await client.me.deletePhoto();In Vue, useAuth() exposes uploadPhoto(file) / deletePhoto() which also
re-hydrate the reactive user state.
client.assets.*
// Upload an org-scoped file (e.g. a task attachment from another Lumen app)
const asset = await client.assets.upload(file, {
app: "pmo",
kind: "task-attachment",
ownerType: "entity",
ownerId: taskId,
orgId: currentOrgId,
metadata: { taskId, uploadedFrom: "web" },
});
console.log(asset.url); // absolute, externally reachable content URL
await client.assets.get(asset.id);
const page = await client.assets.list({ app: "pmo", kind: "task-attachment", orgId: currentOrgId, page: 1, limit: 20 });
await client.assets.update(asset.id, { originalName: "renamed.pdf" });
await client.assets.delete(asset.id);
client.assets.contentUrl(asset.id); // build the content URL from an idAuthorization is enforced server-side per asset: a caller may always touch
their own ownerType=user assets; org-scoped assets require membership of
the asset's org (and admin/owner — or being the uploader — to modify or
delete). GET /assets/:id/content is anonymous only for visibility=public
assets (avatars default to public; everything else defaults to private).
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