@venturekit-pro/tenancy 0.0.0-dev.20260701100017 → 0.0.0-dev.20260704225856

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