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

@birtalanrobert/tenancy

v1.1.0

Published

Tenant resolution, scoped repositories and row-level security

Readme

@birtalanrobert/tenancy

Tenant resolution, scoped repositories and row-level security.

The highest-stakes package in Tier 1: a single missed WHERE tenant_id = … in a multi-tenant system is a data breach, not a bug.

Using it in a NestJS application

import { TenancyModule, sessionResolver } from '@birtalanrobert/tenancy';

@Module({
  imports: [
    // …config, logger, database, redis, http…
    TenancyModule.forRoot({ resolvers: [sessionResolver()] }),
    // AuthModule goes *after* this in module order; see below.
  ],
})
export class AppModule {}

@Global(). forRootAsync exists for when a resolver needs configuration.

Resolver order is the security boundary. sessionResolver() is the only signal the service issued itself, so it needs no further verification and comes first. subdomainResolver() and headerResolver() are for products where a tenant is addressed by hostname or by an API key; both need a slug-to-id lookup, and both must come after the session — a request carrying a session and a conflicting hostname is a request whose session wins.

Module order matters too. TenancyModule applies middleware that reads request.user.tenantId, which authentication is what sets, so the auth module must be registered before it. Middleware runs in module-initialisation order; move it and requests silently lose their tenant.

Opting a route out

@Public()
@AllowNoTenant()
@Get(':token')
async checklist(@Param('token') token: string) { /* … */ }

A client link has no account and no tenant header — the tenant comes from the token's claims instead.

Reading and binding

import { requireTenantId } from '@birtalanrobert/context';
import { runInTenantTransaction } from '@birtalanrobert/tenancy';

await runInTenantTransaction(
  dataSource,
  async (manager) => {
    // every statement here is bound; RLS applies
  },
  { tenantId: requireTenantId() },
);

Every read needs this too, not only writes. Resolving a tenant into ambient context does not bind it to a connection — only a transaction can, because Postgres scopes the setting with SET LOCAL. An unbound read on a protected table returns nothing, and an empty list reads as a customer with no data rather than as a bug.

Two layers, deliberately

TenantScopedRepository stops an unscoped query being written. Every read applies the tenant, every write stamps it, and every returned row is verified before it is handed back. The verification is not redundant with the filter — it also catches rows arriving through a relation, a raw query, or a caller passing an id it should not have.

Row-level security stops an unscoped query returning foreign rows if one is written anyway, through raw SQL, a query builder or a third-party library.

The alternative to both — remembering the predicate at several hundred call sites — fails the first time someone is in a hurry, and fails silently.

RLS fails silently unless you check

Postgres superusers bypass every policy, and FORCE ROW LEVEL SECURITY does not change that. So does any role with BYPASSRLS. In either case every policy is decorative, every query returns every tenant's rows, and nothing reports a problem.

Development databases are very often created with a superuser, so an application can pass its entire test suite with RLS doing nothing at all.

await assertRlsEffective(dataSource); // at boot, in any environment relying on RLS

This package's own tests connect through a dedicated non-superuser role for exactly this reason — running them as the default superuser would prove nothing while appearing to pass.

The binding must be inside the transaction

SET LOCAL is scoped to the current transaction and to the connection running it. Setting it on a different pooled connection applies to nothing; setting it without LOCAL leaks it to whatever request picks that connection up next — which in a multi-tenant system means serving one tenant's rows to another.

runInTenantTransaction() binds it on the transaction's own query runner via the onBegin hook, which is the third reason @birtalanrobert/database's transactional context exists.

Failing closed

  • No tenant bound → the repository throws, rather than reading everything.
  • No tenant bound → RLS matches no row, because current_setting(…, true) is NULL.
  • TenantGuard protects a route unless it opts out with @AllowNoTenant(). The opposite default would mean a forgotten decorator silently exposes data.
  • Cross-tenant access raises its own error code, not a 404, so it can be alerted on separately — it is either a serious bug or an attack.

Escaping scope

unscoped() exists because platform operators, cross-tenant reports and migrations are real. It is deliberately verbose, impossible to reach by accident, and requires a substantive reason so the audit trail can explain why the boundary was crossed.