@dtfoundry/app-tenancy
v0.3.0
Published
Multi-tenant request layer for Digital Trust Foundry applications
Keywords
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 putsreq.tenantId,req.tenantandreq.foundryon the request.TenantRegistry— oneFoundryClientper 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-sdkNode 20+, Express 4 or 5 (a peer dependency — this package bundles no framework).
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'))"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
