@venturekit-pro/tenancy 0.0.0-dev.20260701100017 → 0.0.0-dev.20260704225856
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/dist/crud/crud.d.ts +46 -0
- package/dist/crud/crud.d.ts.map +1 -0
- package/dist/crud/crud.js +127 -0
- package/dist/crud/crud.js.map +1 -0
- package/dist/crud/index.d.ts +9 -0
- package/dist/crud/index.d.ts.map +1 -0
- package/dist/crud/index.js +8 -0
- package/dist/crud/index.js.map +1 -0
- package/dist/crud/types.d.ts +44 -0
- package/dist/crud/types.d.ts.map +1 -0
- package/dist/crud/types.js +14 -0
- package/dist/crud/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/cascade.js +3 -3
- package/dist/lifecycle/cascade.js.map +1 -1
- package/dist/lifecycle/operations.js +2 -2
- package/dist/middleware/handoff-authorize.d.ts +61 -0
- package/dist/middleware/handoff-authorize.d.ts.map +1 -0
- package/dist/middleware/handoff-authorize.js +55 -0
- package/dist/middleware/handoff-authorize.js.map +1 -0
- package/dist/middleware/index.d.ts +6 -0
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +3 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/tenant-roles-claim.d.ts +84 -0
- package/dist/middleware/tenant-roles-claim.d.ts.map +1 -0
- package/dist/middleware/tenant-roles-claim.js +120 -0
- package/dist/middleware/tenant-roles-claim.js.map +1 -0
- package/dist/middleware/tenant-user-scopes-middleware.d.ts +100 -0
- package/dist/middleware/tenant-user-scopes-middleware.d.ts.map +1 -0
- package/dist/middleware/tenant-user-scopes-middleware.js +172 -0
- package/dist/middleware/tenant-user-scopes-middleware.js.map +1 -0
- package/dist/migrations/0000_vk_tenancy_tenants.sql +4 -4
- package/package.json +4 -4
- package/src/migrations/0000_vk_tenancy_tenants.sql +4 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column-agnostic CRUD over `vk_tenants`.
|
|
3
|
+
*
|
|
4
|
+
* Pure SQL against a caller-supplied `Querier`; no `@venturekit/data`
|
|
5
|
+
* import. Reads `SELECT *` so app-added columns ride along (the data
|
|
6
|
+
* client maps them). Writes take a camelCase field bag and infer the
|
|
7
|
+
* right binding per value:
|
|
8
|
+
* - `status` → cast to the `tenant_status` enum.
|
|
9
|
+
* - plain object / array → serialized + cast to `jsonb`.
|
|
10
|
+
* - `Date` / scalar / null → bound as a plain parameter.
|
|
11
|
+
*
|
|
12
|
+
* This lets a consumer that ALTERed extra columns onto `vk_tenants`
|
|
13
|
+
* (e.g. `branding jsonb`, `secret_arn_prefix text`) create / update
|
|
14
|
+
* them through the same helpers without the package knowing the shape.
|
|
15
|
+
*/
|
|
16
|
+
import type { Querier, TenantRecord, TenantStatus, TenantWriteFields } from './types.js';
|
|
17
|
+
/** List every tenant, newest first. */
|
|
18
|
+
export declare function listTenants(querier: Querier): Promise<TenantRecord[]>;
|
|
19
|
+
/** Fetch one tenant by id, or `null` when absent. */
|
|
20
|
+
export declare function getTenantById(querier: Querier, id: string): Promise<TenantRecord | null>;
|
|
21
|
+
/** Fetch one tenant by its unique `slug`, or `null`. */
|
|
22
|
+
export declare function getTenantBySlug(querier: Querier, slug: string): Promise<TenantRecord | null>;
|
|
23
|
+
/** Fetch one tenant by its `primary_domain`, or `null`. */
|
|
24
|
+
export declare function getTenantByPrimaryDomain(querier: Querier, domain: string): Promise<TenantRecord | null>;
|
|
25
|
+
/**
|
|
26
|
+
* Insert a tenant from a camelCase field bag. Omit `id` to let the DB
|
|
27
|
+
* default (`gen_random_uuid()`) assign it. The `slug` unique constraint
|
|
28
|
+
* is DB-enforced — callers map `23505` via `isUniqueViolation`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createTenant(querier: Querier, fields: TenantWriteFields): Promise<TenantRecord>;
|
|
31
|
+
/**
|
|
32
|
+
* Update a tenant from a camelCase field bag. Always bumps `updated_at`.
|
|
33
|
+
* Returns the updated row, or `null` when no tenant matches `id`. A
|
|
34
|
+
* no-op (`fields` empty) still touches `updated_at` and returns the row.
|
|
35
|
+
*/
|
|
36
|
+
export declare function updateTenant(querier: Querier, id: string, fields: TenantWriteFields): Promise<TenantRecord | null>;
|
|
37
|
+
/**
|
|
38
|
+
* Convenience: flip a tenant's lifecycle `status`. Returns the updated
|
|
39
|
+
* row, or `null` when no tenant matches. For richer transitions
|
|
40
|
+
* (stamping `suspended_at` / `archived_at`, cascades) use the lifecycle
|
|
41
|
+
* helpers in `../lifecycle`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function setTenantStatus(querier: Querier, id: string, status: TenantStatus): Promise<TenantRecord | null>;
|
|
44
|
+
/** True when `err` is a Postgres `unique_violation` (23505). */
|
|
45
|
+
export declare function isUniqueViolation(err: unknown): boolean;
|
|
46
|
+
//# sourceMappingURL=crud.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crud.d.ts","sourceRoot":"","sources":["../../src/crud/crud.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EACV,OAAO,EACP,YAAY,EACZ,YAAY,EACZ,iBAAiB,EAClB,MAAM,YAAY,CAAC;AAsCpB,uCAAuC;AACvC,wBAAsB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAI3E;AAED,qDAAqD;AACrD,wBAAsB,aAAa,CACjC,OAAO,EAAE,OAAO,EAChB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAM9B;AAED,wDAAwD;AACxD,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAM9B;AAED,2DAA2D;AAC3D,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAM9B;AAID;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,iBAAiB,GACxB,OAAO,CAAC,YAAY,CAAC,CAsBvB;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,OAAO,EAChB,EAAE,EAAE,MAAM,EACV,MAAM,EAAE,iBAAiB,GACxB,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAiB9B;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,EAAE,EAAE,MAAM,EACV,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAE9B;AAED,gEAAgE;AAChE,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAMvD"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column-agnostic CRUD over `vk_tenants`.
|
|
3
|
+
*
|
|
4
|
+
* Pure SQL against a caller-supplied `Querier`; no `@venturekit/data`
|
|
5
|
+
* import. Reads `SELECT *` so app-added columns ride along (the data
|
|
6
|
+
* client maps them). Writes take a camelCase field bag and infer the
|
|
7
|
+
* right binding per value:
|
|
8
|
+
* - `status` → cast to the `tenant_status` enum.
|
|
9
|
+
* - plain object / array → serialized + cast to `jsonb`.
|
|
10
|
+
* - `Date` / scalar / null → bound as a plain parameter.
|
|
11
|
+
*
|
|
12
|
+
* This lets a consumer that ALTERed extra columns onto `vk_tenants`
|
|
13
|
+
* (e.g. `branding jsonb`, `secret_arn_prefix text`) create / update
|
|
14
|
+
* them through the same helpers without the package knowing the shape.
|
|
15
|
+
*/
|
|
16
|
+
/** `displayName` → `display_name`, `additionalDomains` → `additional_domains`. */
|
|
17
|
+
function camelToSnake(key) {
|
|
18
|
+
return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build a single `column = $n[::cast]` (or VALUES placeholder) for one
|
|
22
|
+
* field, pushing the bound value onto `params`. Centralizes the
|
|
23
|
+
* type-inference rules shared by INSERT + UPDATE.
|
|
24
|
+
*/
|
|
25
|
+
function bindField(key, value, params) {
|
|
26
|
+
const column = camelToSnake(key);
|
|
27
|
+
params.push(value);
|
|
28
|
+
const n = params.length;
|
|
29
|
+
if (key === 'status') {
|
|
30
|
+
return { column, placeholder: `$${n}::tenant_status` };
|
|
31
|
+
}
|
|
32
|
+
const isJsonb = value !== null &&
|
|
33
|
+
typeof value === 'object' &&
|
|
34
|
+
!(value instanceof Date);
|
|
35
|
+
if (isJsonb) {
|
|
36
|
+
// Replace the raw value with its JSON serialization.
|
|
37
|
+
params[n - 1] = JSON.stringify(value);
|
|
38
|
+
return { column, placeholder: `$${n}::jsonb` };
|
|
39
|
+
}
|
|
40
|
+
return { column, placeholder: `$${n}` };
|
|
41
|
+
}
|
|
42
|
+
// ─── Reads ──────────────────────────────────────────────────────────
|
|
43
|
+
/** List every tenant, newest first. */
|
|
44
|
+
export async function listTenants(querier) {
|
|
45
|
+
return querier(`SELECT * FROM vk_tenants ORDER BY created_at DESC`);
|
|
46
|
+
}
|
|
47
|
+
/** Fetch one tenant by id, or `null` when absent. */
|
|
48
|
+
export async function getTenantById(querier, id) {
|
|
49
|
+
const rows = await querier(`SELECT * FROM vk_tenants WHERE id = $1 LIMIT 1`, [id]);
|
|
50
|
+
return rows[0] ?? null;
|
|
51
|
+
}
|
|
52
|
+
/** Fetch one tenant by its unique `slug`, or `null`. */
|
|
53
|
+
export async function getTenantBySlug(querier, slug) {
|
|
54
|
+
const rows = await querier(`SELECT * FROM vk_tenants WHERE slug = $1 LIMIT 1`, [slug]);
|
|
55
|
+
return rows[0] ?? null;
|
|
56
|
+
}
|
|
57
|
+
/** Fetch one tenant by its `primary_domain`, or `null`. */
|
|
58
|
+
export async function getTenantByPrimaryDomain(querier, domain) {
|
|
59
|
+
const rows = await querier(`SELECT * FROM vk_tenants WHERE primary_domain = $1 LIMIT 1`, [domain]);
|
|
60
|
+
return rows[0] ?? null;
|
|
61
|
+
}
|
|
62
|
+
// ─── Writes ─────────────────────────────────────────────────────────
|
|
63
|
+
/**
|
|
64
|
+
* Insert a tenant from a camelCase field bag. Omit `id` to let the DB
|
|
65
|
+
* default (`gen_random_uuid()`) assign it. The `slug` unique constraint
|
|
66
|
+
* is DB-enforced — callers map `23505` via `isUniqueViolation`.
|
|
67
|
+
*/
|
|
68
|
+
export async function createTenant(querier, fields) {
|
|
69
|
+
const keys = Object.keys(fields);
|
|
70
|
+
if (keys.length === 0) {
|
|
71
|
+
throw new Error('createTenant requires at least one field');
|
|
72
|
+
}
|
|
73
|
+
const params = [];
|
|
74
|
+
const columns = [];
|
|
75
|
+
const placeholders = [];
|
|
76
|
+
for (const key of keys) {
|
|
77
|
+
const { column, placeholder } = bindField(key, fields[key], params);
|
|
78
|
+
columns.push(column);
|
|
79
|
+
placeholders.push(placeholder);
|
|
80
|
+
}
|
|
81
|
+
const rows = await querier(`INSERT INTO vk_tenants (${columns.join(', ')})
|
|
82
|
+
VALUES (${placeholders.join(', ')})
|
|
83
|
+
RETURNING *`, params);
|
|
84
|
+
const row = rows[0];
|
|
85
|
+
if (!row)
|
|
86
|
+
throw new Error('vk_tenants insert returned no rows');
|
|
87
|
+
return row;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Update a tenant from a camelCase field bag. Always bumps `updated_at`.
|
|
91
|
+
* Returns the updated row, or `null` when no tenant matches `id`. A
|
|
92
|
+
* no-op (`fields` empty) still touches `updated_at` and returns the row.
|
|
93
|
+
*/
|
|
94
|
+
export async function updateTenant(querier, id, fields) {
|
|
95
|
+
const params = [];
|
|
96
|
+
const sets = [];
|
|
97
|
+
for (const key of Object.keys(fields)) {
|
|
98
|
+
const { column, placeholder } = bindField(key, fields[key], params);
|
|
99
|
+
sets.push(`${column} = ${placeholder}`);
|
|
100
|
+
}
|
|
101
|
+
sets.push('updated_at = NOW()');
|
|
102
|
+
params.push(id);
|
|
103
|
+
const rows = await querier(`UPDATE vk_tenants
|
|
104
|
+
SET ${sets.join(', ')}
|
|
105
|
+
WHERE id = $${params.length}
|
|
106
|
+
RETURNING *`, params);
|
|
107
|
+
return rows[0] ?? null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Convenience: flip a tenant's lifecycle `status`. Returns the updated
|
|
111
|
+
* row, or `null` when no tenant matches. For richer transitions
|
|
112
|
+
* (stamping `suspended_at` / `archived_at`, cascades) use the lifecycle
|
|
113
|
+
* helpers in `../lifecycle`.
|
|
114
|
+
*/
|
|
115
|
+
export async function setTenantStatus(querier, id, status) {
|
|
116
|
+
return updateTenant(querier, id, { status });
|
|
117
|
+
}
|
|
118
|
+
/** True when `err` is a Postgres `unique_violation` (23505). */
|
|
119
|
+
export function isUniqueViolation(err) {
|
|
120
|
+
if (err && typeof err === 'object' && 'code' in err) {
|
|
121
|
+
if (err.code === '23505')
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
125
|
+
return /duplicate key|unique constraint/i.test(message);
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=crud.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crud.js","sourceRoot":"","sources":["../../src/crud/crud.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vk_tenants` CRUD — column-agnostic create / read / update + types.
|
|
3
|
+
*
|
|
4
|
+
* The lean parent-row primitives. Lifecycle transitions with cascade
|
|
5
|
+
* (suspend / archive / restore / hard-delete) live in `../lifecycle`.
|
|
6
|
+
*/
|
|
7
|
+
export type { TenantStatus, TenantRecord, TenantWriteFields, Querier, } from './types.js';
|
|
8
|
+
export { listTenants, getTenantById, getTenantBySlug, getTenantByPrimaryDomain, createTenant, updateTenant, setTenantStatus, isUniqueViolation, } from './crud.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/crud/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,iBAAiB,EACjB,OAAO,GACR,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,GAClB,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vk_tenants` CRUD — column-agnostic create / read / update + types.
|
|
3
|
+
*
|
|
4
|
+
* The lean parent-row primitives. Lifecycle transitions with cascade
|
|
5
|
+
* (suspend / archive / restore / hard-delete) live in `../lifecycle`.
|
|
6
|
+
*/
|
|
7
|
+
export { listTenants, getTenantById, getTenantBySlug, getTenantByPrimaryDomain, createTenant, updateTenant, setTenantStatus, isUniqueViolation, } from './crud.js';
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/crud/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,GAClB,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the bare `vk_tenants` CRUD module.
|
|
3
|
+
*
|
|
4
|
+
* The tenancy package owns the lean parent row (`id`, `slug`,
|
|
5
|
+
* `display_name`, `status`, `primary_domain`, lifecycle + `metadata`).
|
|
6
|
+
* Consuming apps may ALTER the table to add their own columns (e.g. the
|
|
7
|
+
* CMS adds `branding` / `settings` / `additional_domains`). The CRUD here
|
|
8
|
+
* is deliberately **column-agnostic**: reads `SELECT *` (the data client
|
|
9
|
+
* auto-maps every column, app-added ones included) and writes accept a
|
|
10
|
+
* camelCase field bag so app columns flow through without the package
|
|
11
|
+
* enumerating them.
|
|
12
|
+
*/
|
|
13
|
+
/** Lifecycle status — mirrors the DB enum `tenant_status`. */
|
|
14
|
+
export type TenantStatus = 'pending' | 'active' | 'suspended' | 'archived' | 'deleted';
|
|
15
|
+
/**
|
|
16
|
+
* A `vk_tenants` row, post-mapping (snake_case → camelCase). The package
|
|
17
|
+
* guarantees the base columns; app-added columns appear too at runtime
|
|
18
|
+
* (the data client maps them) — consumers cast to a wider row type.
|
|
19
|
+
*/
|
|
20
|
+
export interface TenantRecord {
|
|
21
|
+
id: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
displayName: string;
|
|
24
|
+
status: TenantStatus;
|
|
25
|
+
primaryDomain: string | null;
|
|
26
|
+
metadata: Record<string, unknown>;
|
|
27
|
+
createdAt: Date;
|
|
28
|
+
updatedAt: Date;
|
|
29
|
+
/** App-added columns (branding, settings, …) surface here at runtime. */
|
|
30
|
+
[column: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A camelCase field bag for create / update. Base columns plus any
|
|
34
|
+
* app-added columns the consumer ALTERed onto `vk_tenants`. Object /
|
|
35
|
+
* array values are written as `jsonb`; `status` is cast to the
|
|
36
|
+
* `tenant_status` enum; everything else binds as a plain param.
|
|
37
|
+
*/
|
|
38
|
+
export type TenantWriteFields = Record<string, unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* Querier — same shape as the rest of the workspace. Declared locally so
|
|
41
|
+
* the package carries no hard dependency on `@venturekit/data`.
|
|
42
|
+
*/
|
|
43
|
+
export type Querier = <T = Record<string, unknown>[]>(sql: string, params?: unknown[]) => Promise<T>;
|
|
44
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/crud/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,8DAA8D;AAC9D,MAAM,MAAM,YAAY,GACpB,SAAS,GACT,QAAQ,GACR,WAAW,GACX,UAAU,GACV,SAAS,CAAC;AAEd;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,YAAY,CAAC;IACrB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;IAChB,yEAAyE;IACzE,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;CAC3B;AAED;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAExD;;;GAGG;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAClD,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,OAAO,EAAE,KACf,OAAO,CAAC,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the bare `vk_tenants` CRUD module.
|
|
3
|
+
*
|
|
4
|
+
* The tenancy package owns the lean parent row (`id`, `slug`,
|
|
5
|
+
* `display_name`, `status`, `primary_domain`, lifecycle + `metadata`).
|
|
6
|
+
* Consuming apps may ALTER the table to add their own columns (e.g. the
|
|
7
|
+
* CMS adds `branding` / `settings` / `additional_domains`). The CRUD here
|
|
8
|
+
* is deliberately **column-agnostic**: reads `SELECT *` (the data client
|
|
9
|
+
* auto-maps every column, app-added ones included) and writes accept a
|
|
10
|
+
* camelCase field bag so app columns flow through without the package
|
|
11
|
+
* enumerating them.
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/crud/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG"}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
export * from './types/index.js';
|
|
11
11
|
export { TenantContext, createTenantContext, getCurrentTenant, resolveTenant, } from './context/index.js';
|
|
12
12
|
export * from './middleware/index.js';
|
|
13
|
+
export { listTenants, getTenantById, getTenantBySlug, getTenantByPrimaryDomain, createTenant, updateTenant, setTenantStatus, isUniqueViolation as isTenantUniqueViolation, } from './crud/index.js';
|
|
14
|
+
export type { TenantRecord, TenantWriteFields, TenantStatus as TenantDbStatus, } from './crud/index.js';
|
|
13
15
|
export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, } from './lifecycle/index.js';
|
|
14
16
|
export type { TenantLifecycleStatus, LifecycleArgs, LifecycleOperationArgs, HardDeleteArgs, LifecycleAuditEvent, LifecycleAuditSink, CascadePlanEntry, CascadeOptions, CascadeResult, } from './lifecycle/index.js';
|
|
15
17
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,OAAO,EACL,aAAa,EACb,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAG5B,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,qBAAqB,EACrB,aAAa,EACb,sBAAsB,EACtB,cAAc,EACd,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,aAAa,GACd,MAAM,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,OAAO,EACL,aAAa,EACb,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAG5B,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,IAAI,uBAAuB,GAC7C,MAAM,iBAAiB,CAAC;AACzB,YAAY,EACV,YAAY,EACZ,iBAAiB,EAGjB,YAAY,IAAI,cAAc,GAC/B,MAAM,iBAAiB,CAAC;AAQzB,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,qBAAqB,EACrB,aAAa,EACb,sBAAsB,EACtB,cAAc,EACd,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,aAAa,GACd,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,12 @@ export * from './types/index.js';
|
|
|
13
13
|
export { createTenantContext, getCurrentTenant, resolveTenant, } from './context/index.js';
|
|
14
14
|
// Middleware
|
|
15
15
|
export * from './middleware/index.js';
|
|
16
|
+
// Bare vk_tenants CRUD (column-agnostic create / read / update)
|
|
17
|
+
export { listTenants, getTenantById, getTenantBySlug, getTenantByPrimaryDomain, createTenant, updateTenant, setTenantStatus, isUniqueViolation as isTenantUniqueViolation, } from './crud/index.js';
|
|
18
|
+
// Role → scopes mapping lives in @venturekit/auth (vk_role_scopes +
|
|
19
|
+
// createRoleScopesResolver) — baseline authorization, useful without
|
|
20
|
+
// tenancy. The scopes middleware consumes it through the structural
|
|
21
|
+
// `RoleScopesLookup` type (re-exported via ./middleware above).
|
|
16
22
|
// Lifecycle (suspend / archive / restore / hard-delete + cascade walker)
|
|
17
23
|
export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, } from './lifecycle/index.js';
|
|
18
24
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,QAAQ;AACR,cAAc,kBAAkB,CAAC;AAEjC,UAAU;AACV,OAAO,EAEL,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAE5B,aAAa;AACb,cAAc,uBAAuB,CAAC;AAEtC,yEAAyE;AACzE,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,QAAQ;AACR,cAAc,kBAAkB,CAAC;AAEjC,UAAU;AACV,OAAO,EAEL,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAE5B,aAAa;AACb,cAAc,uBAAuB,CAAC;AAEtC,gEAAgE;AAChE,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,IAAI,uBAAuB,GAC7C,MAAM,iBAAiB,CAAC;AASzB,oEAAoE;AACpE,qEAAqE;AACrE,oEAAoE;AACpE,gEAAgE;AAEhE,yEAAyE;AACzE,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,sBAAsB,CAAC"}
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
export async function planCascade(querier, options = {}) {
|
|
43
43
|
const schema = options.schema ?? 'public';
|
|
44
44
|
const skip = new Set([
|
|
45
|
-
// `
|
|
45
|
+
// `vk_tenants` is the root we're collapsing into; the helper deletes
|
|
46
46
|
// it separately at the very end. Including it in the cascade
|
|
47
47
|
// would cause a "depends on itself" cycle.
|
|
48
|
-
'
|
|
48
|
+
'vk_tenants',
|
|
49
49
|
...(options.skipTables ?? []),
|
|
50
50
|
]);
|
|
51
51
|
const tables = await querier(`SELECT table_schema AS "schema_name", table_name
|
|
@@ -162,7 +162,7 @@ export async function executeCascade(querier, options) {
|
|
|
162
162
|
counts[entry.tableName] = rows.length;
|
|
163
163
|
}
|
|
164
164
|
// Finally, delete the tenants row itself.
|
|
165
|
-
const tenantsTable = `${schema}.
|
|
165
|
+
const tenantsTable = `${schema}.vk_tenants`;
|
|
166
166
|
const rows = await querier(`DELETE FROM ${tenantsTable} WHERE id = $1 RETURNING 1 AS id`, [options.tenantId]);
|
|
167
167
|
counts[tenantsTable] = rows.length;
|
|
168
168
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cascade.js","sourceRoot":"","sources":["../../src/lifecycle/cascade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAqDH;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,OAAgB,EAChB,UAAyD,EAAE;IAE3D,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,QAAQ,CAAC;IAC1C,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC;QACnB,
|
|
1
|
+
{"version":3,"file":"cascade.js","sourceRoot":"","sources":["../../src/lifecycle/cascade.ts"],"names":[],"mappings":"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"}
|
|
@@ -112,7 +112,7 @@ async function mutateStatus(querier, tenantId, status, reason) {
|
|
|
112
112
|
// app-side derived state (e.g. cached counters) is the app's
|
|
113
113
|
// responsibility to refresh.
|
|
114
114
|
if (reason !== undefined && reason !== null) {
|
|
115
|
-
await querier(`UPDATE
|
|
115
|
+
await querier(`UPDATE vk_tenants
|
|
116
116
|
SET status = $2,
|
|
117
117
|
metadata = jsonb_set(
|
|
118
118
|
COALESCE(metadata, '{}'::jsonb),
|
|
@@ -124,7 +124,7 @@ async function mutateStatus(querier, tenantId, status, reason) {
|
|
|
124
124
|
WHERE id = $1`, [tenantId, status, reason]);
|
|
125
125
|
}
|
|
126
126
|
else {
|
|
127
|
-
await querier(`UPDATE
|
|
127
|
+
await querier(`UPDATE vk_tenants
|
|
128
128
|
SET status = $2,
|
|
129
129
|
updated_at = now()
|
|
130
130
|
WHERE id = $1`, [tenantId, status]);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy-side authorization gate for cross-domain session handoff.
|
|
3
|
+
*
|
|
4
|
+
* The handoff MECHANISM (single-use codes, refresh-token exchange,
|
|
5
|
+
* cookie minting) lives in `@venturekit/auth/server` — it is session
|
|
6
|
+
* machinery and knows nothing about tenants. The QUESTION it delegates
|
|
7
|
+
* — *"may this user get a session on that host?"* — is pure tenancy:
|
|
8
|
+
* resolve the host to a tenant, check the user is an active tenant
|
|
9
|
+
* user there. This factory packages that answer so apps don't
|
|
10
|
+
* hand-roll host normalization and the fail-closed rules:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // app code
|
|
14
|
+
* createSessionHandoffRoutes({
|
|
15
|
+
* store,
|
|
16
|
+
* authorize: createTenantHandoffAuthorize({
|
|
17
|
+
* resolveTenantByHost: (host) => loadByDomain(host),
|
|
18
|
+
* isActiveTenantUser: ({ tenantId, userSub }) =>
|
|
19
|
+
* hasApprovedMembership(tenantId, userSub),
|
|
20
|
+
* }),
|
|
21
|
+
* ...
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Fail-closed by construction: unknown host → false, resolver error →
|
|
26
|
+
* false is the CALLER's job to avoid (let errors propagate = request
|
|
27
|
+
* fails, which is also closed), missing tenant user → false.
|
|
28
|
+
*/
|
|
29
|
+
/** Host → tenant resolution, app-provided (usually the same lookup the
|
|
30
|
+
* tenant middleware uses). Receives a bare lowercase hostname — no
|
|
31
|
+
* port, no `www.` prefix. Return null for unknown hosts. */
|
|
32
|
+
export type HandoffTenantResolver = (host: string) => Promise<{
|
|
33
|
+
id: string;
|
|
34
|
+
} | null>;
|
|
35
|
+
export interface TenantHandoffAuthorizeOptions {
|
|
36
|
+
/** Resolve the (normalized) target host to a tenant. */
|
|
37
|
+
resolveTenantByHost: HandoffTenantResolver;
|
|
38
|
+
/**
|
|
39
|
+
* Is this user an ACTIVE tenant user of the resolved tenant?
|
|
40
|
+
* Apps define "active" (approved, not suspended, not deleted, …) —
|
|
41
|
+
* the same rule their scope middleware uses.
|
|
42
|
+
*/
|
|
43
|
+
isActiveTenantUser: (args: {
|
|
44
|
+
tenantId: string;
|
|
45
|
+
userSub: string;
|
|
46
|
+
}) => Promise<boolean>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Normalize a browser-facing host for tenant resolution: lowercase,
|
|
50
|
+
* strip the port and a leading `www.`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function normalizeHandoffHost(targetHost: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Build an `authorize` function for
|
|
55
|
+
* `@venturekit/auth/server`'s `createSessionHandoffRoutes`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createTenantHandoffAuthorize(options: TenantHandoffAuthorizeOptions): (args: {
|
|
58
|
+
userSub: string;
|
|
59
|
+
targetHost: string;
|
|
60
|
+
}) => Promise<boolean>;
|
|
61
|
+
//# sourceMappingURL=handoff-authorize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handoff-authorize.d.ts","sourceRoot":"","sources":["../../src/middleware/handoff-authorize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;6DAE6D;AAC7D,MAAM,MAAM,qBAAqB,GAAG,CAClC,IAAI,EAAE,MAAM,KACT,OAAO,CAAC;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAAC;AAEpC,MAAM,WAAW,6BAA6B;IAC5C,wDAAwD;IACxD,mBAAmB,EAAE,qBAAqB,CAAC;IAC3C;;;;OAIG;IACH,kBAAkB,EAAE,CAAC,IAAI,EAAE;QACzB,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,MAAM,CAAC;KACjB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAM/D;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,6BAA6B,GACrC,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAUrE"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy-side authorization gate for cross-domain session handoff.
|
|
3
|
+
*
|
|
4
|
+
* The handoff MECHANISM (single-use codes, refresh-token exchange,
|
|
5
|
+
* cookie minting) lives in `@venturekit/auth/server` — it is session
|
|
6
|
+
* machinery and knows nothing about tenants. The QUESTION it delegates
|
|
7
|
+
* — *"may this user get a session on that host?"* — is pure tenancy:
|
|
8
|
+
* resolve the host to a tenant, check the user is an active tenant
|
|
9
|
+
* user there. This factory packages that answer so apps don't
|
|
10
|
+
* hand-roll host normalization and the fail-closed rules:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // app code
|
|
14
|
+
* createSessionHandoffRoutes({
|
|
15
|
+
* store,
|
|
16
|
+
* authorize: createTenantHandoffAuthorize({
|
|
17
|
+
* resolveTenantByHost: (host) => loadByDomain(host),
|
|
18
|
+
* isActiveTenantUser: ({ tenantId, userSub }) =>
|
|
19
|
+
* hasApprovedMembership(tenantId, userSub),
|
|
20
|
+
* }),
|
|
21
|
+
* ...
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Fail-closed by construction: unknown host → false, resolver error →
|
|
26
|
+
* false is the CALLER's job to avoid (let errors propagate = request
|
|
27
|
+
* fails, which is also closed), missing tenant user → false.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Normalize a browser-facing host for tenant resolution: lowercase,
|
|
31
|
+
* strip the port and a leading `www.`.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeHandoffHost(targetHost) {
|
|
34
|
+
return (targetHost || '')
|
|
35
|
+
.trim()
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.split(':', 1)[0]
|
|
38
|
+
.replace(/^www\./, '');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build an `authorize` function for
|
|
42
|
+
* `@venturekit/auth/server`'s `createSessionHandoffRoutes`.
|
|
43
|
+
*/
|
|
44
|
+
export function createTenantHandoffAuthorize(options) {
|
|
45
|
+
return async ({ userSub, targetHost }) => {
|
|
46
|
+
const host = normalizeHandoffHost(targetHost);
|
|
47
|
+
if (!host || !userSub)
|
|
48
|
+
return false;
|
|
49
|
+
const tenant = await options.resolveTenantByHost(host);
|
|
50
|
+
if (!tenant)
|
|
51
|
+
return false;
|
|
52
|
+
return options.isActiveTenantUser({ tenantId: tenant.id, userSub });
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=handoff-authorize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handoff-authorize.js","sourceRoot":"","sources":["../../src/middleware/handoff-authorize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAuBH;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAAkB;IACrD,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC;SACtB,IAAI,EAAE;SACN,WAAW,EAAE;SACb,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAE;SACjB,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;AAC3B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,4BAA4B,CAC1C,OAAsC;IAEtC,OAAO,KAAK,EAAE,EAAE,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE;QACvC,MAAM,IAAI,GAAG,oBAAoB,CAAC,UAAU,CAAC,CAAC;QAC9C,IAAI,CAAC,IAAI,IAAI,CAAC,OAAO;YAAE,OAAO,KAAK,CAAC;QAEpC,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAAC;QACvD,IAAI,CAAC,MAAM;YAAE,OAAO,KAAK,CAAC;QAE1B,OAAO,OAAO,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;IACtE,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -5,4 +5,10 @@ export { createTenantMiddleware, TenantNotFoundError, TenantSuspendedError, Tena
|
|
|
5
5
|
export { createQuotaMiddleware, QuotaExceededError, checkQuotas } from './quota-middleware.js';
|
|
6
6
|
export { createRuntimeTenantMiddleware, hostPrefixDomainResolver, } from './runtime-tenant-middleware.js';
|
|
7
7
|
export type { RuntimeTenantResolver, RuntimeTenantMiddlewareOptions, HostPrefixDomainResolverOptions, } from './runtime-tenant-middleware.js';
|
|
8
|
+
export { createTenantUserScopesMiddleware, getTenantUser, requireTenantUser, } from './tenant-user-scopes-middleware.js';
|
|
9
|
+
export type { TenantUserWithRole, TenantUserResolver, TenantUserScopesMiddlewareOptions, RoleScopesLookup, } from './tenant-user-scopes-middleware.js';
|
|
10
|
+
export { TENANT_ROLES_CLAIM, TENANT_ROLES_MAX_LENGTH, packTenantRoles, unpackTenantRoles, } from './tenant-roles-claim.js';
|
|
11
|
+
export type { TenantRoleEntry } from './tenant-roles-claim.js';
|
|
12
|
+
export { createTenantHandoffAuthorize, normalizeHandoffHost, } from './handoff-authorize.js';
|
|
13
|
+
export type { HandoffTenantResolver, TenantHandoffAuthorizeOptions, } from './handoff-authorize.js';
|
|
8
14
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAC/F,OAAO,EACL,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,gCAAgC,CAAC;AACxC,YAAY,EACV,qBAAqB,EACrB,8BAA8B,EAC9B,+BAA+B,GAChC,MAAM,gCAAgC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAC/F,OAAO,EACL,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,gCAAgC,CAAC;AACxC,YAAY,EACV,qBAAqB,EACrB,8BAA8B,EAC9B,+BAA+B,GAChC,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,gCAAgC,EAChC,aAAa,EACb,iBAAiB,GAClB,MAAM,oCAAoC,CAAC;AAC5C,YAAY,EACV,kBAAkB,EAClB,kBAAkB,EAClB,iCAAiC,EACjC,gBAAgB,GACjB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EACL,kBAAkB,EAClB,uBAAuB,EACvB,eAAe,EACf,iBAAiB,GAClB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,EACL,4BAA4B,EAC5B,oBAAoB,GACrB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,qBAAqB,EACrB,6BAA6B,GAC9B,MAAM,wBAAwB,CAAC"}
|
package/dist/middleware/index.js
CHANGED
|
@@ -4,4 +4,7 @@
|
|
|
4
4
|
export { createTenantMiddleware, TenantNotFoundError, TenantSuspendedError, TenantInactiveError, } from './tenant-middleware.js';
|
|
5
5
|
export { createQuotaMiddleware, QuotaExceededError, checkQuotas } from './quota-middleware.js';
|
|
6
6
|
export { createRuntimeTenantMiddleware, hostPrefixDomainResolver, } from './runtime-tenant-middleware.js';
|
|
7
|
+
export { createTenantUserScopesMiddleware, getTenantUser, requireTenantUser, } from './tenant-user-scopes-middleware.js';
|
|
8
|
+
export { TENANT_ROLES_CLAIM, TENANT_ROLES_MAX_LENGTH, packTenantRoles, unpackTenantRoles, } from './tenant-roles-claim.js';
|
|
9
|
+
export { createTenantHandoffAuthorize, normalizeHandoffHost, } from './handoff-authorize.js';
|
|
7
10
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAC/F,OAAO,EACL,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,gCAAgC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAC/F,OAAO,EACL,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,gCAAgC,CAAC;AAMxC,OAAO,EACL,gCAAgC,EAChC,aAAa,EACb,iBAAiB,GAClB,MAAM,oCAAoC,CAAC;AAO5C,OAAO,EACL,kBAAkB,EAClB,uBAAuB,EACvB,eAAe,EACf,iBAAiB,GAClB,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EACL,4BAA4B,EAC5B,oBAAoB,GACrB,MAAM,wBAAwB,CAAC"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packed per-tenant roles claim — the token-side half of
|
|
3
|
+
* `createTenantUserScopesMiddleware`'s claims-first fast path.
|
|
4
|
+
*
|
|
5
|
+
* Multi-tenant apps assign users one role PER TENANT (a membership,
|
|
6
|
+
* a staff assignment, a seat, …). A single Cognito custom attribute
|
|
7
|
+
* can carry all of them:
|
|
8
|
+
*
|
|
9
|
+
* custom:tenantRoles = "<tenantId>:<role>|<tenantId>:<role>|…"
|
|
10
|
+
*
|
|
11
|
+
* Contract (both halves matter — read the write-path rules!):
|
|
12
|
+
*
|
|
13
|
+
* READ — the middleware unpacks the claim from the verified ID
|
|
14
|
+
* token and grants `scopesByRole[role]` for the current tenant
|
|
15
|
+
* with NO store round-trip. A missing tenant falls back to the
|
|
16
|
+
* app's `tenantUser` resolver, so a stale-but-too-narrow claim
|
|
17
|
+
* can never lock a user out — it only costs the query the claim
|
|
18
|
+
* would have saved.
|
|
19
|
+
*
|
|
20
|
+
* WRITE — the app re-derives the FULL pack from its tenant-user
|
|
21
|
+
* store and writes it (e.g. `adminUpdateUserAttributes`) on every
|
|
22
|
+
* tenant-user mutation: sign-in upsert, approve, role change,
|
|
23
|
+
* suspend / unsuspend, leave. Two rules:
|
|
24
|
+
* 1. Pack ACTIVE tenant users only (approved, not suspended,
|
|
25
|
+
* not deleted). "Active" is decided at write time — the
|
|
26
|
+
* claims path deliberately has no `isActive` hook.
|
|
27
|
+
* 2. Always recompute from the store; never string-edit the
|
|
28
|
+
* previous value. Recomputing is self-healing.
|
|
29
|
+
*
|
|
30
|
+
* Staleness — the honest trade-off vs. the per-request store lookup:
|
|
31
|
+
* attributes are baked into tokens at issue time, so a change lands
|
|
32
|
+
* on the caller's NEXT token refresh (≤ the ID-token TTL, 1h by
|
|
33
|
+
* default). GRANTS take effect immediately anyway thanks to the
|
|
34
|
+
* claim-miss fallback; REVOCATIONS (suspend, demote) keep honoring
|
|
35
|
+
* the old role until refresh. Apps for which that window is
|
|
36
|
+
* unacceptable should not enable the claims path.
|
|
37
|
+
*
|
|
38
|
+
* Size — Cognito caps custom attribute values at 2048 chars
|
|
39
|
+
* ({@link TENANT_ROLES_MAX_LENGTH}), ≈ 40 UUID-keyed entries.
|
|
40
|
+
* {@link packTenantRoles} keeps entries in the given order and drops
|
|
41
|
+
* whatever doesn't fit; dropped tenants degrade to the fallback query.
|
|
42
|
+
* Order entries by likelihood of use (e.g. most recently active).
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Default claim name. The pool must declare a `tenantRoles` custom
|
|
46
|
+
* attribute (`customAttributes: ['tenantRoles', …]` on the auth
|
|
47
|
+
* intent in vk.config.ts) — Cognito prefixes it to
|
|
48
|
+
* `custom:tenantRoles` in JWTs.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately NOT `custom:role` (the conventional login-tenant role
|
|
51
|
+
* claim, per-user and single-valued) and NOT `custom:roles`
|
|
52
|
+
* (`@venturekit/auth`'s session helpers parse that as a flat
|
|
53
|
+
* comma-separated role list).
|
|
54
|
+
*/
|
|
55
|
+
export declare const TENANT_ROLES_CLAIM = "custom:tenantRoles";
|
|
56
|
+
/** Cognito's hard cap on a custom attribute value. */
|
|
57
|
+
export declare const TENANT_ROLES_MAX_LENGTH = 2048;
|
|
58
|
+
/** One packed entry: which role the user holds in which tenant. */
|
|
59
|
+
export interface TenantRoleEntry {
|
|
60
|
+
tenantId: string;
|
|
61
|
+
role: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Pack tenant-role entries into the claim value, first-entry-wins on
|
|
65
|
+
* duplicate tenants, dropping trailing entries that would push the
|
|
66
|
+
* value past {@link TENANT_ROLES_MAX_LENGTH} (or a custom `maxLength`
|
|
67
|
+
* in tests). Returns `''` for no entries — write the empty string
|
|
68
|
+
* anyway; it must OVERWRITE the previous pack when the user's last
|
|
69
|
+
* tenant goes away.
|
|
70
|
+
*
|
|
71
|
+
* Throws on entries the codec cannot round-trip (empty fields or
|
|
72
|
+
* separator characters inside ids/roles) — those indicate a bug in
|
|
73
|
+
* the caller's derivation query, not user input.
|
|
74
|
+
*/
|
|
75
|
+
export declare function packTenantRoles(entries: readonly TenantRoleEntry[], maxLength?: number): string;
|
|
76
|
+
/**
|
|
77
|
+
* Decode a packed claim into a `tenantId → role` map. Fail-closed on
|
|
78
|
+
* anything unexpected: a non-string claim, malformed segments, or
|
|
79
|
+
* duplicate tenants (first wins) silently contribute nothing — a
|
|
80
|
+
* garbled claim must never grant scopes, and the middleware's
|
|
81
|
+
* fallback query covers the affected tenant.
|
|
82
|
+
*/
|
|
83
|
+
export declare function unpackTenantRoles(raw: unknown): ReadonlyMap<string, string>;
|
|
84
|
+
//# sourceMappingURL=tenant-roles-claim.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-roles-claim.d.ts","sourceRoot":"","sources":["../../src/middleware/tenant-roles-claim.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AAEvD,sDAAsD;AACtD,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C,mEAAmE;AACnE,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAKD;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,SAAS,eAAe,EAAE,EACnC,SAAS,GAAE,MAAgC,GAC1C,MAAM,CAyBR;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAY3E"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packed per-tenant roles claim — the token-side half of
|
|
3
|
+
* `createTenantUserScopesMiddleware`'s claims-first fast path.
|
|
4
|
+
*
|
|
5
|
+
* Multi-tenant apps assign users one role PER TENANT (a membership,
|
|
6
|
+
* a staff assignment, a seat, …). A single Cognito custom attribute
|
|
7
|
+
* can carry all of them:
|
|
8
|
+
*
|
|
9
|
+
* custom:tenantRoles = "<tenantId>:<role>|<tenantId>:<role>|…"
|
|
10
|
+
*
|
|
11
|
+
* Contract (both halves matter — read the write-path rules!):
|
|
12
|
+
*
|
|
13
|
+
* READ — the middleware unpacks the claim from the verified ID
|
|
14
|
+
* token and grants `scopesByRole[role]` for the current tenant
|
|
15
|
+
* with NO store round-trip. A missing tenant falls back to the
|
|
16
|
+
* app's `tenantUser` resolver, so a stale-but-too-narrow claim
|
|
17
|
+
* can never lock a user out — it only costs the query the claim
|
|
18
|
+
* would have saved.
|
|
19
|
+
*
|
|
20
|
+
* WRITE — the app re-derives the FULL pack from its tenant-user
|
|
21
|
+
* store and writes it (e.g. `adminUpdateUserAttributes`) on every
|
|
22
|
+
* tenant-user mutation: sign-in upsert, approve, role change,
|
|
23
|
+
* suspend / unsuspend, leave. Two rules:
|
|
24
|
+
* 1. Pack ACTIVE tenant users only (approved, not suspended,
|
|
25
|
+
* not deleted). "Active" is decided at write time — the
|
|
26
|
+
* claims path deliberately has no `isActive` hook.
|
|
27
|
+
* 2. Always recompute from the store; never string-edit the
|
|
28
|
+
* previous value. Recomputing is self-healing.
|
|
29
|
+
*
|
|
30
|
+
* Staleness — the honest trade-off vs. the per-request store lookup:
|
|
31
|
+
* attributes are baked into tokens at issue time, so a change lands
|
|
32
|
+
* on the caller's NEXT token refresh (≤ the ID-token TTL, 1h by
|
|
33
|
+
* default). GRANTS take effect immediately anyway thanks to the
|
|
34
|
+
* claim-miss fallback; REVOCATIONS (suspend, demote) keep honoring
|
|
35
|
+
* the old role until refresh. Apps for which that window is
|
|
36
|
+
* unacceptable should not enable the claims path.
|
|
37
|
+
*
|
|
38
|
+
* Size — Cognito caps custom attribute values at 2048 chars
|
|
39
|
+
* ({@link TENANT_ROLES_MAX_LENGTH}), ≈ 40 UUID-keyed entries.
|
|
40
|
+
* {@link packTenantRoles} keeps entries in the given order and drops
|
|
41
|
+
* whatever doesn't fit; dropped tenants degrade to the fallback query.
|
|
42
|
+
* Order entries by likelihood of use (e.g. most recently active).
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Default claim name. The pool must declare a `tenantRoles` custom
|
|
46
|
+
* attribute (`customAttributes: ['tenantRoles', …]` on the auth
|
|
47
|
+
* intent in vk.config.ts) — Cognito prefixes it to
|
|
48
|
+
* `custom:tenantRoles` in JWTs.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately NOT `custom:role` (the conventional login-tenant role
|
|
51
|
+
* claim, per-user and single-valued) and NOT `custom:roles`
|
|
52
|
+
* (`@venturekit/auth`'s session helpers parse that as a flat
|
|
53
|
+
* comma-separated role list).
|
|
54
|
+
*/
|
|
55
|
+
export const TENANT_ROLES_CLAIM = 'custom:tenantRoles';
|
|
56
|
+
/** Cognito's hard cap on a custom attribute value. */
|
|
57
|
+
export const TENANT_ROLES_MAX_LENGTH = 2048;
|
|
58
|
+
const ENTRY_SEPARATOR = '|';
|
|
59
|
+
const PAIR_SEPARATOR = ':';
|
|
60
|
+
/**
|
|
61
|
+
* Pack tenant-role entries into the claim value, first-entry-wins on
|
|
62
|
+
* duplicate tenants, dropping trailing entries that would push the
|
|
63
|
+
* value past {@link TENANT_ROLES_MAX_LENGTH} (or a custom `maxLength`
|
|
64
|
+
* in tests). Returns `''` for no entries — write the empty string
|
|
65
|
+
* anyway; it must OVERWRITE the previous pack when the user's last
|
|
66
|
+
* tenant goes away.
|
|
67
|
+
*
|
|
68
|
+
* Throws on entries the codec cannot round-trip (empty fields or
|
|
69
|
+
* separator characters inside ids/roles) — those indicate a bug in
|
|
70
|
+
* the caller's derivation query, not user input.
|
|
71
|
+
*/
|
|
72
|
+
export function packTenantRoles(entries, maxLength = TENANT_ROLES_MAX_LENGTH) {
|
|
73
|
+
let packed = '';
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
for (const { tenantId, role } of entries) {
|
|
76
|
+
if (!tenantId ||
|
|
77
|
+
!role ||
|
|
78
|
+
tenantId.includes(ENTRY_SEPARATOR) ||
|
|
79
|
+
tenantId.includes(PAIR_SEPARATOR) ||
|
|
80
|
+
role.includes(ENTRY_SEPARATOR) ||
|
|
81
|
+
role.includes(PAIR_SEPARATOR)) {
|
|
82
|
+
throw new Error(`packTenantRoles: entry {tenantId: '${tenantId}', role: '${role}'} ` +
|
|
83
|
+
`is empty or contains a reserved separator ('${ENTRY_SEPARATOR}' / '${PAIR_SEPARATOR}')`);
|
|
84
|
+
}
|
|
85
|
+
if (seen.has(tenantId))
|
|
86
|
+
continue;
|
|
87
|
+
const segment = `${tenantId}${PAIR_SEPARATOR}${role}`;
|
|
88
|
+
const next = packed ? `${packed}${ENTRY_SEPARATOR}${segment}` : segment;
|
|
89
|
+
if (next.length > maxLength)
|
|
90
|
+
break;
|
|
91
|
+
packed = next;
|
|
92
|
+
seen.add(tenantId);
|
|
93
|
+
}
|
|
94
|
+
return packed;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Decode a packed claim into a `tenantId → role` map. Fail-closed on
|
|
98
|
+
* anything unexpected: a non-string claim, malformed segments, or
|
|
99
|
+
* duplicate tenants (first wins) silently contribute nothing — a
|
|
100
|
+
* garbled claim must never grant scopes, and the middleware's
|
|
101
|
+
* fallback query covers the affected tenant.
|
|
102
|
+
*/
|
|
103
|
+
export function unpackTenantRoles(raw) {
|
|
104
|
+
const out = new Map();
|
|
105
|
+
if (typeof raw !== 'string' || raw === '')
|
|
106
|
+
return out;
|
|
107
|
+
for (const segment of raw.split(ENTRY_SEPARATOR)) {
|
|
108
|
+
const sep = segment.indexOf(PAIR_SEPARATOR);
|
|
109
|
+
if (sep <= 0 || sep === segment.length - 1)
|
|
110
|
+
continue;
|
|
111
|
+
const tenantId = segment.slice(0, sep);
|
|
112
|
+
const role = segment.slice(sep + 1);
|
|
113
|
+
if (role.includes(PAIR_SEPARATOR))
|
|
114
|
+
continue;
|
|
115
|
+
if (!out.has(tenantId))
|
|
116
|
+
out.set(tenantId, role);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=tenant-roles-claim.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-roles-claim.js","sourceRoot":"","sources":["../../src/middleware/tenant-roles-claim.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,oBAAoB,CAAC;AAEvD,sDAAsD;AACtD,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,CAAC;AAQ5C,MAAM,eAAe,GAAG,GAAG,CAAC;AAC5B,MAAM,cAAc,GAAG,GAAG,CAAC;AAE3B;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,eAAe,CAC7B,OAAmC,EACnC,YAAoB,uBAAuB;IAE3C,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,KAAK,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,CAAC;QACzC,IACE,CAAC,QAAQ;YACT,CAAC,IAAI;YACL,QAAQ,CAAC,QAAQ,CAAC,eAAe,CAAC;YAClC,QAAQ,CAAC,QAAQ,CAAC,cAAc,CAAC;YACjC,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC;YAC9B,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,KAAK,CACb,sCAAsC,QAAQ,aAAa,IAAI,KAAK;gBAClE,+CAA+C,eAAe,QAAQ,cAAc,IAAI,CAC3F,CAAC;QACJ,CAAC;QACD,IAAI,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC;YAAE,SAAS;QACjC,MAAM,OAAO,GAAG,GAAG,QAAQ,GAAG,cAAc,GAAG,IAAI,EAAE,CAAC;QACtD,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,GAAG,eAAe,GAAG,OAAO,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;QACxE,IAAI,IAAI,CAAC,MAAM,GAAG,SAAS;YAAE,MAAM;QACnC,MAAM,GAAG,IAAI,CAAC;QACd,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACrB,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAY;IAC5C,MAAM,GAAG,GAAG,IAAI,GAAG,EAAkB,CAAC;IACtC,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,EAAE;QAAE,OAAO,GAAG,CAAC;IACtD,KAAK,MAAM,OAAO,IAAI,GAAG,CAAC,KAAK,CAAC,eAAe,CAAC,EAAE,CAAC;QACjD,MAAM,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC;QAC5C,IAAI,GAAG,IAAI,CAAC,IAAI,GAAG,KAAK,OAAO,CAAC,MAAM,GAAG,CAAC;YAAE,SAAS;QACrD,MAAM,QAAQ,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;QACvC,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;QACpC,IAAI,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC;YAAE,SAAS;QAC5C,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC;YAAE,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IAClD,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Middleware, RequestContext } from '@venturekit/runtime';
|
|
2
|
+
/**
|
|
3
|
+
* Dynamic role → scopes lookup. Structurally identical to
|
|
4
|
+
* `@venturekit/auth`'s `RoleScopesLookup` (the `vk_role_scopes`-backed
|
|
5
|
+
* resolver) — declared locally so this package carries no dependency
|
|
6
|
+
* on the auth package; any `(role) => scopes` function fits.
|
|
7
|
+
*/
|
|
8
|
+
export type RoleScopesLookup = (role: string) => Promise<readonly string[]> | readonly string[];
|
|
9
|
+
/**
|
|
10
|
+
* Minimal structural requirement on the app's tenant-user row: the
|
|
11
|
+
* per-tenant role that keys into `scopesByRole`. Apps extend it with
|
|
12
|
+
* their own columns (id, status, timestamps, …) and get them back,
|
|
13
|
+
* typed, from {@link getTenantUser}.
|
|
14
|
+
*/
|
|
15
|
+
export interface TenantUserWithRole {
|
|
16
|
+
role: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Loads the caller's tenant-user row for the CURRENT tenant. Called
|
|
20
|
+
* only when both `ctx.user` and `ctx.tenant` are set. Return `null`
|
|
21
|
+
* for "no row" — never throw for the not-found case.
|
|
22
|
+
*/
|
|
23
|
+
export interface TenantUserResolver<M extends TenantUserWithRole> {
|
|
24
|
+
(ctx: RequestContext): Promise<M | null> | M | null;
|
|
25
|
+
}
|
|
26
|
+
export interface TenantUserScopesMiddlewareOptions<M extends TenantUserWithRole> {
|
|
27
|
+
/**
|
|
28
|
+
* Tenant-user lookup for `(ctx.user, ctx.tenant)` — the store
|
|
29
|
+
* fallback for claim misses, and the lazy row loader behind
|
|
30
|
+
* {@link getTenantUser} / {@link requireTenantUser}.
|
|
31
|
+
*/
|
|
32
|
+
tenantUser: TenantUserResolver<M>;
|
|
33
|
+
/**
|
|
34
|
+
* JWT claim carrying the packed per-tenant roles (see
|
|
35
|
+
* ./tenant-roles-claim.ts for the format and the write-path
|
|
36
|
+
* contract). Defaults to {@link TENANT_ROLES_CLAIM}
|
|
37
|
+
* (`custom:tenantRoles`) — harmless for apps that never write the
|
|
38
|
+
* attribute, since an absent claim always falls back to
|
|
39
|
+
* {@link TenantUserScopesMiddlewareOptions.tenantUser}. Pass
|
|
40
|
+
* `false` to force the store lookup on every request (instant
|
|
41
|
+
* revocation at one query per privileged request).
|
|
42
|
+
*/
|
|
43
|
+
rolesClaim?: string | false;
|
|
44
|
+
/**
|
|
45
|
+
* The role → scopes matrix of the app — either a static table or a
|
|
46
|
+
* (possibly async) lookup function. Roles that resolve to nothing
|
|
47
|
+
* grant nothing (fail-closed). Tiered role models should build
|
|
48
|
+
* cumulative sets so higher roles are supersets of lower ones.
|
|
49
|
+
*
|
|
50
|
+
* For a DB-backed matrix (mutable at runtime, manageable from an
|
|
51
|
+
* admin UI) pass `@venturekit/auth`'s
|
|
52
|
+
* `createRoleScopesResolver(...).lookup` — the mapping lives in
|
|
53
|
+
* `vk_role_scopes` (structure shipped by the auth package's
|
|
54
|
+
* migrations, rows seeded by the app).
|
|
55
|
+
*/
|
|
56
|
+
scopesByRole: Record<string, readonly string[]> | RoleScopesLookup;
|
|
57
|
+
/**
|
|
58
|
+
* Gate evaluated before any role scope is granted — the hook for
|
|
59
|
+
* app-specific "row exists but is not in good standing" rules
|
|
60
|
+
* (pending verification, active suspension, unpaid seat, …).
|
|
61
|
+
* The tenant-user row is still cached for {@link getTenantUser} so
|
|
62
|
+
* handlers can inspect WHY the caller is inactive.
|
|
63
|
+
*
|
|
64
|
+
* Default: every resolved row is active.
|
|
65
|
+
*/
|
|
66
|
+
isActive?: (tenantUser: M) => boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Tenant-INDEPENDENT extra scopes for the authenticated user —
|
|
69
|
+
* evaluated even when no tenant resolved. Use for cross-tenant
|
|
70
|
+
* operator allowlists (platform admins) or per-user scope
|
|
71
|
+
* overrides. Keep it fail-closed: return `[]` on any doubt.
|
|
72
|
+
*/
|
|
73
|
+
userScopes?: (ctx: RequestContext) => Promise<readonly string[]> | readonly string[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build the middleware. See the module header for placement and
|
|
77
|
+
* semantics.
|
|
78
|
+
*/
|
|
79
|
+
export declare function createTenantUserScopesMiddleware<M extends TenantUserWithRole>(options: TenantUserScopesMiddlewareOptions<M>): Middleware<RequestContext>;
|
|
80
|
+
/**
|
|
81
|
+
* The caller's tenant-user row in the current tenant, or `null` when
|
|
82
|
+
* there is none (or the middleware didn't run for this request).
|
|
83
|
+
*
|
|
84
|
+
* Lazy: the claims fast path grants scopes without loading the row,
|
|
85
|
+
* so the first call here triggers the middleware's `tenantUser`
|
|
86
|
+
* resolver; the result — including `null` — is cached on the request
|
|
87
|
+
* context and shared with concurrent callers.
|
|
88
|
+
*/
|
|
89
|
+
export declare function getTenantUser<M extends TenantUserWithRole>(ctx: RequestContext): Promise<M | null>;
|
|
90
|
+
/**
|
|
91
|
+
* Like {@link getTenantUser} but throws `ForbiddenError` (403) when
|
|
92
|
+
* no tenant-user row resolves. For handlers whose scope gate already
|
|
93
|
+
* implies a row exists (any role-granted scope does), a miss here
|
|
94
|
+
* means either a wiring bug (middleware absent from the route's
|
|
95
|
+
* `middleware: [...]` array) or a stale packed claim for an
|
|
96
|
+
* assignment that was just revoked — in both cases 403 is the right
|
|
97
|
+
* answer.
|
|
98
|
+
*/
|
|
99
|
+
export declare function requireTenantUser<M extends TenantUserWithRole>(ctx: RequestContext): Promise<M>;
|
|
100
|
+
//# sourceMappingURL=tenant-user-scopes-middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-user-scopes-middleware.d.ts","sourceRoot":"","sources":["../../src/middleware/tenant-user-scopes-middleware.ts"],"names":[],"mappings":"AA+EA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGtE;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAC7B,IAAI,EAAE,MAAM,KACT,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,GAAG,SAAS,MAAM,EAAE,CAAC;AAEpD;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,kBAAkB;IAC9D,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;CACrD;AAED,MAAM,WAAW,iCAAiC,CAChD,CAAC,SAAS,kBAAkB;IAE5B;;;;OAIG;IACH,UAAU,EAAE,kBAAkB,CAAC,CAAC,CAAC,CAAC;IAElC;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAE5B;;;;;;;;;;;OAWG;IACH,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,GAAG,gBAAgB,CAAC;IAEnE;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,CAAC,UAAU,EAAE,CAAC,KAAK,OAAO,CAAC;IAEtC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CACX,GAAG,EAAE,cAAc,KAChB,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,GAAG,SAAS,MAAM,EAAE,CAAC;CACrD;AAkBD;;;GAGG;AACH,wBAAgB,gCAAgC,CAAC,CAAC,SAAS,kBAAkB,EAC3E,OAAO,EAAE,iCAAiC,CAAC,CAAC,CAAC,GAC5C,UAAU,CAAC,cAAc,CAAC,CA2D5B;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CAAC,CAAC,SAAS,kBAAkB,EAC9D,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAKnB;AAED;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,SAAS,kBAAkB,EAClE,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,CAAC,CAAC,CAMZ"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-user scopes middleware — per-tenant role → scope grants.
|
|
3
|
+
*
|
|
4
|
+
* Multi-tenant apps assign users a role PER TENANT (the `TenantUser`
|
|
5
|
+
* shape: one row per user × tenant — a community membership, a staff
|
|
6
|
+
* assignment, a seat, …). Grants come from two sources, in order:
|
|
7
|
+
*
|
|
8
|
+
* 1. CLAIMS FAST PATH — the packed `custom:tenantRoles` attribute
|
|
9
|
+
* (see ./tenant-roles-claim.ts) carries the caller's role in
|
|
10
|
+
* EVERY tenant, written by the app on each tenant-user mutation.
|
|
11
|
+
* When the current tenant is in the pack, its role maps through
|
|
12
|
+
* {@link TenantUserScopesMiddlewareOptions.scopesByRole} onto
|
|
13
|
+
* `ctx.user.scopes` with zero store round-trips.
|
|
14
|
+
* 2. STORE FALLBACK — when the tenant is NOT in the pack (fresh
|
|
15
|
+
* approval the token hasn't caught up with, first sign-in,
|
|
16
|
+
* overflowed pack, app never writes the claim), the app-supplied
|
|
17
|
+
* {@link TenantUserScopesMiddlewareOptions.tenantUser} resolver
|
|
18
|
+
* loads the tenant-user row and
|
|
19
|
+
* {@link TenantUserScopesMiddlewareOptions.isActive} gates the
|
|
20
|
+
* grant — exactly the pre-claims behavior.
|
|
21
|
+
*
|
|
22
|
+
* The fallback means a too-NARROW claim can never lock a user out.
|
|
23
|
+
* The reverse staleness is the accepted trade-off: a REVOKED or
|
|
24
|
+
* demoted tenant user keeps their packed role until their next token
|
|
25
|
+
* refresh (≤ the ID-token TTL). Apps that cannot tolerate that
|
|
26
|
+
* window should disable the fast path with `rolesClaim: false` and
|
|
27
|
+
* eat one store query per privileged request.
|
|
28
|
+
*
|
|
29
|
+
* Placement: AFTER auth and AFTER tenant resolution
|
|
30
|
+
* (`createRuntimeTenantMiddleware`) in the route's middleware array —
|
|
31
|
+
* it needs `ctx.user` and `ctx.tenant`. The runtime's default auth
|
|
32
|
+
* middleware always runs before route middleware, so auth ordering is
|
|
33
|
+
* automatic.
|
|
34
|
+
*
|
|
35
|
+
* Fail-closed by design: no tenant-user row, an unknown role, a
|
|
36
|
+
* garbled claim, or a row rejected by `isActive` simply gains no
|
|
37
|
+
* scopes — the middleware never throws — and the runtime responds 403
|
|
38
|
+
* to any privileged route.
|
|
39
|
+
*
|
|
40
|
+
* The tenant-user ROW is resolved lazily: the claims path grants
|
|
41
|
+
* scopes without touching the store, so handlers that need the row
|
|
42
|
+
* (ids, timestamps, …) pull it through the async
|
|
43
|
+
* {@link getTenantUser} / {@link requireTenantUser} — one query on
|
|
44
|
+
* first access, cached on the request context afterwards. Routes
|
|
45
|
+
* that only scope-gate never pay it.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* // app/lib/authz.ts
|
|
50
|
+
* const SCOPES_BY_ROLE = {
|
|
51
|
+
* member: ['member.verified'],
|
|
52
|
+
* moderator: ['member.verified', 'moderation.reports.read'],
|
|
53
|
+
* admin: ['member.verified', 'moderation.reports.read', 'admin.members.read'],
|
|
54
|
+
* };
|
|
55
|
+
*
|
|
56
|
+
* export const tenantUserScopes = () =>
|
|
57
|
+
* createTenantUserScopesMiddleware({
|
|
58
|
+
* tenantUser: (ctx) => loadMembership(ctx.tenant!.id, ctx.user!.id),
|
|
59
|
+
* scopesByRole: SCOPES_BY_ROLE,
|
|
60
|
+
* isActive: (m) => m.status === 'approved' && !m.suspendedUntil,
|
|
61
|
+
* });
|
|
62
|
+
*
|
|
63
|
+
* // app/routes/admin/members/get.ts
|
|
64
|
+
* export const main = handler(async (_b, ctx) => {
|
|
65
|
+
* const me = await requireTenantUser(ctx); // lazy row, cached
|
|
66
|
+
* return listMembers(ctx.tenant!.id, me.id);
|
|
67
|
+
* }, {
|
|
68
|
+
* scopes: ['admin.members.read'],
|
|
69
|
+
* middleware: [tenancy, tenantUserScopes()],
|
|
70
|
+
* });
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* Unlike the rest of this package's middleware, this module imports a
|
|
74
|
+
* VALUE (`ForbiddenError`) from `@venturekit/runtime`, not just types.
|
|
75
|
+
* That is deliberate: the middleware only makes sense inside
|
|
76
|
+
* `@venturekit/runtime.handler()` stacks, so the peer is always
|
|
77
|
+
* present wherever this module is loaded.
|
|
78
|
+
*/
|
|
79
|
+
import { ForbiddenError } from '@venturekit/runtime';
|
|
80
|
+
import { TENANT_ROLES_CLAIM, unpackTenantRoles } from './tenant-roles-claim.js';
|
|
81
|
+
const tenantUserStateByCtx = new WeakMap();
|
|
82
|
+
/**
|
|
83
|
+
* Build the middleware. See the module header for placement and
|
|
84
|
+
* semantics.
|
|
85
|
+
*/
|
|
86
|
+
export function createTenantUserScopesMiddleware(options) {
|
|
87
|
+
const { tenantUser: resolveTenantUser, scopesByRole, isActive, userScopes } = options;
|
|
88
|
+
const rolesClaim = options.rolesClaim ?? TENANT_ROLES_CLAIM;
|
|
89
|
+
return {
|
|
90
|
+
name: 'tenant-user-scopes',
|
|
91
|
+
fn: async (ctx, next) => {
|
|
92
|
+
// Unauthenticated request: nothing to grant. Public routes pass
|
|
93
|
+
// through; scoped routes 401 at the runtime gate as usual.
|
|
94
|
+
if (!ctx.user)
|
|
95
|
+
return next();
|
|
96
|
+
const grant = (scopes) => {
|
|
97
|
+
for (const s of scopes) {
|
|
98
|
+
if (!ctx.user.scopes.includes(s))
|
|
99
|
+
ctx.user.scopes.push(s);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
if (userScopes) {
|
|
103
|
+
grant(await userScopes(ctx));
|
|
104
|
+
}
|
|
105
|
+
// Static table or dynamic lookup — normalized to one async shape.
|
|
106
|
+
const scopesForRole = async (role) => typeof scopesByRole === 'function'
|
|
107
|
+
? (await scopesByRole(role)) ?? []
|
|
108
|
+
: scopesByRole[role] ?? [];
|
|
109
|
+
if (ctx.tenant) {
|
|
110
|
+
// Handlers resolve the row on demand through getTenantUser().
|
|
111
|
+
const state = { load: () => Promise.resolve(resolveTenantUser(ctx)) };
|
|
112
|
+
tenantUserStateByCtx.set(ctx, state);
|
|
113
|
+
// Claims fast path: role for the current tenant straight from
|
|
114
|
+
// the verified token — no store round-trip. Packed roles are
|
|
115
|
+
// active-by-contract (the write path packs active tenant
|
|
116
|
+
// users only), so isActive is not consulted here.
|
|
117
|
+
const packedRole = rolesClaim === false
|
|
118
|
+
? undefined
|
|
119
|
+
: unpackTenantRoles(ctx.user.claims?.[rolesClaim]).get(ctx.tenant.id);
|
|
120
|
+
if (packedRole !== undefined) {
|
|
121
|
+
grant(await scopesForRole(packedRole));
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// Store fallback — also covers "no row" (query returns
|
|
125
|
+
// null and nothing is granted). Seed the lazy state so
|
|
126
|
+
// handlers reading the row back don't query twice.
|
|
127
|
+
const row = state.load();
|
|
128
|
+
state.row = row;
|
|
129
|
+
const tenantUser = (await row);
|
|
130
|
+
if (tenantUser && (!isActive || isActive(tenantUser))) {
|
|
131
|
+
grant(await scopesForRole(tenantUser.role));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return next();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The caller's tenant-user row in the current tenant, or `null` when
|
|
141
|
+
* there is none (or the middleware didn't run for this request).
|
|
142
|
+
*
|
|
143
|
+
* Lazy: the claims fast path grants scopes without loading the row,
|
|
144
|
+
* so the first call here triggers the middleware's `tenantUser`
|
|
145
|
+
* resolver; the result — including `null` — is cached on the request
|
|
146
|
+
* context and shared with concurrent callers.
|
|
147
|
+
*/
|
|
148
|
+
export async function getTenantUser(ctx) {
|
|
149
|
+
const state = tenantUserStateByCtx.get(ctx);
|
|
150
|
+
if (!state)
|
|
151
|
+
return null;
|
|
152
|
+
if (!state.row)
|
|
153
|
+
state.row = state.load();
|
|
154
|
+
return (await state.row) ?? null;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Like {@link getTenantUser} but throws `ForbiddenError` (403) when
|
|
158
|
+
* no tenant-user row resolves. For handlers whose scope gate already
|
|
159
|
+
* implies a row exists (any role-granted scope does), a miss here
|
|
160
|
+
* means either a wiring bug (middleware absent from the route's
|
|
161
|
+
* `middleware: [...]` array) or a stale packed claim for an
|
|
162
|
+
* assignment that was just revoked — in both cases 403 is the right
|
|
163
|
+
* answer.
|
|
164
|
+
*/
|
|
165
|
+
export async function requireTenantUser(ctx) {
|
|
166
|
+
const tenantUser = await getTenantUser(ctx);
|
|
167
|
+
if (!tenantUser) {
|
|
168
|
+
throw new ForbiddenError('No tenant-user record in this tenant');
|
|
169
|
+
}
|
|
170
|
+
return tenantUser;
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=tenant-user-scopes-middleware.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-user-scopes-middleware.js","sourceRoot":"","sources":["../../src/middleware/tenant-user-scopes-middleware.ts"],"names":[],"mappings":"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"}
|
|
@@ -57,7 +57,7 @@ CREATE TYPE tenant_status AS ENUM (
|
|
|
57
57
|
|
|
58
58
|
-- ─── tenants ───────────────────────────────────────────────────────
|
|
59
59
|
|
|
60
|
-
CREATE TABLE IF NOT EXISTS
|
|
60
|
+
CREATE TABLE IF NOT EXISTS vk_tenants (
|
|
61
61
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
62
62
|
-- URL-safe identifier. Used for sub-domains, log lines, S3
|
|
63
63
|
-- prefixes (`<slug>/...`), and the tenant-routing middleware.
|
|
@@ -92,14 +92,14 @@ CREATE TABLE IF NOT EXISTS tenants (
|
|
|
92
92
|
-- Slug is the editor-friendly id used in log lines and S3 prefixes.
|
|
93
93
|
-- Unique on lower(slug) so `Demo` and `demo` collide.
|
|
94
94
|
CREATE UNIQUE INDEX IF NOT EXISTS tenants_slug_unique
|
|
95
|
-
ON
|
|
95
|
+
ON vk_tenants (lower(slug));
|
|
96
96
|
|
|
97
97
|
-- Primary-domain lookup is the hot path for the tenancy middleware
|
|
98
98
|
-- (one query per request before any business logic runs).
|
|
99
99
|
CREATE UNIQUE INDEX IF NOT EXISTS tenants_primary_domain_unique
|
|
100
|
-
ON
|
|
100
|
+
ON vk_tenants (lower(primary_domain))
|
|
101
101
|
WHERE primary_domain IS NOT NULL;
|
|
102
102
|
|
|
103
103
|
-- Status filter for admin "active tenants" dashboards.
|
|
104
104
|
CREATE INDEX IF NOT EXISTS tenants_status_idx
|
|
105
|
-
ON
|
|
105
|
+
ON vk_tenants (status);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@venturekit-pro/tenancy",
|
|
3
|
-
"version": "0.0.0-dev.
|
|
3
|
+
"version": "0.0.0-dev.20260704225856",
|
|
4
4
|
"description": "Multi-tenant utilities for VentureKit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@venturekit/core": "0.0.0-dev.
|
|
33
|
+
"@venturekit/core": "0.0.0-dev.20260704225856"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@venturekit/runtime": "0.0.0-dev.
|
|
36
|
+
"@venturekit/runtime": "0.0.0-dev.20260704225856"
|
|
37
37
|
},
|
|
38
38
|
"peerDependenciesMeta": {
|
|
39
39
|
"@venturekit/runtime": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/node": "^25.6.0",
|
|
45
|
-
"@venturekit/runtime": "0.0.0-dev.
|
|
45
|
+
"@venturekit/runtime": "0.0.0-dev.20260704225856",
|
|
46
46
|
"typescript": "^5.3.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
@@ -57,7 +57,7 @@ CREATE TYPE tenant_status AS ENUM (
|
|
|
57
57
|
|
|
58
58
|
-- ─── tenants ───────────────────────────────────────────────────────
|
|
59
59
|
|
|
60
|
-
CREATE TABLE IF NOT EXISTS
|
|
60
|
+
CREATE TABLE IF NOT EXISTS vk_tenants (
|
|
61
61
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
62
62
|
-- URL-safe identifier. Used for sub-domains, log lines, S3
|
|
63
63
|
-- prefixes (`<slug>/...`), and the tenant-routing middleware.
|
|
@@ -92,14 +92,14 @@ CREATE TABLE IF NOT EXISTS tenants (
|
|
|
92
92
|
-- Slug is the editor-friendly id used in log lines and S3 prefixes.
|
|
93
93
|
-- Unique on lower(slug) so `Demo` and `demo` collide.
|
|
94
94
|
CREATE UNIQUE INDEX IF NOT EXISTS tenants_slug_unique
|
|
95
|
-
ON
|
|
95
|
+
ON vk_tenants (lower(slug));
|
|
96
96
|
|
|
97
97
|
-- Primary-domain lookup is the hot path for the tenancy middleware
|
|
98
98
|
-- (one query per request before any business logic runs).
|
|
99
99
|
CREATE UNIQUE INDEX IF NOT EXISTS tenants_primary_domain_unique
|
|
100
|
-
ON
|
|
100
|
+
ON vk_tenants (lower(primary_domain))
|
|
101
101
|
WHERE primary_domain IS NOT NULL;
|
|
102
102
|
|
|
103
103
|
-- Status filter for admin "active tenants" dashboards.
|
|
104
104
|
CREATE INDEX IF NOT EXISTS tenants_status_idx
|
|
105
|
-
ON
|
|
105
|
+
ON vk_tenants (status);
|