@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog — @zerotal/tenancy
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `beta`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.0.0] — 2026-08-05
|
|
12
|
+
|
|
13
|
+
_First public release._
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Moved service provider to `src/provider/`.
|
|
18
|
+
- Config factory renamed to `TenancyConfig` (PascalCase) with a deprecated `tenancyConfig` alias.
|
|
19
|
+
- Added test suite covering tenant isolation.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# @zerotal/tenancy
|
|
2
|
+
|
|
3
|
+
> First-class multi-tenancy: resolve the active tenant per request and scope ORM, storage, and cache.
|
|
4
|
+
|
|
5
|
+
Resolves the active tenant from every incoming request and makes it available
|
|
6
|
+
everywhere in the call stack — ORM queries, storage paths, and cache keys — with
|
|
7
|
+
no manual thread-through. Supports both single-database (a `tenant_id` column,
|
|
8
|
+
scoped automatically by `Tenantable`) and multi-database (a connection per tenant,
|
|
9
|
+
routed automatically from a one-line `connect` factory) strategies — in both, your
|
|
10
|
+
models "just work" with no per-query wiring. **Beta** — APIs are stable but rough
|
|
11
|
+
edges remain.
|
|
12
|
+
|
|
13
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add @zerotal/tenancy
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
Register the provider in `bootstrap/providers.ts`:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { TenancyProvider } from "@zerotal/tenancy";
|
|
27
|
+
import tenancyConfig from "../config/tenancy.ts";
|
|
28
|
+
|
|
29
|
+
export default [
|
|
30
|
+
// …your other providers
|
|
31
|
+
TenancyProvider.withConfig(tenancyConfig),
|
|
32
|
+
];
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Configure it in `config/tenancy.ts`:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// config/tenancy.ts
|
|
39
|
+
import { TenancyConfig, SubdomainResolver } from "@zerotal/tenancy";
|
|
40
|
+
import { env } from "@zerotal/core";
|
|
41
|
+
|
|
42
|
+
export default TenancyConfig({
|
|
43
|
+
strategy: env("TENANCY_STRATEGY", "single-database"),
|
|
44
|
+
tenantColumn: "tenant_id",
|
|
45
|
+
resolvers: [new SubdomainResolver("myapp.com")],
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The tenant registry is **owned by the package** — the `tenants` and `tenant_members`
|
|
50
|
+
tables are provisioned automatically on boot (no migration, no app `Tenant` model,
|
|
51
|
+
no `findTenant` callback). Reach tenants through the `Tenant` facade.
|
|
52
|
+
|
|
53
|
+
Then add `TenancyMiddleware` to the global pipeline (or a route group):
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { TenancyMiddleware } from "@zerotal/tenancy";
|
|
57
|
+
|
|
58
|
+
Application.create({ providers }).use([TenancyMiddleware]);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
Compose `Tenantable` via `BaseModelWith` onto any tenant-owned model (the same flat
|
|
64
|
+
form used for every other mixin). Every query is scoped with
|
|
65
|
+
`WHERE tenant_id = <current tenant>` and `create()` injects the `tenant_id`:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { BaseModelWith, column, table } from "@zerotal/orm";
|
|
69
|
+
import { Tenantable } from "@zerotal/tenancy";
|
|
70
|
+
|
|
71
|
+
@table("projects")
|
|
72
|
+
export class Project extends BaseModelWith(Tenantable) {
|
|
73
|
+
@column() name!: string;
|
|
74
|
+
@column() tenantId!: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Inside a TenancyMiddleware boundary (tenant id = 7):
|
|
78
|
+
await Project.all(); // SELECT * FROM projects WHERE tenant_id = 7
|
|
79
|
+
await Project.create({ name: "A" }); // INSERT … (tenant_id, name) VALUES (7, 'A')
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Bypass scoping when you need cross-tenant access, or run a job under a specific
|
|
83
|
+
tenant:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { TenantContext } from "@zerotal/tenancy";
|
|
87
|
+
|
|
88
|
+
await Project.query().withoutTenancy().get(); // all tenants
|
|
89
|
+
|
|
90
|
+
await TenantContext.run(tenant, async () => {
|
|
91
|
+
// queries here run scoped to `tenant`
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Access the active tenant anywhere in the async call chain, and scope storage and
|
|
96
|
+
cache to it:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { TenantContext, tenantDisk, tenantCache } from "@zerotal/tenancy";
|
|
100
|
+
|
|
101
|
+
const tenant = TenantContext.get(); // throws outside a boundary
|
|
102
|
+
const id = TenantContext.id(); // number | null
|
|
103
|
+
|
|
104
|
+
await tenantDisk().put("avatars/alice.jpg", buffer); // → tenants/<slug>/avatars/alice.jpg
|
|
105
|
+
await tenantCache().set("dashboard:stats", data, 300); // key: tenant:<slug>:dashboard:stats
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### The `Tenant` facade
|
|
109
|
+
|
|
110
|
+
The `Tenant` facade carries the day-to-day tenancy API — the current tenant, tenant
|
|
111
|
+
CRUD, and membership (the `tenant_members` pivot). Mutations are admin-gated against
|
|
112
|
+
the authenticated user; `force*` variants bypass the check for trusted code.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { Tenant } from "@zerotal/tenancy";
|
|
116
|
+
|
|
117
|
+
// Current tenant (inside a TenancyMiddleware boundary)
|
|
118
|
+
Tenant.current(); // the active tenant, or null
|
|
119
|
+
Tenant.check(); // true when there is an active, enabled tenant
|
|
120
|
+
Tenant.id(); // number | null
|
|
121
|
+
|
|
122
|
+
// Lifecycle — the authenticated creator becomes the first admin member
|
|
123
|
+
const acme = await Tenant.create({ slug: "acme", name: "Acme Inc." });
|
|
124
|
+
await Tenant.update({ name: "Acme LLC" }); // requires the current user to be an admin
|
|
125
|
+
await Tenant.forceUpdate({ name: "Acme LLC" }); // bypasses the admin check
|
|
126
|
+
await Tenant.delete(); // admin-gated; removes the tenant + its memberships
|
|
127
|
+
await Tenant.forceDelete();
|
|
128
|
+
|
|
129
|
+
// Membership — members() hydrates full User models from the ORM registry
|
|
130
|
+
await Tenant.addMember(userId, { admin: true });
|
|
131
|
+
await Tenant.members(); // User[]
|
|
132
|
+
await Tenant.member(); // the authenticated user as a member, or null
|
|
133
|
+
await Tenant.isMember(userId);
|
|
134
|
+
await Tenant.isMemberAdmin(userId);
|
|
135
|
+
await Tenant.promote(userId);
|
|
136
|
+
await Tenant.demote(userId);
|
|
137
|
+
await Tenant.removeMember(userId);
|
|
138
|
+
|
|
139
|
+
// Reads + running work under a tenant
|
|
140
|
+
await Tenant.find("acme"); // by slug
|
|
141
|
+
await Tenant.findById(1);
|
|
142
|
+
await Tenant.exists("acme");
|
|
143
|
+
await Tenant.all();
|
|
144
|
+
await Tenant.forId(1, () => Project.all()); // run a callback inside tenant #1's context
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Membership hydration needs an authenticatable `User` model — compose
|
|
148
|
+
`Authenticatable` from `@zerotal/auth` on it. Tenancy discovers it via the ORM
|
|
149
|
+
registry, so there's no hard dependency between the two packages.
|
|
150
|
+
|
|
151
|
+
Clean up tenant-owned data when a tenant is deleted:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import { Tenant } from "@zerotal/tenancy";
|
|
155
|
+
|
|
156
|
+
Tenant.onTenantDeleted(async (tenant) => {
|
|
157
|
+
await Project.query().withoutTenancy().where("tenant_id", tenant.id).delete();
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Multi-database
|
|
162
|
+
|
|
163
|
+
For the database-per-tenant strategy, set `strategy: "multi-database"` and supply a
|
|
164
|
+
one-line `connect` factory. Every model query inside a tenant boundary is then routed
|
|
165
|
+
to that tenant's connection automatically — no `TenantManager` wiring, no raw SQL,
|
|
166
|
+
the same `Project.all()` you already write:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// config/tenancy.ts
|
|
170
|
+
import { TenancyConfig, SubdomainResolver } from "@zerotal/tenancy";
|
|
171
|
+
import { SQL } from "bun";
|
|
172
|
+
|
|
173
|
+
export default TenancyConfig({
|
|
174
|
+
strategy: "multi-database",
|
|
175
|
+
resolvers: [new SubdomainResolver("myapp.com")],
|
|
176
|
+
connect: (tenant) => new SQL(`file:./storage/tenants/${tenant.database}`),
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Routing is `AsyncLocalStorage`-scoped, so concurrent requests for different tenants
|
|
181
|
+
stay isolated. See the [tenancy guide](../../docs/tenancy.md#multi-database-strategy)
|
|
182
|
+
for details.
|
|
183
|
+
|
|
184
|
+
## Exports
|
|
185
|
+
|
|
186
|
+
- `Tenant` — the facade: current tenant, tenant CRUD, and membership.
|
|
187
|
+
- `Tenancy` — the service behind the facade (bound as `"tenancy"`).
|
|
188
|
+
- `TenantModel` — the internal tenant record (owned by the package; reach it via `Tenant`).
|
|
189
|
+
- `TenantDeletedHook` — type of the `Tenant.onTenantDeleted` callback.
|
|
190
|
+
- `TenantContext` — access the active tenant (`get`, `tryGet`, `id`, `slug`, `run`).
|
|
191
|
+
- `TenancyMiddleware` — resolves the tenant per request.
|
|
192
|
+
- `TenancyProvider` — wires tenancy (`.withConfig(...)`).
|
|
193
|
+
- `TenantManager` / `TenantManagerOptions` — per-tenant connections (multi-database).
|
|
194
|
+
- Resolvers: `SubdomainResolver`, `HeaderResolver`, `PathResolver`.
|
|
195
|
+
- `Tenantable` — ORM mixin that scopes queries to the current tenant.
|
|
196
|
+
- `tenantDisk`, `tenantCache` — tenant-scoped storage and cache helpers.
|
|
197
|
+
- `TenancyConfig`, `tenancyConfig` — config factory.
|
|
198
|
+
- Errors: `TenantNotFoundError`, `TenantInactiveError`, `TenantForbiddenError`,
|
|
199
|
+
`TenancyNotConfiguredError`, `TenancyConfigError`, `NoActiveTenantError`.
|
|
200
|
+
- Types: `Tenant`, `MultiDbTenant`, `TenantResolver`, `TenantResolverResult`,
|
|
201
|
+
`TenancyStrategy`, `TenancyConfigShape`.
|
|
202
|
+
|
|
203
|
+
## Documentation
|
|
204
|
+
|
|
205
|
+
- [Multi-Tenancy](../../docs/tenancy.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/tenancy",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "beta",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"src",
|
|
16
|
+
"!src/**/*.test.ts",
|
|
17
|
+
"!src/**/*.test.tsx",
|
|
18
|
+
"!src/**/*.spec.ts",
|
|
19
|
+
"!src/**/__fixtures__/**"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.3.14"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@zerotal/core": "1.0.0",
|
|
33
|
+
"@zerotal/orm": "1.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"typescript": "^5.8.0"
|
|
37
|
+
},
|
|
38
|
+
"description": "Multi-tenancy primitives for Zerotal applications.",
|
|
39
|
+
"keywords": [
|
|
40
|
+
"zerotal",
|
|
41
|
+
"bun",
|
|
42
|
+
"typescript",
|
|
43
|
+
"framework"
|
|
44
|
+
],
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
48
|
+
"directory": "packages/tenancy"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/tenancy#readme",
|
|
51
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
52
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EnsureTenancyMiddleware — gate a route on an active tenant.
|
|
3
|
+
*
|
|
4
|
+
* `TenancyMiddleware` (registered globally by `TenancyProvider`) has already tried to
|
|
5
|
+
* resolve and open the tenant boundary by the time this runs; this middleware simply
|
|
6
|
+
* asserts that it succeeded. If there is no active tenant it responds **404**; otherwise
|
|
7
|
+
* the tenant is guaranteed to be present for the rest of the request.
|
|
8
|
+
*
|
|
9
|
+
* Apply it to route groups that only make sense inside a tenant — e.g. everything under a
|
|
10
|
+
* `/:tenancy` prefix, or an `acme.myapp.com` subdomain group:
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // bootstrap/app.ts
|
|
14
|
+
* .fileBasedRouting({
|
|
15
|
+
* dir: basePath("app/flow/pages/[tenancy]"),
|
|
16
|
+
* prefix: "/:tenancy",
|
|
17
|
+
* middleware: [EnsureTenancyMiddleware],
|
|
18
|
+
* });
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { BaseMiddleware, type NextFn, type HttpContext } from "@zerotal/core";
|
|
22
|
+
import { TenantContext } from "./TenantContext.ts";
|
|
23
|
+
import { TenantNotFoundError } from "./errors.ts";
|
|
24
|
+
|
|
25
|
+
export class EnsureTenancyMiddleware extends BaseMiddleware {
|
|
26
|
+
protected options: {} = {};
|
|
27
|
+
|
|
28
|
+
async handle(_http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
29
|
+
if (TenantContext.tryGet() == null) throw new TenantNotFoundError();
|
|
30
|
+
return next();
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/Tenancy.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { RequestContext } from "@zerotal/core";
|
|
2
|
+
import { DB, modelsByName, type BaseModel } from "@zerotal/orm";
|
|
3
|
+
import { TenantContext } from "./TenantContext.ts";
|
|
4
|
+
import { TenantModel } from "./TenantModel.ts";
|
|
5
|
+
import { NoActiveTenantError, TenantForbiddenError, TenancyConfigError } from "./errors.ts";
|
|
6
|
+
import type { Tenant } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
/** The `tenant_members` pivot — central table linking users to tenants. */
|
|
9
|
+
/**
|
|
10
|
+
* Pivot table holding tenant membership.
|
|
11
|
+
* @internal exported so {@link TenancyMiddleware} can check membership without going
|
|
12
|
+
* through the container-resolved facade — the check must hold on every request, including
|
|
13
|
+
* ones served before or outside a fully-booted container.
|
|
14
|
+
*/
|
|
15
|
+
export const MEMBERS_TABLE = "tenant_members";
|
|
16
|
+
|
|
17
|
+
/** Same brand `@zerotal/auth` stamps on the User model — referenced via `Symbol.for`
|
|
18
|
+
* so tenancy can find the user model without depending on the auth package. */
|
|
19
|
+
const AUTHENTICATABLE = Symbol.for("zerotal.auth.authenticatable");
|
|
20
|
+
|
|
21
|
+
type UserCtor = typeof BaseModel & {
|
|
22
|
+
find(id: number): Promise<BaseModel | null>;
|
|
23
|
+
query(): { whereIn(c: string, v: unknown[]): { get(): Promise<BaseModel[]> } };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Hook fired after a tenant is deleted, for app-level cleanup of tenant-owned data. */
|
|
27
|
+
export type TenantDeletedHook = (tenant: Tenant) => void | Promise<void>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tenancy service — the engine behind the `Tenant` facade. Implements the current-
|
|
31
|
+
* tenant lifecycle, tenant CRUD against the internal `TenantModel`, and membership
|
|
32
|
+
* (the `tenant_members` pivot) with admin-gated mutations.
|
|
33
|
+
*
|
|
34
|
+
* Resolved from the container as `"tenancy"`; use it via the `Tenant` facade.
|
|
35
|
+
*/
|
|
36
|
+
export class Tenancy {
|
|
37
|
+
private readonly _onDeleted: TenantDeletedHook[] = [];
|
|
38
|
+
|
|
39
|
+
// ── Current tenant ──────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** The active tenant, or null outside a tenant boundary. */
|
|
42
|
+
current(): Tenant | null {
|
|
43
|
+
return TenantContext.tryGet() ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when there is an active, enabled tenant. */
|
|
47
|
+
check(): boolean {
|
|
48
|
+
const t = this.current();
|
|
49
|
+
return !!t && t.isActive;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The active tenant's id, or null. */
|
|
53
|
+
id(): number | null {
|
|
54
|
+
return this.current()?.id ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The active tenant's slug, or null. */
|
|
58
|
+
slug(): string | null {
|
|
59
|
+
return this.current()?.slug ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @internal The active tenant, or throw if outside a tenant boundary. */
|
|
63
|
+
private requireCurrent(): Tenant {
|
|
64
|
+
const t = TenantContext.tryGet();
|
|
65
|
+
if (!t) throw new NoActiveTenantError();
|
|
66
|
+
return t;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Running work inside a tenant ─────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/** Run `cb` with `tenant` as the active tenant (jobs, CLI, cross-tenant loops). */
|
|
72
|
+
run<T>(tenant: Tenant, cb: () => T): T {
|
|
73
|
+
return TenantContext.run(tenant, cb);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Load the tenant with `id` and run `cb` inside its context. */
|
|
77
|
+
async forId<T>(id: number, cb: () => T | Promise<T>): Promise<T> {
|
|
78
|
+
const tenant = await TenantModel.findOrFail(id);
|
|
79
|
+
return TenantContext.run(tenant as unknown as Tenant, () => cb());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Reads ─────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
find(slug: string): Promise<TenantModel | null> {
|
|
85
|
+
return TenantModel.query().where("slug", slug).first();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
findById(id: number): Promise<TenantModel | null> {
|
|
89
|
+
return TenantModel.find(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
all(): Promise<TenantModel[]> {
|
|
93
|
+
return TenantModel.query().get();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async exists(slug: string): Promise<boolean> {
|
|
97
|
+
return (await this.find(slug)) !== null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Tenant lifecycle ─────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create a new tenant. If a user is authenticated on the current request, they
|
|
104
|
+
* become the tenant's first **admin** member.
|
|
105
|
+
*/
|
|
106
|
+
async create(data: Partial<Tenant> & { slug: string; name: string }): Promise<TenantModel> {
|
|
107
|
+
const tenant = await TenantModel.create({ isActive: true, ...data } as never);
|
|
108
|
+
const uid = this.currentUserId();
|
|
109
|
+
if (uid != null) {
|
|
110
|
+
await DB.table(MEMBERS_TABLE).insert({ tenant_id: tenant.id, user_id: uid, is_admin: 1 });
|
|
111
|
+
}
|
|
112
|
+
return tenant;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Update the current tenant. Throws unless the current user is an admin member. */
|
|
116
|
+
async update(data: Partial<Tenant>): Promise<TenantModel> {
|
|
117
|
+
if (!(await this.isMemberAdmin())) throw new TenantForbiddenError();
|
|
118
|
+
return this.forceUpdate(data);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Update the current tenant, bypassing the admin check. Use from trusted code only. */
|
|
122
|
+
async forceUpdate(data: Partial<Tenant>): Promise<TenantModel> {
|
|
123
|
+
const model = await TenantModel.findOrFail(this.requireCurrent().id);
|
|
124
|
+
await model.fill(data as never).save();
|
|
125
|
+
return model;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Delete the current tenant and its memberships. Throws unless current user is admin. */
|
|
129
|
+
async delete(): Promise<void> {
|
|
130
|
+
if (!(await this.isMemberAdmin())) throw new TenantForbiddenError();
|
|
131
|
+
return this.forceDelete();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Delete the current tenant and its memberships, bypassing the admin check. */
|
|
135
|
+
async forceDelete(): Promise<void> {
|
|
136
|
+
const tenant = this.requireCurrent();
|
|
137
|
+
await DB.table(MEMBERS_TABLE).where("tenant_id", tenant.id).delete();
|
|
138
|
+
const model = await TenantModel.find(tenant.id);
|
|
139
|
+
if (model) await model.delete();
|
|
140
|
+
for (const hook of this._onDeleted) await hook(tenant);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Register a cleanup hook fired after a tenant is deleted (drop tenant-owned data). */
|
|
144
|
+
onTenantDeleted(hook: TenantDeletedHook): void {
|
|
145
|
+
this._onDeleted.push(hook);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Membership ──────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** The authenticated user as a member of the current tenant, or null if not a member. */
|
|
151
|
+
async member<T = BaseModel>(): Promise<T | null> {
|
|
152
|
+
const uid = this.currentUserId();
|
|
153
|
+
if (uid == null || !(await this.isMember(uid))) return null;
|
|
154
|
+
return (await this.userModel().find(uid)) as T | null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Every member of the current tenant, hydrated as User models. */
|
|
158
|
+
async members<T = BaseModel>(): Promise<T[]> {
|
|
159
|
+
const tid = this.requireCurrent().id;
|
|
160
|
+
const rows = await DB.table(MEMBERS_TABLE).where("tenant_id", tid).get<{ user_id: number }>();
|
|
161
|
+
const ids = rows.map((r) => r.user_id);
|
|
162
|
+
if (ids.length === 0) return [];
|
|
163
|
+
return (await this.userModel().query().whereIn("id", ids).get()) as T[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Number of members in the current tenant. */
|
|
167
|
+
async memberCount(): Promise<number> {
|
|
168
|
+
return DB.table(MEMBERS_TABLE).where("tenant_id", this.requireCurrent().id).count();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True when the user (default: current authenticated user) belongs to the current tenant. */
|
|
172
|
+
async isMember(userId: number | null = this.currentUserId()): Promise<boolean> {
|
|
173
|
+
const tid = this.id();
|
|
174
|
+
if (userId == null || tid == null) return false;
|
|
175
|
+
const row = await DB.table(MEMBERS_TABLE)
|
|
176
|
+
.where("tenant_id", tid)
|
|
177
|
+
.where("user_id", userId)
|
|
178
|
+
.first();
|
|
179
|
+
return !!row;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** True when the user (default: current authenticated user) is an admin of the current tenant. */
|
|
183
|
+
async isMemberAdmin(userId: number | null = this.currentUserId()): Promise<boolean> {
|
|
184
|
+
const tid = this.id();
|
|
185
|
+
if (userId == null || tid == null) return false;
|
|
186
|
+
const row = await DB.table(MEMBERS_TABLE)
|
|
187
|
+
.where("tenant_id", tid)
|
|
188
|
+
.where("user_id", userId)
|
|
189
|
+
.first<{ is_admin: number | boolean }>();
|
|
190
|
+
return !!row && !!row.is_admin;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Add `userId` to the current tenant (idempotent). Pass `{ admin: true }` for an admin. */
|
|
194
|
+
async addMember(userId: number, opts: { admin?: boolean } = {}): Promise<void> {
|
|
195
|
+
const tid = this.requireCurrent().id;
|
|
196
|
+
const flag = opts.admin ? 1 : 0;
|
|
197
|
+
const existing = await DB.table(MEMBERS_TABLE)
|
|
198
|
+
.where("tenant_id", tid)
|
|
199
|
+
.where("user_id", userId)
|
|
200
|
+
.first();
|
|
201
|
+
if (existing) {
|
|
202
|
+
if (opts.admin !== undefined) {
|
|
203
|
+
await DB.table(MEMBERS_TABLE)
|
|
204
|
+
.where("tenant_id", tid)
|
|
205
|
+
.where("user_id", userId)
|
|
206
|
+
.update({ is_admin: flag });
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
await DB.table(MEMBERS_TABLE).insert({ tenant_id: tid, user_id: userId, is_admin: flag });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Remove `userId` from the current tenant. */
|
|
214
|
+
async removeMember(userId: number): Promise<void> {
|
|
215
|
+
await DB.table(MEMBERS_TABLE)
|
|
216
|
+
.where("tenant_id", this.requireCurrent().id)
|
|
217
|
+
.where("user_id", userId)
|
|
218
|
+
.delete();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Grant the member admin rights in the current tenant. */
|
|
222
|
+
promote(userId: number): Promise<void> {
|
|
223
|
+
return this.addMember(userId, { admin: true });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Revoke the member's admin rights in the current tenant. */
|
|
227
|
+
demote(userId: number): Promise<void> {
|
|
228
|
+
return this.addMember(userId, { admin: false });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/** The authenticated user's id on the active request, read structurally (no auth dep). */
|
|
234
|
+
private currentUserId(): number | null {
|
|
235
|
+
const user = (RequestContext.tryGet() as { user?: { id?: number } } | undefined)?.user;
|
|
236
|
+
return user?.id ?? null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Resolve the app's authenticatable (User) model from the ORM registry. */
|
|
240
|
+
private userModel(): UserCtor {
|
|
241
|
+
let first: UserCtor | undefined;
|
|
242
|
+
for (const model of modelsByName.values()) {
|
|
243
|
+
if ((model as unknown as Record<symbol, unknown>)[AUTHENTICATABLE]) {
|
|
244
|
+
if ((model as { name?: string }).name === "User") return model as unknown as UserCtor;
|
|
245
|
+
first ??= model as unknown as UserCtor;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!first) {
|
|
249
|
+
throw new TenancyConfigError(
|
|
250
|
+
"Tenant membership requires an authenticatable User model. Compose `Authenticatable` " +
|
|
251
|
+
"(from @zerotal/auth) on your User model.",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return first;
|
|
255
|
+
}
|
|
256
|
+
}
|