Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@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
cachemodule wraps anyTenantResolverwith a bounded, TTL-aware in-process cache that prevents lookup stampedes…- cache — The
cachemodule wraps anyTenantResolverwith a bounded, TTL-aware in-process cache that prevents lookup stampedes…
- cache — The
- context — The
contextmodule shipscreateTenantContext(tenant)— a pure factory that wraps a resolvedTenantin an ergonomi…- context — The
contextmodule shipscreateTenantContext(tenant)— a pure factory that wraps a resolvedTenantin an ergonomi…
- context — The
- error-codes — The
error-codesmodule defines the seven stableTENANT.*failure codes shipped by@suaveplan/api-tenantand the c…- error-codes — The
error-codesmodule defines the seven stableTENANT.*failure codes shipped by@suaveplan/api-tenantand the c…
- error-codes — The
- lifecycle — The
lifecyclemodule shipsTenantLifecycle, a thin dispatcher that wires the seven tenant lifecycle hooks (`onTenan…- lifecycle — The
lifecyclemodule shipsTenantLifecycle, a thin dispatcher that wires the seven tenant lifecycle hooks (`onTenan…
- lifecycle — The
- middleware — The
middlewaremodule shipscreateTenantMiddleware(options)— the Hono middleware that resolves the inbound tenant,…- middleware — The
middlewaremodule shipscreateTenantMiddleware(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 ontenant.enabledPlugins.includes(name). The…
- middleware — The
- rate-limit — The
rate-limitmodule shipstenantRateLimit(options)— a Hono middleware that throttles requests per resolved tenan…- rate-limit — The
rate-limitmodule shipstenantRateLimit(options)— a Hono middleware that throttles requests per resolved tenan…
- rate-limit — The
- resolver — The
resolvermodule ships the six built-inTenantResolverimplementations consumed bycreateTenantMiddlewareplus…- resolver — The
resolvermodule ships the six built-inTenantResolverimplementations consumed bycreateTenantMiddlewareplus…
- resolver — The
- storage — The
storagemodule ships the opt-inAsyncLocalStoragethread for the resolved tenant. defaults the thread O…- storage — The
storagemodule ships the opt-inAsyncLocalStoragethread for the resolved tenant. defaults the thread O…
- storage — The
- types — The
tenantmodule ships the single canonicalTenantinterface consumed by every other module in `@suaveplan/api-ten…- tenant — The
tenantmodule ships the single canonicalTenantinterface consumed by every other module in `@suaveplan/api-ten…
- tenant — The
What you get
Tenant— one canonical entity.c.set("tenant", tenant)is the only context key the middleware writes; the HonoContextVariableMapaugmentation givesc.get("tenant")a typedTenantwith no cast.- Six resolver strategies —
HeaderTenantResolver,SubdomainTenantResolver,DomainTenantResolver,PathTenantResolver,JWTTenantResolver,ChainedTenantResolver. ATimeoutTenantResolverwraps any resolver with a millisecond timeout that surfaces asTenantError(TENANT_LOOKUP_TIMEOUT). TenantCache.wrap(resolver, options)— TTL + negative-entry cache- in-flight request coalescing + LRU eviction at
maxEntries.
- in-flight request coalescing + LRU eviction at
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 pluggableRateLimitStoreinterface; defaultMemoryRateLimitStoreis bounded bymaxEntrieswith LRU eviction.TenantAwarePluginDescriptor+TenantLifecycle— seven tenant lifecycle hooks dispatched through theHookExecutorshared with@suaveplan/api-plugin.tenantScopes: "all" | "explicit" | "none"controls how the events fan out.- Opt-in
AsyncLocalStoragethread viawithTenantStorage(true)+tenantStorageMiddleware(); default OFF sogetCurrentTenant()isundefinedand cannot disagree withc.get("tenant"). NullTenantContext— fail-closed sentinel for tenant-less code paths. Every predicate returnsfalse/undefined.- 7 stable
TENANT.*error codes viadefineCodes.
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