@mounaji_npm/tenancy-core
v0.6.6
Published
Config-driven multi-tenant engine ? define a tenancy hierarchy (organizations, projects, custom levels) once and generate the scope model, SQL schema, stores, handlers and route factory from it
Maintainers
Readme
@mounaji_npm/tenancy-core
Config-driven multi-tenant engine for the Mounaji npm component platform. Define
your tenancy hierarchy once and derive the scope model, SQL schema, stores,
API handlers and route factory from it. Adding a level (e.g. workspace) is
configuration — not a rewrite.
- Auth: bring your own (NextAuth + Supabase by default). This package never
handles login; it consumes a session via
getSession. - Isolation: app-layer guards + Supabase service-role client.
- Roles: custom per tenant — role templates are cloned per entity on creation.
1. Define your hierarchy
// lib/tenancy/model.js
import { defineTenancy } from '@mounaji_npm/tenancy-core';
export const tenancy = defineTenancy(); // org → project default
// or: defineTenancy({ levels: [...custom...] })2. Run the migrations (Supabase SQL editor)
import { generateTenancyMigration } from '@mounaji_npm/tenancy-core/server';
console.log(generateTenancyMigration(tenancy)); // paste output into SupabaseAlso run the RBAC scope migration from @mounaji_npm/organization-team:
src/server/stores/migration.sql (fresh) or migration.scope.sql (existing DB).
3. Build the store (server)
// lib/tenancy/store.js
import { createClient } from '@supabase/supabase-js';
import {
createTenancyStore, createSupabaseRbac, createInvitationEmailSender,
} from '@mounaji_npm/tenancy-core/server';
import { createResendSender } from '@mounaji_npm/notifications/server';
import { EventBus } from '@mounaji_npm/event-core';
import { tenancy } from './model.js';
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
const rbac = createSupabaseRbac(supabase);
// Invitation email — decoupled: pass any mailer with sendEmail()/sendNotification().
const mailer = createResendSender({ apiKey: process.env.RESEND_API_KEY, from: 'Acme <[email protected]>' });
export const store = createTenancyStore(tenancy, {
supabase, rbac,
// fires after each invitation is persisted → builds the accept magic-link + email
sendInvitationEmail: createInvitationEmailSender({
mailer,
appUrl: process.env.APP_URL, // https://app.acme.com
acceptPath: '/accept-invitation/:token', // default; matches AcceptInvitationPanel
model: tenancy, // level labels ("Organization"/"Project")
resolveScope: async (inv) => ({
name: (await store.entities(inv.scope_type).get(inv.scope_id))?.name,
}),
}),
// optional: fan tenancy activity into the event/notifications pipeline
events: EventBus, // emits invitation.sent/accepted/declined + member.joined/removed/role_changed
});events accepts anything EventBus-like ({ emit(type, payload) }); subscribe
with EventBus.on('invitation.accepted', …) to trigger welcome emails, audit
logs, webhooks, etc. Works for any level with invitable: true —
organization, project, or a custom personal level you add in the config.
For tests / offline: createMemoryTenancyStore(tenancy, { rbac: createMemoryRbac() }).
4. Mount the routes (Next.js App Router)
// app/api/tenancy/[level]/route.js
import { createTenancyRoutes } from '@mounaji_npm/tenancy-core/nextjs';
import { store } from '@/lib/tenancy/store';
import { getSession } from '@/lib/auth/getSession'; // returns { user: { email, id?, name? } }
const routes = createTenancyRoutes({ store, getSession });
export const GET = routes.entities.list;
export const POST = routes.entities.create;See src/nextjs/createTenancyRoutes.js for the full route map
([level]/[id], .../members, .../members/invite, .../members/[userId],
invitations/[token], invitations/[token]/accept, scopes).
5. Client (React)
import { ScopeProvider, useScope } from '@mounaji_npm/scope-context';
import { TenancyProvider, MembersPanel, EntityCreateForm, TenancyScopeSwitcher }
from '@mounaji_npm/tenancy-core/client';
<ScopeProvider>
<TenancyProvider basePath="/api/tenancy">
<EntityCreateForm level="organization" onCreated={...} />
<MembersPanel level="organization" id={orgId} canManage />
{/* switch tenant; feed the chosen scope to scope-context */}
<TenancyScopeSwitcher onChange={(scope) => setScope(scope)} />
</TenancyProvider>
</ScopeProvider>The acting scope for writes is taken from the path (level + id), so the
server always checks permissions against the exact entity. Use scope-context
only to decide which entity the UI is showing.
Architecture
forum-contracts (createScopeSystem) ← zero-dep scope engine
▲
tenancy-core
defineTenancy ─ scope system + level graph + per-level actions/roles
server/ generateTenancyMigration · createTenancyStore · createSupabaseRbac
createMemoryTenancyStore · createMemoryRbac · createTenancyHandlers
createInvitationEmailSender (email glue, decoupled from notifications)
nextjs/ createTenancyRoutes (requireScopePerm guard)
client/ TenancyProvider · MembersPanel · InviteMemberForm ·
EntityCreateForm · AcceptInvitationPanel · TenancyScopeSwitcher
▲
organization-team (rbac_roles/_user_roles gain scope_type+scope_id)
module-registry-core / module-gates ← capabilities per scope (future entities)