Files
suaveplan/packages/api-stack/api/api-tenant
2026-05-27 23:29:53 +01:00
..
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00

@suaveplan/api-tenant - Multi-tenant resolution + scoping layer for the Suaveplan API. Ships a canonical Tenant type with role/permission helpers, six resolver strategies plus ChainedTenantResolver, a pluggable TenantCache with TTL + negative-entry cache + request coalescing, fail-closed NullTenantContext, a single-context-key Hono middleware (Hono ContextVariableMap augmentation), requirePlugin/requireFeature fail-closed guards, pluggable tenantRateLimit with bounded MemoryRateLimitStore (LRU eviction), TenantAwarePluginDescriptor with tenantScopes opt-out, and opt-in AsyncLocalStorage threading. Server-tier — runs in Bun and Node.

Multi-tenant resolution + scoping for the Suaveplan API. Server-tier — runs on Bun and Node, depends on hono and node:async_hooks.

Documentation tree

  • cache — The cache module wraps any TenantResolver with a bounded, TTL-aware in-process cache that prevents lookup stampedes…
    • cache — The cache module wraps any TenantResolver with a bounded, TTL-aware in-process cache that prevents lookup stampedes…
  • context — The context module ships createTenantContext(tenant) — a pure factory that wraps a resolved Tenant in an ergonomi…
    • context — The context module ships createTenantContext(tenant) — a pure factory that wraps a resolved Tenant in an ergonomi…
  • error-codes — The error-codes module defines the seven stable TENANT.* failure codes shipped by @suaveplan/api-tenant and the c…
    • error-codes — The error-codes module defines the seven stable TENANT.* failure codes shipped by @suaveplan/api-tenant and the c…
  • lifecycle — The lifecycle module ships TenantLifecycle, a thin dispatcher that wires the seven tenant lifecycle hooks (`onTenan…
    • lifecycle — The lifecycle module ships TenantLifecycle, a thin dispatcher that wires the seven tenant lifecycle hooks (`onTenan…
  • middleware — The middleware module ships createTenantMiddleware(options) — the Hono middleware that resolves the inbound tenant,…
    • middleware — The middleware module ships createTenantMiddleware(options) — the Hono middleware that resolves the inbound tenant,…
    • require-feature — requireFeature(name) returns a Hono middleware that gates route access on the boolean at `tenant.settings.features?.[…
    • require-plugin — requirePlugin(name) returns a Hono middleware that gates route access on tenant.enabledPlugins.includes(name). The…
  • rate-limit — The rate-limit module ships tenantRateLimit(options) — a Hono middleware that throttles requests per resolved tenan…
    • rate-limit — The rate-limit module ships tenantRateLimit(options) — a Hono middleware that throttles requests per resolved tenan…
  • resolver — The resolver module ships the six built-in TenantResolver implementations consumed by createTenantMiddleware plus…
    • resolver — The resolver module ships the six built-in TenantResolver implementations consumed by createTenantMiddleware plus…
  • storage — The storage module ships the opt-in AsyncLocalStorage thread for the resolved tenant. defaults the thread O…
    • storage — The storage module ships the opt-in AsyncLocalStorage thread for the resolved tenant. defaults the thread O…
  • types — The tenant module ships the single canonical Tenant interface consumed by every other module in `@suaveplan/api-ten…
    • tenant — The tenant module ships the single canonical Tenant interface consumed by every other module in `@suaveplan/api-ten…

What you get

  • Tenant — one canonical entity. c.set("tenant", tenant) is the only context key the middleware writes; the Hono ContextVariableMap augmentation gives c.get("tenant") a typed Tenant with no cast.
  • Six resolver strategies — HeaderTenantResolver, SubdomainTenantResolver, DomainTenantResolver, PathTenantResolver, JWTTenantResolver, ChainedTenantResolver. A TimeoutTenantResolver wraps any resolver with a millisecond timeout that surfaces as TenantError(TENANT_LOOKUP_TIMEOUT).
  • TenantCache.wrap(resolver, options) — TTL + negative-entry cache
    • in-flight request coalescing + LRU eviction at maxEntries.
  • createTenantMiddleware({ resolver, required?, excludePaths?, allowSuspended?, onError?, onResolved? }) — Hono middleware that applies the status gate (deleted → TENANT_NOT_FOUND, suspended → TENANT_SUSPENDED, pending → TENANT_PENDING).
  • requirePlugin(name) / requireFeature(name) — fail-closed guards. No tenant resolved → TENANT_NOT_FOUND. Missing entitlement → TENANT_INVALID_INPUT.
  • tenantRateLimit({ maxRequests, windowMs, store? }) with the pluggable RateLimitStore interface; default MemoryRateLimitStore is bounded by maxEntries with LRU eviction.
  • TenantAwarePluginDescriptor + TenantLifecycle — seven tenant lifecycle hooks dispatched through the HookExecutor shared with @suaveplan/api-plugin. tenantScopes: "all" | "explicit" | "none" controls how the events fan out.
  • Opt-in AsyncLocalStorage thread via withTenantStorage(true) + tenantStorageMiddleware(); default OFF so getCurrentTenant() is undefined and cannot disagree with c.get("tenant").
  • NullTenantContext — fail-closed sentinel for tenant-less code paths. Every predicate returns false / undefined.
  • 7 stable TENANT.* error codes via defineCodes.

Quick-start

import { Hono } from "hono";
import {
    chainResolvers,
    createTenantMiddleware,
    headerResolver,
    requirePlugin,
    subdomainResolver,
    tenantRateLimit,
    TenantCache,
    TenantError,
} from "@suaveplan/api-tenant";

const app = new Hono();

const resolver = TenantCache.wrap(
    chainResolvers(
        headerResolver("x-tenant-id", (id) => db.tenants.byId(id)),
        subdomainResolver("suaveplan.io", (slug) =>
            db.tenants.bySlug(slug)
        )
    ),
    { ttlMs: 30_000, negativeTtlMs: 5_000 }
);

app.use("*", createTenantMiddleware({ resolver }));
app.use("*", tenantRateLimit({ maxRequests: 60, windowMs: 60_000 }));

app.get("/payments", requirePlugin("payment"), (c) =>
    c.json({ tenant: c.get("tenant").id })
);

app.onError((err, c) => {
    if (err instanceof TenantError) {
        return c.json(
            { code: err.code, message: err.message },
            err.code === "TENANT.NOT_FOUND" ? 404 : 403
        );
    }
    return c.text("Server error", 500);
});

Cache configuration

import { TenantCache, headerResolver } from "@suaveplan/api-tenant";

const cached = TenantCache.wrap(
    headerResolver("x-tenant-id", lookup),
    {
        ttlMs: 60_000,
        negativeTtlMs: 5_000,
        maxEntries: 10_000,
        keyFn: (c) => c.req.header("x-tenant-id") ?? "",
        clock: () => Date.now(),
    }
);

Pluggable rate-limit store

import {
    type RateLimitStore,
    tenantRateLimit,
} from "@suaveplan/api-tenant";

const redisStore: RateLimitStore = {
    async get(key) {
        const raw = await redis.get(key);
        return raw ? JSON.parse(raw) : undefined;
    },
    async set(key, value) {
        await redis.set(key, JSON.stringify(value), "PX", value.resetAt);
    },
    async delete(key) {
        await redis.del(key);
    },
};

app.use("*", tenantRateLimit({
    maxRequests: 1000,
    windowMs: 60_000,
    store: redisStore,
    keyPrefix: "rl:v1",
}));

Opt-in AsyncLocalStorage

import {
    getCurrentTenant,
    tenantStorageMiddleware,
    withTenantStorage,
} from "@suaveplan/api-tenant";

withTenantStorage(true);
app.use("*", tenantStorageMiddleware());

async function deep() {
    const tenant = getCurrentTenant();
    return tenant?.id ?? "anonymous";
}

When withTenantStorage is false (the default), getCurrentTenant() always returns undefined. This is deliberate — it eliminates the upstream dual-access-path footgun where ALS state could diverge from c.get("tenant").

Error codes

TENANT_NOT_FOUND, TENANT_SUSPENDED, TENANT_PENDING, TENANT_RESOLUTION_FAILED, TENANT_RATE_LIMITED, TENANT_INVALID_INPUT, TENANT_LOOKUP_TIMEOUT. All thrown as TenantError extends SuaveplanError<TenantErrorCode>.

License

MIT