npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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).
  • 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(
  "[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 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