@blixis-io/tenancy 0.1.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/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +118 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Voeten
|
|
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,26 @@
|
|
|
1
|
+
# `@blixis-io/tenancy`
|
|
2
|
+
|
|
3
|
+
Request-scoped multi-tenant access control (`TenantScopedGuard`, a fail-closed `tenantScope()` query helper). Mechanism only — no Organization/Space/Membership data model — reusable by any multi-tenant app.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @blixis-io/tenancy @blixis-io/http @blixis-io/core @blixis-io/di
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { defineTenancyModule } from "@blixis-io/tenancy";
|
|
11
|
+
|
|
12
|
+
export const { TenancyModule, TenantScopedGuard, getTenant, requireTenant } = defineTenancyModule({
|
|
13
|
+
getActor: (ctx) => getCurrentUser(ctx),
|
|
14
|
+
resolveMembership: (actor, spaceId) => membershipService.find(actor.id, spaceId),
|
|
15
|
+
});
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
@UseGuards(AuthGuard, TenantScopedGuard)
|
|
20
|
+
@Controller("spaces/:spaceId/entries")
|
|
21
|
+
class EntriesController {}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Fail-closed by design: an unknown or inaccessible space returns `404`, never `403` — a non-member can't tell a space exists at all.
|
|
25
|
+
|
|
26
|
+
Part of [Blixis Framework](https://github.com/blixis-io/framework) — full docs: [Tenancy](https://blixis-io.github.io/framework/concepts/tenancy/) · [API reference](https://blixis-io.github.io/framework/reference/blixis-tenancy/).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { DynamicModule } from "@blixis-io/core";
|
|
2
|
+
import { Class } from "@blixis-io/di";
|
|
3
|
+
import { CanActivate, RequestContext } from "@blixis-io/http";
|
|
4
|
+
import { SQL } from "drizzle-orm";
|
|
5
|
+
import { PgColumn } from "drizzle-orm/pg-core";
|
|
6
|
+
//#region src/errors.d.ts
|
|
7
|
+
/**
|
|
8
|
+
* Thrown by `requireTenant()` and `tenantScope()` when there's no tenant in
|
|
9
|
+
* the current request context — the fail-closed guarantee: neither ever
|
|
10
|
+
* falls back to returning unscoped data.
|
|
11
|
+
*/
|
|
12
|
+
export declare class MissingTenantError extends Error {
|
|
13
|
+
constructor();
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/module.d.ts
|
|
17
|
+
interface TenantContext {
|
|
18
|
+
organizationId: string;
|
|
19
|
+
spaceId: string;
|
|
20
|
+
/** Defaults to `"main"` when the route has no `:environment` param. */
|
|
21
|
+
environmentId: string;
|
|
22
|
+
role: string;
|
|
23
|
+
}
|
|
24
|
+
interface Membership {
|
|
25
|
+
organizationId: string;
|
|
26
|
+
role: string;
|
|
27
|
+
}
|
|
28
|
+
interface TenancyModuleOptions<Actor> {
|
|
29
|
+
/** Reads the current actor out of RequestContext — app-provided, since this package doesn't know which auth mechanism populated it. */
|
|
30
|
+
getActor: (ctx: RequestContext) => Actor | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Given the actor and a `spaceId` (from the route), returns their
|
|
33
|
+
* membership, or `null`/`undefined` if they have none. Called on every
|
|
34
|
+
* tenant-scoped request — a `null` result becomes a `404`, never a
|
|
35
|
+
* `403`, so a non-member can't tell a space exists at all.
|
|
36
|
+
*/
|
|
37
|
+
resolveMembership: (actor: Actor, spaceId: string) => Membership | null | undefined | Promise<Membership | null | undefined>;
|
|
38
|
+
}
|
|
39
|
+
interface TenancyForRootOptions {
|
|
40
|
+
/** Makes `TenantScopedGuard` visible to every module without each one importing this one directly. Defaults to `false`. */
|
|
41
|
+
global?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** For a resource loaded another way (e.g. by id, via another module's service) — confirms it actually belongs to the current tenant. Mismatch is a 404, not a 403, same fail-closed reasoning as everywhere else here. Doesn't need any per-`defineTenancyModule()` state, so it's a plain module-level export, not part of the closure. */
|
|
44
|
+
export declare function assertSameTenant(resourceSpaceId: string, tenant: TenantContext): void;
|
|
45
|
+
/**
|
|
46
|
+
* Builds a `TenantScopedGuard` bound to one membership-resolution strategy.
|
|
47
|
+
* Owns the request-scoping *mechanism* only — no Organization/Space/
|
|
48
|
+
* Membership data model or CRUD lives here; that's app-specific CMS domain
|
|
49
|
+
* data, supplied via `resolveMembership`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function defineTenancyModule<Actor>(options: TenancyModuleOptions<Actor>): {
|
|
52
|
+
TenancyModule: {
|
|
53
|
+
forRoot(forRootOptions?: TenancyForRootOptions): DynamicModule;
|
|
54
|
+
};
|
|
55
|
+
TenantScopedGuard: Class<CanActivate>;
|
|
56
|
+
getTenant: (ctx: RequestContext) => TenantContext | undefined;
|
|
57
|
+
requireTenant: (ctx: RequestContext) => TenantContext;
|
|
58
|
+
assertSameTenant: (resourceSpaceId: string, tenant: TenantContext) => void;
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/scope.d.ts
|
|
62
|
+
/**
|
|
63
|
+
* A `where` predicate scoping a query to `tenant`'s space — throws
|
|
64
|
+
* `MissingTenantError` instead of silently returning an unscoped query
|
|
65
|
+
* when `tenant` is `undefined`. Compose with other conditions via
|
|
66
|
+
* Drizzle's own `and(tenantScope(posts.spaceId, tenant), ...)`.
|
|
67
|
+
*/
|
|
68
|
+
export declare function tenantScope(spaceIdColumn: PgColumn, tenant: TenantContext | undefined): SQL;
|
|
69
|
+
/** Spread into an app's own `pgTable()` definition — `organizationId`/`spaceId` columns, present on every tenant-scoped table, including child tables (no join needed to scope a query). */
|
|
70
|
+
export declare function tenantColumns(): {
|
|
71
|
+
organizationId: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgUUIDBuilderInitial<"organization_id">>;
|
|
72
|
+
spaceId: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgUUIDBuilderInitial<"space_id">>;
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
export type { Membership, TenancyForRootOptions, TenancyModuleOptions, TenantContext };
|
|
76
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/errors.ts","../src/module.ts","../src/scope.ts"],"mappings":";;;;;;;;;;;qBAKa,2BAA2B;EACtC;;;;UCKe;EACf;EACA;;EAEA;EACA;;UAGe;EACf;EACA;;UAGe,qBAAqB;;EAEpC,WAAW,KAAK,mBAAmB;;;;;;;EAOnC,oBACE,OAAO,OACP,oBACG,gCAAgC,QAAQ;;UAG9B;;EAEf;;;wBAUc,iBAAiB,yBAAyB,QAAQ;;;;;;;wBAYlD,oBAAoB,OAClC,SAAS,qBAAqB;EAE9B;IAAiB,QAAQ,iBAAiB,wBAAwB;;EAClE,mBAAmB,MAAM;EACzB,YAAY,KAAK,mBAAmB;EACpC,gBAAgB,KAAK,mBAAmB;EACxC,mBAAmB,yBAAyB,QAAQ;;;;;;;;;;wBC3DtC,YAAY,eAAe,UAAU,QAAQ,4BAA4B;;wBAQzE;EAEZ,sCAAc,sCAAA;EACd,+BAAO,sCAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Module } from "@blixis-io/core";
|
|
2
|
+
import { Injectable } from "@blixis-io/di";
|
|
3
|
+
import { NotFoundException, RequestContext, UnauthorizedException } from "@blixis-io/http";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { uuid } from "drizzle-orm/pg-core";
|
|
6
|
+
//#region src/errors.ts
|
|
7
|
+
/**
|
|
8
|
+
* Thrown by `requireTenant()` and `tenantScope()` when there's no tenant in
|
|
9
|
+
* the current request context — the fail-closed guarantee: neither ever
|
|
10
|
+
* falls back to returning unscoped data.
|
|
11
|
+
*/
|
|
12
|
+
var MissingTenantError = class extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super("No tenant in request context — apply TenantScopedGuard to this route first, or this was called outside a tenant-scoped request.");
|
|
15
|
+
this.name = "MissingTenantError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
|
|
20
|
+
function __decorateMetadata(k, v) {
|
|
21
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
|
|
25
|
+
function __decorate(decorators, target, key, desc) {
|
|
26
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
27
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
28
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
29
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/module.ts
|
|
33
|
+
let tenancyInstanceCounter = 0;
|
|
34
|
+
/** For a resource loaded another way (e.g. by id, via another module's service) — confirms it actually belongs to the current tenant. Mismatch is a 404, not a 403, same fail-closed reasoning as everywhere else here. Doesn't need any per-`defineTenancyModule()` state, so it's a plain module-level export, not part of the closure. */
|
|
35
|
+
function assertSameTenant(resourceSpaceId, tenant) {
|
|
36
|
+
if (resourceSpaceId !== tenant.spaceId) throw new NotFoundException();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Builds a `TenantScopedGuard` bound to one membership-resolution strategy.
|
|
40
|
+
* Owns the request-scoping *mechanism* only — no Organization/Space/
|
|
41
|
+
* Membership data model or CRUD lives here; that's app-specific CMS domain
|
|
42
|
+
* data, supplied via `resolveMembership`.
|
|
43
|
+
*/
|
|
44
|
+
function defineTenancyModule(options) {
|
|
45
|
+
var _TenancyModule;
|
|
46
|
+
const TENANT_KEY = `blixis.tenancy.tenant.${tenancyInstanceCounter++}`;
|
|
47
|
+
function getTenant(ctx) {
|
|
48
|
+
return ctx.get(TENANT_KEY);
|
|
49
|
+
}
|
|
50
|
+
function requireTenant(ctx) {
|
|
51
|
+
const tenant = getTenant(ctx);
|
|
52
|
+
if (!tenant) throw new MissingTenantError();
|
|
53
|
+
return tenant;
|
|
54
|
+
}
|
|
55
|
+
let TenantScopedGuard = class TenantScopedGuard {
|
|
56
|
+
constructor(ctx) {
|
|
57
|
+
this.ctx = ctx;
|
|
58
|
+
}
|
|
59
|
+
async canActivate({ params }) {
|
|
60
|
+
const spaceId = params["spaceId"];
|
|
61
|
+
if (!spaceId) throw new Error("TenantScopedGuard requires a :spaceId route param");
|
|
62
|
+
const actor = options.getActor(this.ctx);
|
|
63
|
+
if (!actor) throw new UnauthorizedException();
|
|
64
|
+
const membership = await options.resolveMembership(actor, spaceId);
|
|
65
|
+
if (!membership) throw new NotFoundException();
|
|
66
|
+
const tenant = {
|
|
67
|
+
organizationId: membership.organizationId,
|
|
68
|
+
spaceId,
|
|
69
|
+
environmentId: params["environment"] ?? "main",
|
|
70
|
+
role: membership.role
|
|
71
|
+
};
|
|
72
|
+
this.ctx.set(TENANT_KEY, tenant);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
TenantScopedGuard = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [typeof RequestContext === "undefined" ? Object : RequestContext])], TenantScopedGuard);
|
|
77
|
+
let TenancyModule = _TenancyModule = class TenancyModule {
|
|
78
|
+
static forRoot(forRootOptions = {}) {
|
|
79
|
+
return {
|
|
80
|
+
module: _TenancyModule,
|
|
81
|
+
providers: [TenantScopedGuard],
|
|
82
|
+
exports: [TenantScopedGuard],
|
|
83
|
+
global: forRootOptions.global ?? false
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
TenancyModule = _TenancyModule = __decorate([Module()], TenancyModule);
|
|
88
|
+
return {
|
|
89
|
+
TenancyModule,
|
|
90
|
+
TenantScopedGuard,
|
|
91
|
+
getTenant,
|
|
92
|
+
requireTenant,
|
|
93
|
+
assertSameTenant
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/scope.ts
|
|
98
|
+
/**
|
|
99
|
+
* A `where` predicate scoping a query to `tenant`'s space — throws
|
|
100
|
+
* `MissingTenantError` instead of silently returning an unscoped query
|
|
101
|
+
* when `tenant` is `undefined`. Compose with other conditions via
|
|
102
|
+
* Drizzle's own `and(tenantScope(posts.spaceId, tenant), ...)`.
|
|
103
|
+
*/
|
|
104
|
+
function tenantScope(spaceIdColumn, tenant) {
|
|
105
|
+
if (!tenant) throw new MissingTenantError();
|
|
106
|
+
return eq(spaceIdColumn, tenant.spaceId);
|
|
107
|
+
}
|
|
108
|
+
/** Spread into an app's own `pgTable()` definition — `organizationId`/`spaceId` columns, present on every tenant-scoped table, including child tables (no join needed to scope a query). */
|
|
109
|
+
function tenantColumns() {
|
|
110
|
+
return {
|
|
111
|
+
organizationId: uuid("organization_id").notNull(),
|
|
112
|
+
spaceId: uuid("space_id").notNull()
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
export { MissingTenantError, assertSameTenant, defineTenancyModule, tenantColumns, tenantScope };
|
|
117
|
+
|
|
118
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/errors.ts","../src/module.ts","../src/scope.ts"],"sourcesContent":["/**\n * Thrown by `requireTenant()` and `tenantScope()` when there's no tenant in\n * the current request context — the fail-closed guarantee: neither ever\n * falls back to returning unscoped data.\n */\nexport class MissingTenantError extends Error {\n constructor() {\n super(\n \"No tenant in request context — apply TenantScopedGuard to this route first, or this was called outside a tenant-scoped request.\",\n );\n this.name = \"MissingTenantError\";\n }\n}\n","import { Module, type DynamicModule } from \"@blixis-io/core\";\nimport { Injectable, type Class } from \"@blixis-io/di\";\nimport {\n NotFoundException,\n RequestContext,\n UnauthorizedException,\n type CanActivate,\n type ExecutionContext,\n} from \"@blixis-io/http\";\nimport { MissingTenantError } from \"./errors.js\";\n\nexport interface TenantContext {\n organizationId: string;\n spaceId: string;\n /** Defaults to `\"main\"` when the route has no `:environment` param. */\n environmentId: string;\n role: string;\n}\n\nexport interface Membership {\n organizationId: string;\n role: string;\n}\n\nexport interface TenancyModuleOptions<Actor> {\n /** Reads the current actor out of RequestContext — app-provided, since this package doesn't know which auth mechanism populated it. */\n getActor: (ctx: RequestContext) => Actor | undefined;\n /**\n * Given the actor and a `spaceId` (from the route), returns their\n * membership, or `null`/`undefined` if they have none. Called on every\n * tenant-scoped request — a `null` result becomes a `404`, never a\n * `403`, so a non-member can't tell a space exists at all.\n */\n resolveMembership: (\n actor: Actor,\n spaceId: string,\n ) => Membership | null | undefined | Promise<Membership | null | undefined>;\n}\n\nexport interface TenancyForRootOptions {\n /** Makes `TenantScopedGuard` visible to every module without each one importing this one directly. Defaults to `false`. */\n global?: boolean;\n}\n\n// One RequestContext key per defineTenancyModule() call, not a fixed\n// literal — otherwise two calls in the same app would read/write the same\n// slot and clobber each other. (Real bug found this exact way in\n// @blixis-io/auth; fixed proactively here.)\nlet tenancyInstanceCounter = 0;\n\n/** For a resource loaded another way (e.g. by id, via another module's service) — confirms it actually belongs to the current tenant. Mismatch is a 404, not a 403, same fail-closed reasoning as everywhere else here. Doesn't need any per-`defineTenancyModule()` state, so it's a plain module-level export, not part of the closure. */\nexport function assertSameTenant(resourceSpaceId: string, tenant: TenantContext): void {\n if (resourceSpaceId !== tenant.spaceId) {\n throw new NotFoundException();\n }\n}\n\n/**\n * Builds a `TenantScopedGuard` bound to one membership-resolution strategy.\n * Owns the request-scoping *mechanism* only — no Organization/Space/\n * Membership data model or CRUD lives here; that's app-specific CMS domain\n * data, supplied via `resolveMembership`.\n */\nexport function defineTenancyModule<Actor>(\n options: TenancyModuleOptions<Actor>,\n): {\n TenancyModule: { forRoot(forRootOptions?: TenancyForRootOptions): DynamicModule };\n TenantScopedGuard: Class<CanActivate>;\n getTenant: (ctx: RequestContext) => TenantContext | undefined;\n requireTenant: (ctx: RequestContext) => TenantContext;\n assertSameTenant: (resourceSpaceId: string, tenant: TenantContext) => void;\n} {\n const TENANT_KEY = `blixis.tenancy.tenant.${tenancyInstanceCounter++}`;\n\n function getTenant(ctx: RequestContext): TenantContext | undefined {\n return ctx.get<TenantContext>(TENANT_KEY);\n }\n\n function requireTenant(ctx: RequestContext): TenantContext {\n const tenant = getTenant(ctx);\n if (!tenant) {\n throw new MissingTenantError();\n }\n return tenant;\n }\n\n @Injectable()\n class TenantScopedGuard implements CanActivate {\n constructor(private readonly ctx: RequestContext) {}\n\n async canActivate({ params }: ExecutionContext): Promise<boolean> {\n const spaceId = params[\"spaceId\"];\n if (!spaceId) {\n throw new Error(\"TenantScopedGuard requires a :spaceId route param\");\n }\n\n const actor = options.getActor(this.ctx);\n if (!actor) {\n throw new UnauthorizedException();\n }\n\n const membership = await options.resolveMembership(actor, spaceId);\n if (!membership) {\n throw new NotFoundException();\n }\n\n const tenant: TenantContext = {\n organizationId: membership.organizationId,\n spaceId,\n environmentId: params[\"environment\"] ?? \"main\",\n role: membership.role,\n };\n this.ctx.set(TENANT_KEY, tenant);\n return true;\n }\n }\n\n @Module()\n class TenancyModule {\n static forRoot(forRootOptions: TenancyForRootOptions = {}): DynamicModule {\n return {\n module: TenancyModule,\n providers: [TenantScopedGuard],\n exports: [TenantScopedGuard],\n global: forRootOptions.global ?? false,\n };\n }\n }\n\n return { TenancyModule, TenantScopedGuard, getTenant, requireTenant, assertSameTenant };\n}\n","import { eq, type SQL } from \"drizzle-orm\";\nimport { uuid, type PgColumn } from \"drizzle-orm/pg-core\";\nimport { MissingTenantError } from \"./errors.js\";\nimport type { TenantContext } from \"./module.js\";\n\n/**\n * A `where` predicate scoping a query to `tenant`'s space — throws\n * `MissingTenantError` instead of silently returning an unscoped query\n * when `tenant` is `undefined`. Compose with other conditions via\n * Drizzle's own `and(tenantScope(posts.spaceId, tenant), ...)`.\n */\nexport function tenantScope(spaceIdColumn: PgColumn, tenant: TenantContext | undefined): SQL {\n if (!tenant) {\n throw new MissingTenantError();\n }\n return eq(spaceIdColumn, tenant.spaceId);\n}\n\n/** Spread into an app's own `pgTable()` definition — `organizationId`/`spaceId` columns, present on every tenant-scoped table, including child tables (no join needed to scope a query). */\nexport function tenantColumns() {\n return {\n organizationId: uuid(\"organization_id\").notNull(),\n spaceId: uuid(\"space_id\").notNull(),\n };\n}\n"],"mappings":";;;;;;;;;;;AAKA,IAAa,qBAAb,cAAwC,MAAM;CAC5C,cAAc;EACZ,MACE,iIACF;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;;ACoCA,IAAI,yBAAyB;;AAG7B,SAAgB,iBAAiB,iBAAyB,QAA6B;CACrF,IAAI,oBAAoB,OAAO,SAC7B,MAAM,IAAI,kBAAkB;AAEhC;;;;;;;AAQA,SAAgB,oBACd,SAOA;;CACA,MAAM,aAAa,yBAAyB;CAE5C,SAAS,UAAU,KAAgD;EACjE,OAAO,IAAI,IAAmB,UAAU;CAC1C;CAEA,SAAS,cAAc,KAAoC;EACzD,MAAM,SAAS,UAAU,GAAG;EAC5B,IAAI,CAAC,QACH,MAAM,IAAI,mBAAmB;EAE/B,OAAO;CACT;CAEA,IACM,oBADN,MACM,kBAAyC;EAC7C,YAAY,KAAsC;GAArB,KAAA,MAAA;EAAsB;EAEnD,MAAM,YAAY,EAAE,UAA8C;GAChE,MAAM,UAAU,OAAO;GACvB,IAAI,CAAC,SACH,MAAM,IAAI,MAAM,mDAAmD;GAGrE,MAAM,QAAQ,QAAQ,SAAS,KAAK,GAAG;GACvC,IAAI,CAAC,OACH,MAAM,IAAI,sBAAsB;GAGlC,MAAM,aAAa,MAAM,QAAQ,kBAAkB,OAAO,OAAO;GACjE,IAAI,CAAC,YACH,MAAM,IAAI,kBAAkB;GAG9B,MAAM,SAAwB;IAC5B,gBAAgB,WAAW;IAC3B;IACA,eAAe,OAAO,kBAAkB;IACxC,MAAM,WAAW;GACnB;GACA,KAAK,IAAI,IAAI,YAAY,MAAM;GAC/B,OAAO;EACT;CACF;CA7BC,oBAAA,WAAA,CAAA,WAAW,GAAA,mBAAA,qBAAA,CAAA,OAAA,mBAAA,cAAA,SAAA,cAAA,CAAA,CAAA,GAAA,iBAAA;CA+BZ,IACM,gBAAA,iBADN,MACM,cAAc;EAClB,OAAO,QAAQ,iBAAwC,CAAC,GAAkB;GACxE,OAAO;IACL,QAAA;IACA,WAAW,CAAC,iBAAiB;IAC7B,SAAS,CAAC,iBAAiB;IAC3B,QAAQ,eAAe,UAAU;GACnC;EACF;CACF;CAVC,gBAAA,iBAAA,WAAA,CAAA,OAAO,CAAA,GAAA,aAAA;CAYR,OAAO;EAAE;EAAe;EAAmB;EAAW;EAAe;CAAiB;AACxF;;;;;;;;;ACvHA,SAAgB,YAAY,eAAyB,QAAwC;CAC3F,IAAI,CAAC,QACH,MAAM,IAAI,mBAAmB;CAE/B,OAAO,GAAG,eAAe,OAAO,OAAO;AACzC;;AAGA,SAAgB,gBAAgB;CAC9B,OAAO;EACL,gBAAgB,KAAK,iBAAiB,CAAC,CAAC,QAAQ;EAChD,SAAS,KAAK,UAAU,CAAC,CAAC,QAAQ;CACpC;AACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blixis-io/tenancy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Request-scoped multi-tenant access control (TenantScopedGuard, a fail-closed tenantScope() query helper). Mechanism only — no Organization/Space/Membership data model — reusable by any multi-tenant app.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Michael Voeten <michael@voeten.online>",
|
|
7
|
+
"homepage": "https://blixis-io.github.io/framework/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/blixis-io/framework.git",
|
|
11
|
+
"directory": "packages/tenancy"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=24"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"multi-tenancy",
|
|
19
|
+
"authorization",
|
|
20
|
+
"typescript"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"@blixis-io/source": "./src/index.ts",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@blixis-io/core": "0.1.0",
|
|
37
|
+
"@blixis-io/di": "0.1.0",
|
|
38
|
+
"@blixis-io/http": "0.1.0",
|
|
39
|
+
"drizzle-orm": "^0.45.3"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/pg": "^8.15.6",
|
|
43
|
+
"pg": "^8.23.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
47
|
+
"build": "rolldown -c"
|
|
48
|
+
}
|
|
49
|
+
}
|