@venturekit-pro/tenancy 0.0.1 → 0.0.3

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.
@@ -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;AAGzB,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
@@ -15,6 +15,10 @@ 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
+ // 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).
18
22
  // Lifecycle (suspend / archive / restore / hard-delete + cascade walker)
19
23
  export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, } from './lifecycle/index.js';
20
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,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,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"}
@@ -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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturekit-pro/tenancy",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
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.1"
33
+ "@venturekit/core": "0.0.3"
34
34
  },
35
35
  "peerDependencies": {
36
- "@venturekit/runtime": "0.0.1"
36
+ "@venturekit/runtime": "0.0.3"
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.1",
45
+ "@venturekit/runtime": "0.0.3",
46
46
  "typescript": "^5.3.0"
47
47
  },
48
48
  "scripts": {