@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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * TenancyMiddleware — resolves the active tenant and opens a TenantContext boundary.
3
+ *
4
+ * You do not register this yourself: `TenancyProvider` adds it to the **global**
5
+ * pipeline automatically (and it is not exported from the package). It runs on every
6
+ * request, so once a tenant is resolved every downstream service — `Auth.user()`, model
7
+ * queries, cache, storage — is transparently scoped to that tenant.
8
+ *
9
+ * It is deliberately **lenient about absence**: a request that resolves no tenant simply
10
+ * continues with no boundary, which is what lets tenant-less routes (login, signup,
11
+ * marketing) coexist with tenant-scoped ones. That is safe because `Tenantable`'s scope
12
+ * matches nothing outside a boundary — no tenant means no rows, not everyone's rows. Apply
13
+ * {@link EnsureTenancyMiddleware} to routes that must *have* a tenant.
14
+ *
15
+ * It is **strict about identity**: every resolver except {@link AuthResolver} takes its
16
+ * identifier from the request (a subdomain, a header, a path segment), so an authenticated
17
+ * requester who names a tenant must be a member of it. See {@link TenantResolver.trusted}.
18
+ *
19
+ * Outcomes:
20
+ * - no resolver matches → continue, no boundary (tenant-scoped models return nothing)
21
+ * - resolver names a tenant that does not exist → continue, no boundary
22
+ * - tenant exists but `isActive` is false → 403
23
+ * - signed-in requester named a tenant they do not belong to → 403
24
+ * - tenant exists and is active → open the boundary
25
+ */
26
+
27
+ import { BaseMiddleware, type NextFn, type HttpContext } from "@zerotal/core";
28
+ import { TenantContext } from "./TenantContext.ts";
29
+ import { TenantModel } from "./TenantModel.ts";
30
+ import { DB } from "@zerotal/orm";
31
+ import { MEMBERS_TABLE } from "./Tenancy.ts";
32
+ import { TenantInactiveError, TenancyNotConfiguredError, TenantForbiddenError } from "./errors.ts";
33
+ import type { Tenant, TenancyConfigShape } from "./types.ts";
34
+
35
+ /**
36
+ * The authenticated user's id, or `null` for an anonymous request.
37
+ *
38
+ * `AuthenticatedUser` is an empty interface until an app augments it, so reaching `id`
39
+ * needs one structural view. Kept here so the membership check has exactly one.
40
+ * @internal
41
+ */
42
+ function _authUserId(http: HttpContext): number | null {
43
+ const user = (http as { user?: { id?: unknown } | null }).user;
44
+ return typeof user?.id === "number" ? user.id : null;
45
+ }
46
+
47
+ export class TenancyMiddleware extends BaseMiddleware {
48
+ protected options: {} = {};
49
+
50
+ private static _config?: TenancyConfigShape;
51
+
52
+ /** Called by TenancyProvider during boot. */
53
+ static configure(config: TenancyConfigShape): void {
54
+ TenancyMiddleware._config = config;
55
+ }
56
+
57
+ async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
58
+ const config = TenancyMiddleware._config;
59
+ if (!config)
60
+ throw new TenancyNotConfiguredError(
61
+ "TenancyMiddleware used before TenancyProvider was booted.",
62
+ );
63
+
64
+ // Try each resolver in order until one identifies a tenant. Each resolver reads
65
+ // whatever it needs off the HttpContext (route params, the auth user, headers, …).
66
+ let identifier: string | number | null = null;
67
+ let by: "slug" | "id" = "slug";
68
+ let trusted = false;
69
+ for (const resolver of config.resolvers) {
70
+ const result = resolver.resolve(http);
71
+ if (result != null && result.identifier !== "" && result.identifier != null) {
72
+ identifier = result.identifier;
73
+ by = result.by ?? "slug";
74
+ trusted = resolver.trusted === true;
75
+ break;
76
+ }
77
+ }
78
+
79
+ // No tenant could be identified — run tenant-less against the default database.
80
+ // Routes that must have a tenant reject this later via EnsureTenancyMiddleware.
81
+ if (identifier == null) return next();
82
+
83
+ // Load the tenant from the internal registry (owned by @zerotal/tenancy), matching
84
+ // on slug or primary key depending on how the resolver identified it.
85
+ const column = by === "id" ? "id" : "slug";
86
+ const tenant = (await TenantModel.query().where(column, identifier).first()) as Tenant | null;
87
+
88
+ // An identifier that maps to no tenant is treated leniently (no boundary) — the
89
+ // EnsureTenancyMiddleware gate turns this into a 404 only where a tenant is required.
90
+ if (!tenant) return next();
91
+
92
+ // A known-but-disabled tenant is a definitive rejection, surfaced everywhere.
93
+ if (!tenant.isActive) throw new TenantInactiveError();
94
+
95
+ // An authenticated requester who *names* a tenant must belong to it. Subdomains,
96
+ // headers, path segments and route params are all attacker-chosen, so without this a
97
+ // user of tenant A reaches tenant B by editing one header — the tenant boundary would
98
+ // be decided entirely by the client. Anonymous requests are left alone: a public
99
+ // tenant surface (marketing page, login form) has no membership to check, and nothing
100
+ // downstream is authorised on identity yet.
101
+ if (!trusted) {
102
+ await this._assertMembership(http, tenant);
103
+ }
104
+
105
+ // Attach tenant to http for convenient access in controllers.
106
+ (http as unknown as Record<string, unknown>)["tenant"] = tenant;
107
+
108
+ // Establish the ALS boundary so all downstream code can call TenantContext.get().
109
+ return new Promise<Response | void>((resolve, reject) => {
110
+ TenantContext.run(tenant, () => {
111
+ next().then(resolve, reject);
112
+ });
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Reject an authenticated requester who named a tenant they do not belong to.
118
+ *
119
+ * Queries the membership pivot directly rather than going through the `Tenant` facade:
120
+ * this check runs on every request and must not depend on the container being resolvable,
121
+ * and `Tenancy.isMember()` reads the *current* tenant id — which is precisely the tenant
122
+ * we have not entered yet.
123
+ *
124
+ * @throws {@link TenantForbiddenError} When a signed-in user is not a member.
125
+ */
126
+ private async _assertMembership(http: HttpContext, tenant: Tenant): Promise<void> {
127
+ const userId = _authUserId(http);
128
+ if (userId === null) return; // anonymous — nothing to check against
129
+
130
+ const row = await DB.table(MEMBERS_TABLE)
131
+ .where("tenant_id", tenant.id)
132
+ .where("user_id", userId)
133
+ .first();
134
+ if (!row) throw new TenantForbiddenError();
135
+ }
136
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * TenantContext — AsyncLocalStorage-based tenant store.
3
+ *
4
+ * Once TenancyMiddleware resolves a tenant and calls `TenantContext.run()`,
5
+ * every async operation downstream (including ORM queries, mail jobs, queue
6
+ * workers dispatched from within the same request) can call
7
+ * `TenantContext.get()` to retrieve the active tenant without threading it
8
+ * manually through function arguments.
9
+ *
10
+ * This parallels how RequestContext works in @zerotal/core.
11
+ */
12
+
13
+ import { AsyncLocalStorage } from "node:async_hooks";
14
+ import { NoActiveTenantError } from "./errors.ts";
15
+ import type { Tenant } from "./types.ts";
16
+
17
+ const _storage = new AsyncLocalStorage<Tenant>();
18
+
19
+ export class TenantContext {
20
+ /**
21
+ * Execute `callback` inside a tenant boundary.
22
+ * Called by TenancyMiddleware before forwarding to the next handler.
23
+ */
24
+ static run<T>(tenant: Tenant, callback: () => T): T {
25
+ return _storage.run(tenant, callback);
26
+ }
27
+
28
+ /**
29
+ * Return the active tenant.
30
+ * Throws if called outside a tenant context (i.e. without TenancyMiddleware).
31
+ */
32
+ static get(): Tenant {
33
+ const t = _storage.getStore();
34
+ if (!t) throw new NoActiveTenantError();
35
+ return t;
36
+ }
37
+
38
+ /**
39
+ * Return the active tenant, or undefined if called outside a tenant context.
40
+ * Useful in shared code that runs in both tenant and non-tenant contexts
41
+ * (CLI commands, background jobs that process all tenants).
42
+ */
43
+ static tryGet(): Tenant | undefined {
44
+ return _storage.getStore();
45
+ }
46
+
47
+ /** Convenience: return the current tenant's id, or null outside a context. */
48
+ static id(): number | null {
49
+ return _storage.getStore()?.id ?? null;
50
+ }
51
+
52
+ /** Convenience: return the current tenant's slug, or null outside a context. */
53
+ static slug(): string | null {
54
+ return _storage.getStore()?.slug ?? null;
55
+ }
56
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * TenantManager — the per-tenant database connection pool for the multi-database
3
+ * strategy. You normally never touch it directly: when you give `TenancyConfig` a
4
+ * `connect` factory, `TenancyProvider` builds a TenantManager from it and registers
5
+ * an ORM connection resolver, so every model query inside a tenant boundary is routed
6
+ * to the right database automatically.
7
+ *
8
+ * It is exported for the rare case where you need the raw connection for a tenant
9
+ * outside the ORM (a manual migration, a bulk import). In the single-database strategy
10
+ * you don't need this class at all — `Tenantable` isolates via `WHERE tenant_id = ?`.
11
+ *
12
+ * @example
13
+ * // Pre-warm connections for all known tenants at startup:
14
+ * const tenants = await Tenant.all();
15
+ * for (const t of tenants) manager.warmUp(t);
16
+ */
17
+
18
+ import type { SQLInstance } from "@zerotal/orm";
19
+ import { TenantContext } from "./TenantContext.ts";
20
+ import type { MultiDbTenant, Tenant } from "./types.ts";
21
+
22
+ export interface TenantManagerOptions {
23
+ /**
24
+ * Factory that opens a new database connection for the given tenant.
25
+ * Called once per tenant on first use; the result is cached.
26
+ */
27
+ connect(tenant: MultiDbTenant): SQLInstance;
28
+ }
29
+
30
+ export class TenantManager {
31
+ private readonly _pool = new Map<number, SQLInstance>();
32
+ private readonly _opts: TenantManagerOptions;
33
+
34
+ constructor(opts: TenantManagerOptions) {
35
+ this._opts = opts;
36
+ }
37
+
38
+ /**
39
+ * Return the database connection for the currently active tenant.
40
+ * Throws if called outside a TenantContext boundary.
41
+ */
42
+ connection(): SQLInstance {
43
+ const tenant = TenantContext.get() as MultiDbTenant;
44
+ return this._forTenant(tenant);
45
+ }
46
+
47
+ /**
48
+ * Return the database connection for a specific tenant without requiring
49
+ * an active TenantContext. Useful in background jobs that iterate tenants.
50
+ */
51
+ connectionFor(tenant: MultiDbTenant): SQLInstance {
52
+ return this._forTenant(tenant);
53
+ }
54
+
55
+ /**
56
+ * Pre-open the connection for a tenant so the first request is instant.
57
+ * Safe to call at boot time before any requests arrive.
58
+ */
59
+ warmUp(tenant: MultiDbTenant): void {
60
+ this._forTenant(tenant);
61
+ }
62
+
63
+ /** Close and evict a tenant's connection (e.g. after a tenant is deleted). */
64
+ evict(tenant: Tenant): void {
65
+ const conn = this._pool.get(tenant.id);
66
+ if (conn) _closeConnection(conn);
67
+ this._pool.delete(tenant.id);
68
+ }
69
+
70
+ /** Close all open connections. */
71
+ closeAll(): void {
72
+ for (const [id, conn] of this._pool) {
73
+ _closeConnection(conn);
74
+ this._pool.delete(id);
75
+ }
76
+ }
77
+
78
+ private _forTenant(tenant: MultiDbTenant): SQLInstance {
79
+ let conn = this._pool.get(tenant.id);
80
+ if (!conn) {
81
+ conn = this._opts.connect(tenant);
82
+ this._pool.set(tenant.id, conn);
83
+ }
84
+ return conn;
85
+ }
86
+ }
87
+
88
+ /** Close a connection via whichever teardown method it exposes (`end`/`close`). */
89
+ function _closeConnection(conn: SQLInstance): void {
90
+ const c = conn as unknown as { end?: () => unknown; close?: () => unknown };
91
+ if (typeof c.end === "function") c.end();
92
+ else if (typeof c.close === "function") c.close();
93
+ }
@@ -0,0 +1,25 @@
1
+ import { BaseModel, column, table } from "@zerotal/orm";
2
+
3
+ /**
4
+ * The internal tenant record — owned by `@zerotal/tenancy` (like `AuditLog` is owned
5
+ * by `@zerotal/audit`). Its table (`tenants`) and the `tenant_members` pivot are
6
+ * provisioned on boot by `tenantSchemaConcern`, so apps don't define a tenant model
7
+ * or write migrations for it. Reach tenants through the `Tenant` facade, not this
8
+ * class directly.
9
+ *
10
+ * `TenantModel` is deliberately NOT `Tenantable` — it lives in the central/platform
11
+ * database and is what bootstraps a tenant context, not something scoped within one.
12
+ */
13
+ @(table("tenants").withTimestamps())
14
+ export class TenantModel extends BaseModel {
15
+ @column() slug!: string;
16
+ @column() name!: string;
17
+ @column("boolean") isActive!: boolean;
18
+ /**
19
+ * Connection target for the multi-database strategy; null in single-database.
20
+ * Nullable so a single-database `Tenant.create()` (no `database`) doesn't violate a
21
+ * NOT NULL column — this must match `tenantSchemaConcern`, and it is what the ORM's
22
+ * model auto-synchronize uses when it provisions the `tenants` table.
23
+ */
24
+ @column({ nullable: true }) database?: string | null;
25
+ }
@@ -0,0 +1,138 @@
1
+ // ── Tenantable ──────────────────────────────────────────────────────────────
2
+ //
3
+ // Multi-tenant model mixin. Compose it via `BaseModelWith`, like every other mixin:
4
+ //
5
+ // import { BaseModelWith, column, table } from "@zerotal/orm";
6
+ // import { Tenantable } from "@zerotal/tenancy";
7
+ //
8
+ // @table("projects").withTimestamps()
9
+ // export class Project extends BaseModelWith(Tenantable) {
10
+ // @column() name!: string;
11
+ // @column() tenantId!: number;
12
+ // }
13
+ //
14
+ // Configure the tenant FK column by overriding the static field (default
15
+ // `tenant_id`):
16
+ //
17
+ // protected static tenantColumn = "org_id";
18
+ //
19
+ // It registers two behaviours against the model:
20
+ // 1. Query scoping — every SELECT/UPDATE/DELETE gets `WHERE <col> = <tenant id>`
21
+ // via the ORM global-scope system. Outside a tenant context the scope matches
22
+ // *nothing*, so a missing boundary is an empty result, never every tenant's rows.
23
+ // 2. Create injection — inside a boundary the tenant column is set from the context and
24
+ // is authoritative, so a client-supplied `tenant_id` cannot redirect the write.
25
+ //
26
+ // const projects = await Project.all(); // … WHERE tenant_id = 7
27
+ // const all = await Project.query().withoutTenancy().get(); // bypass for admin
28
+ //
29
+ // Registration is deferred: the mixin enqueues the anonymous class at module-load
30
+ // (before the ORM context is live) and TenancyProvider.onBooted() drains the queue
31
+ // and wires each class to the live ORM context. This matches the Auditable pattern
32
+ // and avoids hooks/scopes being lost when registerAppScope resets the ORM context.
33
+
34
+ import { TenantContext } from "./TenantContext.ts";
35
+ import { _globalScopeRegistry, ModelQueryBuilder as MQB, HookRegistry } from "@zerotal/orm";
36
+ import type { ModelQueryBuilder } from "@zerotal/orm";
37
+ import type { BaseModel } from "@zerotal/orm";
38
+
39
+ const SCOPE_NAME = "__tenant__";
40
+ const DEFAULT_COLUMN = "tenant_id";
41
+
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic mixin base bound
43
+ type Constructor<T = object> = new (...args: any[]) => T;
44
+
45
+ /** Resolve the tenant column for a concrete model class (honours static override). */
46
+ function _tenantColumn(ctor: unknown): string {
47
+ return (ctor as { tenantColumn?: string })?.tenantColumn ?? DEFAULT_COLUMN;
48
+ }
49
+
50
+ /**
51
+ * Classes awaiting tenant-scope registration. `Tenantable` adds to this set at
52
+ * module-load; `TenancyProvider.onBooted()` drains it once the ORM context is live.
53
+ * After boot, new classes are registered immediately via `_markTenantProviderBooted()`.
54
+ */
55
+ export const _pendingTenantable = new Set<Function>();
56
+ let _tenantProviderBooted = false;
57
+ export function _markTenantProviderBooted(): void {
58
+ _tenantProviderBooted = true;
59
+ }
60
+
61
+ /**
62
+ * Wire the global query scope and beforeCreate hook onto a model class.
63
+ * Called by `TenancyProvider.onBooted()` for each `Tenantable`-composed class.
64
+ */
65
+ export function registerTenantScoping(cls: Function): void {
66
+ // ── 1. Global query scope ────────────────────────────────────────────────
67
+ const registry = _globalScopeRegistry();
68
+ let scopes = registry.get(cls as unknown as typeof BaseModel);
69
+ if (!scopes) {
70
+ scopes = new Map();
71
+ registry.set(cls as unknown as typeof BaseModel, scopes);
72
+ }
73
+ scopes.set(SCOPE_NAME, (qb: ModelQueryBuilder<BaseModel>) => {
74
+ const column = _tenantColumn((qb as unknown as { _ModelClass?: unknown })._ModelClass);
75
+ const tenantId = TenantContext.id();
76
+ if (tenantId !== null) {
77
+ qb.where(column, tenantId);
78
+ return;
79
+ }
80
+ // No tenant context: match nothing rather than everything. A tenant-scoped model
81
+ // queried outside a boundary — the apex domain, a queue worker, a resolver that found
82
+ // no tenant — otherwise returned every tenant's rows, and did it silently. Failing
83
+ // closed turns that into an obviously-empty result, and code that genuinely wants
84
+ // cross-tenant reach says so with `withoutTenancy()`.
85
+ qb.whereRaw("1 = 0");
86
+ });
87
+
88
+ // ── 2. Set the tenant column on create ───────────────────────────────────
89
+ HookRegistry.register(cls as unknown as typeof BaseModel, "beforeCreate", (model: BaseModel) => {
90
+ const column = _tenantColumn(model.constructor);
91
+ const rec = model as unknown as Record<string, unknown>;
92
+ const tenantId = TenantContext.id();
93
+ // Inside a boundary the context is authoritative and overwrites whatever is there.
94
+ // Fill-if-absent made the column mass-assignable: `Project.create(request.all())` with
95
+ // an attacker-supplied `tenant_id` wrote straight into another tenant, with the
96
+ // mass-assignment guard as the only thing in the way. Cross-tenant writes are done by
97
+ // running inside that tenant's context, not by naming it in the payload.
98
+ if (tenantId !== null) rec[column] = tenantId;
99
+ });
100
+ }
101
+
102
+ export function Tenantable<TBase extends Constructor>(Base: TBase) {
103
+ const cls = class extends Base {
104
+ /** Tenant foreign-key column. Override per model to change it. */
105
+ static tenantColumn = DEFAULT_COLUMN;
106
+ };
107
+
108
+ _pendingTenantable.add(cls);
109
+ if (_tenantProviderBooted) {
110
+ // Provider already booted — register into the live context now.
111
+ // The class stays in _pendingTenantable so future onBooted() calls
112
+ // (new Application instances) re-register it into the new context.
113
+ registerTenantScoping(cls);
114
+ }
115
+ return cls;
116
+ }
117
+
118
+ // ── Augment ModelQueryBuilder with withoutTenancy() ──────────────────────────
119
+
120
+ declare module "@zerotal/orm" {
121
+ interface ModelQueryBuilder<M extends BaseModel> {
122
+ /**
123
+ * Remove the tenant global scope for this query only.
124
+ *
125
+ * @example
126
+ * // Admin route — load all projects across tenants:
127
+ * const all = await Project.query().withoutTenancy().get();
128
+ */
129
+ withoutTenancy(): this;
130
+ }
131
+ }
132
+
133
+ // Patch the prototype once at module-load time.
134
+ (MQB.prototype as unknown as Record<string, unknown>)["withoutTenancy"] = function (
135
+ this: ModelQueryBuilder<BaseModel>,
136
+ ): ModelQueryBuilder<BaseModel> {
137
+ return this.withoutGlobalScope(SCOPE_NAME);
138
+ };
package/src/config.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Tenancy config factory.
3
+ *
4
+ * @example
5
+ * // config/tenancy.ts
6
+ * import { TenancyConfig, SubdomainResolver } from "@zerotal/tenancy";
7
+ *
8
+ * export default TenancyConfig({
9
+ * strategy: "single-database",
10
+ * resolvers: [new SubdomainResolver("myapp.com")],
11
+ * });
12
+ *
13
+ * The tenant registry (`tenants` table) is owned by the package — no `findTenant`
14
+ * callback or app tenant model needed. Reach tenants via the `Tenant` facade.
15
+ */
16
+
17
+ import { deepMerge } from "@zerotal/core";
18
+ import type { TenancyConfigShape } from "./types.ts";
19
+
20
+ // `resolvers` has no default — it is required from the caller. deepMerge passes the
21
+ // resolver instances through by reference (it only deep-clones plain objects/arrays).
22
+ const defaults: Partial<TenancyConfigShape> = {
23
+ strategy: "single-database",
24
+ tenantColumn: "tenant_id",
25
+ };
26
+
27
+ export function TenancyConfig(
28
+ config: Partial<TenancyConfigShape> & Pick<TenancyConfigShape, "resolvers">,
29
+ ): TenancyConfigShape {
30
+ return deepMerge(defaults as TenancyConfigShape, config);
31
+ }
32
+
33
+ // Register this package's config namespace for typed config() dot-paths.
34
+ declare module "@zerotal/core" {
35
+ interface ConfigRegistry {
36
+ tenancy: TenancyConfigShape;
37
+ }
38
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { HttpError, ZerotalError } from "@zerotal/core";
2
+
3
+ /**
4
+ * No tenant could be resolved from the request, or the resolved identifier
5
+ * doesn't match any tenant. Renders as a 404 (content-negotiated by the
6
+ * framework's exception handler).
7
+ */
8
+ export class TenantNotFoundError extends HttpError {
9
+ constructor(message = "This account doesn’t exist.") {
10
+ super(message, 404, "E_TENANT_NOT_FOUND");
11
+ }
12
+ }
13
+
14
+ /**
15
+ * The tenant exists but is not active (`is_active === false`). Renders as a 403.
16
+ */
17
+ export class TenantInactiveError extends HttpError {
18
+ constructor(message = "This account has been disabled.") {
19
+ super(message, 403, "E_TENANT_INACTIVE");
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Tenancy was used before it was configured — `TenancyProvider` isn't registered
25
+ * or `TenancyMiddleware` ran before boot. A misconfiguration (500).
26
+ */
27
+ export class TenancyNotConfiguredError extends HttpError {
28
+ constructor(
29
+ message = "Tenancy is not configured. Register TenancyProvider and apply TenancyMiddleware.",
30
+ ) {
31
+ super(message, 500, "E_TENANCY_NOT_CONFIGURED");
32
+ }
33
+ }
34
+
35
+ /**
36
+ * The current user tried an admin-only tenant action (`update`/`delete`) without
37
+ * being an admin member of the tenant. Renders as a 403. Use the `force*` variants
38
+ * to bypass the check from trusted server code.
39
+ */
40
+ export class TenantForbiddenError extends HttpError {
41
+ constructor(message = "You don’t have permission to manage this account.") {
42
+ super(message, 403, "E_TENANT_FORBIDDEN");
43
+ }
44
+ }
45
+
46
+ /**
47
+ * A required companion package or configuration for a tenancy feature is missing
48
+ * (e.g. `tenantDisk()` without a configured disk). A misconfiguration (500).
49
+ */
50
+ export class TenancyConfigError extends ZerotalError {
51
+ constructor(message: string) {
52
+ super(message, "E_TENANCY_CONFIG", 500);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * `TenantContext.get()` (or `Tenant.current()`) was called outside an active
58
+ * tenant boundary. A programming error — wrap the code in TenancyMiddleware, or
59
+ * use `TenantContext.getOrNull()` in shared (tenant + non-tenant) code.
60
+ */
61
+ export class NoActiveTenantError extends ZerotalError {
62
+ constructor(
63
+ message = "No active tenant. This code ran outside a tenant boundary — is TenancyMiddleware applied? (use TenantContext.getOrNull() in shared code).",
64
+ ) {
65
+ super(message, "E_NO_ACTIVE_TENANT", 500);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Raised when a caller-supplied storage key would escape the active tenant's directory.
71
+ *
72
+ * `tenantDisk()` prefixes keys with `tenants/<slug>/`, but the underlying LocalDriver confines
73
+ * paths to the *disk root*, not the tenant directory — so a key containing `..` resolved to a
74
+ * sibling tenant's folder and passed the driver's own check. Traversal is rejected at the
75
+ * prefixing layer instead.
76
+ */
77
+ export class TenantStoragePathError extends ZerotalError {
78
+ constructor(path: string, slug: string) {
79
+ super(
80
+ `Storage path "${path}" is not allowed for tenant "${slug}": it would resolve outside ` +
81
+ `the tenant's directory. Use a relative key with no ".." segments and no leading "/".`,
82
+ "E_TENANT_STORAGE_PATH",
83
+ 400,
84
+ );
85
+ }
86
+ }
@@ -0,0 +1,32 @@
1
+ import { createFacade } from "@zerotal/core";
2
+ import type { Tenancy } from "../Tenancy.ts";
3
+ import type { Tenant as TenantRecord } from "../types.ts";
4
+
5
+ declare module "@zerotal/core" {
6
+ interface ContainerBindings {
7
+ tenancy: Tenancy;
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Tenant facade — the primary entry point to tenancy.
13
+ *
14
+ * @example
15
+ * import { Tenant } from "@zerotal/tenancy";
16
+ *
17
+ * Tenant.current(); // the active tenant (or null)
18
+ * Tenant.check(); // is there an active, enabled tenant?
19
+ * await Tenant.members(); // hydrated User models
20
+ * await Tenant.update({ name }); // admin-gated
21
+ *
22
+ * (The `Tenant` *type* — the tenant record shape — is exported separately and
23
+ * coexists with this value, so `Tenant.current(): Tenant` reads naturally.)
24
+ */
25
+ export const Tenant = createFacade("tenancy");
26
+
27
+ /**
28
+ * The tenant **record** shape (`{ id, slug, name, isActive, … }`). Shares the `Tenant`
29
+ * name with the facade value above — value and type namespaces coexist — so both
30
+ * `Tenant.current()` (value) and `: Tenant` (type) read naturally from one import.
31
+ */
32
+ export type Tenant = TenantRecord;
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ // @zerotal/tenancy — public API
2
+
3
+ // Facade + service (the primary entry point). The `Tenant` export is both the facade
4
+ // *value* and the tenant-record *type* (they share the name from one module).
5
+ export { Tenant } from "./facades/Tenant.ts";
6
+ export { Tenancy } from "./Tenancy.ts";
7
+ export type { TenantDeletedHook } from "./Tenancy.ts";
8
+ export { TenantModel } from "./TenantModel.ts";
9
+
10
+ // Core
11
+ export { TenantContext } from "./TenantContext.ts";
12
+ // TenancyMiddleware is intentionally NOT exported — TenancyProvider registers it globally.
13
+ // Apps gate tenant-required routes with EnsureTenancyMiddleware instead.
14
+ export { EnsureTenancyMiddleware } from "./EnsureTenancyMiddleware.ts";
15
+ export { TenancyProvider } from "./provider/TenancyProvider.ts";
16
+ export { TenantManager } from "./TenantManager.ts";
17
+ export type { TenantManagerOptions } from "./TenantManager.ts";
18
+
19
+ // Resolvers
20
+ export { SubdomainResolver } from "./resolvers/SubdomainResolver.ts";
21
+ export { HeaderResolver } from "./resolvers/HeaderResolver.ts";
22
+ export { PathResolver } from "./resolvers/PathResolver.ts";
23
+ export { RouteParamResolver } from "./resolvers/RouteParamResolver.ts";
24
+ export { AuthResolver } from "./resolvers/AuthResolver.ts";
25
+
26
+ // Model mixin
27
+ export { Tenantable } from "./Tenantable.ts";
28
+
29
+ // Storage / Cache helpers
30
+ export { tenantDisk, tenantCache } from "./tenantStorage.ts";
31
+
32
+ // Config
33
+ export { TenancyConfig } from "./config.ts";
34
+
35
+ // Errors
36
+ export {
37
+ TenantNotFoundError,
38
+ TenantInactiveError,
39
+ TenantForbiddenError,
40
+ TenancyNotConfiguredError,
41
+ TenancyConfigError,
42
+ TenantStoragePathError,
43
+ NoActiveTenantError,
44
+ } from "./errors.ts";
45
+
46
+ // Types
47
+ // Note: the `Tenant` record type is exported from ./facades/Tenant.ts (alongside the
48
+ // facade value), so it is intentionally absent here.
49
+ export type {
50
+ MultiDbTenant,
51
+ TenantResolver,
52
+ TenantResolverResult,
53
+ TenancyStrategy,
54
+ TenancyConfigShape,
55
+ } from "./types.ts";