@venturekit-pro/tenancy 0.0.38 → 0.0.40
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/README.md +59 -0
- package/dist/hierarchy/index.d.ts +5 -0
- package/dist/hierarchy/index.d.ts.map +1 -0
- package/dist/hierarchy/index.js +3 -0
- package/dist/hierarchy/index.js.map +1 -0
- package/dist/hierarchy/reach.d.ts +90 -0
- package/dist/hierarchy/reach.d.ts.map +1 -0
- package/dist/hierarchy/reach.js +106 -0
- package/dist/hierarchy/reach.js.map +1 -0
- package/dist/hierarchy/tree.d.ts +91 -0
- package/dist/hierarchy/tree.d.ts.map +1 -0
- package/dist/hierarchy/tree.js +149 -0
- package/dist/hierarchy/tree.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/cascade.d.ts +51 -0
- package/dist/lifecycle/cascade.d.ts.map +1 -1
- package/dist/lifecycle/cascade.js +95 -7
- package/dist/lifecycle/cascade.js.map +1 -1
- package/dist/lifecycle/index.d.ts +2 -2
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/index.js +1 -1
- package/dist/lifecycle/index.js.map +1 -1
- package/dist/middleware/runtime-tenant-middleware.d.ts +11 -3
- package/dist/middleware/runtime-tenant-middleware.d.ts.map +1 -1
- package/dist/middleware/runtime-tenant-middleware.js +1 -1
- package/dist/middleware/runtime-tenant-middleware.js.map +1 -1
- package/dist/migrations/0000_vk_tenancy_tree.sql +44 -0
- package/package.json +6 -6
- package/src/migrations/0000_vk_tenancy_tree.sql +44 -0
package/README.md
CHANGED
|
@@ -77,6 +77,65 @@ const tenant = getCurrentTenant(ctx);
|
|
|
77
77
|
// { id: 'acme', slug: 'acme', metadata: { ... } }
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
## Hierarchy
|
|
81
|
+
|
|
82
|
+
Tenants form trees — a group and its schools, a franchise and its stores. The tree is
|
|
83
|
+
`vk_tenants.parent_id` plus `vk_tenant_closure` (every ancestor→descendant path with its
|
|
84
|
+
depth, migration `0000_vk_tenancy_tree`), maintained in code and read in one statement:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { createTenantTree, unpackTenantRoles } from '@venturekit-pro/tenancy';
|
|
88
|
+
import { query } from '@venturekit/data';
|
|
89
|
+
|
|
90
|
+
const tree = createTenantTree(); // or { tenants: 'platform.tenant_ref', closure: 'platform.tenant_closure' } on a projection
|
|
91
|
+
await tree.setParent(query, schoolId, groupId); // rewrites the subtree's closure; refuses a cycle
|
|
92
|
+
await tree.descendantsOf(query, groupId); // [{ tenantId, depth }], shallowest first
|
|
93
|
+
await tree.ancestorsOf(query, schoolId); // nearest first
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A role held in a parent counts in its descendants — a group's owner is the owner of each
|
|
97
|
+
of its schools without a membership row in any. The rules are pure (`inheritedTenantRole`,
|
|
98
|
+
`reachableTenants`), and the tree feeds them:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const pack = unpackTenantRoles(ctx.user.claims['custom:tenantRoles']);
|
|
102
|
+
const isSystemRole = (role: string) => role in SYSTEM_ROLES; // custom roles are rows of the tenant that defined them
|
|
103
|
+
|
|
104
|
+
// a request naming another tenant: may this session act as it, and as what?
|
|
105
|
+
const held = await tree.roleIn(query, pack, requestedTenantId, { inherits: isSystemRole });
|
|
106
|
+
if (!held) throw new ForbiddenError('This session may not act as that tenant');
|
|
107
|
+
|
|
108
|
+
// a session description / tenant switcher: every tenant this user may act as
|
|
109
|
+
const sites = await tree.reach(query, pack, { inherits: isSystemRole });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
An explicit membership in a child always wins over an inherited one, and a nearer
|
|
113
|
+
ancestor over a farther one. The store is read only when the memberships alone do not
|
|
114
|
+
settle the question.
|
|
115
|
+
|
|
116
|
+
## Erasure and export
|
|
117
|
+
|
|
118
|
+
The cascade walker that backs `hardDeleteTenant` also serves the two
|
|
119
|
+
per-user obligations (GDPR art. 17 / art. 20). Both key on the column that
|
|
120
|
+
marks a row as the user's — `user_id` by default — and introspect the schema,
|
|
121
|
+
so a new table is covered the day it is added.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import { exportUserData, executeUserErasure, planUserErasure } from '@venturekit-pro/tenancy';
|
|
125
|
+
|
|
126
|
+
// Portability: every row of the user, per table, ready to redact and hand over
|
|
127
|
+
const dump = await exportUserData(query, { userId, skipTables: ['audit_events'] });
|
|
128
|
+
|
|
129
|
+
// Erasure: dry-run first, then delete FK children before parents
|
|
130
|
+
const plan = await planUserErasure(query, { column: 'member_id' });
|
|
131
|
+
await withTransaction((tx) => executeUserErasure(tx.query, { userId, column: 'member_id', skipTables: ['audit_events'] }));
|
|
132
|
+
// …then adminDeleteUser() from @venturekit/auth/server for the Cognito account.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Rows keyed by another column (`author_id`, `created_by`) need one call per
|
|
136
|
+
column; tables in `skipTables` (an append-only ledger you must keep) are the
|
|
137
|
+
ones to anonymise instead.
|
|
138
|
+
|
|
80
139
|
## API Reference
|
|
81
140
|
|
|
82
141
|
See the [API reference](https://venturekit.dev/api-reference/tenancy) for full documentation.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { inheritedTenantRole, reachableTenants } from './reach.js';
|
|
2
|
+
export type { HeldTenantRole, HierarchyOptions, TenantDescendant, TenantMembership } from './reach.js';
|
|
3
|
+
export { createTenantTree, TenantTreeCycleError, TenantTreeDepthError, TENANT_TREE_MAX_DEPTH, VK_TENANT_TREE } from './tree.js';
|
|
4
|
+
export type { TenantAncestor, TenantTree, TenantTreeTables, TreeQuerier } from './tree.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hierarchy/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACnE,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACvG,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAChI,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hierarchy/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnE,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant hierarchy — what a role in a parent tenant is worth in its children.
|
|
3
|
+
*
|
|
4
|
+
* Multi-tenant products grow trees: a school group and its schools, a franchise
|
|
5
|
+
* and its stores, an agency and its clients' workspaces. The membership model
|
|
6
|
+
* stays flat (one row per user × tenant, the `TenantUser` shape the scopes
|
|
7
|
+
* middleware reads), and the tree adds ONE rule on top: **a role held in a
|
|
8
|
+
* parent is held in every descendant, unless the app says that role does not
|
|
9
|
+
* travel.** A group's owner is the owner of each school without a membership row
|
|
10
|
+
* in any of them; an explicit membership in a child always wins over the
|
|
11
|
+
* inherited one; a nearer ancestor wins over a farther one.
|
|
12
|
+
*
|
|
13
|
+
* These are the rules, pure, over what the caller supplies: the memberships
|
|
14
|
+
* (typically the packed `custom:tenantRoles` claim, see `unpackTenantRoles`) and
|
|
15
|
+
* the tenant's ancestors or a membership tenant's descendants. The store that
|
|
16
|
+
* answers those — `vk_tenants.parent_id` and `vk_tenant_closure`, or a projection's
|
|
17
|
+
* own pair — is `./tree.ts`, whose `roleIn` / `reach` feed these two functions.
|
|
18
|
+
* Two questions, two functions:
|
|
19
|
+
*
|
|
20
|
+
* - {@link inheritedTenantRole} — "may this caller act as tenant X, and as
|
|
21
|
+
* what?" — for a request that names a tenant other than its own.
|
|
22
|
+
* - {@link reachableTenants} — "which tenants may this caller act as at all?"
|
|
23
|
+
* — for a session description or a tenant switcher.
|
|
24
|
+
*
|
|
25
|
+
* Which roles travel is the app's call ({@link HierarchyOptions.inherits}). The
|
|
26
|
+
* default is that every role does; an app whose custom roles are rows of the
|
|
27
|
+
* tenant that defined them (their scopes mean nothing in a child) passes a
|
|
28
|
+
* predicate that keeps inheritance to its system roles.
|
|
29
|
+
*/
|
|
30
|
+
/** One membership: which role the caller holds in which tenant. Same shape as a packed claim entry. */
|
|
31
|
+
export interface TenantMembership {
|
|
32
|
+
tenantId: string;
|
|
33
|
+
role: string;
|
|
34
|
+
}
|
|
35
|
+
export interface HierarchyOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Whether a role held in a parent counts in its descendants. Default: every
|
|
38
|
+
* role does. Return `false` for roles whose meaning is bound to the tenant
|
|
39
|
+
* that defined them (custom roles keyed by a per-tenant id, for instance).
|
|
40
|
+
*/
|
|
41
|
+
inherits?: (role: string, tenantId: string) => boolean;
|
|
42
|
+
}
|
|
43
|
+
/** How a caller comes to hold a role in a tenant. */
|
|
44
|
+
export interface HeldTenantRole {
|
|
45
|
+
tenantId: string;
|
|
46
|
+
role: string;
|
|
47
|
+
/** The tenant the membership is actually in: the tenant itself, or the ancestor the role came from. */
|
|
48
|
+
via: string;
|
|
49
|
+
/** Hops from `via` down to `tenantId`; `0` is the caller's own membership. */
|
|
50
|
+
depth: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The role a caller holds in `tenantId`, and through which tenant — or `null`
|
|
54
|
+
* when it has no business there.
|
|
55
|
+
*
|
|
56
|
+
* The caller's own membership in the tenant wins outright, whatever the role.
|
|
57
|
+
* Otherwise `ancestors` is walked **nearest first** (the app reads them from
|
|
58
|
+
* its tree: parent, grandparent, …) and the first ancestor the caller holds an
|
|
59
|
+
* inheritable role in decides. Roles the app marks as non-inheriting are
|
|
60
|
+
* skipped, not refused — a farther ancestor with a system role still counts.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* const pack = unpackTenantRoles(claims['custom:tenantRoles']);
|
|
65
|
+
* const ancestors = await closure.ancestorsOf(requestedTenantId); // nearest first
|
|
66
|
+
* const held = inheritedTenantRole(pack, requestedTenantId, ancestors, {
|
|
67
|
+
* inherits: (role) => isSystemRole(role),
|
|
68
|
+
* });
|
|
69
|
+
* if (!held) throw new ForbiddenError('This session may not act as that tenant');
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export declare function inheritedTenantRole(memberships: ReadonlyMap<string, string> | readonly TenantMembership[], tenantId: string, ancestors: readonly string[], options?: HierarchyOptions): HeldTenantRole | null;
|
|
73
|
+
/** A descendant of a tenant, as the app's tree reports it. */
|
|
74
|
+
export interface TenantDescendant {
|
|
75
|
+
tenantId: string;
|
|
76
|
+
/** Hops down from the ancestor asked about: a child is `1`. */
|
|
77
|
+
depth: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Every tenant a caller may act as: each membership's own tenant, plus — for a
|
|
81
|
+
* membership whose role inherits — every descendant of that tenant, carrying the
|
|
82
|
+
* role. A tenant reached more than once keeps the **nearest** claim to it: an
|
|
83
|
+
* explicit membership (depth 0) over any inherited one, a parent's role over a
|
|
84
|
+
* grandparent's. `descendantsOf` is asked once per membership whose role
|
|
85
|
+
* inherits, so a caller with only leaf memberships costs no tree read at all.
|
|
86
|
+
*
|
|
87
|
+
* The result is unordered; the app joins names and sorts for display.
|
|
88
|
+
*/
|
|
89
|
+
export declare function reachableTenants(memberships: ReadonlyMap<string, string> | readonly TenantMembership[], descendantsOf: (tenantId: string) => readonly TenantDescendant[], options?: HierarchyOptions): HeldTenantRole[];
|
|
90
|
+
//# sourceMappingURL=reach.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reach.d.ts","sourceRoot":"","sources":["../../src/hierarchy/reach.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,uGAAuG;AACvG,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;CACxD;AAED,qDAAqD;AACrD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,uGAAuG;IACvG,GAAG,EAAE,MAAM,CAAC;IACZ,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAC;CACf;AAaD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,gBAAgB,EAAE,EACtE,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,OAAO,GAAE,gBAAqB,GAC7B,cAAc,GAAG,IAAI,CAYvB;AAED,8DAA8D;AAC9D,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,gBAAgB,EAAE,EACtE,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,SAAS,gBAAgB,EAAE,EAChE,OAAO,GAAE,gBAAqB,GAC7B,cAAc,EAAE,CAiBlB"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant hierarchy — what a role in a parent tenant is worth in its children.
|
|
3
|
+
*
|
|
4
|
+
* Multi-tenant products grow trees: a school group and its schools, a franchise
|
|
5
|
+
* and its stores, an agency and its clients' workspaces. The membership model
|
|
6
|
+
* stays flat (one row per user × tenant, the `TenantUser` shape the scopes
|
|
7
|
+
* middleware reads), and the tree adds ONE rule on top: **a role held in a
|
|
8
|
+
* parent is held in every descendant, unless the app says that role does not
|
|
9
|
+
* travel.** A group's owner is the owner of each school without a membership row
|
|
10
|
+
* in any of them; an explicit membership in a child always wins over the
|
|
11
|
+
* inherited one; a nearer ancestor wins over a farther one.
|
|
12
|
+
*
|
|
13
|
+
* These are the rules, pure, over what the caller supplies: the memberships
|
|
14
|
+
* (typically the packed `custom:tenantRoles` claim, see `unpackTenantRoles`) and
|
|
15
|
+
* the tenant's ancestors or a membership tenant's descendants. The store that
|
|
16
|
+
* answers those — `vk_tenants.parent_id` and `vk_tenant_closure`, or a projection's
|
|
17
|
+
* own pair — is `./tree.ts`, whose `roleIn` / `reach` feed these two functions.
|
|
18
|
+
* Two questions, two functions:
|
|
19
|
+
*
|
|
20
|
+
* - {@link inheritedTenantRole} — "may this caller act as tenant X, and as
|
|
21
|
+
* what?" — for a request that names a tenant other than its own.
|
|
22
|
+
* - {@link reachableTenants} — "which tenants may this caller act as at all?"
|
|
23
|
+
* — for a session description or a tenant switcher.
|
|
24
|
+
*
|
|
25
|
+
* Which roles travel is the app's call ({@link HierarchyOptions.inherits}). The
|
|
26
|
+
* default is that every role does; an app whose custom roles are rows of the
|
|
27
|
+
* tenant that defined them (their scopes mean nothing in a child) passes a
|
|
28
|
+
* predicate that keeps inheritance to its system roles.
|
|
29
|
+
*/
|
|
30
|
+
const ALL_ROLES_INHERIT = () => true;
|
|
31
|
+
function asMap(memberships) {
|
|
32
|
+
if (memberships instanceof Map)
|
|
33
|
+
return memberships;
|
|
34
|
+
const out = new Map();
|
|
35
|
+
for (const { tenantId, role } of memberships) {
|
|
36
|
+
if (!out.has(tenantId))
|
|
37
|
+
out.set(tenantId, role);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The role a caller holds in `tenantId`, and through which tenant — or `null`
|
|
43
|
+
* when it has no business there.
|
|
44
|
+
*
|
|
45
|
+
* The caller's own membership in the tenant wins outright, whatever the role.
|
|
46
|
+
* Otherwise `ancestors` is walked **nearest first** (the app reads them from
|
|
47
|
+
* its tree: parent, grandparent, …) and the first ancestor the caller holds an
|
|
48
|
+
* inheritable role in decides. Roles the app marks as non-inheriting are
|
|
49
|
+
* skipped, not refused — a farther ancestor with a system role still counts.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const pack = unpackTenantRoles(claims['custom:tenantRoles']);
|
|
54
|
+
* const ancestors = await closure.ancestorsOf(requestedTenantId); // nearest first
|
|
55
|
+
* const held = inheritedTenantRole(pack, requestedTenantId, ancestors, {
|
|
56
|
+
* inherits: (role) => isSystemRole(role),
|
|
57
|
+
* });
|
|
58
|
+
* if (!held) throw new ForbiddenError('This session may not act as that tenant');
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function inheritedTenantRole(memberships, tenantId, ancestors, options = {}) {
|
|
62
|
+
const held = asMap(memberships);
|
|
63
|
+
const inherits = options.inherits ?? ALL_ROLES_INHERIT;
|
|
64
|
+
const own = held.get(tenantId);
|
|
65
|
+
if (own !== undefined)
|
|
66
|
+
return { tenantId, role: own, via: tenantId, depth: 0 };
|
|
67
|
+
for (const [index, ancestor] of ancestors.entries()) {
|
|
68
|
+
const role = held.get(ancestor);
|
|
69
|
+
if (role !== undefined && inherits(role, ancestor)) {
|
|
70
|
+
return { tenantId, role, via: ancestor, depth: index + 1 };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Every tenant a caller may act as: each membership's own tenant, plus — for a
|
|
77
|
+
* membership whose role inherits — every descendant of that tenant, carrying the
|
|
78
|
+
* role. A tenant reached more than once keeps the **nearest** claim to it: an
|
|
79
|
+
* explicit membership (depth 0) over any inherited one, a parent's role over a
|
|
80
|
+
* grandparent's. `descendantsOf` is asked once per membership whose role
|
|
81
|
+
* inherits, so a caller with only leaf memberships costs no tree read at all.
|
|
82
|
+
*
|
|
83
|
+
* The result is unordered; the app joins names and sorts for display.
|
|
84
|
+
*/
|
|
85
|
+
export function reachableTenants(memberships, descendantsOf, options = {}) {
|
|
86
|
+
const held = asMap(memberships);
|
|
87
|
+
const inherits = options.inherits ?? ALL_ROLES_INHERIT;
|
|
88
|
+
const best = new Map();
|
|
89
|
+
const offer = (candidate) => {
|
|
90
|
+
const current = best.get(candidate.tenantId);
|
|
91
|
+
if (!current || candidate.depth < current.depth)
|
|
92
|
+
best.set(candidate.tenantId, candidate);
|
|
93
|
+
};
|
|
94
|
+
for (const [tenantId, role] of held) {
|
|
95
|
+
offer({ tenantId, role, via: tenantId, depth: 0 });
|
|
96
|
+
if (!inherits(role, tenantId))
|
|
97
|
+
continue;
|
|
98
|
+
for (const { tenantId: descendant, depth } of descendantsOf(tenantId)) {
|
|
99
|
+
if (depth < 1)
|
|
100
|
+
continue;
|
|
101
|
+
offer({ tenantId: descendant, role, via: tenantId, depth });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...best.values()];
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=reach.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reach.js","sourceRoot":"","sources":["../../src/hierarchy/reach.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tenant tree in the store: the parent edge on the tenants table and the
|
|
3
|
+
* closure table beside it (`0000_vk_tenancy_tree.sql`), read and maintained here.
|
|
4
|
+
*
|
|
5
|
+
* Two structures, one owner. `parent_id` is the fact; `vk_tenant_closure` is the
|
|
6
|
+
* fact unrolled — every ancestor→descendant path with its depth, the self-row
|
|
7
|
+
* at 0 — so the questions a request asks ("the tenants under X", "the ancestors
|
|
8
|
+
* of Y") are one indexed read each. Both are written **only** through
|
|
9
|
+
* {@link TenantTree.setParent} / {@link TenantTree.rebuildClosure}, which
|
|
10
|
+
* rebuild the closure wholesale for the tenant and its whole subtree: the
|
|
11
|
+
* graphs are small and shallow, and wholesale is easier to prove right than
|
|
12
|
+
* incremental edits. No trigger does it — a rule in a trigger runs where the
|
|
13
|
+
* tests do not look.
|
|
14
|
+
*
|
|
15
|
+
* The table names are configurable ({@link TenantTreeTables}) because a service
|
|
16
|
+
* that only *projects* tenants from another system keeps the same two structures
|
|
17
|
+
* under its own names (a `tenant_ref` with a `parent_id`, a closure beside it)
|
|
18
|
+
* and wants the same maintenance code rather than a copy.
|
|
19
|
+
*
|
|
20
|
+
* The hierarchy *rules* — what a role in a parent is worth in a child — are
|
|
21
|
+
* `./reach.ts`, pure; {@link TenantTree.reach} and {@link TenantTree.roleIn} are
|
|
22
|
+
* those rules fed from this store.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const tree = createTenantTree(); // vk_tenants / vk_tenant_closure
|
|
27
|
+
* await tree.setParent(query, schoolId, groupId); // refuses a cycle
|
|
28
|
+
* const sites = await tree.reach(query, unpackTenantRoles(claims['custom:tenantRoles']), { inherits: isSystemRole });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import type { HeldTenantRole, HierarchyOptions, TenantDescendant, TenantMembership } from './reach.js';
|
|
32
|
+
/**
|
|
33
|
+
* Any querier that runs SQL with positional parameters and resolves to the rows —
|
|
34
|
+
* `@venturekit/data`'s `query`, a transaction's `query`, a `pg` client wrapped.
|
|
35
|
+
* Row columns are read under the aliases the statements give them, so a client
|
|
36
|
+
* that maps snake_case to camelCase and one that does not both work.
|
|
37
|
+
*/
|
|
38
|
+
export type TreeQuerier = (sql: string, params?: unknown[]) => Promise<unknown>;
|
|
39
|
+
/** Where the tree lives. Defaults are the package's own tables. */
|
|
40
|
+
export interface TenantTreeTables {
|
|
41
|
+
/** The tenants table; must have `id` and the parent column. */
|
|
42
|
+
tenants: string;
|
|
43
|
+
/** The parent edge column on {@link tenants}. */
|
|
44
|
+
parentColumn: string;
|
|
45
|
+
/** The closure table: `ancestor_id`, `descendant_id`, `depth`. */
|
|
46
|
+
closure: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const VK_TENANT_TREE: TenantTreeTables;
|
|
49
|
+
/** How deep a tree may go — a guard against a runaway walk, not a product limit anyone should meet. */
|
|
50
|
+
export declare const TENANT_TREE_MAX_DEPTH = 16;
|
|
51
|
+
/** A tenant's place relative to another: how many hops away. */
|
|
52
|
+
export interface TenantAncestor {
|
|
53
|
+
tenantId: string;
|
|
54
|
+
/** Hops up from the tenant asked about: the parent is `1`. */
|
|
55
|
+
depth: number;
|
|
56
|
+
}
|
|
57
|
+
export interface TenantTree {
|
|
58
|
+
readonly tables: TenantTreeTables;
|
|
59
|
+
/**
|
|
60
|
+
* Make `parentId` the parent of `tenantId` (or `null` to make it a root) and
|
|
61
|
+
* rebuild the closure for the tenant and everything under it. Refuses a tenant
|
|
62
|
+
* as its own ancestor ({@link TenantTreeCycleError}) and a tree deeper than
|
|
63
|
+
* {@link TENANT_TREE_MAX_DEPTH} ({@link TenantTreeDepthError}). Idempotent for
|
|
64
|
+
* an unchanged parent — safe to call right after inserting the tenant row.
|
|
65
|
+
*/
|
|
66
|
+
setParent(q: TreeQuerier, tenantId: string, parentId: string | null): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Rewrite the closure rows of `tenantId` and its whole subtree from the parent
|
|
69
|
+
* column as it stands — for a row whose parent was written by other means (a
|
|
70
|
+
* projection, a backfill). What `setParent` calls after writing the edge.
|
|
71
|
+
*/
|
|
72
|
+
rebuildClosure(q: TreeQuerier, tenantId: string): Promise<void>;
|
|
73
|
+
/** The ancestors of a tenant, nearest first; empty for a root. */
|
|
74
|
+
ancestorsOf(q: TreeQuerier, tenantId: string): Promise<TenantAncestor[]>;
|
|
75
|
+
/** Every tenant under a tenant, shallowest first; empty for a leaf. */
|
|
76
|
+
descendantsOf(q: TreeQuerier, tenantId: string): Promise<TenantDescendant[]>;
|
|
77
|
+
/** The descendants of several tenants in one read, keyed by the tenant asked about (absent = none). */
|
|
78
|
+
descendantsOfAll(q: TreeQuerier, tenantIds: readonly string[]): Promise<Map<string, TenantDescendant[]>>;
|
|
79
|
+
/** `reachableTenants` over this tree: every tenant a caller with these memberships may act as. One read, for the inheriting memberships only. */
|
|
80
|
+
reach(q: TreeQuerier, memberships: ReadonlyMap<string, string> | readonly TenantMembership[], options?: HierarchyOptions): Promise<HeldTenantRole[]>;
|
|
81
|
+
/** `inheritedTenantRole` over this tree: the role held in `tenantId`, or `null`. The store is read only when the memberships alone do not settle it. */
|
|
82
|
+
roleIn(q: TreeQuerier, memberships: ReadonlyMap<string, string> | readonly TenantMembership[], tenantId: string, options?: HierarchyOptions): Promise<HeldTenantRole | null>;
|
|
83
|
+
}
|
|
84
|
+
export declare class TenantTreeCycleError extends Error {
|
|
85
|
+
constructor(tenantId: string, parentId: string);
|
|
86
|
+
}
|
|
87
|
+
export declare class TenantTreeDepthError extends Error {
|
|
88
|
+
constructor(tenantId: string, depth: number);
|
|
89
|
+
}
|
|
90
|
+
export declare function createTenantTree(tables?: Partial<TenantTreeTables>): TenantTree;
|
|
91
|
+
//# sourceMappingURL=tree.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tree.d.ts","sourceRoot":"","sources":["../../src/hierarchy/tree.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEvG;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhF,mEAAmE;AACnE,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,YAAY,EAAE,MAAM,CAAC;IACrB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,eAAO,MAAM,cAAc,EAAE,gBAI5B,CAAC;AAEF,uGAAuG;AACvG,eAAO,MAAM,qBAAqB,KAAK,CAAC;AAExC,gEAAgE;AAChE,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC;;;;;;OAMG;IACH,SAAS,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpF;;;;OAIG;IACH,cAAc,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChE,kEAAkE;IAClE,WAAW,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;IACzE,uEAAuE;IACvE,aAAa,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IAC7E,uGAAuG;IACvG,gBAAgB,CAAC,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC;IACzG,iJAAiJ;IACjJ,KAAK,CAAC,CAAC,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,gBAAgB,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;IACrJ,wJAAwJ;IACxJ,MAAM,CAAC,CAAC,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,gBAAgB,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;CAC9K;AAED,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CAI/C;AAED,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;CAI5C;AAgBD,wBAAgB,gBAAgB,CAAC,MAAM,GAAE,OAAO,CAAC,gBAAgB,CAAM,GAAG,UAAU,CAoGnF"}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tenant tree in the store: the parent edge on the tenants table and the
|
|
3
|
+
* closure table beside it (`0000_vk_tenancy_tree.sql`), read and maintained here.
|
|
4
|
+
*
|
|
5
|
+
* Two structures, one owner. `parent_id` is the fact; `vk_tenant_closure` is the
|
|
6
|
+
* fact unrolled — every ancestor→descendant path with its depth, the self-row
|
|
7
|
+
* at 0 — so the questions a request asks ("the tenants under X", "the ancestors
|
|
8
|
+
* of Y") are one indexed read each. Both are written **only** through
|
|
9
|
+
* {@link TenantTree.setParent} / {@link TenantTree.rebuildClosure}, which
|
|
10
|
+
* rebuild the closure wholesale for the tenant and its whole subtree: the
|
|
11
|
+
* graphs are small and shallow, and wholesale is easier to prove right than
|
|
12
|
+
* incremental edits. No trigger does it — a rule in a trigger runs where the
|
|
13
|
+
* tests do not look.
|
|
14
|
+
*
|
|
15
|
+
* The table names are configurable ({@link TenantTreeTables}) because a service
|
|
16
|
+
* that only *projects* tenants from another system keeps the same two structures
|
|
17
|
+
* under its own names (a `tenant_ref` with a `parent_id`, a closure beside it)
|
|
18
|
+
* and wants the same maintenance code rather than a copy.
|
|
19
|
+
*
|
|
20
|
+
* The hierarchy *rules* — what a role in a parent is worth in a child — are
|
|
21
|
+
* `./reach.ts`, pure; {@link TenantTree.reach} and {@link TenantTree.roleIn} are
|
|
22
|
+
* those rules fed from this store.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const tree = createTenantTree(); // vk_tenants / vk_tenant_closure
|
|
27
|
+
* await tree.setParent(query, schoolId, groupId); // refuses a cycle
|
|
28
|
+
* const sites = await tree.reach(query, unpackTenantRoles(claims['custom:tenantRoles']), { inherits: isSystemRole });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import { inheritedTenantRole, reachableTenants } from './reach.js';
|
|
32
|
+
export const VK_TENANT_TREE = {
|
|
33
|
+
tenants: 'vk_tenants',
|
|
34
|
+
parentColumn: 'parent_id',
|
|
35
|
+
closure: 'vk_tenant_closure',
|
|
36
|
+
};
|
|
37
|
+
/** How deep a tree may go — a guard against a runaway walk, not a product limit anyone should meet. */
|
|
38
|
+
export const TENANT_TREE_MAX_DEPTH = 16;
|
|
39
|
+
export class TenantTreeCycleError extends Error {
|
|
40
|
+
constructor(tenantId, parentId) {
|
|
41
|
+
super(`Tenant ${tenantId} cannot have ${parentId} as its parent: that tenant is beneath it`);
|
|
42
|
+
this.name = 'TenantTreeCycleError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export class TenantTreeDepthError extends Error {
|
|
46
|
+
constructor(tenantId, depth) {
|
|
47
|
+
super(`Tenant ${tenantId} would sit ${depth} levels deep; the tree stops at ${TENANT_TREE_MAX_DEPTH}`);
|
|
48
|
+
this.name = 'TenantTreeDepthError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/* SQL identifiers come from code, never from a request — but a typo must not become SQL. */
|
|
52
|
+
const IDENTIFIER = /^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$/;
|
|
53
|
+
function identifier(value, what) {
|
|
54
|
+
if (!IDENTIFIER.test(value))
|
|
55
|
+
throw new Error(`createTenantTree: ${what} '${value}' is not a plain SQL identifier`);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function asMemberships(memberships) {
|
|
59
|
+
return memberships instanceof Map
|
|
60
|
+
? [...memberships].map(([tenantId, role]) => ({ tenantId, role }))
|
|
61
|
+
: [...memberships];
|
|
62
|
+
}
|
|
63
|
+
export function createTenantTree(tables = {}) {
|
|
64
|
+
const T = identifier(tables.tenants ?? VK_TENANT_TREE.tenants, 'tenants table');
|
|
65
|
+
const P = identifier(tables.parentColumn ?? VK_TENANT_TREE.parentColumn, 'parent column');
|
|
66
|
+
const C = identifier(tables.closure ?? VK_TENANT_TREE.closure, 'closure table');
|
|
67
|
+
const rows = async (q, sql, params) => (await q(sql, params));
|
|
68
|
+
/* the tenant and everything under it, walking the parent column (the closure may be what is being repaired) */
|
|
69
|
+
const subtree = `
|
|
70
|
+
with recursive sub as (
|
|
71
|
+
select id, 0 as depth from ${T} where id = $1::uuid
|
|
72
|
+
union all
|
|
73
|
+
select t.id, s.depth + 1 from ${T} t join sub s on t.${P} = s.id where s.depth < ${TENANT_TREE_MAX_DEPTH}
|
|
74
|
+
)`;
|
|
75
|
+
const tree = {
|
|
76
|
+
tables: { tenants: T, parentColumn: P, closure: C },
|
|
77
|
+
async setParent(q, tenantId, parentId) {
|
|
78
|
+
if (parentId !== null) {
|
|
79
|
+
if (parentId === tenantId)
|
|
80
|
+
throw new TenantTreeCycleError(tenantId, parentId);
|
|
81
|
+
/* the new parent's ancestry, by the parent column: the tenant must not be in it, and the depth must fit */
|
|
82
|
+
const up = await rows(q, `with recursive up as (
|
|
83
|
+
select id as "tenantId", 0 as depth, ${P} as parent_id from ${T} where id = $1::uuid
|
|
84
|
+
union all
|
|
85
|
+
select t.id, up.depth + 1, t.${P} from ${T} t join up on t.id = up.parent_id where up.depth < ${TENANT_TREE_MAX_DEPTH + 1}
|
|
86
|
+
)
|
|
87
|
+
select "tenantId", depth from up`, [parentId]);
|
|
88
|
+
if (up.some((a) => a.tenantId === tenantId))
|
|
89
|
+
throw new TenantTreeCycleError(tenantId, parentId);
|
|
90
|
+
const parentDepth = up.length; // the parent's own row counts: a root parent puts the tenant at depth 1
|
|
91
|
+
const below = await rows(q, `${subtree} select coalesce(max(depth), 0)::int as n from sub`, [tenantId]);
|
|
92
|
+
const deepest = parentDepth + Number(below[0]?.n ?? 0);
|
|
93
|
+
if (deepest > TENANT_TREE_MAX_DEPTH)
|
|
94
|
+
throw new TenantTreeDepthError(tenantId, deepest);
|
|
95
|
+
}
|
|
96
|
+
await q(`update ${T} set ${P} = $2::uuid where id = $1::uuid`, [tenantId, parentId]);
|
|
97
|
+
await tree.rebuildClosure(q, tenantId);
|
|
98
|
+
},
|
|
99
|
+
async rebuildClosure(q, tenantId) {
|
|
100
|
+
await q(`${subtree} delete from ${C} where descendant_id in (select id from sub)`, [tenantId]);
|
|
101
|
+
await q(`${subtree},
|
|
102
|
+
up as (
|
|
103
|
+
select s.id as descendant_id, s.id as ancestor_id, 0 as depth, t.${P} as parent_id
|
|
104
|
+
from sub s join ${T} t on t.id = s.id
|
|
105
|
+
union all
|
|
106
|
+
select up.descendant_id, t.id, up.depth + 1, t.${P}
|
|
107
|
+
from up join ${T} t on t.id = up.parent_id
|
|
108
|
+
where up.depth < ${TENANT_TREE_MAX_DEPTH}
|
|
109
|
+
)
|
|
110
|
+
insert into ${C} (ancestor_id, descendant_id, depth)
|
|
111
|
+
select ancestor_id, descendant_id, depth from up`, [tenantId]);
|
|
112
|
+
},
|
|
113
|
+
async ancestorsOf(q, tenantId) {
|
|
114
|
+
return rows(q, `select ancestor_id as "tenantId", depth from ${C} where descendant_id = $1::uuid and depth > 0 order by depth`, [tenantId]);
|
|
115
|
+
},
|
|
116
|
+
async descendantsOf(q, tenantId) {
|
|
117
|
+
return rows(q, `select descendant_id as "tenantId", depth from ${C} where ancestor_id = $1::uuid and depth > 0 order by depth`, [tenantId]);
|
|
118
|
+
},
|
|
119
|
+
async descendantsOfAll(q, tenantIds) {
|
|
120
|
+
const out = new Map();
|
|
121
|
+
if (tenantIds.length === 0)
|
|
122
|
+
return out;
|
|
123
|
+
const found = await rows(q, `select ancestor_id as "rootId", descendant_id as "tenantId", depth from ${C} where ancestor_id = any($1::uuid[]) and depth > 0 order by depth`, [[...tenantIds]]);
|
|
124
|
+
for (const { rootId, tenantId, depth } of found) {
|
|
125
|
+
const list = out.get(rootId) ?? [];
|
|
126
|
+
list.push({ tenantId, depth: Number(depth) });
|
|
127
|
+
out.set(rootId, list);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
},
|
|
131
|
+
async reach(q, memberships, options = {}) {
|
|
132
|
+
const held = asMemberships(memberships);
|
|
133
|
+
const inherits = options.inherits ?? (() => true);
|
|
134
|
+
const roots = held.filter((m) => inherits(m.role, m.tenantId)).map((m) => m.tenantId);
|
|
135
|
+
const below = await tree.descendantsOfAll(q, roots);
|
|
136
|
+
return reachableTenants(held, (tenantId) => below.get(tenantId) ?? [], options);
|
|
137
|
+
},
|
|
138
|
+
async roleIn(q, memberships, tenantId, options = {}) {
|
|
139
|
+
const held = asMemberships(memberships);
|
|
140
|
+
const direct = inheritedTenantRole(held, tenantId, [], options);
|
|
141
|
+
if (direct || held.length === 0)
|
|
142
|
+
return direct;
|
|
143
|
+
const ancestors = await tree.ancestorsOf(q, tenantId);
|
|
144
|
+
return inheritedTenantRole(held, tenantId, ancestors.map((a) => a.tenantId), options);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
return tree;
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=tree.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tree.js","sourceRoot":"","sources":["../../src/hierarchy/tree.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { TenantContext, createTenantContext, getCurrentTenant, resolveTenant, }
|
|
|
12
12
|
export * from './middleware/index.js';
|
|
13
13
|
export { listTenants, getTenantById, getTenantBySlug, getTenantByPrimaryDomain, createTenant, updateTenant, setTenantStatus, isUniqueViolation as isTenantUniqueViolation, } from './crud/index.js';
|
|
14
14
|
export type { TenantRecord, TenantWriteFields, TenantStatus as TenantDbStatus, } from './crud/index.js';
|
|
15
|
-
export
|
|
16
|
-
export
|
|
15
|
+
export * from './hierarchy/index.js';
|
|
16
|
+
export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, planUserErasure, executeUserErasure, exportUserData, } from './lifecycle/index.js';
|
|
17
|
+
export type { TenantLifecycleStatus, LifecycleArgs, LifecycleOperationArgs, HardDeleteArgs, LifecycleAuditEvent, LifecycleAuditSink, CascadePlanEntry, CascadeOptions, CascadeResult, UserDataOptions, UserDataExport, } from './lifecycle/index.js';
|
|
17
18
|
//# 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,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;
|
|
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;AAMzB,cAAc,sBAAsB,CAAC;AASrC,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,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,EACb,eAAe,EACf,cAAc,GACf,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -15,10 +15,16 @@ export { createTenantContext, getCurrentTenant, resolveTenant, } from './context
|
|
|
15
15
|
export * from './middleware/index.js';
|
|
16
16
|
// Bare vk_tenants CRUD (column-agnostic create / read / update)
|
|
17
17
|
export { listTenants, getTenantById, getTenantBySlug, getTenantByPrimaryDomain, createTenant, updateTenant, setTenantStatus, isUniqueViolation as isTenantUniqueViolation, } from './crud/index.js';
|
|
18
|
+
// Hierarchy: the tree in the store (`createTenantTree` — `vk_tenants.parent_id` +
|
|
19
|
+
// `vk_tenant_closure`, or a projection's own pair) and the rules over it (a role in a
|
|
20
|
+
// parent tenant, held in its descendants: `inheritedTenantRole` for a request naming
|
|
21
|
+
// another tenant, `reachableTenants` for a session's tenant switcher)
|
|
22
|
+
export * from './hierarchy/index.js';
|
|
18
23
|
// Role → scopes mapping lives in @venturekit/auth (vk_role_scopes +
|
|
19
24
|
// createRoleScopesResolver) — baseline authorization, useful without
|
|
20
25
|
// tenancy. The scopes middleware consumes it through the structural
|
|
21
26
|
// `RoleScopesLookup` type (re-exported via ./middleware above).
|
|
22
|
-
// Lifecycle (suspend / archive / restore / hard-delete + cascade walker
|
|
23
|
-
|
|
27
|
+
// Lifecycle (suspend / archive / restore / hard-delete + cascade walker,
|
|
28
|
+
// per-user erasure & export)
|
|
29
|
+
export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, planUserErasure, executeUserErasure, exportUserData, } from './lifecycle/index.js';
|
|
24
30
|
//# 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,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"}
|
|
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,kFAAkF;AAClF,sFAAsF;AACtF,qFAAqF;AACrF,sEAAsE;AACtE,cAAc,sBAAsB,CAAC;AAErC,oEAAoE;AACpE,qEAAqE;AACrE,oEAAoE;AACpE,gEAAgE;AAEhE,yEAAyE;AACzE,6BAA6B;AAC7B,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,cAAc,GACf,MAAM,sBAAsB,CAAC"}
|
|
@@ -96,4 +96,55 @@ export declare function planCascade(querier: Querier, options?: Pick<CascadeOpti
|
|
|
96
96
|
* querier in.
|
|
97
97
|
*/
|
|
98
98
|
export declare function executeCascade(querier: Querier, options: CascadeOptions): Promise<CascadeResult>;
|
|
99
|
+
export interface UserDataOptions {
|
|
100
|
+
/** The user's id as stored in `column` (a Cognito sub, a members.id, …). */
|
|
101
|
+
userId: string;
|
|
102
|
+
/**
|
|
103
|
+
* Column that marks a row as belonging to the user. Default `user_id`.
|
|
104
|
+
* Apps whose ownership column differs (`member_id`, `created_by`) name
|
|
105
|
+
* it here; call once per column if several apply.
|
|
106
|
+
*/
|
|
107
|
+
column?: string;
|
|
108
|
+
/** Schema to introspect. Default `public`. */
|
|
109
|
+
schema?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Tables to leave alone even though they carry the column — e.g. an
|
|
112
|
+
* append-only audit log the regime requires you to KEEP, or a billing
|
|
113
|
+
* ledger. For erasure these are the tables you anonymise instead.
|
|
114
|
+
*/
|
|
115
|
+
skipTables?: string[];
|
|
116
|
+
}
|
|
117
|
+
export interface UserDataExport {
|
|
118
|
+
userId: string;
|
|
119
|
+
column: string;
|
|
120
|
+
/** Rows per schema-qualified table, in introspection order. */
|
|
121
|
+
tables: Record<string, Record<string, unknown>[]>;
|
|
122
|
+
/** Tables that were listed but skipped. */
|
|
123
|
+
skipped: string[];
|
|
124
|
+
exportedAt: string;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Plan a per-user erasure: every table in `schema` with `column`,
|
|
128
|
+
* children before parents. Same guarantees as {@link planCascade}.
|
|
129
|
+
*/
|
|
130
|
+
export declare function planUserErasure(querier: Querier, options?: Omit<UserDataOptions, 'userId'>): Promise<CascadePlanEntry[]>;
|
|
131
|
+
/**
|
|
132
|
+
* Erase one user's rows from every `column`-bearing table, leaves first.
|
|
133
|
+
* The user's Cognito account and any row keyed by a DIFFERENT column
|
|
134
|
+
* (e.g. `author_id`) are the caller's responsibility — run this once per
|
|
135
|
+
* ownership column, then `adminDeleteUser`.
|
|
136
|
+
*
|
|
137
|
+
* **Destructive.** Wrap in `withTransaction()` and pass `tx.query` for
|
|
138
|
+
* all-or-nothing semantics; otherwise re-running is idempotent.
|
|
139
|
+
*/
|
|
140
|
+
export declare function executeUserErasure(querier: Querier, options: UserDataOptions): Promise<CascadeResult>;
|
|
141
|
+
/**
|
|
142
|
+
* Export one user's rows from every `column`-bearing table — the raw
|
|
143
|
+
* material for a data-portability response. Rows come back as stored
|
|
144
|
+
* (`SELECT *`); the app decides which columns to redact (other people's
|
|
145
|
+
* identifiers, internal flags) before handing the file over.
|
|
146
|
+
*
|
|
147
|
+
* Read-only; safe to run against a replica.
|
|
148
|
+
*/
|
|
149
|
+
export declare function exportUserData(querier: Querier, options: UserDataOptions): Promise<UserDataExport>;
|
|
99
150
|
//# sourceMappingURL=cascade.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cascade.d.ts","sourceRoot":"","sources":["../../src/lifecycle/cascade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C,MAAM,WAAW,gBAAgB;IAC/B,yDAAyD;IACzD,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,iDAAiD;IACjD,IAAI,EAAE,gBAAgB,EAAE,CAAC;IACzB,oCAAoC;IACpC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;OAUG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;
|
|
1
|
+
{"version":3,"file":"cascade.d.ts","sourceRoot":"","sources":["../../src/lifecycle/cascade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C,MAAM,WAAW,gBAAgB;IAC/B,yDAAyD;IACzD,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,iDAAiD;IACjD,IAAI,EAAE,gBAAgB,EAAE,CAAC;IACzB,oCAAoC;IACpC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;OAUG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AA4BD;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,YAAY,CAAM,GAC1D,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAS7B;AAmHD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CA8BxB;AAMD,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;IAClD,2CAA2C;IAC3C,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAM,GAC5C,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAM7B;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,aAAa,CAAC,CAaxB;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,cAAc,CAAC,CAiCzB"}
|
|
@@ -31,6 +31,18 @@
|
|
|
31
31
|
* hard delete; the package doesn't enforce that delay (compliance
|
|
32
32
|
* regimes vary), but it's the recommended pattern.
|
|
33
33
|
*/
|
|
34
|
+
/**
|
|
35
|
+
* The column a cascade / export keys on is interpolated into SQL (it is a
|
|
36
|
+
* schema identifier, not a value). Restrict it to plain unquoted
|
|
37
|
+
* identifiers so a caller can never smuggle anything else in.
|
|
38
|
+
*/
|
|
39
|
+
const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
40
|
+
function assertColumnName(column) {
|
|
41
|
+
if (!SAFE_IDENTIFIER.test(column)) {
|
|
42
|
+
throw new Error(`[tenancy/cascade] column must be a plain lowercase identifier (got ${JSON.stringify(column)})`);
|
|
43
|
+
}
|
|
44
|
+
return column;
|
|
45
|
+
}
|
|
34
46
|
/**
|
|
35
47
|
* Plan the cascade WITHOUT executing it. Useful for dry-runs in the
|
|
36
48
|
* admin UI ("about to delete N rows from M tables — confirm?").
|
|
@@ -40,18 +52,29 @@
|
|
|
40
52
|
* tenant-scoped set fail loud with a clear error.
|
|
41
53
|
*/
|
|
42
54
|
export async function planCascade(querier, options = {}) {
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
return planColumnCascade(querier, {
|
|
56
|
+
column: 'tenant_id',
|
|
57
|
+
schema: options.schema,
|
|
45
58
|
// `vk_tenants` is the root we're collapsing into; the helper deletes
|
|
46
59
|
// it separately at the very end. Including it in the cascade
|
|
47
60
|
// would cause a "depends on itself" cycle.
|
|
48
|
-
'vk_tenants',
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
skipTables: ['vk_tenants', ...(options.skipTables ?? [])],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Shared walker: every table in `schema` with a column named `column`,
|
|
66
|
+
* ordered so that FK children come before their parents. `planCascade`
|
|
67
|
+
* (tenant erasure) and `planUserErasure` (per-user erasure) are both
|
|
68
|
+
* thin wrappers over it.
|
|
69
|
+
*/
|
|
70
|
+
async function planColumnCascade(querier, options) {
|
|
71
|
+
const schema = options.schema ?? 'public';
|
|
72
|
+
const column = assertColumnName(options.column);
|
|
73
|
+
const skip = new Set(options.skipTables ?? []);
|
|
51
74
|
const tables = await querier(`SELECT table_schema AS "schema_name", table_name
|
|
52
75
|
FROM information_schema.columns
|
|
53
76
|
WHERE table_schema = $1
|
|
54
|
-
AND column_name =
|
|
77
|
+
AND column_name = $2`, [schema, column]);
|
|
55
78
|
const tenantTables = new Set(tables.map((t) => t.table_name).filter((name) => !skip.has(name)));
|
|
56
79
|
if (tenantTables.size === 0)
|
|
57
80
|
return [];
|
|
@@ -111,7 +134,7 @@ export async function planCascade(querier, options = {}) {
|
|
|
111
134
|
}
|
|
112
135
|
if (leaves.length === 0) {
|
|
113
136
|
const remaining = Array.from(parentToChildren.keys()).sort();
|
|
114
|
-
throw new Error(`[tenancy/cascade] FK cycle detected among
|
|
137
|
+
throw new Error(`[tenancy/cascade] FK cycle detected among ${column}-scoped tables: [${remaining.join(', ')}]. ` +
|
|
115
138
|
`Add the offending tables to skipTables and delete them manually.`);
|
|
116
139
|
}
|
|
117
140
|
leaves.sort(); // deterministic order within a depth level
|
|
@@ -171,4 +194,69 @@ export async function executeCascade(querier, options) {
|
|
|
171
194
|
durationMs: Date.now() - startedAt,
|
|
172
195
|
};
|
|
173
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Plan a per-user erasure: every table in `schema` with `column`,
|
|
199
|
+
* children before parents. Same guarantees as {@link planCascade}.
|
|
200
|
+
*/
|
|
201
|
+
export async function planUserErasure(querier, options = {}) {
|
|
202
|
+
return planColumnCascade(querier, {
|
|
203
|
+
column: options.column ?? 'user_id',
|
|
204
|
+
schema: options.schema,
|
|
205
|
+
skipTables: options.skipTables,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Erase one user's rows from every `column`-bearing table, leaves first.
|
|
210
|
+
* The user's Cognito account and any row keyed by a DIFFERENT column
|
|
211
|
+
* (e.g. `author_id`) are the caller's responsibility — run this once per
|
|
212
|
+
* ownership column, then `adminDeleteUser`.
|
|
213
|
+
*
|
|
214
|
+
* **Destructive.** Wrap in `withTransaction()` and pass `tx.query` for
|
|
215
|
+
* all-or-nothing semantics; otherwise re-running is idempotent.
|
|
216
|
+
*/
|
|
217
|
+
export async function executeUserErasure(querier, options) {
|
|
218
|
+
const startedAt = Date.now();
|
|
219
|
+
const column = assertColumnName(options.column ?? 'user_id');
|
|
220
|
+
const plan = await planUserErasure(querier, { ...options, column });
|
|
221
|
+
const counts = {};
|
|
222
|
+
for (const entry of plan) {
|
|
223
|
+
const rows = await querier(`DELETE FROM ${entry.tableName} WHERE ${column} = $1 RETURNING 1 AS id`, [options.userId]);
|
|
224
|
+
counts[entry.tableName] = rows.length;
|
|
225
|
+
}
|
|
226
|
+
return { plan, counts, durationMs: Date.now() - startedAt };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Export one user's rows from every `column`-bearing table — the raw
|
|
230
|
+
* material for a data-portability response. Rows come back as stored
|
|
231
|
+
* (`SELECT *`); the app decides which columns to redact (other people's
|
|
232
|
+
* identifiers, internal flags) before handing the file over.
|
|
233
|
+
*
|
|
234
|
+
* Read-only; safe to run against a replica.
|
|
235
|
+
*/
|
|
236
|
+
export async function exportUserData(querier, options) {
|
|
237
|
+
const schema = options.schema ?? 'public';
|
|
238
|
+
const column = assertColumnName(options.column ?? 'user_id');
|
|
239
|
+
const skip = new Set(options.skipTables ?? []);
|
|
240
|
+
const tables = await querier(`SELECT table_schema AS "schema_name", table_name
|
|
241
|
+
FROM information_schema.columns
|
|
242
|
+
WHERE table_schema = $1
|
|
243
|
+
AND column_name = $2
|
|
244
|
+
ORDER BY table_name`, [schema, column]);
|
|
245
|
+
const out = {
|
|
246
|
+
userId: options.userId,
|
|
247
|
+
column,
|
|
248
|
+
tables: {},
|
|
249
|
+
skipped: [],
|
|
250
|
+
exportedAt: new Date().toISOString(),
|
|
251
|
+
};
|
|
252
|
+
for (const t of tables) {
|
|
253
|
+
const qualified = `${schema}.${t.table_name}`;
|
|
254
|
+
if (skip.has(t.table_name)) {
|
|
255
|
+
out.skipped.push(qualified);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
out.tables[qualified] = await querier(`SELECT * FROM ${qualified} WHERE ${column} = $1`, [options.userId]);
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
174
262
|
//# sourceMappingURL=cascade.js.map
|
|
@@ -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,
|
|
1
|
+
{"version":3,"file":"cascade.js","sourceRoot":"","sources":["../../src/lifecycle/cascade.ts"],"names":[],"mappings":"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"}
|
|
@@ -17,6 +17,6 @@
|
|
|
17
17
|
export type { Querier, TenantLifecycleStatus, LifecycleArgs, LifecycleAuditEvent, LifecycleAuditSink, } from './types.js';
|
|
18
18
|
export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, } from './operations.js';
|
|
19
19
|
export type { LifecycleOperationArgs, HardDeleteArgs } from './operations.js';
|
|
20
|
-
export { planCascade, executeCascade, } from './cascade.js';
|
|
21
|
-
export type { CascadePlanEntry, CascadeOptions, CascadeResult, } from './cascade.js';
|
|
20
|
+
export { planCascade, executeCascade, planUserErasure, executeUserErasure, exportUserData, } from './cascade.js';
|
|
21
|
+
export type { CascadePlanEntry, CascadeOptions, CascadeResult, UserDataOptions, UserDataExport, } from './cascade.js';
|
|
22
22
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lifecycle/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,YAAY,EACV,OAAO,EACP,qBAAqB,EACrB,aAAa,EACb,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,GACjB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,sBAAsB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE9E,OAAO,EACL,WAAW,EACX,cAAc,GACf,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lifecycle/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,YAAY,EACV,OAAO,EACP,qBAAqB,EACrB,aAAa,EACb,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,GACjB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,sBAAsB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE9E,OAAO,EACL,WAAW,EACX,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,cAAc,GACf,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,eAAe,EACf,cAAc,GACf,MAAM,cAAc,CAAC"}
|
package/dist/lifecycle/index.js
CHANGED
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
* `hardDeleteTenant`).
|
|
16
16
|
*/
|
|
17
17
|
export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, } from './operations.js';
|
|
18
|
-
export { planCascade, executeCascade, } from './cascade.js';
|
|
18
|
+
export { planCascade, executeCascade, planUserErasure, executeUserErasure, exportUserData, } from './cascade.js';
|
|
19
19
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/lifecycle/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAUH,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,GACjB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EACL,WAAW,EACX,cAAc,GACf,MAAM,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/lifecycle/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAUH,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,GACjB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EACL,WAAW,EACX,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,cAAc,GACf,MAAM,cAAc,CAAC"}
|
|
@@ -88,9 +88,17 @@ export interface HostPrefixDomainResolverOptions<T extends TenantContext> {
|
|
|
88
88
|
*/
|
|
89
89
|
lookup: (domain: string, ctx: RequestContext) => Promise<T | null> | T | null;
|
|
90
90
|
/**
|
|
91
|
-
* Dev-only overrides honored when `process.env.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
91
|
+
* Dev-only overrides honored when `process.env.VENTURE_LOCAL === 'true'`
|
|
92
|
+
* — the marker `vk dev` injects and nothing deployed ever carries
|
|
93
|
+
* (the same switch the repo's other dev-only surfaces key on). Lets
|
|
94
|
+
* `vk dev` serve tenanted routes from `localhost:3001` by accepting an
|
|
95
|
+
* explicit slug from a header or query param.
|
|
96
|
+
*
|
|
97
|
+
* Deliberately NOT keyed on a stage name: `dev` is a deployable stage,
|
|
98
|
+
* and a deployed API that let any caller pick the tenant with a header
|
|
99
|
+
* would be a cross-tenant read. (Historically this read `STAGE=dev`,
|
|
100
|
+
* which no deploy path set — the override was dead locally and one
|
|
101
|
+
* `STAGE=dev` env var away from live in the cloud.)
|
|
94
102
|
*
|
|
95
103
|
* Defaults: header `X-Tenant-Slug`, query `tenant`. Set to `false`
|
|
96
104
|
* to disable.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime-tenant-middleware.d.ts","sourceRoot":"","sources":["../../src/middleware/runtime-tenant-middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIrF,MAAM,WAAW,qBAAqB;IACpC,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,GAAG,aAAa,GAAG,IAAI,CAAC;CAC7E;AAED,MAAM,WAAW,8BAA8B;IAC7C;;;;;;;OAOG;IACH,QAAQ,EAAE,qBAAqB,CAAC;IAEhC;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,8BAA8B,GACtC,UAAU,CAAC,cAAc,CAAC,CAiB5B;AAED,MAAM,WAAW,+BAA+B,CAAC,CAAC,SAAS,aAAa;IACtE;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,EAAE,CACN,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,cAAc,KAChB,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;IAClC
|
|
1
|
+
{"version":3,"file":"runtime-tenant-middleware.d.ts","sourceRoot":"","sources":["../../src/middleware/runtime-tenant-middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIrF,MAAM,WAAW,qBAAqB;IACpC,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,GAAG,aAAa,GAAG,IAAI,CAAC;CAC7E;AAED,MAAM,WAAW,8BAA8B;IAC7C;;;;;;;OAOG;IACH,QAAQ,EAAE,qBAAqB,CAAC;IAEhC;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,8BAA8B,GACtC,UAAU,CAAC,cAAc,CAAC,CAiB5B;AAED,MAAM,WAAW,+BAA+B,CAAC,CAAC,SAAS,aAAa;IACtE;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,EAAE,CACN,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,cAAc,KAChB,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;IAClC;;;;;;;;;;;;;;;OAeG;IACH,YAAY,CAAC,EACT,KAAK,GACL;QACE,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,YAAY,EAAE,CACZ,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,cAAc,KAChB,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;KACnC,CAAC;CACP;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,SAAS,aAAa,EAC9D,OAAO,EAAE,+BAA+B,CAAC,CAAC,CAAC,GAC1C,qBAAqB,CAgCvB"}
|
|
@@ -58,7 +58,7 @@ export function hostPrefixDomainResolver(options) {
|
|
|
58
58
|
const dev = options.devOverrides;
|
|
59
59
|
return async (ctx) => {
|
|
60
60
|
const headers = ctx.rawEvent.headers ?? {};
|
|
61
|
-
if (process.env.
|
|
61
|
+
if (process.env.VENTURE_LOCAL === 'true' && dev !== false) {
|
|
62
62
|
const headerName = (dev?.headerName ?? 'x-tenant-slug').toLowerCase();
|
|
63
63
|
const queryName = dev?.queryName ?? 'tenant';
|
|
64
64
|
const headerValue = headers[headerName] ??
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime-tenant-middleware.js","sourceRoot":"","sources":["../../src/middleware/runtime-tenant-middleware.ts"],"names":[],"mappings":"AA4BA,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AA2B7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,UAAU,6BAA6B,CAC3C,OAAuC;IAEvC,MAAM,EAAE,QAAQ,EAAE,QAAQ,GAAG,KAAK,EAAE,GAAG,OAAO,CAAC;IAC/C,OAAO;QACL,IAAI,EAAE,gBAAgB;QACtB,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;YACtB,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC;YACnC,IAAI,CAAC,MAAM,EAAE,CAAC;gBACZ,IAAI,QAAQ,EAAE,CAAC;oBACb,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC;oBAClB,OAAO,IAAI,EAAE,CAAC;gBAChB,CAAC;gBACD,MAAM,IAAI,mBAAmB,CAAC,SAAS,CAAC,CAAC;YAC3C,CAAC;YACD,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC;YACpB,OAAO,IAAI,EAAE,CAAC;QAChB,CAAC;KACF,CAAC;AACJ,CAAC;
|
|
1
|
+
{"version":3,"file":"runtime-tenant-middleware.js","sourceRoot":"","sources":["../../src/middleware/runtime-tenant-middleware.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- @venturekit-pro/tenancy — the tenant tree.
|
|
2
|
+
--
|
|
3
|
+
-- Multi-tenant products grow hierarchies: a group and its schools, a franchise
|
|
4
|
+
-- and its stores, an agency and its clients' workspaces. Two structures carry it:
|
|
5
|
+
--
|
|
6
|
+
-- - `vk_tenants.parent_id` — the edge, one per tenant, NULL at a root.
|
|
7
|
+
-- - `vk_tenant_closure` — every ancestor→descendant path, the self-row at
|
|
8
|
+
-- depth 0 included, so "the tenants under X" and "the ancestors of Y" are one
|
|
9
|
+
-- indexed read each rather than a recursive walk per request.
|
|
10
|
+
--
|
|
11
|
+
-- The closure is maintained in CODE (`createTenantTree().setParent` /
|
|
12
|
+
-- `rebuildClosure`), wholesale for the re-parented tenant's whole subtree —
|
|
13
|
+
-- small, shallow graphs, and wholesale is easier to prove right than incremental
|
|
14
|
+
-- edits. No trigger: a rule in a trigger runs where tests do not look.
|
|
15
|
+
--
|
|
16
|
+
-- Named to sort right after `0000_vk_tenancy_tenants.sql` and before any
|
|
17
|
+
-- project migration, so a project's own backfill (copying a parent column of
|
|
18
|
+
-- its overlay table into `parent_id`, say) can rely on both structures.
|
|
19
|
+
|
|
20
|
+
ALTER TABLE vk_tenants
|
|
21
|
+
ADD COLUMN IF NOT EXISTS parent_id uuid REFERENCES vk_tenants (id) ON DELETE RESTRICT;
|
|
22
|
+
|
|
23
|
+
-- a tenant is never its own parent; deeper cycles are refused in code (`setParent`)
|
|
24
|
+
ALTER TABLE vk_tenants
|
|
25
|
+
DROP CONSTRAINT IF EXISTS vk_tenants_no_self_parent;
|
|
26
|
+
ALTER TABLE vk_tenants
|
|
27
|
+
ADD CONSTRAINT vk_tenants_no_self_parent CHECK (parent_id IS NULL OR parent_id <> id);
|
|
28
|
+
|
|
29
|
+
CREATE INDEX IF NOT EXISTS vk_tenants_parent_idx ON vk_tenants (parent_id) WHERE parent_id IS NOT NULL;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS vk_tenant_closure (
|
|
32
|
+
ancestor_id uuid NOT NULL REFERENCES vk_tenants (id) ON DELETE CASCADE,
|
|
33
|
+
descendant_id uuid NOT NULL REFERENCES vk_tenants (id) ON DELETE CASCADE,
|
|
34
|
+
-- 0 is the tenant itself; a child is 1
|
|
35
|
+
depth integer NOT NULL CHECK (depth >= 0),
|
|
36
|
+
PRIMARY KEY (ancestor_id, descendant_id)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS vk_tenant_closure_descendant_idx ON vk_tenant_closure (descendant_id);
|
|
40
|
+
|
|
41
|
+
-- every existing tenant is a root until told otherwise: its self-row
|
|
42
|
+
INSERT INTO vk_tenant_closure (ancestor_id, descendant_id, depth)
|
|
43
|
+
SELECT id, id, 0 FROM vk_tenants
|
|
44
|
+
ON CONFLICT DO NOTHING;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@venturekit-pro/tenancy",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.40",
|
|
4
4
|
"description": "Multi-tenant utilities for VentureKit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@venturekit/core": "0.0.
|
|
33
|
+
"@venturekit/core": "0.0.40"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@venturekit/data": "0.0.
|
|
37
|
-
"@venturekit/runtime": "0.0.
|
|
36
|
+
"@venturekit/data": "0.0.40",
|
|
37
|
+
"@venturekit/runtime": "0.0.40"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
40
|
"@venturekit/data": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^26.5.1",
|
|
49
|
-
"@venturekit/data": "0.0.
|
|
50
|
-
"@venturekit/runtime": "0.0.
|
|
49
|
+
"@venturekit/data": "0.0.40",
|
|
50
|
+
"@venturekit/runtime": "0.0.40",
|
|
51
51
|
"typescript": "^5.3.0"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- @venturekit-pro/tenancy — the tenant tree.
|
|
2
|
+
--
|
|
3
|
+
-- Multi-tenant products grow hierarchies: a group and its schools, a franchise
|
|
4
|
+
-- and its stores, an agency and its clients' workspaces. Two structures carry it:
|
|
5
|
+
--
|
|
6
|
+
-- - `vk_tenants.parent_id` — the edge, one per tenant, NULL at a root.
|
|
7
|
+
-- - `vk_tenant_closure` — every ancestor→descendant path, the self-row at
|
|
8
|
+
-- depth 0 included, so "the tenants under X" and "the ancestors of Y" are one
|
|
9
|
+
-- indexed read each rather than a recursive walk per request.
|
|
10
|
+
--
|
|
11
|
+
-- The closure is maintained in CODE (`createTenantTree().setParent` /
|
|
12
|
+
-- `rebuildClosure`), wholesale for the re-parented tenant's whole subtree —
|
|
13
|
+
-- small, shallow graphs, and wholesale is easier to prove right than incremental
|
|
14
|
+
-- edits. No trigger: a rule in a trigger runs where tests do not look.
|
|
15
|
+
--
|
|
16
|
+
-- Named to sort right after `0000_vk_tenancy_tenants.sql` and before any
|
|
17
|
+
-- project migration, so a project's own backfill (copying a parent column of
|
|
18
|
+
-- its overlay table into `parent_id`, say) can rely on both structures.
|
|
19
|
+
|
|
20
|
+
ALTER TABLE vk_tenants
|
|
21
|
+
ADD COLUMN IF NOT EXISTS parent_id uuid REFERENCES vk_tenants (id) ON DELETE RESTRICT;
|
|
22
|
+
|
|
23
|
+
-- a tenant is never its own parent; deeper cycles are refused in code (`setParent`)
|
|
24
|
+
ALTER TABLE vk_tenants
|
|
25
|
+
DROP CONSTRAINT IF EXISTS vk_tenants_no_self_parent;
|
|
26
|
+
ALTER TABLE vk_tenants
|
|
27
|
+
ADD CONSTRAINT vk_tenants_no_self_parent CHECK (parent_id IS NULL OR parent_id <> id);
|
|
28
|
+
|
|
29
|
+
CREATE INDEX IF NOT EXISTS vk_tenants_parent_idx ON vk_tenants (parent_id) WHERE parent_id IS NOT NULL;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS vk_tenant_closure (
|
|
32
|
+
ancestor_id uuid NOT NULL REFERENCES vk_tenants (id) ON DELETE CASCADE,
|
|
33
|
+
descendant_id uuid NOT NULL REFERENCES vk_tenants (id) ON DELETE CASCADE,
|
|
34
|
+
-- 0 is the tenant itself; a child is 1
|
|
35
|
+
depth integer NOT NULL CHECK (depth >= 0),
|
|
36
|
+
PRIMARY KEY (ancestor_id, descendant_id)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS vk_tenant_closure_descendant_idx ON vk_tenant_closure (descendant_id);
|
|
40
|
+
|
|
41
|
+
-- every existing tenant is a root until told otherwise: its self-row
|
|
42
|
+
INSERT INTO vk_tenant_closure (ancestor_id, descendant_id, depth)
|
|
43
|
+
SELECT id, id, 0 FROM vk_tenants
|
|
44
|
+
ON CONFLICT DO NOTHING;
|