@zerotal/tenancy 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +52 -0
- package/src/EnsureTenancyMiddleware.ts +32 -0
- package/src/Tenancy.ts +256 -0
- package/src/TenancyMiddleware.ts +136 -0
- package/src/TenantContext.ts +56 -0
- package/src/TenantManager.ts +93 -0
- package/src/TenantModel.ts +25 -0
- package/src/Tenantable.ts +138 -0
- package/src/config.ts +38 -0
- package/src/errors.ts +86 -0
- package/src/facades/Tenant.ts +32 -0
- package/src/index.ts +55 -0
- package/src/provider/TenancyProvider.ts +136 -0
- package/src/resolvers/AuthResolver.ts +56 -0
- package/src/resolvers/HeaderResolver.ts +24 -0
- package/src/resolvers/PathResolver.ts +52 -0
- package/src/resolvers/RouteParamResolver.ts +40 -0
- package/src/resolvers/SubdomainResolver.ts +34 -0
- package/src/tenantSchemaConcern.ts +41 -0
- package/src/tenantStorage.ts +179 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenancyProvider — wires @zerotal/tenancy into the Zerotal application.
|
|
3
|
+
*
|
|
4
|
+
* 1. Binds the tenancy config to the container under the `'tenancy'` key.
|
|
5
|
+
* 2. Configures TenancyMiddleware with the resolved config so it can resolve
|
|
6
|
+
* tenants without needing the container on every request.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* // bootstrap/app.ts
|
|
10
|
+
* import { TenancyProvider } from '@zerotal/tenancy';
|
|
11
|
+
*
|
|
12
|
+
* Application.create()
|
|
13
|
+
* .register([TenancyProvider, /* … *\/])
|
|
14
|
+
* .use([TenancyMiddleware]); // or attach per-route-group
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ServiceProvider } from "@zerotal/core";
|
|
18
|
+
import type { AppEnvironment } from "@zerotal/core";
|
|
19
|
+
import { registerConnectionResolver } from "@zerotal/orm";
|
|
20
|
+
import { TenancyMiddleware } from "../TenancyMiddleware.ts";
|
|
21
|
+
import { TenancyNotConfiguredError, TenancyConfigError } from "../errors.ts";
|
|
22
|
+
import { Tenancy } from "../Tenancy.ts";
|
|
23
|
+
import { TenantManager } from "../TenantManager.ts";
|
|
24
|
+
import { TenantModel } from "../TenantModel.ts";
|
|
25
|
+
import { TenantContext } from "../TenantContext.ts";
|
|
26
|
+
import { tenantSchemaConcern } from "../tenantSchemaConcern.ts";
|
|
27
|
+
import {
|
|
28
|
+
_pendingTenantable,
|
|
29
|
+
registerTenantScoping,
|
|
30
|
+
_markTenantProviderBooted,
|
|
31
|
+
} from "../Tenantable.ts";
|
|
32
|
+
import type { MultiDbTenant, TenancyConfigShape } from "../types.ts";
|
|
33
|
+
|
|
34
|
+
export class TenancyProvider extends ServiceProvider {
|
|
35
|
+
static override provides = ["tenancy"] as const;
|
|
36
|
+
static override environments: AppEnvironment[] = ["web", "console", "worker", "test", "repl"];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Supply the tenancy configuration.
|
|
40
|
+
* Call this *before* registering TenancyProvider:
|
|
41
|
+
*
|
|
42
|
+
* TenancyProvider.withConfig(tenancyConfig({ … }));
|
|
43
|
+
* Application.create().register([TenancyProvider]);
|
|
44
|
+
*/
|
|
45
|
+
static withConfig(config: TenancyConfigShape): typeof TenancyProvider {
|
|
46
|
+
TenancyProvider._config = config;
|
|
47
|
+
return TenancyProvider;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private static _config?: TenancyConfigShape;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the active config: an explicit {@link withConfig} call wins, otherwise
|
|
54
|
+
* the auto-discovered `config/tenancy.ts` (loaded into the container's ConfigManager
|
|
55
|
+
* under the `tenancy` namespace). This is what makes the common setup a two-liner —
|
|
56
|
+
* register the provider and drop a `config/tenancy.ts`, no `withConfig()` needed.
|
|
57
|
+
*/
|
|
58
|
+
private _resolveConfig(): TenancyConfigShape | undefined {
|
|
59
|
+
if (TenancyProvider._config) return TenancyProvider._config;
|
|
60
|
+
const config = this.app.container.makeSync("config") as {
|
|
61
|
+
get(key: string): unknown;
|
|
62
|
+
};
|
|
63
|
+
const fromFile = config.get("tenancy") as TenancyConfigShape | undefined;
|
|
64
|
+
if (fromFile && Array.isArray(fromFile.resolvers)) {
|
|
65
|
+
TenancyProvider._config = fromFile;
|
|
66
|
+
return fromFile;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The per-tenant connection pool for the multi-database strategy (null otherwise). */
|
|
72
|
+
private static _manager?: TenantManager;
|
|
73
|
+
|
|
74
|
+
override onRegister(): void {
|
|
75
|
+
const config = this._resolveConfig();
|
|
76
|
+
if (!config) {
|
|
77
|
+
throw new TenancyNotConfiguredError(
|
|
78
|
+
"No tenancy config found. Add a `config/tenancy.ts` (default export of TenancyConfig({ … })) " +
|
|
79
|
+
"or call TenancyProvider.withConfig(TenancyConfig({ … })) before registering the provider.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (config.strategy === "multi-database" && !config.connect) {
|
|
83
|
+
throw new TenancyConfigError(
|
|
84
|
+
"The multi-database strategy requires a `connect` factory in TenancyConfig({ … }) " +
|
|
85
|
+
"so each tenant's database connection can be opened.",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
// The `tenancy` binding is the Tenancy service behind the `Tenant` facade.
|
|
89
|
+
this.app.container.singleton("tenancy", () => new Tenancy());
|
|
90
|
+
// Provision the `tenants` + `tenant_members` tables on boot — no migration needed.
|
|
91
|
+
this.app.registerConcern?.(tenantSchemaConcern);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override async onBooted(): Promise<void> {
|
|
95
|
+
const config = this._resolveConfig() as TenancyConfigShape;
|
|
96
|
+
TenancyMiddleware.configure(config);
|
|
97
|
+
// Pre-resolve the `tenancy` singleton so the `Tenant` facade works at request time —
|
|
98
|
+
// `createFacade` uses `makeSync`, which only returns an already-instantiated singleton.
|
|
99
|
+
await this.app.container.make("tenancy");
|
|
100
|
+
// Register the resolver/boundary middleware globally so every request is tenant-aware
|
|
101
|
+
// (Auth.user(), model queries, cache, storage). It is lenient — routes that *require* a
|
|
102
|
+
// tenant opt into EnsureTenancyMiddleware. Registered after AuthProvider's
|
|
103
|
+
// PersistUserMiddleware (its onBooting runs first) so AuthResolver can read `http.user`.
|
|
104
|
+
this.app.useOnce(TenancyMiddleware as never);
|
|
105
|
+
for (const cls of _pendingTenantable) {
|
|
106
|
+
registerTenantScoping(cls);
|
|
107
|
+
}
|
|
108
|
+
if (config.strategy === "multi-database" && config.connect) {
|
|
109
|
+
await this._wireMultiDatabase(config.connect);
|
|
110
|
+
}
|
|
111
|
+
_markTenantProviderBooted();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Make the multi-database strategy seamless: route every model query inside a tenant
|
|
116
|
+
* boundary to that tenant's connection via the ORM's context resolver. App models stay
|
|
117
|
+
* plain — `Project.all()` "just works". The internal `TenantModel` is exempt (the tenant
|
|
118
|
+
* registry lives in the central/default database).
|
|
119
|
+
*/
|
|
120
|
+
private async _wireMultiDatabase(
|
|
121
|
+
connect: NonNullable<TenancyConfigShape["connect"]>,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const manager = new TenantManager({ connect });
|
|
124
|
+
TenancyProvider._manager = manager;
|
|
125
|
+
|
|
126
|
+
registerConnectionResolver((ModelClass) => {
|
|
127
|
+
if (ModelClass === TenantModel) return null; // registry → central DB
|
|
128
|
+
const tenant = TenantContext.tryGet();
|
|
129
|
+
return tenant ? manager.connectionFor(tenant as MultiDbTenant) : null;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Drop a tenant's pooled connection when the tenant is deleted.
|
|
133
|
+
const tenancy = await this.app.container.make<Tenancy>("tenancy");
|
|
134
|
+
tenancy.onTenantDeleted((tenant) => manager.evict(tenant));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthResolver — resolves the active tenant from the authenticated user.
|
|
3
|
+
*
|
|
4
|
+
* The logged-in user carries the tenant it belongs to (default column `tenantId`),
|
|
5
|
+
* and this resolver reads that foreign key off `http.user` — the user populated
|
|
6
|
+
* upstream by the auth layer's `PersistUserMiddleware`. Because it reads `http.user`
|
|
7
|
+
* (typed by core's `AuthenticatedUser`) rather than importing the auth package,
|
|
8
|
+
* `@zerotal/tenancy` stays decoupled from `@zerotal/auth`.
|
|
9
|
+
*
|
|
10
|
+
* Place it after URL-based resolvers so an explicit `/:tenancy` in the path wins,
|
|
11
|
+
* and the auth user is the fallback for tenant-scoped routes without a slug:
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // config/tenancy.ts
|
|
15
|
+
* resolvers: [
|
|
16
|
+
* new RouteParamResolver({ param: "tenancy" }),
|
|
17
|
+
* new AuthResolver(), // falls back to the logged-in user's tenantId
|
|
18
|
+
* ]
|
|
19
|
+
*
|
|
20
|
+
* Requests with no authenticated user (login, signup) resolve to `null` here — and
|
|
21
|
+
* with `strict: false` (the default) fall through to the default database.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { HttpContext } from "@zerotal/core";
|
|
25
|
+
import type { TenantResolver, TenantResolverResult } from "../types.ts";
|
|
26
|
+
|
|
27
|
+
interface AuthResolverOptions {
|
|
28
|
+
/**
|
|
29
|
+
* The property on the authenticated user holding the tenant foreign key.
|
|
30
|
+
* Default: `"tenantId"`.
|
|
31
|
+
*/
|
|
32
|
+
column?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class AuthResolver implements TenantResolver {
|
|
36
|
+
/**
|
|
37
|
+
* The identifier comes off the authenticated user's own record, so it is by construction
|
|
38
|
+
* a tenant this requester belongs to — no membership check is needed or meaningful.
|
|
39
|
+
*/
|
|
40
|
+
readonly trusted = true;
|
|
41
|
+
|
|
42
|
+
private readonly column: string;
|
|
43
|
+
|
|
44
|
+
constructor(opts: AuthResolverOptions = {}) {
|
|
45
|
+
this.column = opts.column ?? "tenantId";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
resolve(http: HttpContext): TenantResolverResult | null {
|
|
49
|
+
// Read `user` structurally: it is augmented onto HttpContext by @zerotal/auth,
|
|
50
|
+
// which this package intentionally does not depend on.
|
|
51
|
+
const user = (http as unknown as { user?: Record<string, unknown> | null }).user;
|
|
52
|
+
const value = user?.[this.column];
|
|
53
|
+
if (value === undefined || value === null || value === "") return null;
|
|
54
|
+
return { identifier: value as string | number, by: "id" };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeaderResolver — extracts the tenant slug from a request header.
|
|
3
|
+
*
|
|
4
|
+
* Useful for API-first apps where the client sends the tenant in a header
|
|
5
|
+
* instead of using subdomains.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* new HeaderResolver('X-Tenant-ID')
|
|
9
|
+
* // Request: GET /api/users X-Tenant-ID: acme
|
|
10
|
+
* // Resolved identifier: 'acme'
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { HttpContext } from "@zerotal/core";
|
|
14
|
+
import type { TenantResolver, TenantResolverResult } from "../types.ts";
|
|
15
|
+
|
|
16
|
+
export class HeaderResolver implements TenantResolver {
|
|
17
|
+
constructor(private readonly header: string) {}
|
|
18
|
+
|
|
19
|
+
resolve(http: HttpContext): TenantResolverResult | null {
|
|
20
|
+
const value = http.request.headers.get(this.header);
|
|
21
|
+
if (!value) return null;
|
|
22
|
+
return { identifier: value.trim() };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PathResolver — extracts the tenant slug from a URL path segment.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* new PathResolver({ segment: 0 })
|
|
6
|
+
* // Request: GET /acme/dashboard
|
|
7
|
+
* // Resolved identifier: 'acme'
|
|
8
|
+
*
|
|
9
|
+
* new PathResolver({ segment: 1, prefix: '/tenants' })
|
|
10
|
+
* // Request: GET /tenants/acme/settings
|
|
11
|
+
* // Resolved identifier: 'acme'
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { HttpContext } from "@zerotal/core";
|
|
15
|
+
import type { TenantResolver, TenantResolverResult } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
interface PathResolverOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Zero-based index of the path segment that holds the tenant slug.
|
|
20
|
+
* Default: 0 (i.e. the first segment after the leading slash).
|
|
21
|
+
*/
|
|
22
|
+
segment?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Optional static prefix to strip before indexing.
|
|
25
|
+
* Example: prefix '/tenants' with segment 0 matches `/tenants/acme/…`.
|
|
26
|
+
*/
|
|
27
|
+
prefix?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class PathResolver implements TenantResolver {
|
|
31
|
+
private readonly segment: number;
|
|
32
|
+
private readonly prefix: string;
|
|
33
|
+
|
|
34
|
+
constructor(opts: PathResolverOptions = {}) {
|
|
35
|
+
this.segment = opts.segment ?? 0;
|
|
36
|
+
this.prefix = opts.prefix ?? "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
resolve(http: HttpContext): TenantResolverResult | null {
|
|
40
|
+
let pathname = new URL(http.request.url).pathname;
|
|
41
|
+
|
|
42
|
+
if (this.prefix && pathname.startsWith(this.prefix)) {
|
|
43
|
+
pathname = pathname.slice(this.prefix.length);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Split and filter empty strings produced by leading/trailing slashes.
|
|
47
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
48
|
+
const slug = parts[this.segment];
|
|
49
|
+
|
|
50
|
+
return slug ? { identifier: slug } : null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RouteParamResolver — reads the tenant slug from a named route parameter.
|
|
3
|
+
*
|
|
4
|
+
* Pair it with a `/:tenancy` group prefix: the router captures the segment into
|
|
5
|
+
* `http.params.tenancy` before middleware runs, and this resolver hands that slug
|
|
6
|
+
* to `TenancyMiddleware`.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* // config/tenancy.ts
|
|
10
|
+
* resolvers: [new RouteParamResolver({ param: "tenancy" })]
|
|
11
|
+
*
|
|
12
|
+
* // bootstrap/app.ts
|
|
13
|
+
* .fileBasedRouting({
|
|
14
|
+
* dir: basePath("app/routes"),
|
|
15
|
+
* prefix: "/:tenancy", // GET /acme/dashboard → params.tenancy = "acme"
|
|
16
|
+
* middleware: [TenancyMiddleware],
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { HttpContext } from "@zerotal/core";
|
|
21
|
+
import type { TenantResolver, TenantResolverResult } from "../types.ts";
|
|
22
|
+
|
|
23
|
+
interface RouteParamResolverOptions {
|
|
24
|
+
/** The route parameter name holding the tenant slug. Default: `"tenancy"`. */
|
|
25
|
+
param?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class RouteParamResolver implements TenantResolver {
|
|
29
|
+
private readonly param: string;
|
|
30
|
+
|
|
31
|
+
constructor(opts: RouteParamResolverOptions = {}) {
|
|
32
|
+
this.param = opts.param ?? "tenancy";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
resolve(http: HttpContext): TenantResolverResult | null {
|
|
36
|
+
const raw = (http.params as Record<string, unknown>)[this.param];
|
|
37
|
+
const slug = typeof raw === "string" ? raw.trim() : "";
|
|
38
|
+
return slug ? { identifier: slug, by: "slug" } : null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubdomainResolver — extracts the tenant slug from the first subdomain segment.
|
|
3
|
+
*
|
|
4
|
+
* Given `domain: 'myapp.com'` and a request to `acme.myapp.com`,
|
|
5
|
+
* the resolved identifier is `'acme'`.
|
|
6
|
+
*
|
|
7
|
+
* Any number of additional subdomains before the tenant segment is ignored —
|
|
8
|
+
* only the leftmost segment is treated as the tenant slug.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { HttpContext } from "@zerotal/core";
|
|
12
|
+
import type { TenantResolver, TenantResolverResult } from "../types.ts";
|
|
13
|
+
|
|
14
|
+
export class SubdomainResolver implements TenantResolver {
|
|
15
|
+
constructor(private readonly domain: string) {}
|
|
16
|
+
|
|
17
|
+
resolve(http: HttpContext): TenantResolverResult | null {
|
|
18
|
+
const host = new URL(http.request.url).hostname;
|
|
19
|
+
|
|
20
|
+
// Strip the base domain and any trailing dot.
|
|
21
|
+
const suffix = `.${this.domain}`;
|
|
22
|
+
if (!host.endsWith(suffix)) return null;
|
|
23
|
+
|
|
24
|
+
const sub = host.slice(0, host.length - suffix.length);
|
|
25
|
+
if (!sub) return null;
|
|
26
|
+
|
|
27
|
+
// Take only the rightmost subdomain segment as the slug
|
|
28
|
+
// (e.g. "www.acme.myapp.com" → "acme").
|
|
29
|
+
const parts = sub.split(".");
|
|
30
|
+
const slug = parts[parts.length - 1]!;
|
|
31
|
+
|
|
32
|
+
return slug ? { identifier: slug } : null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ConcernDescriptor } from "@zerotal/core";
|
|
2
|
+
import { Schema } from "@zerotal/orm";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Provisions the tenancy tables on boot — the `tenants` registry and the
|
|
6
|
+
* `tenant_members` pivot — so apps don't define a tenant model or write migrations.
|
|
7
|
+
* Runs once after model discovery (order 70), additively and idempotently. Restricted
|
|
8
|
+
* to DB-backed environments; DDL/connection errors are swallowed so boot never fails.
|
|
9
|
+
*/
|
|
10
|
+
export const tenantSchemaConcern: ConcernDescriptor = {
|
|
11
|
+
name: "tenancy-schema",
|
|
12
|
+
order: 70,
|
|
13
|
+
envs: ["web", "worker", "test"],
|
|
14
|
+
async run() {
|
|
15
|
+
try {
|
|
16
|
+
if (!(await Schema.hasTable("tenants"))) {
|
|
17
|
+
await Schema.create("tenants", (t) => {
|
|
18
|
+
t.increments("id");
|
|
19
|
+
t.string("slug");
|
|
20
|
+
t.string("name");
|
|
21
|
+
t.boolean("is_active");
|
|
22
|
+
t.string("database").nullable(); // multi-database strategy target
|
|
23
|
+
t.timestamp("created_at").nullable();
|
|
24
|
+
t.timestamp("updated_at").nullable();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!(await Schema.hasTable("tenant_members"))) {
|
|
29
|
+
await Schema.create("tenant_members", (t) => {
|
|
30
|
+
t.increments("id");
|
|
31
|
+
t.integer("tenant_id");
|
|
32
|
+
t.integer("user_id");
|
|
33
|
+
t.boolean("is_admin");
|
|
34
|
+
t.timestamp("created_at").nullable();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// No database (or DDL not permitted) in this runtime — skip silently.
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-scoped storage and cache helpers.
|
|
3
|
+
*
|
|
4
|
+
* These thin wrappers prefix all keys and paths with the active tenant's
|
|
5
|
+
* slug so files and cache entries are automatically isolated per tenant.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // Storage — saves to /storage/tenants/acme/logo.png on the local disk:
|
|
9
|
+
* import { tenantDisk } from '@zerotal/tenancy';
|
|
10
|
+
* await tenantDisk().put('logo.png', buffer);
|
|
11
|
+
* const url = await tenantDisk().url('logo.png');
|
|
12
|
+
*
|
|
13
|
+
* // Cache — key becomes 'tenant:acme:stats':
|
|
14
|
+
* import { tenantCache } from '@zerotal/tenancy';
|
|
15
|
+
* await tenantCache().set('stats', data, 300);
|
|
16
|
+
* const hit = await tenantCache().get<Stats>('stats');
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { TenantContext } from "./TenantContext.ts";
|
|
20
|
+
import { TenancyConfigError, TenantStoragePathError } from "./errors.ts";
|
|
21
|
+
|
|
22
|
+
// ── Storage ───────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
type StorageDriver = {
|
|
25
|
+
put(path: string, data: unknown): Promise<void>;
|
|
26
|
+
get(path: string): Promise<unknown>;
|
|
27
|
+
exists(path: string): Promise<boolean>;
|
|
28
|
+
delete(path: string): Promise<void>;
|
|
29
|
+
url(path: string): Promise<string> | string;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type StorageManagerLike = {
|
|
34
|
+
disk(name?: string): StorageDriver;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Return a proxy over the named storage disk that prefixes every path with
|
|
39
|
+
* `tenants/<slug>/` for the currently active tenant.
|
|
40
|
+
*
|
|
41
|
+
* @param disk The disk name passed to `Storage.disk()`. Defaults to the default disk.
|
|
42
|
+
* @param storageManager Optional — pass the `StorageManager` instance directly (useful
|
|
43
|
+
* in tests or when the facade isn't available). When omitted the global `Storage`
|
|
44
|
+
* facade is resolved dynamically at call time.
|
|
45
|
+
*/
|
|
46
|
+
export function tenantDisk(disk?: string, storageManager?: StorageManagerLike): StorageDriver {
|
|
47
|
+
const slug = TenantContext.get().slug;
|
|
48
|
+
|
|
49
|
+
const _getDriver = (): StorageDriver => {
|
|
50
|
+
const manager = storageManager ?? _resolveStorage();
|
|
51
|
+
return manager.disk(disk);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const _root = `tenants/${slug}/`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Confine a caller-supplied key to this tenant's directory.
|
|
58
|
+
*
|
|
59
|
+
* A bare template prefix was not enough. LocalDriver._fullPath confines paths to the *disk
|
|
60
|
+
* root*, not to the tenant directory, so `../victim/invoice.txt` resolved to
|
|
61
|
+
* `<root>/tenants/victim/invoice.txt`, passed the root check, and read another tenant's file.
|
|
62
|
+
*/
|
|
63
|
+
const _prefix = (path: string): string => {
|
|
64
|
+
if (typeof path !== "string") {
|
|
65
|
+
throw new TenantStoragePathError(String(path), slug);
|
|
66
|
+
}
|
|
67
|
+
const normalised = path.replaceAll("\\", "/");
|
|
68
|
+
if (
|
|
69
|
+
normalised.startsWith("/") ||
|
|
70
|
+
normalised.split("/").some((segment) => segment === ".." || segment === "~")
|
|
71
|
+
) {
|
|
72
|
+
throw new TenantStoragePathError(path, slug);
|
|
73
|
+
}
|
|
74
|
+
return `${_root}${normalised}`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Methods whose *second* argument is also a path within the same disk. */
|
|
78
|
+
const _twoPathMethods = new Set(["copy", "move"]);
|
|
79
|
+
|
|
80
|
+
return new Proxy({} as StorageDriver, {
|
|
81
|
+
get(_target, prop: string) {
|
|
82
|
+
const driver = _getDriver();
|
|
83
|
+
const method = (driver as Record<string, unknown>)[prop];
|
|
84
|
+
if (typeof method === "function") {
|
|
85
|
+
return (path: string, ...rest: unknown[]) => {
|
|
86
|
+
const args = [...rest];
|
|
87
|
+
// copy(from, to) / move(from, to): prefixing only `from` let the destination escape
|
|
88
|
+
// the tenant entirely — proxy.copy("mine.txt", "tenants/victim/pwned.txt") wrote
|
|
89
|
+
// straight into another tenant's directory.
|
|
90
|
+
if (_twoPathMethods.has(prop) && typeof args[0] === "string") {
|
|
91
|
+
args[0] = _prefix(args[0]);
|
|
92
|
+
}
|
|
93
|
+
return (method as (p: string, ...a: unknown[]) => unknown).call(
|
|
94
|
+
driver,
|
|
95
|
+
_prefix(path),
|
|
96
|
+
...args,
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return method;
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _resolveStorage(): StorageManagerLike {
|
|
106
|
+
try {
|
|
107
|
+
// Resolved lazily so tenancy works in an app that never configures a disk.
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
109
|
+
const { Storage } = require("@zerotal/core/storage") as { Storage: StorageManagerLike };
|
|
110
|
+
return Storage;
|
|
111
|
+
} catch {
|
|
112
|
+
throw new TenancyConfigError(
|
|
113
|
+
"tenantDisk() requires a configured storage disk (see @zerotal/core/storage).",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Cache ─────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
type CacheManagerLike = {
|
|
121
|
+
get<T>(key: string): Promise<T | null>;
|
|
122
|
+
set(key: string, value: unknown, ttl?: number): Promise<void>;
|
|
123
|
+
forget(key: string): Promise<void>;
|
|
124
|
+
flush(): Promise<void>;
|
|
125
|
+
[key: string]: unknown;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type CacheResolverLike =
|
|
129
|
+
| {
|
|
130
|
+
store(name?: string): CacheManagerLike;
|
|
131
|
+
}
|
|
132
|
+
| CacheManagerLike;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Return a proxy over the cache that prefixes every key with
|
|
136
|
+
* `tenant:<slug>:` for the currently active tenant.
|
|
137
|
+
*
|
|
138
|
+
* @param store Optional store name (e.g. `'redis'`).
|
|
139
|
+
* @param cacheResolver Optional — inject the Cache facade or manager directly.
|
|
140
|
+
*/
|
|
141
|
+
export function tenantCache(store?: string, cacheResolver?: CacheResolverLike): CacheManagerLike {
|
|
142
|
+
const slug = TenantContext.get().slug;
|
|
143
|
+
|
|
144
|
+
const _getManager = (): CacheManagerLike => {
|
|
145
|
+
const resolver = cacheResolver ?? _resolveCache();
|
|
146
|
+
return typeof (resolver as Record<string, unknown>)["store"] === "function"
|
|
147
|
+
? (resolver as { store(n?: string): CacheManagerLike }).store(store)
|
|
148
|
+
: (resolver as CacheManagerLike);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const _prefix = (key: string) => `tenant:${slug}:${key}`;
|
|
152
|
+
|
|
153
|
+
return new Proxy({} as CacheManagerLike, {
|
|
154
|
+
get(_target, prop: string) {
|
|
155
|
+
const manager = _getManager();
|
|
156
|
+
const method = (manager as Record<string, unknown>)[prop];
|
|
157
|
+
if (typeof method === "function") {
|
|
158
|
+
if (prop === "flush") {
|
|
159
|
+
// flush() takes no key — delegate as-is.
|
|
160
|
+
return () => (method as () => unknown).call(manager);
|
|
161
|
+
}
|
|
162
|
+
return (key: string, ...rest: unknown[]) =>
|
|
163
|
+
(method as (k: string, ...a: unknown[]) => unknown).call(manager, _prefix(key), ...rest);
|
|
164
|
+
}
|
|
165
|
+
return method;
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _resolveCache(): CacheResolverLike {
|
|
171
|
+
try {
|
|
172
|
+
const { Cache } = require("@zerotal/cache") as { Cache: CacheResolverLike };
|
|
173
|
+
return Cache;
|
|
174
|
+
} catch {
|
|
175
|
+
throw new TenancyConfigError(
|
|
176
|
+
"tenantCache() requires @zerotal/cache to be installed and configured.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for @zerotal/tenancy.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SQLInstance } from "@zerotal/orm";
|
|
6
|
+
import type { HttpContext } from "@zerotal/core";
|
|
7
|
+
|
|
8
|
+
/** Minimal shape every tenant record satisfies (matches the internal `TenantModel`). */
|
|
9
|
+
export interface Tenant {
|
|
10
|
+
/** Database primary key. */
|
|
11
|
+
id: number;
|
|
12
|
+
/** URL-safe identifier used in subdomains and storage paths. */
|
|
13
|
+
slug: string;
|
|
14
|
+
/** Display name. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** Whether this tenant is active. Inactive tenants are rejected by TenancyMiddleware. */
|
|
17
|
+
isActive: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Extended tenant shape for the multi-database strategy. */
|
|
21
|
+
export interface MultiDbTenant extends Tenant {
|
|
22
|
+
/** SQLite database file path OR connection string for this tenant's database. */
|
|
23
|
+
database: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Resolver types ────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** A resolver extracts a tenant identifier from the incoming request. */
|
|
29
|
+
export interface TenantResolverResult {
|
|
30
|
+
/** The raw identifier extracted (subdomain slug, header value, path segment, auth user, …). */
|
|
31
|
+
identifier: string | number;
|
|
32
|
+
/**
|
|
33
|
+
* How to look the tenant up in the registry. `"slug"` (default) matches the URL-safe
|
|
34
|
+
* slug; `"id"` matches the primary key — used by resolvers that read a foreign key such
|
|
35
|
+
* as the authenticated user's `tenantId`.
|
|
36
|
+
*/
|
|
37
|
+
by?: "slug" | "id";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TenantResolver {
|
|
41
|
+
/**
|
|
42
|
+
* Return an identifier if this resolver can handle the request, or null to skip.
|
|
43
|
+
*
|
|
44
|
+
* Resolvers receive the full {@link HttpContext} so they can read route params
|
|
45
|
+
* (`http.params`), the authenticated user (`http.user`), headers, or the raw
|
|
46
|
+
* `http.request` — whichever they resolve from.
|
|
47
|
+
*/
|
|
48
|
+
resolve(http: HttpContext): TenantResolverResult | null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the identifier this resolver produces is *already known to belong to the
|
|
52
|
+
* requester* — i.e. it came from server-held state rather than from the request.
|
|
53
|
+
*
|
|
54
|
+
* Only {@link AuthResolver} is trusted: it reads the tenant off the authenticated
|
|
55
|
+
* user's own record. Subdomains, headers, path segments and route params are all
|
|
56
|
+
* attacker-chosen, so `TenancyMiddleware` requires an authenticated requester to be a
|
|
57
|
+
* member of the tenant they named before opening the boundary. Without that check, a
|
|
58
|
+
* user of tenant A reaches tenant B's data by editing one header.
|
|
59
|
+
*
|
|
60
|
+
* Defaults to `false` — a custom resolver is untrusted until it says otherwise, which is
|
|
61
|
+
* the right way round for a security default.
|
|
62
|
+
*/
|
|
63
|
+
readonly trusted?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Strategy ─────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export type TenancyStrategy = "single-database" | "multi-database";
|
|
69
|
+
|
|
70
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export interface TenancyConfigShape {
|
|
73
|
+
/** Persistence strategy. Default: 'single-database'. */
|
|
74
|
+
strategy: TenancyStrategy;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One or more resolvers tried in order until one returns a non-null result.
|
|
78
|
+
* If all resolvers return null the request is treated as tenant-less
|
|
79
|
+
* (TenancyMiddleware returns a 404).
|
|
80
|
+
*/
|
|
81
|
+
resolvers: TenantResolver[];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Column name used for tenant scoping in the single-database strategy.
|
|
85
|
+
* Default: 'tenant_id'.
|
|
86
|
+
*/
|
|
87
|
+
tenantColumn?: string;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* **Multi-database strategy only — required.** Opens (or returns) the database
|
|
91
|
+
* connection for a tenant. Called once per tenant; the result is pooled. Once
|
|
92
|
+
* supplied, every model query inside a tenant boundary is transparently routed to
|
|
93
|
+
* this connection — no `TenantManager` wiring or raw SQL needed.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* connect: (tenant) => new SQL(`file:./storage/tenants/${tenant.database}`),
|
|
97
|
+
*/
|
|
98
|
+
connect?: (tenant: MultiDbTenant) => SQLInstance;
|
|
99
|
+
}
|