@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
+ * 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
+ }