@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 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,3 @@
1
+ export { inheritedTenantRole, reachableTenants } from './reach.js';
2
+ export { createTenantTree, TenantTreeCycleError, TenantTreeDepthError, TENANT_TREE_MAX_DEPTH, VK_TENANT_TREE } from './tree.js';
3
+ //# sourceMappingURL=index.js.map
@@ -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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAqBnE,MAAM,CAAC,MAAM,cAAc,GAAqB;IAC9C,OAAO,EAAE,YAAY;IACrB,YAAY,EAAE,WAAW;IACzB,OAAO,EAAE,mBAAmB;CAC7B,CAAC;AAEF,uGAAuG;AACvG,MAAM,CAAC,MAAM,qBAAqB,GAAG,EAAE,CAAC;AAqCxC,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IAC7C,YAAY,QAAgB,EAAE,QAAgB;QAC5C,KAAK,CAAC,UAAU,QAAQ,gBAAgB,QAAQ,2CAA2C,CAAC,CAAC;QAC7F,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAC;IACrC,CAAC;CACF;AAED,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IAC7C,YAAY,QAAgB,EAAE,KAAa;QACzC,KAAK,CAAC,UAAU,QAAQ,cAAc,KAAK,mCAAmC,qBAAqB,EAAE,CAAC,CAAC;QACvG,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAC;IACrC,CAAC;CACF;AAED,4FAA4F;AAC5F,MAAM,UAAU,GAAG,yCAAyC,CAAC;AAE7D,SAAS,UAAU,CAAC,KAAa,EAAE,IAAY;IAC7C,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,qBAAqB,IAAI,KAAK,KAAK,iCAAiC,CAAC,CAAC;IACnH,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,aAAa,CAAC,WAAsE;IAC3F,OAAO,WAAW,YAAY,GAAG;QAC/B,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QAClE,CAAC,CAAC,CAAC,GAAI,WAA2C,CAAC,CAAC;AACxD,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,SAAoC,EAAE;IACrE,MAAM,CAAC,GAAG,UAAU,CAAC,MAAM,CAAC,OAAO,IAAI,cAAc,CAAC,OAAO,EAAE,eAAe,CAAC,CAAC;IAChF,MAAM,CAAC,GAAG,UAAU,CAAC,MAAM,CAAC,YAAY,IAAI,cAAc,CAAC,YAAY,EAAE,eAAe,CAAC,CAAC;IAC1F,MAAM,CAAC,GAAG,UAAU,CAAC,MAAM,CAAC,OAAO,IAAI,cAAc,CAAC,OAAO,EAAE,eAAe,CAAC,CAAC;IAChF,MAAM,IAAI,GAAG,KAAK,EAAK,CAAc,EAAE,GAAW,EAAE,MAAiB,EAAgB,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,CAAQ,CAAC;IAEtH,+GAA+G;IAC/G,MAAM,OAAO,GAAG;;mCAEiB,CAAC;;sCAEE,CAAC,sBAAsB,CAAC,2BAA2B,qBAAqB;MACxG,CAAC;IAEL,MAAM,IAAI,GAAe;QACvB,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE;QAEnD,KAAK,CAAC,SAAS,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ;YACnC,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;gBACtB,IAAI,QAAQ,KAAK,QAAQ;oBAAE,MAAM,IAAI,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;gBAC9E,2GAA2G;gBAC3G,MAAM,EAAE,GAAG,MAAM,IAAI,CACnB,CAAC,EACD;oDAC0C,CAAC,sBAAsB,CAAC;;4CAEhC,CAAC,SAAS,CAAC,sDAAsD,qBAAqB,GAAG,CAAC;;4CAE1F,EAClC,CAAC,QAAQ,CAAC,CACX,CAAC;gBACF,IAAI,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,QAAQ,CAAC;oBAAE,MAAM,IAAI,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;gBAChG,MAAM,WAAW,GAAG,EAAE,CAAC,MAAM,CAAC,CAAC,wEAAwE;gBACvG,MAAM,KAAK,GAAG,MAAM,IAAI,CAAgB,CAAC,EAAE,GAAG,OAAO,oDAAoD,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;gBACvH,MAAM,OAAO,GAAG,WAAW,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;gBACvD,IAAI,OAAO,GAAG,qBAAqB;oBAAE,MAAM,IAAI,oBAAoB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;YACzF,CAAC;YACD,MAAM,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,iCAAiC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC;YACrF,MAAM,IAAI,CAAC,cAAc,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;QACzC,CAAC;QAED,KAAK,CAAC,cAAc,CAAC,CAAC,EAAE,QAAQ;YAC9B,MAAM,CAAC,CAAC,GAAG,OAAO,gBAAgB,CAAC,8CAA8C,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;YAC/F,MAAM,CAAC,CACL,GAAG,OAAO;;8EAE4D,CAAC;+BAChD,CAAC;;4DAE4B,CAAC;4BACjC,CAAC;+BACE,qBAAqB;;uBAE7B,CAAC;0DACkC,EAClD,CAAC,QAAQ,CAAC,CACX,CAAC;QACJ,CAAC;QAED,KAAK,CAAC,WAAW,CAAC,CAAC,EAAE,QAAQ;YAC3B,OAAO,IAAI,CAAiB,CAAC,EAAE,gDAAgD,CAAC,8DAA8D,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;QAC9J,CAAC;QAED,KAAK,CAAC,aAAa,CAAC,CAAC,EAAE,QAAQ;YAC7B,OAAO,IAAI,CAAmB,CAAC,EAAE,kDAAkD,CAAC,4DAA4D,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;QAChK,CAAC;QAED,KAAK,CAAC,gBAAgB,CAAC,CAAC,EAAE,SAAS;YACjC,MAAM,GAAG,GAAG,IAAI,GAAG,EAA8B,CAAC;YAClD,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;gBAAE,OAAO,GAAG,CAAC;YACvC,MAAM,KAAK,GAAG,MAAM,IAAI,CACtB,CAAC,EACD,2EAA2E,CAAC,mEAAmE,EAC/I,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC,CACjB,CAAC;YACF,KAAK,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,KAAK,EAAE,CAAC;gBAChD,MAAM,IAAI,GAAG,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;gBACnC,IAAI,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;gBAC9C,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;YACxB,CAAC;YACD,OAAO,GAAG,CAAC;QACb,CAAC;QAED,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,EAAE,OAAO,GAAG,EAAE;YACtC,MAAM,IAAI,GAAG,aAAa,CAAC,WAAW,CAAC,CAAC;YACxC,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;YAClD,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;YACtF,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,gBAAgB,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;YACpD,OAAO,gBAAgB,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,OAAO,CAAC,CAAC;QAClF,CAAC;QAED,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,OAAO,GAAG,EAAE;YACjD,MAAM,IAAI,GAAG,aAAa,CAAC,WAAW,CAAC,CAAC;YACxC,MAAM,MAAM,GAAG,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC;YAChE,IAAI,MAAM,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;gBAAE,OAAO,MAAM,CAAC;YAC/C,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;YACtD,OAAO,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC;QACxF,CAAC;KACF,CAAC;IACF,OAAO,IAAI,CAAC;AACd,CAAC"}
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 { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, } from './lifecycle/index.js';
16
- export type { TenantLifecycleStatus, LifecycleArgs, LifecycleOperationArgs, HardDeleteArgs, LifecycleAuditEvent, LifecycleAuditSink, CascadePlanEntry, CascadeOptions, CascadeResult, } from './lifecycle/index.js';
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
@@ -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;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"}
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
- export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, } from './lifecycle/index.js';
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;AAYD;;;;;;;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,CA2G7B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CA8BxB"}
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
- const schema = options.schema ?? 'public';
44
- const skip = new Set([
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
- ...(options.skipTables ?? []),
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 = 'tenant_id'`, [schema]);
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 tenant-scoped tables: [${remaining.join(', ')}]. ` +
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":"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"}
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,GACd,MAAM,cAAc,CAAC"}
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"}
@@ -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.STAGE === 'dev'`.
92
- * Lets `vk dev` serve tenanted routes from `localhost:3001` by
93
- * accepting an explicit slug from a header or query param.
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;;;;;;;OAOG;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"}
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.STAGE === 'dev' && dev !== false) {
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":"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"}
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.38",
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.38"
33
+ "@venturekit/core": "0.0.40"
34
34
  },
35
35
  "peerDependencies": {
36
- "@venturekit/data": "0.0.38",
37
- "@venturekit/runtime": "0.0.38"
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.38",
50
- "@venturekit/runtime": "0.0.38",
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;