@seifer-webapp-factory/organizations
v0.1.0
Published
organizations — zevende capability-module: verticale, pluggable full-stack multi-tenancy (organisaties/teams: lidmaatschap, rollen, uitnodigingen, groepen, org-context) die de kits samenbindt via één contract. Eigendom is host-gedeclareerd per resource-ty
Readme
organizations — capability module
The organizations capability module — a vertical, full-stack multi-tenancy feature: organizations / teams with membership, roles, email invitations, optional groups, and an active-org context. It pairs a frontend slice with a backend slice through one shared contract, composed from the foundation kits. It is the first module to introduce a tenant boundary as a cross-cutting concern, and the first where data ownership is host-declared per resource type rather than assumed by the module.
Design doc:
../organizations.md· Tier model + conventions:../README.md.
What it is
| Part | Contents |
|---|---|
| contract/ | the seam — endpoint catalogue, DTOs (zod), error taxonomy, events, config, and the ownership/teardown catalogue (ResourceOwnershipDescriptor + the owner→legal-strategy invariant). Both halves derive types from it. |
| backend/src | mechanism (pinned dep): framework-free flow services (create/invite/accept/role-change/leave/delete), a ScopeResolver over access-control ReBAC, and the teardown registry (isomorphic to the privacy provider registry). |
| backend/templates | surface (materialized): NestJS controller/module, organizations/memberships/org_invitations/org_groups (+ org_relations fallback) migrations, pg stores, privacy DataProvider, config fragment, security helpers. |
| frontend/src | mechanism: the typed client + Vue composables (useOrganizations / useMembers / useInvitations) + an SSR-safe active-org context. |
| frontend/templates | surface: organizations.vue, organization-members.vue, organization-invitations.vue, accept-invitation.vue, nav/switcher, i18n, runtime. |
| manifest.ts | facades @seifer-webapp-factory/capability-spec; backendRoutes derived from the contract. |
| scaffolder/ | version-aware materializer + the requires.modules presence-check (refuses without authentication). |
The core idea — ownership is host-declared, per resource type
Ownership is not a module assumption and not a single per-integration default — it varies per
resource type within one app. In zangles a delivered lesson is subject-owned (it survives the school
being deleted, because the student bought it) while the roster is org-owned. The host therefore declares a
ResourceOwnershipDescriptor[] (one per resource type, like user-settings' SettingDescriptor[]) and the
module runs an isomorphic teardown registry over it (like the privacy DataProvider registry). The
owner root constrains the legal teardown strategies (subject ⇒ never cascaded), enforced by the
registry — so a subject-owned record can never be silently deleted because an org left. The same module
serves zangles (mixed ownership) and a corporate LMS (everything → org → cascaded) with no fork.
Composition
- Backend kits:
access-control(ReBAC scope primitive),persistence,mailer,privacy,audit-log,http-kernel,config,i18n. - Frontend kits:
auth,forms,http-client,data,notifications,i18n,app-kernel. - Requires module:
authentication(subject + email via aSubjectProviderport; the single-use token for invitations). Recommended companion:authorization(itsPolicyStoreformember/ownsrelation scoping — without it the module ships anorg_relationsfallback + flat roles). - Requires-ports (host provides):
SubjectProvider,SingleUseTokenService,Mailer, apgPool; optionalrelationStore(authorization's),ownershipDescriptors(app resources),auditLog,events.
Install & wire (backend)
import { OrganizationsModule } from '@seifer-webapp-factory/organizations/backend'; // materialized surface
import { ORGANIZATIONS_SCOPE_RESOLVER } from '@seifer-webapp-factory/organizations/backend';
OrganizationsModule.forRoot({
pool,
// from the authentication module:
subjectProvider: { verify: (token) => authTokenService.verify(token) }, // → { subject, email }
singleUseTokenService, // auth-kit single-use TTL token
mailer, // mailer-kit adapter (invitations)
config: { roles: ['owner', 'admin', 'member'], grouping: 'none', membership: 'multi' },
// host declares ownership per app resource type (drives teardown):
ownershipDescriptors: [lessonDescriptor /* subject/severed */, rosterDescriptor /* org/cascaded */],
});Run the four migrations via the persistence-kit migration runner. Register the module's DataProvider
(ORGANIZATIONS_DATA_PROVIDER) in your privacy registry so subject-erasure removes the subject's
memberships. Consume ORGANIZATIONS_SCOPE_RESOLVER in any tenant-aware module to scope reads to the active org.
Required host env / secrets
Invitations send email, so the module needs a working Mailer. When deploying, the target's deployer must
forward the mailer's env/secrets (e.g. SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM) —
otherwise a missing var arrives empty, the config-kit fails fast, and the container crash-loops before the
migration gate. The module itself owns no other secrets (Postgres comes from the host pool).
Lifecycle (the proven happy path)
POST /organizations → create (creator = first owner; writes member+owner relations)
POST /organizations/:id/invitations→ invite by email (single-use TTL token, mailed)
POST /organizations/invitations/accept → accept (verifies token + email match) → member added
PATCH /organizations/:id/members/:subjectId → change role (last-owner invariant enforced)
POST /organizations/:id/members/leave → leave (last owner cannot)
DELETE /organizations/:id → delete → runs the teardown registry (per-type ownership) → auditedDivergence ladder
- Configure — role set, grouping (none/flat/nested), membership multiplicity, implicit-org, invite TTL.
- Extend — register a
ResourceOwnershipDescriptorfor an app resource; consumeORGANIZATIONS_SCOPE_RESOLVERin another module. - Materialize & edit — eject any
*/templatesfile (e.g.organization-members.vue); the mechanism keeps upgrading via semver, template upgrades become a three-way merge. - Fork — replace the ReBAC scope resolver or the teardown registry (last resort).
Verification (all green)
| Gate | Result |
|---|---|
| Contract + manifest + backend services + frontend client/composables/pages + scaffolder + divergence proof | 74 unit/component tests |
| Backend e2e (Postgres testcontainer + Nest surface) | 6 tests |
| Vertical Playwright e2e (browser → Vite → proxy → Nest → Postgres, assembled with real authentication) | 1 test |
| build · typecheck · typecheck:backend · openapi drift | pass |
Run: npm test (unit) · npm run test:e2e (backend, needs Docker) · npx playwright test (vertical,
needs Docker + browser).
Known debt
The scaffolder core (materialize/presence/ports/errors) is thin re-exports of the shared
@seifer-webapp-factory/scaffolder-core with module-named aliases (materializeOrganizationsModule …), as
flagged in ../README.md. The org_relations fallback exists so the module runs without
authorization; a host with authorization should inject its PolicyStore as relationStore instead.
Production defaults & scaling
OrganizationsModule.forRoot defaults the audit store to inMemoryAuditStore() — in-memory /
per-process. Fine for a single node, but in a multi-node deployment the org audit trail fragments across
processes and is lost on restart. Inject a durable, shared audit-log adapter via the auditLog option
before running more than one node. (Org/membership rows live in Postgres, so tenant state itself is unaffected.)
Accessibility
The frontend/templates surface ships unstyled (styling delegated to the host DesignSystem).
Destructive actions (remove member, delete org, leave) confirm via aria-live. The host must meet the
WCAG 2.2 AA obligations in ../ACCESSIBILITY.md (contrast, visible focus, reduced
motion, <main> landmark).
