Breaking changes and upgrade notes for downstream projects.
The /api/docs UI is now served by redoc-express instead of @scalar/express-api-reference. Redoc renders the same OpenAPI spec (/api/spec.json) with a cleaner three-panel layout better suited to a consumer-facing API reference (no try-it-out panel — the API is API-key-gated and meant for programmatic use).
package.json—@scalar/express-api-referenceremoved,redoc-expressaddedlib/services/express.js—initSwaggermountsredoc({ title, specUrl: '/api/spec.json', redocOptions: { hideDownloadButton, hideSchemaTitles, expandResponses } })instead of the Scalar middleware. Spec assembly, guides loader, YAML merge, and/api/spec.jsonhandler are unchanged.lib/helpers/guides.js— comments updated (Scalar → Redoc); behavior unchanged.modules/core/tests/core.integration.tests.js—describe('Redoc API reference', …)rename; assertions (HTML content-type, valid OpenAPI spec) unchanged.
- Run
/update-stackto pull the change — no project-side YAML, config, or CSP tweaks required. - Visual check: hit
/api/docsand confirm the new Redoc UI renders the merged spec (guides sidebar + endpoint reference).
Rate-limit middleware now keys authenticated requests by user._id (with req.ip fallback) instead of always using IP. Production config enables trust.proxy: 1 so req.ip reflects the real client IP behind a single reverse proxy (Traefik, Nginx).
lib/middlewares/rateLimiter.js— defaultkeyGeneratorusesreq.user._id.toString() || req.ip; custom profilekeyGeneratoris respected via??config/defaults/production.config.js— addstrust.proxy: 1(single hop)
- Run
/update-stackto pull the change - If your production setup has multiple proxy layers, override
trust.proxywith the correct hop count or subnet in your project config
GET /api/tasks/stats now requires authentication and organization context, consistent with all other task endpoints.
modules/tasks/routes/tasks.routes.js— added JWT +resolveOrganization+isAllowedmiddlewaremodules/tasks/controllers/tasks.controller.js— passesreq.organizationto service, uses try/catchmodules/tasks/services/tasks.service.js—stats()accepts organization and filters byorganizationIdmodules/tasks/repositories/tasks.repository.js—stats()usescountDocuments(filter)instead ofestimatedDocumentCount()
- Any unauthenticated call to
/api/tasks/statswill now return401 - Authenticated calls return the count scoped to the user's current organization
- Run
/update-stackto pull the change
Dead scripts and dev-local data removed from the stack. Downstream projects may have local copies or npm scripts referencing these.
scripts/ci/generate-ssl-certs.sh— HTTPS never active in default configsscripts/crons/purgeUploads.js— not wired to any cron or npm scriptscripts/db/mongodump.sh— dev-local only, not used in CIscripts/db/mongorestore.sh— dev-local only, not used in CIscripts/db/dump/— MongoDB fixture data (WaosNodeDev)- npm scripts removed:
seed:mongodump,seed:mongorestore,generate:sllCerts(note: this was a typo ofsslCerts— remove whichever key your project has)
- Delete any local override of the removed scripts if you copied them
- Remove from your
package.jsonany scripts referencingseed:mongodump,seed:mongorestore,generate:sllCerts - If you used
scripts/db/dump/as dev fixtures, move them outside the repo and add to.gitignore - Run
/update-stackto pull the change
The hardcoded route→type map in audit.middleware.js has been removed. Each module now declares its own mapping via audit.routeTypeMap in its module config.
The previous hardcoded map forced optional modules (tasks, billing) to appear in core audit middleware — a violation of module isolation. Moving the map to config means each module owns its audit-type mapping, reducing coupling and keeping cross-module dependencies explicit. New modules can add their own mapping without modifying core code.
modules/audit/middlewares/audit.middleware.js—deriveTargetTypereadsconfig.audit.routeTypeMapinstead of a hardcoded objectmodules/audit/config/audit.development.config.js— added emptyrouteTypeMap: {}basemodules/auth/config/auth.development.config.js— addedaudit.routeTypeMap: { auth: 'User' }modules/users/config/users.development.config.js— addedaudit.routeTypeMap: { users: 'User' }modules/billing/config/billing.development.config.js— addedaudit.routeTypeMap: { billing: 'Organization' }modules/organizations/config/organizations.development.config.js— addedaudit.routeTypeMap: { organizations: 'Organization' }modules/tasks/config/tasks.development.config.js— addedaudit.routeTypeMap: { tasks: 'Task' }
- Run
/update-stackto pull the change - If your project has custom modules that need audit-type labelling, add
audit.routeTypeMapto the module's development config:
// modules/payments/config/payments.development.config.js
const config = {
audit: {
routeTypeMap: {
payments: 'Payment',
},
},
// ... rest of module config
};
export default config;- If no
routeTypeMapentry exists for a route segment, the segment is capitalised as a fallback (same behaviour as before for unknown segments)
The stack no longer provides generic GDPR data export and bulk deletion endpoints. These are downstream product concerns and should be implemented per-project.
GET /api/users/data— export all user dataDELETE /api/users/data— delete user and all associated dataGET /api/users/data/mail— email user data exportmodules/users/controllers/users.data.controller.jsmodules/users/services/users.data.service.jsconfig/templates/data-privacy-email.html
- If your project exposes these endpoints, move the logic into a project-level module
- Remove any frontend calls to
/api/users/data,/api/users/data/mail - Run
/update-stackto pull the change
The config loader now supports per-module project config files in addition to the existing global config/defaults/{project}.config.js.
config/index.js— Layer 3.5 added: auto-discovers and mergesmodules/*/config/*.{project}.config.jsfor non-standardNODE_ENVvalues (i.e. downstream project names)- Per-module project overrides: create
modules/{name}/config/{name}.{project}.config.jsin your downstream project (see README for pattern and examples)
| Layer | Source |
|---|---|
| 1 | modules/*/config/*.development.config.js |
| 2 | config/defaults/development.config.js |
| 3 | config/defaults/{project}.config.js |
| 3.5 | modules/*/config/*.{project}.config.js ← new |
| 4 | DEVKIT_NODE_* env vars |
- Run
/update-stackto pull the change - No breaking change — existing configs are unaffected
- To add per-module project overrides, create
modules/{name}/config/{name}.{yourproject}.config.js
Modules can now ship their own OpenAPI YAML in modules/{name}/doc/{name}.yml. These files are auto-discovered via the modules/*/doc/*.yml glob, merged into the base spec from modules/core/doc/index.yml, and served at /api/spec.json (+ Scalar UI at /api/docs).
modules/core/doc/index.yml— added shared component schemas (SuccessResponse,ErrorResponse) and reusable responses (Unauthorized,Forbidden,NotFound,UnprocessableEntity)modules/tasks/doc/tasks.yml— reference OpenAPI doc for the tasks module (all CRUD + stats endpoints)
- Run
/update-stackto pull the change - No breaking change — existing modules without a
doc/folder are unaffected - To document a custom module, create
modules/{name}/doc/{name}.ymlwith paths, schemas, and tags
swagger-ui-express has been removed. The API documentation UI is now powered by Scalar via @scalar/express-api-reference.
initSwagger()inlib/services/express.jsno longer writes./public/swagger.ymlto disk- New endpoint
GET /api/spec.jsonserves the merged OpenAPI spec as JSON /api/docsnow serves the Scalar UI instead of Swagger UI- Removed unused swagger config options:
swaggerUrl,explore - Removed dependency:
swagger-ui-express - Added dependency:
@scalar/express-api-reference
- Run
/update-stackto pull the change - Remove any references to
./public/swagger.yml— it is no longer generated - If you customized swagger options (e.g.
swaggerUrl,explore), remove them — they are no longer used - The
/api/docsand/api/spec.jsonroutes are available as before
Per-module activated: true/false config flag. When activated: false, the module's routes, policies, models, and swagger YAML are excluded from the app entirely.
- New
filterByActivation(files, config)inlib/helpers/config.js— filters all globbed file arrays by module activation status config/index.jsapplies filtering after config merge to: routes, policies, models, swagger YAML, preRoutes, configs- Core modules (
core,auth,users,home) are always active regardless of flag - New module config files with
activated: truedefault:audit,billing,organizations,uploads,tasks
- Run
/update-stackto pull the change - No breaking change — all modules default to
activated: true(backward compatible) - To deactivate a module, set
DEVKIT_NODE_{moduleName}_activated=falsein env vars or override in config:// config/defaults/development.config.js tasks: { activated: false }
- If you have custom modules, add
activated: truein their config file to be explicit
Subject resolution in lib/middlewares/policy.js is now registry-based instead of hardcoded. Each module's policy file exports a *SubjectRegistration() function that registers its own document-level and path-level subjects during discoverPolicies().
resolveSubject()iteratesdocumentSubjectRegistryinstead of hardcoded if/else chainderiveSubjectType()iteratespathSubjectRegistryinstead of hardcoded if/else chain- New exports:
registerDocumentSubject,registerPathSubject - New helper:
lib/helpers/authorize.js— simple middleware for route-level CASL checks - Each module policy file now exports a
*SubjectRegistration({ registerDocumentSubject, registerPathSubject })function
- Run
/update-stackto pull the change - If you have custom modules with policy files, add a
*SubjectRegistration()export following the pattern in any existing module (e.g.modules/tasks/policies/tasks.policy.js) policy.isAllowedcontinues to work unchanged — no route file modifications needed- Optional: use
authorize(action, subject)fromlib/helpers/authorize.jsfor simple route guards
Deprecation notice:
policy.isAllowedis supported for this release cycle only. New routes should useauthorize(action, subject)fromlib/helpers/authorize.js. Custom modules usingpolicy.isAllowedshould migrate toauthorize()before the next major version. The legacy middleware will be removed once all built-in module routes have been migrated.
New config flags to control IP and User-Agent capture in audit logs for GDPR compliance.
Add to your audit config (e.g. modules/audit/config/audit.development.config.js):
audit: {
captureIp: true, // set false to stop storing client IP addresses
captureUserAgent: true, // set false to stop storing User-Agent strings
}Both default to true (backward compatible). When set to false, the audit log stores an empty string instead of the real value.
- Run
/update-stackto pull the change - Optionally set
captureIp: falseand/orcaptureUserAgent: falsein your audit config for GDPR compliance - No DB migration needed — existing entries are unaffected
Structured logging, audit trail, Sentry error capture, and enriched health check.
modules/audit/ — auto-discovered, no manual registration needed.
@sentry/node— error tracking (no-op when unconfigured)
Add to your env-specific config or override via DEVKIT_NODE_* env vars:
// Audit log (modules/audit/config/audit.development.config.js)
audit: {
enabled: true, // set false to disable audit logging
ttlDays: 90, // auto-purge after N days (MongoDB TTL index)
}
// Sentry (config/defaults/development.config.js)
sentry: {
dsn: '', // Sentry DSN — empty = disabled
environment: 'development',
enabled: false,
}
// Logging (config/defaults/development.config.js)
log: {
json: false, // true = structured JSON output (recommended for prod)
level: 'info', // Winston log level
}All features are no-op when not configured — safe to deploy without Sentry or audit.
| Feature | File | Notes |
|---|---|---|
| Winston JSON logging | lib/services/logger.js |
Structured JSON when log.json: true, configurable level |
| X-Request-ID | lib/middlewares/requestId.js |
UUID per request, req.id + response header |
| Sentry SDK | lib/services/sentry.js |
Error capture, no-op when DSN empty |
| AuditLog model | audit.model.mongoose.js |
TTL index, auto-purge via audit.ttlDays |
| Audit middleware | audit.middleware.js |
Auto-captures POST/PUT/DELETE mutations (same pattern as analytics) |
| Audit API | GET /api/audit |
Admin-only, paginated, filterable by action/userId/orgId |
| Audit policy | audit.policy.js |
CASL: admin read-only |
| Health endpoint | GET /api/health |
Public: { status }, Admin (JWT): { status, db, uptime, version, memory } |
| Collection | Model | Purpose | TTL |
|---|---|---|---|
auditlogs |
AuditLog |
Action audit trail (who did what when) | Configurable via audit.ttlDays |
- Run
/update-stackto pull the new modules - Set env vars if needed:
DEVKIT_NODE_sentry__dsn,DEVKIT_NODE_audit__ttlDays - No DB migration needed — collection and TTL index auto-created on first write
Server-side analytics, user/org identification, API auto-capture, and feature flags via PostHog.
modules/analytics/ — auto-discovered, no manual registration needed.
Uncomment and set in your env-specific config (e.g. modules/analytics/config/analytics.development.config.js):
posthog: {
apiKey: process.env.DEVKIT_NODE_posthog_apiKey ?? '',
host: process.env.DEVKIT_NODE_posthog_host ?? 'https://us.i.posthog.com',
}All features are no-op when apiKey is empty — safe to deploy without PostHog.
| Feature | File | Notes |
|---|---|---|
| Analytics service | analytics.service.js |
track(), identify(), groupIdentify() |
| Auto-capture middleware | analytics.middleware.js |
Captures api_request on all routes (except health/public) |
| Feature flags service | analytics.featureFlags.service.js |
isEnabled() (safe default false when not configured), getVariant() (undefined when not configured) |
requireFeatureFlag middleware |
analytics.requireFeatureFlag.js |
401 when unauthenticated, 403 when flag disabled, fail-open when analytics not configured |
| Billing integration | analytics.init.js |
Listens to plan.changed event → groupIdentify |
- Run
/update-stackto pull the new module - Set env vars:
DEVKIT_NODE_posthog_apiKey,DEVKIT_NODE_posthog_host - No DB migration needed — all data stored in PostHog
This guide is for downstream projects (e.g. lou-node, pierreb-node) migrating to the new organizations + CASL document-level authorization system introduced on the feature/signup-org-flow branch.
- Route-level rules replaced by document-level abilities. Policy files no longer call
policy.registerRules()with route paths. Instead, each policy file exports named functions (<module>Abilitiesand optionally<module>GuestAbilities) that receive(user, membership, { can, cannot })and define CASL conditions on subject types (e.g.'Task','Upload'). policy.isOwnermiddleware removed. Ownership is now enforced automatically via CASL conditions (e.g.{ user: String(user._id) }). Remove allpolicy.isOwnercalls from routes and allreq.isOwnerassignments from controllers/param middleware.policy.registerRules()removed. Replaced bypolicy.registerAbilities()(called automatically bypolicy.discoverPolicies()).- Policy auto-discovery.
initModulesServerPolicies()inlib/services/express.jsnow callspolicy.discoverPolicies(policyPaths)instead of looping overinvokeRolesPolicies().
- Signup response now includes
organization,abilities(array of CASL rules), andorganizationSetupRequiredfields. - JWT payload remains
{ userId }(unchanged), but the user's organization context is resolved server-side viauser.currentOrganization.
- User model: new
currentOrganizationfield (ObjectIdref toOrganization). - Task model: new
organizationIdfield (ObjectIdref toOrganization). - Upload model: new
metadata.organizationIdfield (ObjectIdref toOrganization). - Task schema (Zod): new optional
organizationIdfield. - User schema (Zod): new optional
currentOrganizationfield; added towhitelists.users.defaultandwhitelists.users.update.
| Collection | Mongoose model | Purpose |
|---|---|---|
organizations |
Organization |
Multi-tenant organization records |
memberships |
Membership |
User-to-organization membership + role |
migrations |
Migration |
Tracks executed migration scripts |
None for Node (CASL @casl/ability was already installed). No new npm packages required.
- New
organizationssection inmodules/auth/config/auth.development.config.jswith keysenabled,autoCreate, anddomainMatching.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/organizations |
JWT | List user's organizations |
POST |
/api/organizations |
JWT | Create a new organization |
GET |
/api/organizations/:organizationId |
JWT | Get organization details |
PUT |
/api/organizations/:organizationId |
JWT | Update organization |
DELETE |
/api/organizations/:organizationId |
JWT | Delete organization |
GET |
/api/admin/organizations |
JWT+Admin | Platform admin: list all orgs |
GET |
/api/admin/organizations/:organizationId |
JWT+Admin | Platform admin: get org |
DELETE |
/api/admin/organizations/:organizationId |
JWT+Admin | Platform admin: delete org |
GET |
/api/organizations/:organizationId/members |
JWT | List members |
POST |
/api/organizations/:organizationId/members/invite |
JWT | Invite a member |
PUT |
/api/organizations/:organizationId/members/:memberId |
JWT | Update member role |
DELETE |
/api/organizations/:organizationId/members/:memberId |
JWT | Remove member |
- MongoDB accessible and writable (the migration script creates documents at boot).
- Current stack version on
master(before migration) — your downstream project should be up to date with the latestmasterbefore merging the feature branch.
Three new files power the automatic migration system:
| File | Purpose |
|---|---|
lib/services/migrations.js |
Discovers modules/*/migrations/*.js files, checks the migrations collection, runs pending up() functions in filename order |
modules/core/models/migration.model.mongoose.js |
Mongoose model for tracking executed migrations (name, executedAt) |
lib/app.js |
Calls migrations.run() after MongoDB connects, before Express starts |
The migration runner is integrated into the bootstrap sequence in lib/app.js:
db = await startMongoose();
await migrations.run(); // <-- new line
app = await startExpress();Migration files live in modules/<name>/migrations/ and are named with a date prefix for ordering (e.g. 20260310120000-organizations-init.js). Each file exports an up() function.
Before (master):
// Global rules registry — route-path based
const rulesRegistry = [];
const registerRules = (rules) => rulesRegistry.push(...rules);
const defineAbilityFor = async (user) => {
const roles = user ? user.roles : ['guest'];
for (const rule of rulesRegistry) {
if (rule.roles.some((r) => roles.includes(r))) {
can(rule.actions, rule.subject); // subject = route path like '/api/tasks'
}
}
return build();
};
const isAllowed = async (req, res, next) => {
const ability = await defineAbilityFor(req.user);
if (ability.can(action, req.route.path)) return next(); // checks route path
// ...
};
const isOwner = (req, res, next) => {
if (req.user && req.isOwner && String(req.isOwner) === String(req.user._id)) return next();
// ...
};
export default { registerRules, isAllowed, isOwner };After (feature branch):
// Abilities registry — document/subject-type based
const abilitiesRegistry = [];
const registerAbilities = (entry) => {
abilitiesRegistry.push(entry);
};
const defineAbilityFor = async (user, membership) => {
for (const entry of abilitiesRegistry) {
if (user && entry.abilities) {
entry.abilities(user, membership || null, { can, cannot });
} else if (!user && entry.guestAbilities) {
entry.guestAbilities({ can, cannot });
}
}
return build();
};
const isAllowed = async (req, res, next) => {
const ability = await defineAbilityFor(req.user, req.membership || null);
const subjectInfo = resolveSubject(req); // checks req.task, req.upload, etc.
if (subjectInfo) {
// Document-level check with CASL subject()
if (ability.can(action, subject(subjectInfo.subjectType, subjectInfo.document))) return next();
} else {
// Collection-level check — derive subject type from route path
const subjectType = deriveSubjectType(req.route.path);
if (subjectType && ability.can(action, subjectType)) return next();
}
// ...
};
// isOwner is REMOVED — no longer exported
export default { registerAbilities, defineAbilityFor, isAllowed, discoverPolicies, deriveSubjectType };Key additions in the new policy middleware:
normalizeForCasl(doc)— converts Mongoose documents to plain objects with string IDs for CASL condition matching.resolveSubject(req)— mapsreq.task,req.upload,req.model,req.membershipDoc,req.organizationto CASL subject types.deriveSubjectType(routePath)— maps route path prefixes to subject type strings for collection-level checks.discoverPolicies(policyPaths)— auto-discovers and registers ability builder functions from policy files.
Before:
const initModulesServerPolicies = async () => {
for (const policyPath of config.files.policies) {
const policy = await import(path.resolve(policyPath));
policy.default.invokeRolesPolicies();
}
};After:
const initModulesServerPolicies = async () => {
const policyMod = await import('../middlewares/policy.js');
await policyMod.default.discoverPolicies(config.files.policies);
};Tasks policy (modules/tasks/policies/tasks.policy.js)
Before:
import policy from '../../../lib/middlewares/policy.js';
const invokeRolesPolicies = () => {
policy.registerRules([
{ roles: ['user'], actions: 'manage', subject: '/api/tasks' },
{ roles: ['user'], actions: 'manage', subject: '/api/tasks/:taskId' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks/stats' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks' },
]);
};
export default { invokeRolesPolicies };After:
export function taskAbilities(user, membership, { can }) {
if (user.roles.includes('admin')) { can('manage', 'all'); return; }
if (membership) {
const organizationId = String(membership.organizationId);
can('create', 'Task', { organizationId });
can('read', 'Task', { organizationId });
can('update', 'Task', { organizationId, user: String(user._id) });
can('delete', 'Task', { organizationId, user: String(user._id) });
} else {
can('read', 'Task');
can('create', 'Task');
can('update', 'Task', { user: String(user._id) });
can('delete', 'Task', { user: String(user._id) });
}
}
export function taskGuestAbilities({ can }) {
can('read', 'Task');
}Uploads policy (modules/uploads/policies/uploads.policy.js)
Before:
const invokeRolesPolicies = () => {
policy.registerRules([
{ roles: ['user', 'admin'], actions: ['read', 'delete'], subject: '/api/uploads/:uploadName' },
{ roles: ['guest', 'user', 'admin'], actions: ['read'], subject: '/api/uploads/images/:imageName' },
]);
};
export default { invokeRolesPolicies };After:
export function uploadAbilities(user, membership, { can }) {
if (user.roles.includes('admin')) { can('manage', 'all'); return; }
can('read', 'Upload');
can('delete', 'Upload', { 'metadata.user': String(user._id) });
}
export function uploadGuestAbilities({ can }) {
can('read', 'Upload');
}Home policy (modules/home/policies/home.policy.js)
Before:
const invokeRolesPolicies = () => {
policy.registerRules([
{ roles: ['guest'], actions: ['read'], subject: '/api/home/releases' },
{ roles: ['guest'], actions: ['read'], subject: '/api/home/changelogs' },
{ roles: ['guest'], actions: ['read'], subject: '/api/home/team' },
{ roles: ['guest'], actions: ['read'], subject: '/api/home/pages/:name' },
]);
};
export default { invokeRolesPolicies };After:
export function homeAbilities(user, membership, { can }) {
can('read', 'Home');
}
export function homeGuestAbilities({ can }) {
can('read', 'Home');
}Users account policy (modules/users/policies/users.account.policy.js) — new file, replaces the user-related rules that were previously in a single users policy.
export function userAccountAbilities(user, membership, { can }) {
if (user.roles.includes('admin')) { can('manage', 'all'); return; }
can('read', 'UserAccount');
can('create', 'UserAccount');
can('update', 'UserAccount');
can('delete', 'UserAccount');
can('update', 'UserSelf');
can('delete', 'UserSelf');
}
export function userAccountGuestAbilities({ can }) {
can('read', 'UserAccount');
}Users admin policy (modules/users/policies/users.admin.policy.js) — new file.
export function userAdminAbilities(user, membership, { can }) {
if (user.roles.includes('admin')) {
can('manage', 'UserAdmin');
can('read', 'UserSelf');
}
}In routes that previously used policy.isOwner, remove those calls. Ownership is now enforced by CASL conditions in policy.isAllowed.
Tasks routes — before:
app.route('/api/tasks/:taskId')
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
.get(tasks.get)
.put(model.isValid(tasksSchema.TaskUpdate), policy.isOwner, tasks.update)
.delete(policy.isOwner, tasks.remove);Tasks routes — after:
app.route('/api/tasks/:taskId')
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
.get(tasks.get)
.put(model.isValid(tasksSchema.TaskUpdate), tasks.update)
.delete(tasks.remove);Note: organization.resolveOrganization is added to org-scoped routes (tasks). The policy.isOwner calls are gone.
If your downstream project sets req.isOwner in any param middleware (e.g. taskByID), remove those assignments. They are no longer used.
Every policy's abilities function should start with:
if (user.roles.includes('admin')) {
can('manage', 'all');
return;
}This gives platform admins full access to everything, matching the old behavior where admins had manage on all routes.
The organizations module follows the standard Devkit module structure:
modules/organizations/
controllers/
organizations.controller.js # CRUD + adminList + organizationByID param middleware
organizations.membership.controller.js # list, invite, updateRole, remove + memberByID
helpers/
slug.js # slugify() + generateOrganizationSlug()
migrations/
20260310120000-organizations-init.js # Creates default orgs for existing users, backfills tasks
models/
organizations.model.mongoose.js # Organization Mongoose model (name, slug, domain, plan, createdBy)
organizations.schema.js # Zod validation schema
organizations.membership.model.mongoose.js # Membership Mongoose model (userId, organizationId, role)
organizations.membership.schema.js # Zod validation (MembershipInvite, MembershipUpdate)
policies/
organizations.policy.js # CASL abilities for Organization + Membership subjects
repositories/
organizations.repository.js # Data access for organizations
organizations.membership.repository.js # Data access for memberships
routes/
organizations.routes.js # Organization CRUD + admin routes
organizations.membership.routes.js # Member management routes (nested under org)
services/
organizations.service.js # Business logic for organizations
organizations.membership.service.js # Business logic for memberships
tests/
organizations.integration.tests.js
organizations.membership.integration.tests.js
organizations.migration.integration.tests.js
organizations.migration.unit.tests.js
Membership roles:
owner— full control over the organization and its membersadmin— can update the organization and manage members, but cannot delete the organizationmember— read-only access to the organization and its member list
The resolveOrganization middleware:
- Reads the organization ID from
req.params.organizationIdorreq.user.currentOrganization. - Loads the
Organizationdocument ontoreq.organization. - Loads the user's
Membershipdocument ontoreq.membership. - Platform admins (
roles: ['admin']) bypass the membership check and receive a synthetic owner-level membership. - If no organization context is present, the middleware passes through silently (backward compatibility).
Task model (modules/tasks/models/tasks.model.mongoose.js):
organizationId: {
type: Schema.ObjectId,
ref: 'Organization',
},Upload model (modules/uploads/models/uploads.model.mongoose.js) — inside metadata:
metadata: {
// ... existing fields
organizationId: {
type: Schema.ObjectId,
ref: 'Organization',
},
}Task Zod schema (modules/tasks/models/tasks.schema.js):
organizationId: z.string().trim().optional(),User model (modules/users/models/user.model.mongoose.js):
currentOrganization: {
type: Schema.ObjectId,
ref: 'Organization',
},User Zod schema (modules/users/models/user.schema.js):
currentOrganization: z.string().trim().optional(),Also update modules/auth/config/auth.development.config.js to add currentOrganization to whitelists.users.default and whitelists.users.update.
TasksService.list(organization)— accepts optional organization, filters byorganizationIdwhen present.TasksService.create(body, user, organization)— setsorganizationIdon the task when an organization is provided.tasks.controller.js— passesreq.organizationto service calls.
Add organization.resolveOrganization to routes that need org context:
import organization from '../../../lib/middlewares/organization.js';
// In task routes:
app.route('/api/tasks')
.post(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed, ...);
app.route('/api/tasks/:taskId')
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed);modules/auth/controllers/auth.controller.js now calls AuthOrganizationService.handleSignupOrganization(user) after creating the user. The response includes:
{
"user": { ... },
"tokenExpiresIn": 1234567890,
"organization": { "name": "...", "slug": "...", ... },
"abilities": [ { "action": "read", "subject": "Task", ... }, ... ],
"organizationSetupRequired": false,
"type": "sucess",
"message": "Sign up"
}Handles four scenarios based on config:
organizations.enabled |
autoCreate |
domainMatching |
Behavior |
|---|---|---|---|
false |
- | - | Creates a silent default org named "{firstName}'s organization" |
true |
false |
- | Returns null; user sets up org manually (organizationSetupRequired: true) |
true |
true |
true |
Joins existing org with matching email domain, or creates new domain-based org |
true |
true |
false |
Always creates a personal org |
The signup response includes abilities — an array of CASL rule objects that the frontend can use to build its own CASL ability instance for UI permission checks.
Added to modules/auth/config/auth.development.config.js:
organizations: {
enabled: false, // when false, a silent default org is created for the user (B2C mode)
autoCreate: true, // when true, org is created/joined automatically at signup
domainMatching: true, // when true, new users join existing orgs with matching email domain
},Override via environment variables:
DEVKIT_NODE_organizations_enabled=true
DEVKIT_NODE_organizations_autoCreate=true
DEVKIT_NODE_organizations_domainMatching=falsecurrentOrganization is added to both whitelists.users.default and whitelists.users.update arrays so it can be read and updated via the API.
The migration script runs automatically at boot (in lib/app.js, after MongoDB connects). No manual step is needed.
What the 20260310120000-organizations-init.js migration does:
- Finds all users who do not yet have a membership.
- For each user, creates a personal organization (
"{firstName}'s organization") with a unique slug, and anownermembership. - Backfills
organizationIdon all tasks that are missing one, using the task owner's owner membership to determine the org.
The migration is idempotent: users who already have a membership are skipped, tasks with an existing organizationId are not touched. It is safe to run multiple times.
Tracking: Executed migrations are recorded in the migrations collection. The runner checks this collection before each run and skips already-executed scripts.
- Every route has a CASL policy (check
policy.isAllowedis in every route chain) - No route bypasses CASL (no unprotected endpoints)
- 403 tested for unauthorized access on every endpoint
- Ownership verified via CASL conditions (not
isOwnermiddleware) - Org isolation: no cross-org data leak (tasks filtered by
organizationId) - Platform admin access verified (
can('manage', 'all')) - Migration script is idempotent (safe to run repeatedly)
-
isOwnermiddleware fully removed from all routes and controllers -
req.isOwnerassignments removed from all param middleware
| Key | Type | Default | Description |
|---|---|---|---|
organizations.enabled |
boolean |
false |
true = B2B mode (explicit orgs), false = B2C mode (silent default org per user) |
organizations.autoCreate |
boolean |
true |
When enabled, automatically create/join an org at signup |
organizations.domainMatching |
boolean |
true |
When enabled + autoCreate, match new users to existing orgs by email domain |
If you need to revert after merging:
- Git revert:
git revert <merge-commit>to undo the merge. - Database cleanup (optional, only if the migration has run):
- The
organizations,memberships, andmigrationscollections can be dropped if no production data depends on them. - The
organizationIdfield on tasks andcurrentOrganizationon users can be left in place (Mongoose ignores unknown fields) or removed via a manual migration script.
- The
- Restore old policies: The git revert will restore the old
invokeRolesPoliciespattern andisOwnermiddleware. - Restart the application: The old boot sequence (without
migrations.run()) will be restored.
Warning: If users have already created organizations or memberships in production, dropping those collections will lose that data. Plan accordingly.
All config files now follow the module.env.kind.js naming convention consistently.
- Global defaults renamed:
config.{env}.js→{env}.config.js(e.g.development.config.js) - Module defaults renamed:
config.{module}.js→{module}.development.config.js(e.g.auth.development.config.js) - Init files renamed:
{module}.config.js→{module}.init.js(e.g.auth.init.js) to avoid collision with config suffix - Loader updated:
config/index.jsglobsmodules/*/config/*.development.config.jsfor defaults - Assets glob updated:
config/assets.jsglobsmodules/*/config/*.init.jsfor module init files - Template renamed:
config/defaults/myproject.config.js
| File type | Pattern | Example |
|---|---|---|
| Global default | {env}.config.js |
development.config.js |
| Global override | {env}.config.js |
production.config.js |
| Module default | {module}.development.config.js |
auth.development.config.js |
| Module env override | {module}.{env}.config.js |
uploads.test.config.js |
| Downstream project | {project}.config.js |
myproject.config.js |
| Module init (Express) | {module}.init.js |
auth.init.js |
Config belongs to the module that semantically owns the data, even if other modules read it. Global keeps only pure infrastructure (db, cors, api, log, mailer, etc.). This enables autonomous, pluggable modules.
| Key | Owner | Why |
|---|---|---|
jwt, sign, oAuth, zxcvbn, rateLimit |
auth |
Auth defines how users authenticate |
whitelists, blacklists |
users |
Users defines its own field visibility |
uploads, sharp |
uploads |
Uploads defines its own processing rules |
organizations, roles, roleDescriptions, publicDomains |
organizations |
Orgs defines its own structure |
repos |
home |
Home defines its own data sources |
app, swagger, api, db, log, cors, cookie, mailer, seedDB |
global | Pure infrastructure, no module owns them |
config/defaults/
development.config.js ← infra only (app, swagger, api, db, log, csrf, cors, cookie, mailer, seedDB)
production.config.js ← production overrides (standalone)
test.config.js ← test overrides (standalone)
myproject.config.js ← template for downstream projects
modules/auth/config/
auth.init.js ← passport init (loaded by assets glob)
auth.development.config.js ← sign, jwt, oAuth, zxcvbn, rateLimit
modules/users/config/
users.development.config.js ← whitelists, blacklists
modules/uploads/config/
uploads.development.config.js ← uploads, sharp
modules/organizations/config/
organizations.development.config.js ← organizations, roles, roleDescriptions, publicDomains
modules/home/config/
home.development.config.js ← repos
- Module defaults —
modules/*/config/*.development.config.js - Global defaults —
config/defaults/development.config.js - Global env overrides —
config/defaults/${NODE_ENV}.config.js(if NODE_ENV ≠ development) DEVKIT_NODE_*environment variables
Create NODE_ENV=staging by adding any of:
config/defaults/staging.config.js(global overrides)
Files must be named {projectname}.config.js. A template is provided at config/defaults/myproject.config.js.
- Rename global config files:
config.{env}.js→{env}.config.js - Rename module config files:
config.{module}.js→{module}.development.config.js - Rename init files:
{module}.config.js→{module}.init.js - Rename project config files:
config.{project}.js→{project}.config.js - Run
npm run lint && npm testto confirm everything works.
The monolithic config/defaults/development.js has been split into per-module config files.
See "Config file naming convention (2026-03-13)" above for the current naming standard.
acl@0.4.11 (unmaintained since 2018) has been replaced by @casl/ability.
lib/middlewares/policy.jsno longer exportsAcl.- Policy files now call
policy.registerRules([...])instead ofpolicy.Acl.allow([...]). isAllowedandisOwnermiddleware signatures are unchanged — routes do not need to be updated.
| HTTP method | CASL action |
|---|---|
GET |
read |
POST |
create |
PUT / PATCH |
update |
DELETE |
delete |
* (all) |
manage |
Before (acl):
import policy from '../../../lib/middlewares/policy.js';
const invokeRolesPolicies = () => {
policy.Acl.allow([
{
roles: ['user'],
allows: [
{ resources: '/api/tasks', permissions: '*' },
{ resources: '/api/tasks/:taskId', permissions: '*' },
],
},
{
roles: ['guest'],
allows: [
{ resources: '/api/tasks/stats', permissions: ['get'] },
{ resources: '/api/tasks', permissions: ['get'] },
{ resources: '/api/tasks/:taskId', permissions: ['get'] },
],
},
]);
};
export default { invokeRolesPolicies };After (@casl/ability):
import policy from '../../../lib/middlewares/policy.js';
const invokeRolesPolicies = () => {
policy.registerRules([
{ roles: ['user'], actions: 'manage', subject: '/api/tasks' },
{ roles: ['user'], actions: 'manage', subject: '/api/tasks/:taskId' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks/stats' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks/:taskId' },
]);
};
export default { invokeRolesPolicies };policy.defineAbilityFor(user) returns a Promise<Ability> (lazy-loads @casl/ability on first call). Express isAllowed middleware is async and works unchanged. If you test defineAbilityFor directly, await it:
// Unit test
const ability = await policy.defineAbilityFor(null);
expect(ability.can('read', '/api/tasks')).toBe(true);Jest note:
policy.jsmust be a static top-level import in the test file (not only reached via dynamicimport()). This pre-loads the module in Jest's VM registry before policy files are dynamically imported inbeforeAll.import policy from '../../../lib/middlewares/policy.js'; // required at top level
npm remove acl && npm install @casl/ability- Update every
modules/*/policies/*.policy.jsfollowing the pattern above. - Remove any direct use of
policy.Acl(it is no longer exported). - If you have unit tests that call
defineAbilityFor, addimport policy from '...policy.js'as a top-level static import andawaitthe call. - Run
npm run lint && npm test— all existing 403/200 assertions should pass unchanged.
@hapi/joi (abandoned), body-parser (built into Express 4.16+), swig and consolidate (template engine, unused in API-only mode) have been removed.
lib/helpers/joi.jsdeleted →lib/helpers/zod.js(zxcvbnsuperRefinehelper).lib/middlewares/model.js:getResultFromJoi(body, schema, options)→getResultFromZod(body, schema)(no options arg).model.isValid(schema)middleware interface is unchanged — routes do not need updating.config.joirenamed toconfig.validation;validationOptionskey removed (Zod handles stripping and defaults internally).- PUT routes should use a
.partial()schema (TaskUpdate,UserUpdate) for partial updates.
Before (@hapi/joi):
import Joi from '@hapi/joi';
const TaskSchema = Joi.object().keys({
title: Joi.string().trim().default('').required(),
description: Joi.string().allow('').default('').required(),
});
export default { Task: TaskSchema };After (zod@3):
import { z } from 'zod';
const Task = z.object({
title: z.string().trim().min(1),
description: z.string().default(''),
}).strip();
const TaskUpdate = Task.partial();
export default { Task, TaskUpdate };Replace schema.Task.validate(data, options) with schema.Task.safeParse(data). The result shape changes:
| Joi | Zod | |
|---|---|---|
| Success | { value: T, error: undefined } |
{ success: true, data: T } |
| Failure | { value: T, error: ValidationError } |
{ success: false, error: ZodError } |
Assertions like expect(result.error).toBeFalsy() / .toBeDefined() work unchanged. To verify field stripping, check result.data?.unknownField (not result.unknownField).
npm remove @hapi/joi body-parser swig consolidate && npm install zod@3- Rewrite
modules/*/models/*.schema.jsusing the Zod pattern above. - If you call
model.getResultFromJoi(body, schema, options)directly, replace withmodel.getResultFromZod(body, schema). - Rename
config.joi→config.validationin allconfig/defaults/*.js; removevalidationOptions. - Update unit tests from
.validate()to.safeParse(). - Run
npm run lint && npm test— all existing 422/200 assertions should pass unchanged.