JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 170
  • Score
    100M100P100Q103129F
  • License MIT

Official TypeScript SDK for Lumen Accounting — central auth, organization management, Stripe checkout, license gating, and usage reporting for the Lumen product suite.

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-rolled FormData / fetch in consuming apps.
  • client.me.uploadPhoto(file) / client.me.deletePhoto() — profile-picture convenience; keeps user.photoUrl in sync. In Vue, useAuth() exposes uploadPhoto() / 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, contentUrl
  • 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).
  • TokenStorage adapterMemoryTokenStorage (default) or BrowserLocalStorageTokenStorage for 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) with status, code, requestId, and body fields.
  • Active organization contextclient.setActiveOrg(orgId) attaches x-organization-id to every subsequent request.
  • New eventsauth:tokens-refreshed, auth:logout.

Migration notes (non-breaking for existing callers):

  • The flat methods (client.login, client.checkLicense, …) still exist as @deprecated thin wrappers that delegate to the namespaces. Existing code keeps working without changes.
  • setTokens and getRefreshToken are now async (return a Promise). Existing synchronous callers that don't await still work with the default MemoryTokenStorage; add await to be forward-compatible.

The SDK is a two-in-one package:

  1. 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.
  2. 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

pnpm add @vss-software/lumen-account-sdk
# or
npm install @vss-software/lumen-account-sdk

Requirements: 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:

  1. Explicit authToken passed for a single call (used by verifyToken)
  2. Stored accessToken on the client (user mode)
  3. serviceApiKey (service mode), unless omitServiceKey: true is 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 valid is false the server explicitly returns null for tier, features, limits, and unitCount — not undefined. This lets you destructure the response without null-guards when you know it is valid, and lets you reliably check result.tier !== null when 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 id

Authorization 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 sessionStorage

Events

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:

  1. The x-organization-id header (case-insensitive)
  2. 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. Pass timeout in the constructor to override.
  • On final failure the client throws an Error whose 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 test

The 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, and createRequireLicense end-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