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

@zudojs/tenancy

v1.4.0

Published

Multi-tenant context and isolation with tenant resolution, AsyncLocalStorage propagation, resolver chains, trust levels, and guard middleware.

Readme

@zudojs/tenancy

Multi-tenant context and isolation: tenant resolution, context propagation, resolver chains, and guard middleware.

Documentation: zudojs.oyinlola.site/docs/packages-tenancy · For AI agents: Markdown version, llms.txt

Installation

npm install @zudojs/tenancy

Quick Start

import {
  createDomainResolver,
  createJwtResolver,
  createMemoryTenantRepository,
  createResolveTenantMiddleware,
  createResolverChain,
  createSubdomainResolver,
  createTenantContextStorage,
  createTenantId,
} from "@zudojs/tenancy";

const storage = createTenantContextStorage();
const repository = createMemoryTenantRepository();
repository.add(
  { id: createTenantId("t-1001"), name: "Acme", slug: "acme", status: "active", metadata: {} },
  ["acme.io"], // custom domain
);

const resolver = createResolverChain([
  createJwtResolver(), // priority 100, trusted
  createDomainResolver({ repository }), // priority 75: acme.io → t-1001
  createSubdomainResolver({ baseDomain: "example.com" }), // priority 70
]);

// Resolves the tenant, enforces its status and the route's trust floor
// (default "verified"), then runs the rest of the request inside the tenant
// context. acme.example.com reaches t-1001 through its slug.
const middleware = createResolveTenantMiddleware({
  resolver, // a chain as it is, or a single resolver
  repository,
  storage,
});

resolver takes a chain directly. Before 1.3.1 it took only a single TenantResolver, so a chain had to go through chain.asResolver(): passing the chain itself was a type error, and cast through, every request was refused, because a chain's resolve returns { resolution, candidates, conflict } rather than a resolution. The middleware now recognises a chain by its asResolver method and adapts it. resolver.asResolver() still works.

The chain's context type is inferred from its resolvers — what the JWT, domain and subdomain resolvers read, which HttpResolverContext provides — so the call needs no type argument and no casts. (Before 1.3 it failed with TS2322 unless you wrote createResolverChain<HttpResolverContext>; that form still works.)

The middleware runs inside the real @zudojs/http pipeline without depending on it: headers are read through request.getHeader() when present, and otherwise from a plain object or a Map, case-insensitively. Its HttpMiddleware type is assignable to @zudojs/http's, so it goes straight into a route's middleware list:

router.get("/projects", listProjects, { middleware: [middleware] });

getClaims reads verified token claims for the JWT resolver (by default they come from the tenancy:claims state key). It may be typed with @zudojs/http's own HttpMiddlewareContext, so a helper your auth layer already has plugs in without a cast:

import type { HttpMiddlewareContext } from "@zudojs/http";
import type { TenantClaims } from "@zudojs/tenancy";

const claimsOf = (context: HttpMiddlewareContext) =>
  context.state.get<TenantClaims>("auth:claims");

createResolveTenantMiddleware({ resolver, repository, storage, getClaims: claimsOf });

The option is generic over the context it reads, bounded by this package's structural mirror, which the real context satisfies. A reader for something that is not a middleware context, such as a bare request, is still refused.

A refusal — 400, 401, 403, 404 — is a GuardResponse (createGuardResponse from @zudojs/middleware), which @zudojs/http sends with that status. The helpers createBadRequest, createUnauthorized, createForbidden, createNotFound, createJsonErrorResponse and createJsonResponse all return one. They used to return plain { status, body, headers } objects, which @zudojs/http did not treat as a response, so a request carrying only an x-tenant-id header was refused (the handler never ran) but answered 200 instead of 403.

Read the current tenant anywhere downstream:

import { createContextManager } from "@zudojs/tenancy";

const context = createContextManager({ storage });
const tenant = context.requireCurrentTenant();

Resolution Trust

| Source | Default trust | Note | | --------------------- | ------------- | --------------------------- | | jwt, api-key | trusted | verified credential | | subdomain, domain | verified | host-derived | | header, path | untrusted | client-supplied on the wire |

createResolveTenantMiddleware requires verified trust by default, so a tenant named only by x-tenant-id or a URL path is refused (403) — otherwise an unauthenticated client picks the tenant its request runs in. Raise the header with createHeaderResolver({ trust: "verified" }) only where a trusted proxy strips and re-sets it at the edge, or pass minimumTrust: "untrusted" explicitly on routes where something else ties the tenant to the principal.

Features

  • Tenant resolution from JWT claims, subdomain (by id or slug), custom domain (createDomainResolver), header, or path
  • Resolver chains with trust grading and conflict detection
  • AsyncLocalStorage context propagation
  • Tenant repository with slug and custom-domain indexes
  • Guard middleware and key scoping helpers

Safety Notes

  • A resolver that throws rejected a credential, and aborts the chain. Only returning undefined means "found nothing" and advances to the next resolver — so a failed JWT verification can never fall through to a client-supplied header.
  • Conflict detection is opt-in: by default a chain stops at the first resolver that finds a tenant (highest priority wins) and does not look at the others. Pass createResolverChain(resolvers, { detectConflicts: true }) to run them all and throw TenantResolutionConflictError when they disagree; the middleware answers that with 403.
  • Tenant ids are normalized (NFKC, trimmed, lowercased) and constrained to ^[a-z0-9][a-z0-9_-]*$, so an id cannot forge a separator in a cache key, a schema name, or a path. tenantKey escapes its segments as well.
  • For @zudojs/cache, use createTenantCacheScope(tenantId, key) rather than createTenantCacheKey: the cache refuses : inside a key part (that is how it stops a raw key from forging a namespace), so tenant:acme:totals throws ERR_INVALID_INPUT there. The scope helper returns { namespace: "tenant.acme", key: "totals" }, and the cache's namespace is the isolation boundary: await cache.get(scope.key, { namespace: scope.namespace }).
  • Non-active tenants are refused during resolution. Pass allowInactive on routes that exist to serve suspended tenants.
  • Every refusal body is { "error": message, "code": ERR_TENANT_* }, the shape @zudojs/http uses, so a client can tell ERR_TENANT_NOT_FOUND from an application 404. The codes are exported as TENANCY_RESPONSE_CODE.
  • An unknown tenant and a non-active one get the same 404 Tenant not found, and the guard middleware answers 403 Tenant is not available without the id or status, so a caller cannot enumerate tenants or learn which are suspended.
  • A subdomain or path value is looked up by id first, then by slug (repository.findBySlug); pass slugLookup: false to turn that off.
  • A request that resolves to no tenant at all is answered 404. Pass optional: true to createResolveTenantMiddleware on routes where a tenant may be absent; a tenant that was named but is unknown, untrusted or suspended is still refused.
  • createPathResolver({ prefix }) only names a tenant for paths under the prefix; /health never resolves to a tenant called health.

Use Cases

  • SaaS multi-tenancy
  • Per-tenant data isolation
  • Tenant-scoped caching and configuration