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

@dtfoundry/app-tenancy

v0.6.0

Published

Multi-tenant request layer for Digital Trust Foundry applications

Readme

@dtfoundry/app-tenancy

One deployment, many customers.

@dtfoundry/foundry-sdk is the client for one tenant's Foundry Platform. This package is the layer in front of it: it works out which tenant a request belongs to, checks that tenant has this application enabled and is not suspended, and hands the request that tenant's client — and no one else's.

Four pieces:

  • tenantMiddleware — resolves the tenant and puts req.tenantId, req.tenant and req.foundry on the request.
  • TenantRegistry — one FoundryClient per tenant, keyed by tenant and credential version, so a rotated key drops the old client with no restart.
  • createProvisionRouter/internal/tenants, the contract Foundry calls when a tenant enables, suspends or re-credentials this application.
  • createCredentialCipher — AES-256-GCM, so the stored Platform key is encrypted at rest and bound to the tenant it belongs to.

One rule: an application never constructs a FoundryClient itself. It asks the registry. A client built by hand is a client built from whatever credential was nearest, which is how one customer ends up reading another's data.

Install

pnpm add @dtfoundry/app-tenancy @dtfoundry/foundry-sdk

Node 20+ is an engine requirement. Express 4 or 5 and @dtfoundry/foundry-sdk are peer dependencies — both are things the application already has, and this package bundles neither.

The SDK is a peer for a reason worth knowing. req.foundry is a client this package built. If the SDK were an ordinary dependency pinned to a caret range, an application that upgraded past that range would install a second copy and keep being handed a client from the older one — reading responses typed without the fields it upgraded for, with nothing in the install saying so. As a peer, the package manager resolves the application's own SDK whenever it satisfies >=0.1.0 <1, so the client you are handed is built from the version you chose.

Wiring it up

import {
  createCredentialCipher,
  createProvisionRouter,
  TenantRegistry,
  tenantMiddleware,
  tenantOf,
} from "@dtfoundry/app-tenancy";

const registry = new TenantRegistry({
  store, // your TenantStore — see below
  cipher: createCredentialCipher(process.env.CREDENTIAL_KEY!),
});

// Service-to-service. Mount it before the tenant middleware: Foundry calls it
// with a service token, not as a tenant.
app.use(
  "/internal/tenants",
  createProvisionRouter({
    registry,
    productSlug: "files",
    serviceToken: process.env.SERVICE_TOKEN,
  }),
);

app.use(
  tenantMiddleware(registry, {
    baseDomain: "digitaltrustfoundry.com",
    edgeSecret: process.env.EDGE_SECRET,
    skip: (req) => req.path === "/health",
  }),
);

app.get("/api/documents", async (req, res) => {
  const { tenant, foundry } = tenantOf(req);
  const trustables = await foundry.listTrustables({ limit: 20 });
  res.json({ tenant: tenant.tenantSlug, trustables });
});

Generate the credential key once per deployment and keep it out of the database the ciphertext lives in:

node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

Who is signed in

requireSsoSession puts the person on req.ssoUser:

app.get("/api/documents", sso.requireSsoSession, (req, res) => {
  const { userId, email, role, startingRole } = req.ssoUser!;
});

role is the workspace role Global Auth carries — admin or user. Your application's own role taxonomy is yours, and it belongs in your database.

startingRole is optional and advisory: it is the role the admin who granted this person access chose, from the vocabulary your application declared in the catalog. Apply it once, when you create the local profile, and never again — re-applying it on every sign-on would undo any role change an administrator made inside your application. It is absent when nobody chose one, so test for its presence rather than comparing against a default:

function initialRole(session: SsoUser): Role {
  if (session.startingRole && ROLES.includes(session.startingRole)) {
    return session.startingRole as Role;
  }
  return session.role === "admin" ? "admin" : "viewer";
}

The store

The one interface an application implements. Each app keeps this in its own database, next to its own data.

interface TenantStore {
  findById(tenantId: string): Promise<StoredTenant | null>;
  findBySlug(tenantSlug: string): Promise<StoredTenant | null>;
  upsert(record: StoredTenant): Promise<StoredTenant>;
  update(tenantId: string, patch: TenantPatch): Promise<StoredTenant | null>;
  delete(tenantId: string): Promise<boolean>;
}

upsert must be idempotent on tenantId, and tenantSlug must be unique — both are lookup keys on the request path. encryptedApiKey is opaque: it arrives already encrypted and the store never sees the credential in plaintext.

How a tenant is recognised

X-DTF-Tenant (stamped by the edge router after it resolves the route) → subdomain → a dev-only override header.

The subdomain step needs baseDomain and reads X-DTF-Tenant-Host before Host, because the proxy chain rewrites Host and X-Forwarded-Host on the way through. files.uni.example.com and uni.example.com both name the tenant uni. A hostname outside the configured zone resolves to nothing.

The dev override (X-DTF-Dev-Tenant) is ignored unless allowDevOverride is explicitly true, so shipping it enabled takes a deliberate act.

Every step of that ladder is a request header. If the application's origin is reachable without going through the edge router — a platform-assigned hostname, an instance-prefixed name the router does not see — a caller can stamp X-DTF-Tenant themselves and be handed any provisioned tenant's client. Set edgeSecret to the shared secret the router stamps as X-DTF-Edge-Secret, and a request that does not present it resolves to no tenant at all. Leave it unset only where the origin genuinely cannot be reached except through the router.

edgeSecret also takes a list, which is what makes rotation possible: the router stamps one value at a time, so origins accept both across the window (edgeSecret: [current, next]), the router switches, then the old value comes out. Reversing that order takes down every origin that has not yet accepted the new value, until it does.

Leaving edgeSecret unset skips the check. Setting it to something with no usable value in it — "", [], a pair of environment variables that turned out to be empty — resolves no tenant at all, so a guard that was configured wrongly refuses requests rather than waving them through.

A request that resolves to no tenant is 400 TENANT_NOT_RESOLVED; one for a tenant this instance does not serve is 404 TENANT_NOT_FOUND; a suspended tenant is 403 TENANT_SUSPENDED. All in the standard envelope.

Provisioning

createProvisionRouter implements the contract Foundry calls:

| Route | Effect | | ------------------------------------ | ------------------------------------------------------------ | | POST / | Bind a tenant. Idempotent — a repeat re-binds and resumes. | | PATCH /:tenantId | Suspend, resume, or replace the config. | | DELETE /:tenantId | Unbind. Idempotent; the application's own data is untouched. | | POST /:tenantId/rotate-credentials | Replace the key. Caches are dropped before it answers 200. | | GET /:tenantId/health | Per-tenant database and Platform checks. |

Auth is a shared service token in X-Service-Token, compared in constant time. It fails closed: an instance started without one answers 503 to every route rather than accepting anything.

Licence

Apache-2.0