Breaking changes and upgrade notes for downstream projects.
Unmatched routes return the same content-negotiated 404 on every HTTP verb (2026-08-03, closes gap from #3975)
An earlier change (#3975) replaced the implicit 200 previously returned for any unmatched GET with a content-negotiated 404 (JSON { error: 'not_found' } for API paths/JSON-accepting clients, minimal HTML otherwise) — but the catch-all was registered with app.get only. Unmatched POST/PUT/PATCH/DELETE were unaffected by that change and already 404'd via Express's own default finalhandler, just as unstructured HTML (Cannot POST /...) rather than the negotiated shape. The catch-all is now registered with app.all, so every unmatched verb gets the same negotiated 404 body/content-type as GET. OPTIONS is unaffected: the cors middleware answers preflight requests before this route is ever reached.
Action required: this only changes the response shape for non-GET verbs (still a 404, now content-negotiated); the status-code flip from implicit 200 to 404 only ever applied to GET, and only ever needed action there (per #3975: any readiness/health check must target a real, declared route, e.g. GET /api/health, not an undeclared path). If any tooling parses the body of a 404 on a non-GET verb expecting HTML, it now gets the negotiated JSON/HTML shape instead.
Migration runner: claim-with-status — interrupted runs resume instead of being skipped forever (2026-07-28)
Fixes a data-integrity gap in lib/services/migrations.js: the runner previously claimed a migration as executed (an insert into the migrations collection) BEFORE calling its up(). A hard process kill mid-up() (OOM, SIGKILL, pod eviction) left that claim in place with no completion signal — on the next boot the migration was treated as already done and permanently skipped, even though up() never finished (found reviewing #3990's backfill: an interrupted updateMany could strand a subset of documents; the runner semantics were the generic root cause).
Every migration's up() MUST be safe to re-run from scratch — this was already the de facto style in this repo (every existing migration backfills/creates conditionally, e.g. skip-if-already-set, skip-if-index-already-exact-spec), but it is now a hard requirement, not a convention: the boot-time stale-claim resume below re-runs a migration whenever a 'running' claim is found stuck past the grace window, with no way to know how far the interrupted run got. A non-idempotent up() would corrupt data on resume.
modules/core/models/migration.model.mongoose.js— the Migration schema gainsstatus: 'running' | 'done'(no default),startedAt,finishedAt, and forensic contextpid/host(captured at claim time).modules/core/repositories/migration.repository.js— newclaim(name, {pid, host})(insertsstatus:'running'),markDone(name)(atomic$set: {status:'done', finishedAt}),listRunning(),findByName(name).listExecuted()now projectsstatustoo.create(name)(used byrecordMigration) is unchanged — it never setsstatus, which is correct: see back-compat below.lib/services/migrations.js:claimMigrationnow callsrepository.claim(...)instead of a bare insert — the claim record isstatus:'running'from the moment it's written.- On
up()success,runMigrationcalls the newmarkMigrationDone(name)to atomically flip the claim tostatus:'done'. The thrown-error path is unchanged —up()throwing still unclaims (deletes) the record so the next boot retries, exactly as before. - New
resolveStaleClaims(cfg), called byrun()BEFORE the files/executed comparison. Scans everystatus:'running'claim:- Age <
config.migrations.staleRunningGraceMs(default 10 minutes): presumed a genuinely concurrent runner (another instance mid-deploy) — WAITS, polling the live record every ~1s until it flips to'done'or disappears (unclaimed elsewhere on failure). The unique claim index already serializes any brand-new claim against this one; this wait only covers an already-existing claim. - Age ≥ grace window: presumed crash residue from a hard kill. Logs a loud
WARNnaming the migration, deletes the stale claim, and lets the normal claim/run loop inrun()re-claim and re-execute it — safe because of the idempotence requirement above.
- Age <
- New config knob
config.migrations.staleRunningGraceMs(config/defaults/development.config.js, default10 * 60 * 1000). getExecutedMigrations()now filterslistExecuted()by status: onlystatus:'done'OR nostatusfield at all count as "already done" and are skipped.status:'running'is never treated as done — it is exclusivelyresolveStaleClaims's concern.
An existing claim record predating this change has no status field. It is always treated as 'done' — it completed under the old semantics (a bare insert WAS the completion signal), and there is no way to distinguish it from a genuinely-finished migration after the fact. This is explicit and unconditional in getExecutedMigrations(): status == null || status === 'done'. Getting this wrong in either direction is bad — reinterpreting a legacy record as anything else would re-run migration history on every already-deployed database.
- All changes are devkit-owned stack files → arrive via
/update-stack(--theirs). No data migration — the newstatus/startedAt/finishedAt/pid/hostfields are additive; existingmigrationscollection rows are untouched (and correctly treated as done, see back-compat above). - No action needed for the default 10-minute grace window unless a project has a migration whose
up()is expected to legitimately run longer than that under normal (non-crashed) conditions — overrideconfig.migrations.staleRunningGraceMsinconfig/defaults/{project}.config.jsif so. - Confirm every project-owned migration (
modules/{name}/migrations/*.jsoutside the stack) is idempotent — re-runnable after a partial application. This was already best practice; it is now enforced by the resume behavior above. - No env var changes, no breaking API/contract change.
Fixes a silent write-loss bug: the meter-mode (organizationId, weekKey) unique index on billingusages declared sparse: true on a COMPOUND index. MongoDB's sparse-exclusion rule for a compound index only skips a document when it is missing ALL indexed fields — since organizationId is always present (on legacy AND meter-mode documents alike), sparse never excluded anything: every legacy (weekKey-less) document was indexed too, with weekKey treated as null. A second legacy document for the same organization — regardless of month — then collided on {organizationId, weekKey: null} and was rejected as a duplicate key; BillingUsageRepository.increment's duplicate-key retry filter ({organizationId, month}) matched nothing for the new month, so the write silently resolved to null with no error surfaced. Net effect: under meterMode: false (the default), a downstream consumer's legacy usage counters could only ever be recorded for the first month an organization was active — every subsequent month's write was lost.
modules/billing/models/billing.usage.model.mongoose.js— the(organizationId, weekKey)index now declarespartialFilterExpression: { weekKey: { $exists: true } }instead ofsparse: true, and an explicit distinct nameorganizationId_1_weekKey_1_partial(was the defaultorganizationId_1_weekKey_1). The distinct name is required, not cosmetic: the oldsparse: truespec is valid MongoDB syntax and is already LIVE on every deployed database (unlike #3990's invalid spec, which never built anywhere) — reusing the same default name would make autoIndex (which now runs BEFORE migrations and surfaces build failures loudly, #3990) reject withIndexOptionsConflicton every boot until an operator manually dropped the old index, a self-inflicted boot-crash loop. The distinct name lets the new index build ALONGSIDE the still-live old one with no conflict (mirrorsmodules/users/migrations/20260610120000-users-email-ci-unique-index.js's coexistence technique).- New migration
modules/billing/migrations/20260728120000-fix-usage-weekkey-index-partial.js— the authoritative creator (new index) + old-index dropper on already-deployed databases: (a) duplicate pre-check on the meter shape (weekKeypresent, grouped byorganizationId+weekKey, count>1), abort loud on any pre-existing duplicate; (b) create the new partial index first (idempotent — skipped if already exact-spec), so there is never a window without a uniqueness guard on meter-mode documents; (c) drop the old sparse index (and any other divergent same-key index). Skip-window fast path when the new index is already exact-spec and the old one is already gone (steady-state no-op). A duplicate-key error on the finalcreateIndexcall (a concurrent race landing a duplicate between the pre-check and the create) is caught and converted into the same actionable abort as the pre-check, never a bare driver error. Idempotent on re-run. modules/billing/services/billing.usage.service.js—increment()now logslogger.errorwith full context (organizationId,month,key,amount) whenever the repository's duplicate-key retry matches nothing (an anomaly that should not occur post-fix, but is no longer silently invisible if it ever does — silent-catch convention: a swallowed write failure must never be invisible). Does not throw — no caller (in this repo or any downstream consumer, sinceincrementis public API) ever treated a non-null return as guaranteed, and throwing would be a breaking behavior change for a generic stack module.
Run this against your production billingusages collection before deploying this change. Any result means the migration will abort boot until you remediate (delete/merge the offending rows) — better to catch it ahead of time:
db.billingusages.aggregate([
{ $match: { weekKey: { $exists: true } } },
{ $group: { _id: { organizationId: '$organizationId', weekKey: '$weekKey' }, count: { $sum: 1 }, ids: { $push: '$_id' } } },
{ $match: { count: { $gt: 1 } } },
]);In practice this should always return empty — the old sparse: true index already enforced uniqueness correctly for documents that DO have weekKey set (sparse behaves as intended when the field is present); this audit is a defensive pre-check, not a known-affected case. Downstreams running exclusively in legacy (non-meter) mode will also always get an empty result (no document ever has weekKey set).
- All changes are devkit-owned stack files → arrive via
/update-stack(--theirs). - Run the duplicate-data audit above against prod before deploying — expected empty, but confirm.
- No manual index action needed: the migration creates the new index and drops the old one automatically at boot, with no window where meter-mode documents lack a uniqueness guard.
- If your downstream project runs
meterMode: false(the default) and has been live for more than one month per organization, expect this migration to un-block legacy usage tracking that was previously silently stuck at month one — verify your usage dashboards after deploying.
Fixes a silent index-creation failure: the legacy (organizationId, month) unique index on billingusages declared partialFilterExpression: { weekKey: { $exists: false } }, which MongoDB does not support (only $eq, $exists: true, $gt, $gte, $lt, $lte, $type, and top-level $and are allowed inside a partial filter). Mongoose autoIndex reported the creation failure on the model's unlistened 'index' event, so the index never existed on any deployed database — the uniqueness guard ran on application code alone (a racy upsert). While fixing it, boot itself was hardened: it no longer treats mongoose.connect() resolving as "ready" (autoIndex builds run in the background).
lib/services/mongoose.js— newawaitIndexBuilds(), called bylib/app.js#startMongoose()right afterconnect()and BEFOREmigrations.run(). It awaits every registered model'sModel#init()(mongoose already triggers this once on model compile; this just awaits the in-flight promise) and now SURFACES a rejection instead of it being swallowed on the unlistened'index'event — this applies to every module, not just billing: any schema that declares an unsupported/invalid index will now fail loudly at boot (or time out — see the config knob below) instead of silently never building.- New config knob
db.awaitIndexBuilds(config/defaults/development.config.js, inherited by all envs) — bounds the wait so a big-collection index build can't stall readiness stack-wide on a rolling deploy: default{ timeoutMs: 60000 }. On timeout, boot continues in a degraded (pre-fix) state — the build keeps going in the background and a loud warning names the still-building model(s); an eventual build failure is still logged after the fact. Set tofalseto skip the wait entirely (restores the pre-#3990 fire-and-forget behavior). modules/billing/models/billing.usage.model.mongoose.js— the index now filters on a newlegacyPeriod: Booleandiscriminator (partialFilterExpression: { legacyPeriod: { $exists: true } }) instead of the unsupportedweekKeynegative check.legacyPeriodis set only by the legacy (non-meter) write path (BillingUsageRepository.increment's$setOnInsert) — meter-mode documents never carry it.- New migration
modules/billing/migrations/20260727120000-fix-usage-month-index-partial-filter.js— the authoritative index creator on already-deployed databases. Ordering is deliberately boot-ordering-safe (boot now awaits index builds BEFORE migrations run, so the partial index above may already be LIVE AND EMPTY by the time this migration executes): (a) duplicate pre-check FIRST via a plain query (weekKey: { $exists: false }, not an index filter — zero writes), abort loud on any pre-existing duplicate(organizationId, month)pair; then, only if the index isn't already the exact target shape with nothing left to backfill (fast-path skip, the steady-state case): (b) drop the index if present (boot-built-empty or divergent); (c) backfilllegacyPeriod: trueonto legacy documents; (d) recreate the index — a duplicate-key error here (a still-serving old instance racing a write into the (b)-(d) window on a rolling deploy) is caught and re-thrown as the same actionable abort as (a), never a bare driver error. Idempotent on re-run.
Run this against your production billingusages collection before deploying this change. Any result means the migration will abort boot until you remediate (delete/merge the offending rows) — better to catch it ahead of time:
db.billingusages.aggregate([
{ $match: { weekKey: { $exists: false } } },
{ $group: { _id: { organizationId: '$organizationId', month: '$month' }, count: { $sum: 1 }, ids: { $push: '$_id' } } },
{ $match: { count: { $gt: 1 } } },
]);Downstreams running exclusively in meter mode (every billingusages document has weekKey set) will always get an empty result — this only applies to legacy (non-meter) usage tracking.
- All changes are devkit-owned stack files → arrive via
/update-stack(--theirs). - Run the duplicate-data audit above against prod before deploying. If it returns any group, resolve the duplicates first — otherwise the migration aborts boot on next deploy (loud error naming the offending doc ids, zero writes performed).
- No action needed on the
db.awaitIndexBuildsknob — default ({ timeoutMs: 60000 }) is safe for normal collection sizes. Only override it (inconfig/defaults/{project}.config.js) if you have an unusually large collection with a slow index build and want a longer/shorter timeout, orfalseto opt back into fire-and-forget autoIndex. - Because index-build failures now surface loudly stack-wide (not just for billing), watch the first post-deploy boot log for any
Index builds still in flightwarning or a boot failure — it means some model's schema declares an index MongoDB rejects, previously silent. - Migrations run at boot before
listen(); the index swap +legacyPeriodbackfill land automatically once the duplicate-data audit passes. - Rolling deploys only: the very first successful run of this migration on a given database briefly drops the index while backfilling (old, still-serving instances writing into that window can trip a duplicate-key abort — self-healing, retried on next boot). For a strict no-window guarantee, run this specific deploy during a maintenance window or scale to a single instance first. Every later boot (including every other instance in the same rolling deploy once the database has converged) skips the window entirely.
New opt-in config.docs.excludeModules (default [] → no behavior change). It drops a module's doc/*.yml (OpenAPI) + doc/guides/*.md (guide tree) from the public spec (/api/spec.json) and guide tree (/api/public/docs), independent of module runtime activation — so it works even for core modules (core/auth/users/home), which filterByActivation never filters.
Why: a module's activated flag gates both its routes/models and its doc contribution, and core modules bypass that filter entirely. So a core module's sample docs/guides were always served, with no opt-out. A project whose own guides reuse the sample slugs (e.g. welcome/quickstart, shipped by home) collided on duplicate slugs and could only fix it by deleting stack files — which conflicts with keeping stack files byte-identical and recurs on every sync.
- New config knob
config.docs.excludeModules: [](config/defaults/development.config.js, in the existingdocs:block). - New helper
filterByDocExclusion(files, config)(lib/helpers/config.js, next tofilterByActivation) — drops doc files of listed modules, noCORE_MODULESbypass; missing/empty/non-array list = no-op. config/index.js— second filter pass appliesfilterByDocExclusionto theopenapi+guidesfile keys only, after the activation filter. Runtime file keys (routes/models/policies/...) are unaffected.
-
All changes are devkit-owned stack files → arrive via
/update-stack. Default[]: no action = no behavior change (sample guides remain a working tutorial). -
A project that keeps a module runtime-active but does not want its sample docs/guides in the public spec/tree (e.g. it ships its own guides under the same slugs) sets it in
config/defaults/{project}.config.js:docs: { excludeModules: ['home'] }
Non-core demo modules can instead be dropped wholesale via
config.{module}.activated = false(existing mechanism);excludeModulesis for modules that must stay active.
The mis-named config.swagger namespace is renamed to config.openapi: it gates the OpenAPI JSON spec served at /api/spec.json, and there is no Swagger-UI (the Redoc UI was decommissioned earlier). Pure rename, no behavior change.
config.swagger→config.openapi(defaults inconfig/defaults/development.config.js+production.config.js).config.swagger.publicInProd→config.openapi.public(theInProdsuffix is dropped — the flag means "serve the spec publicly"; still secure-by-defaultpublic: false).config.files.swaggerglob key →config.files.openapi(lib/helpers/config.js+ thefileKeysfilter list inconfig/index.js).- The gate in
lib/services/express.js(initApiSpec) reads the new keys; internal log prefixes[swagger]→[openapi].
- Rename the
swaggerblock toopenapiinconfig/defaults/{project}.config.jsand renamepublicInProd→publicif set. No other action — the glob key + gate are devkit-owned stack files that arrive via/update-stack.
Standard referral reward (#3842, the real tracker behind the old in-code TODO(#5) refs). The invitation.accepted no-op seam in billing.init.js is now the config-gated grant listener: on every accepted invite it idempotently credits meter units to the referrer's and referee's organizations on the BillingExtraBalance ledger (kind:'topup', source:'referral', keys referral:<invitationId>:referrer|referee, expiry like pack credits).
-
New config knob (
modules/billing/config/billing.development.config.js) — stack default OFF, zero behavior change for existing deployments:billing: { referral: { enabled: false, referrerUnits: 0, refereeUnits: 0, expiryDays: 365 } }
-
billing.init.js— the P8a no-op listener is replaced by the grant impl (async, self-guarded: a grant failure is logged, never escapes as an unhandledRejection). Skips the referrer grant wheninvitedByis null orinvitedBy === acceptedUserId(cheap self-referral floor; the full guard is #3833). -
New service
modules/billing/services/billing.referral.service.js— maps user-scoped referral actors onto the org-scoped ledger (actor'scurrentOrganization, active-membership fallback; an actor without an org yet — e.g. mailer-configured signups before email verification — is left to the cron). -
creditGrant(billing.extraBalance.repository.js) now accepts{ refId, expiresAt }options (explicit idempotency key + expiry); backward compatible — signup grant unchanged.sourceenums (Mongoose + Zod) gain'referral'. NewfindExistingRefIdshelper. -
New reconcile cron
modules/billing/crons/billing.referralReconcile.js(house cron pattern: jitter + distributed lockbilling.referralReconcile10 min TTL; gates onbilling.referral.enabled, NOTmeterMode): scans ALLinvitations { status:'accepted' }vs the grant ledger keys and back-fills misses idempotently. The listener is latency; the cron is truth. -
New index
invitations.invitedBy(schema-declared, built by Mongoose autoIndex at boot) — the reconcile + future referral lists query it.
-
All changes are devkit-owned stack files → arrive via
/update-stack(--theirs). Default OFF: no action = no behavior change. -
To enable referral rewards, flip the knob in
config/defaults/{project}.config.js(NEVER editbilling.init.js):billing: { referral: { enabled: true, referrerUnits: 1000, refereeUnits: 500 } } // example values
⚠️ Merging is safe everywhere (default OFF); do NOT enable until #3833 lands — only the cheap self-referral floor ships here. -
When enabling, add a k8s CronJob manifest for
billing.referralReconcile.jsin the infra repo (mirror the existing billing cron manifests; recommended daily0 4 * * *). Also confirmbilling.extrasExpiration.jsruns — referral credits expire through the same sweep. Note: the first reconcile run retro-grants every previously accepted invitation — if unwanted, pre-seed thereferral:<id>:*ledger keys before enabling. -
The
invitations.invitedByindex is created automatically at boot (autoIndex, small collection — no manual migration needed).
Phase 5a of the invitations↔org decouple epic (#3813). Replaces the deleted org email-invite with a consent-safe add-member flow: an owner/admin adds an existing user, creating a PENDING owner_add membership that the invited user must accept — the owner can NEVER approve it (consent invariant).
- Membership model (
organizations.membership.model.mongoose.js) — newsourceenum field{ 'join_request', 'owner_add' }with NO default + apre('validate')hook that throws if a PENDING row has nosource(a forgotten source must fail loudly, never silently become an owner-approvable join request). New optionaladdedBy(ObjectId, audit-only). - Constants — new
PENDING_SOURCES = { JOIN_REQUEST:'join_request', OWNER_ADD:'owner_add' }. - Service (
organizations.membership.service.js) —addMember(orgId, userId, role, addedBy)creates a PENDING owner_add (status set EXPLICITLY — the schema defaults status to'active'); rejects if ANY membership already exists for (user, org); last-owner-safe.acceptMembership(id, userId)flips PENDING→ACTIVE only for asource:'owner_add'membership whoseuserIdis the caller (setscurrentOrganizationif unset).createJoinRequest's single-pending-global rule is now source-scoped to join_request (a pending owner_add no longer blocks a join request, and vice-versa). NewlistPendingOwnerAddsByUser. - Approval surface scoped to join_request —
listPending,listPendingByUser, and therequestByIDapprove/reject gate now matchsource:'join_request'(with an E17source $exists:falselegacy fallback) so an owner_add is invisible to the owner-approval surface. The auth-payloadpendingRequestsis unchanged in shape — still the user's own join requests. - Routes —
POST /api/organizations/:organizationId/members(owner/admin; CASLcreate Membership) adds a member;GET /api/organizations/:organizationId/members/search?email=(owner/admin) looks up a user by exact email (GDPR: no fuzzy directory enumeration);GET /api/membership-requests/mine/pendinglists the user's pending owner_add invitations;PUT /api/membership-requests/:membershipId/acceptlets the invited user accept (auth-only; consent gate in the service, no org-CASL). - Migration
modules/organizations/migrations/20260610140000-backfill-membership-source.js: setssource:'join_request'on all existing PENDING memberships (they were all join requests pre-change). Idempotent (filter requiressourceabsent). Raw collection driver (house style).
- Module/model/migration changes are devkit-owned → arrive via
/update-stack(--theirs). - Migration ORDERING (E17 — critical): the backfill
20260610140000MUST run BEFORE the source-filtering code deploys, so no pre-existing join request is hidden from the approval list. The migration runs at boot beforelisten(); downstream rollouts must sequence it before the code deploy. The service/controller carry a temporarysource $exists:falsefallback so legacy rows stay visible even if the code lands first; that fallback is removed in a follow-up once every environment's backfill is confirmed. - No platform-invitation (
sign.cap/?inviteToken=) behavior changes. Vue add-member UI + pending-invitation list land in Vue #4281.
Phase 4 of the invitations↔org decouple epic (#3812). The organization's own email-invite flow is deleted — distinct from the platform invitations module (single-use signup-token gate), which is unchanged. This is the owner-invites-an-email-to-join-their-org flow that lived on the membership doc as status:'invited' + inviteToken / invitedEmail / inviteExpiresAt. The 2-step "invite to platform, then add the resulting user as a member" flow replaces it (org.addMember lands in a later phase / #3813).
- Routes removed (now 404):
POST /api/organizations/:organizationId/invites,GET /api/invites/:token,POST /api/invites/:token/accept. Org join-requests (/requests,/requests/:id/approve,/requests/:id/reject,/membership-requests/mine) and member CRUD are unchanged. - Service/controller (
organizations.membership.service.js,organizations.membershipRequest.{controller,routes}.js) —invite/acceptInvite/getInvitedeleted. - Membership model (
organizations.membership.model.mongoose.js) — dropped theinviteToken/invitedEmail/inviteExpiresAtfields, removed'invited'(and the never-written'rejected') from thestatusenum, and removed the sparseinviteToken_1index. The partial-unique(userId, organizationId), theorganizationId, and the(organizationId, status)indexes are kept. - Constants —
MEMBERSHIP_STATUSESnow{ ACTIVE, PENDING }(INVITEDand the deadREJECTEDremoved;rejectRequesthard-deletes the doc, sorejectedwas never written). - Template
config/templates/org-invite.htmldeleted. - Migration
modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js: deletes leftoverstatus:'invited'memberships (oftenuserId:nullorphans), unsets the removed invite fields on any survivor, and drops theinviteToken_1index (idempotent; absent index swallowed).
- The module/model/migration changes are devkit-owned → arrive via
/update-stack(--theirs). - The migration runs at boot before
listen()and removes any leftover orginvitedmemberships + drops the index automatically. (downstream deployments may carry a few such legacy rows — removed automatically.) - No platform-invitation (
sign.cap/?inviteToken=signup gate) behavior changes. Any downstream UI calling the removed/invitesorg routes must migrate to the add-member flow (#3813 / Vue #4280).
Phase 3 of the invitations↔org decouple epic (#3811). Two downstream-relevant changes.
users.emailis now lowercased + case-insensitively unique. Inlineunique:trueremoved;lowercase:trueadded; an explicit collation index{ email:1 }, { unique:true, name:'email_ci_unique', collation:{ locale:'en', strength:2 } }declared on the schema. Email inputs normalized to lowercase infindByEmail/ get-by-email /linkProviderByEmail/remove. New migrationmodules/users/migrations/20260610120000-users-email-ci-unique-index.js: pre-checks for case-variant duplicate emails and ABORTS boot if any exist (no auto-merge), then creates the collation index FIRST and drops the legacyemail_1(never a window without a unique index).lib/helpers/errors.jsgetUniqueMessagenow derives the field fromerr.keyPattern(index-name-agnostic) so the collation index still yields a friendly "Email already exists." message.- Invitation
consumingAttwo-phase claim (new optional field on theinvitationscollection): the signup gate now atomically claims an invite before user creation and finalizes/releases after, with a lazy stale-claim sweep (15 min, no scheduler). Pure additive schema change — no data migration needed.
- The model/repository/migration changes are devkit-owned → arrive via
/update-stack(--theirs). - 🔴 Before the first boot that carries the new schema, pre-check prod for case-variant duplicate emails (
db.users.aggregate([{$group:{_id:{$toLower:'$email'},n:{$sum:1}}},{$match:{n:{$gt:1}}}])). If any exist, resolve them BEFORE deploy — otherwise the migration aborts boot. Mixed-case singles need no action: the migration now lowercases them in place (post-dup-check, pre-index) so binary lookups keep finding those accounts. (each downstream runs this pre-check before its own rollout.) - Migrations run at boot before
listen(); the index swap + theconsumingAtfield land automatically once the dupe pre-check passes. - No client/contract change; existing 200/422 signup assertions pass unchanged.
@casl/ability upgraded from ^6.8.1 to ^7.0.0.
-
lib/middlewares/policy.js— v7 renamesPureAbilitytoAbilityand drops its default conditions matcher, so theAbilityexport no longer does MongoDB-style condition matching out of the box (createMongoAbilityis the replacement for the old behavior).defineAbilityFor()now builds viacreateMongoAbility:// before (v6) const { AbilityBuilder, Ability } = await import('@casl/ability'); const { can, cannot, build } = new AbilityBuilder(Ability); // after (v7) const { AbilityBuilder, createMongoAbility } = await import('@casl/ability'); const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
Without this, conditions like
can('manage', 'Organization', { _id })stop matching → authorization silently denies → endpoints return 403/422. -
JSDoc type refs
import('@casl/ability').Ability→MongoAbility(lib/middlewares/policy.js,lib/helpers/abilities.js). -
package.json—@casl/ability^6.8.1→^7.0.0.
The policy.js fix is a devkit-owned file → it arrives via /update-stack (--theirs). The dependency bump does not auto-propagate (package.json is --ours):
- Bump
@casl/abilityto^7.0.0inpackage.jsonand reinstall. - After
/update-stack, verifylib/middlewares/policy.js(~line 95) readsnew AbilityBuilder(createMongoAbility). - Module policy files need no change — they use
can/cannotclosures, never theAbilityclass. - The serialized rules format is unchanged (
createMongoAbilitykeeps the MongoQuery rule shape), so Node→client rule packing stays compatible. - Run unit + integration + e2e to confirm authorization paths still pass.
The @sentry/node integration shipped in 2026-03-26 (still documented below as PostHog Analytics (2026-03-26) + the now-removed Sentry monitoring section) is dropped. Error capture moves entirely to PostHog Error Tracking via posthog.capture('$exception', ...).
- Deleted :
lib/services/sentry.js+ its unit tests + the@sentry/nodedependency. lib/services/errorTracker.jssimplified to PostHog-only path. ThecaptureExceptionPostHogOnlyfan-out helper is removed (collapsed intocaptureExceptionsince there is no longer a double-reporting risk from a parallel Sentry Express handler).lib/app.js— Sentry init/shutdown calls removed from bootstrap and shutdown paths.config/defaults/{development,production,test}.config.js—sentry: { ... }blocks deleted.config/defaults/development.config.js+production.config.js—posthog.errorTrackingdefault flipped fromfalse→true. Error capture is now enabled by default wheneverposthog.apiKeyis set.modules/home/services/home.service.jsgetReadinessStatus()— themonitoringrow (Sentry presence) is replaced by anerrorTrackingrow that gates onposthog.apiKey && posthog.errorTracking === true.- NEW
lib/middlewares/posthog-context.middleware.js— parses theUser-Agentheader, attachesreq.posthogContext = { source: 'cli'|'web', cli_version? }for CLI-source attribution. Wired inlib/services/express.jsafter CORS / before routes. lib/services/analytics.jscapture()accepts an optionalreqparam. When provided,req.posthogContextis merged into event defaults so that CLI-originated requests carrysource+cli_versionautomatically. Backward-compatible: callers that omitreqsee no behaviour change.
- Drop env vars
SENTRY_DSN+ anySENTRY_*references from.env, K8s manifests (clusters/*/apps/*-node.yaml),.env.example, deploy scripts, and CI secrets — they are no longer read. - Drop
@sentry/*deps from projectpackage.jsonif pinned downstream. Runnpm installto regen lockfile. - Remove project
config/defaults/*.config.jsoverrides of thesentry: { ... }block — they were either referencing the now-removed config path (no-op merge) or overriding fields that no longer exist. - Confirm
posthog.errorTracking: if downstream config explicitly setsposthog.errorTracking: falseto suppress capture, that override still wins via deepmerge. To opt into error tracking, set it totrue(or rely on the new default if you remove the override). - Optional — wire
reqinto existingcapture()callers : if you want CLI-source attribution on existing events, changecapture({ distinctId, event, properties })→capture({ distinctId, event, properties, req }). Without this opt-in, events still capture correctly but lack thesource/cli_versionproperties.
Cf infra/docs/superpowers/plans/2026-05-10-posthog-observability-followups.md (decision matrix). PostHog Error Tracking is GA, free tier covers 100k exceptions/mo, and the single-tracker setup eliminates dual-config drift + cross-tool funnel friction.
Default test database is now mongodb://127.0.0.1:27017/NodeTest_${process.pid} instead of the shared NodeTest. Concurrent jest invocations (e.g. multiple agent worktrees running npm run test:coverage in parallel) get isolated databases, eliminating the 401 / 404 / 422 / MongoPoolClosedError flake patterns seen in parallel runs.
config/defaults/test.config.js—db.uriis now computed at module load withprocess.pidsuffix.scripts/jest.globalTeardown.js— new file; drops the resolved per-pid DB after the suite finishes so local Mongo doesn't accumulate orphanNodeTest_<pid>databases. Reuses the same NODE_ENV +/test/iguards asglobalSetup.jest.config.js— registers the newglobalTeardown.- New regression tests:
scripts/tests/jest.globalTeardown.unit.tests.jsandscripts/tests/testConfig.perPid.unit.tests.js.
The NodeTest_ prefix preserves the /test/i DB-name guard in scripts/jest.globalSetup.js (#3476) — the guard refuses to drop any DB whose name does not contain test. Keeping the literal substring keeps the belt-and-suspenders intact.
CI workflows (.github/workflows/CI.yml and downstream copies) set DEVKIT_NODE_db_uri explicitly, which lands in Layer 4 of config/index.js and overrides this default. Per-pid never applies on CI runs.
/update-stackpulls the change.- No env var changes required — your CI workflow's
DEVKIT_NODE_db_urikeeps working. - If a downstream README / docs / make target references the literal
NodeTestDB name (e.g. a manualmongo NodeTest --eval ...command), update it to point at the new default or invokemongoshagainst the resolved URI fromconfig.db.uri. - No Mongo data migration — test DBs are dropped on every run by design.
- CI is unchanged (env var override wins, see above).
- All test scripts (
npm run test,test:integration,test:coverage, etc.) keep working with no flag changes. docker-compose.test.ymlstill ships an explicitDEVKIT_NODE_db_urioverride (mongodb://mongo:27017/NodeTest) so containerised runs stay deterministic.
When this lands in your project via /update-stack, the new parallel-smoke CI job ships a default SMOKE_TEST_PATTERN of organizations.integration|tasks.integration — which only matches in the upstream Devkit. You MUST override SMOKE_TEST_PATTERN in your CI parallel-smoke job (set it under the job's env: in .github/workflows/CI.yml) to match your project's integration test paths.
Each downstream Node project that consumes this stack must set the override:
| Project | Suggested SMOKE_TEST_PATTERN |
|---|---|
<project>_node |
project-specific integration globs (e.g. foo.integration|bar.integration) |
(The exact globs are illustrative — replace with whatever integration files actually exist in your repo. The point is: pick at least two real integration suites so the parallel-smoke job exercises the per-pid DB isolation rather than passing on zero matches.)
Without an override, the smoke would historically have silently passed with 0 tests run, defeating the regression gate. As of #3518 the orchestrator passes --passWithNoTests=false to jest, so a 0-match pattern now exits non-zero and fails the smoke loudly — but the actionable fix is still to point the pattern at real integration paths in your repo.
The orchestrator also enforces a global timeout (SMOKE_GLOBAL_TIMEOUT_MS, default 2 × SMOKE_TIMEOUT_MS + 30s) on top of the per-child timer, so a child whose exit event is dropped (rare ARC edge) no longer hangs the job until the 15-min CI cap.
The GET /api/auth/oauth/:strategy/callback redirect now carries a JSON-encoded error payload mirroring the canonical lib/helpers/responses.js shape, so the Vue client can surface OAuth failures with the same parser it uses for every other API error.
- New private helper
oauthErrorRedirect(res, err, fallbackTitle)inmodules/auth/controllers/auth.controller.js— builds the redirect URL and stamps a canonical error envelope into theerrorquery param (URLSearchParamsensures proper encoding). - Both failure branches of
oauthCallback(passport error,!user) now delegate to the helper instead of hand-rolling query strings with hardcoded titles. - The
messagequery now reflects the realAppError.message(e.g.Signup error) instead of the hardcodedUnprocessable Entity/Could not define user in oAuth. logger.error(...)calls are preserved — observability unchanged.
Redirect URL is ${getBaseUrl()}/token?message=<title>&error=<json> where <json> is a stringified envelope:
{
"type": "error",
"message": "<err.message || fallbackTitle>",
"code": 422,
"status": 422,
"errorCode": "<err.code || 'OAUTH_ERROR'>",
"description": "<err.details.message || ''>",
"details": { "message": "<err.details.message || title>" }
}code and status are fixed at 422 (Unprocessable Entity) — OAuth callback failures surface via 302 redirect (not a JSON 4xx) so there is no live HTTP status; 422 matches the canonical shape of Zod / AppError validation failures elsewhere in the API.
Current downstream token.view.vue parsers read error.details.message rather than the canonical error.description / error.message. Shipping the canonical envelope AND the legacy details.message field lets Node deploy ahead of Vue without regressing the user-visible error toast during rollout.
Once every downstream Vue deploy has adopted the canonical parser (tracked in Vue issue #4021), the details field will be removed from the payload. A follow-up Node PR will ship that cleanup.
- Successful OAuth redirect (
${baseUrl}/token+TOKENcookie) is unchanged. - The
messagequery still exists — only its value changed (from hardcoded constants to the actual error title). - The
errorquery used to be a URL-encoded plain string; it is now URL-encoded JSON. Downstream clients that triedJSON.parseon the old payload were already throwing — the fix aligns them with the canonical parser path.
/update-stackpulls the change.- No env var changes.
- No Mongo migration.
- Vue consumers that currently read
error.details.messagekeep working; new consumers should readerror.message/error.description/error.errorCodeper the canonical envelope.
Completes the platform-admin bypass started in #3509, and centralizes the repeated Array.isArray(req.user?.roles) && req.user.roles.includes('admin') check into a shared helper.
- New helper
lib/helpers/isGlobalAdmin.js— single source of truth for the global admin check used by moderation guards. modules/organizations/controllers/organizations.membership.controller.js—updateRolenow admits global admins who are not members of the target org (required to transfer ownership during moderation).removenow uses the shared helper.modules/organizations/controllers/organizations.controller.js—removenow uses the shared helper (no behavior change).
updateRole had exactly the same buggy pattern that remove used to have before #3509: if (!req.membership || req.membership.role !== OWNER) rejected global admins with req.membership === undefined when they were not a member of the target org. The inline comment even said "Belt-and-suspenders: only owners (CASL blocks admins via no 'update Membership')" — the intent never anticipated platform admins. Same class of bug, same fix shape.
While at it, the duplicated isGlobalAdmin expression across three call-sites was extracted into a helper. Policies (organizations.policy.js, users.policy.js, etc.) still inline the check for now — migrating them is out of scope here (wider refactor, different test surface).
- No contract changes for regular users / owners / non-global admins.
- New capability: a user with
roles: ['admin']canPUT /api/organizations/:orgId/memberships/:memberIdwithout needing a membership on the target org. - Belt-and-suspenders guard is preserved: the handler still blocks non-owner, non-admin org roles regardless of CASL.
/update-stackpulls the change.- No env var changes.
- No Mongo migration.
New POST /api/auth/signout endpoint that clears the httpOnly TOKEN cookie on the client.
Before: signout was purely client-side — the Vue client dropped its in-memory user state but the httpOnly TOKEN cookie remained in the browser. On the next page load the cookie was replayed to /api/auth/token, the user was silently re-logged in, and the signout button was effectively a no-op.
Now: the route calls res.clearCookie('TOKEN', { httpOnly, secure, sameSite }) with options mirroring tokenCookieOptions. Browsers only delete cookies whose secure/sameSite/path/domain attributes match the original Set-Cookie, so the options must match exactly.
POST /api/auth/signout→200 { type: 'success', message: 'Signed out' }Set-Cookie: TOKEN=; Max-Age=0; HttpOnly; …(expired cookie — browser discards it)- No JWT middleware: signout works even if the token is expired, invalid, or missing
- Rate-limited via the standard
authLimiter
Additive endpoint. No existing contract changes. Downstream projects can adopt it at their own pace:
- Vue: call
POST /api/auth/signoutfrom the signout action, then clear the Vuex/Pinia user state - No env var changes
- No Mongo migration
- Run
/update-stackto pull the endpoint. - (Vue side, separately) wire the signout action to call
POST /api/auth/signoutbefore resetting client state.
Two related auth fixes that ship together.
Enabling Google OAuth on a downstream project used to crash on first signin with Cannot read properties of undefined (reading 'strategy'). Root cause: Express 5 leaves req.body as undefined on GET requests (Express 4 initialized it to {}).
modules/auth/controllers/auth.controller.js—oauthCallbackoptional-chainsreq.bodyaccess- Apple OAuth (POST
form_post) was never affected — no change to behavior
Before: a local signup at user@x.com followed by a Google signin with the same email crashed on Mongo's unique-email index (E11000) — user locked out.
Now: checkOAuthUserProfile follows a 4-step lookup:
(provider, providerData[key])— primary identity (OAuth-first users)additionalProvidersData[provider][key]— linked users on subsequent signinsemailmatch with provider-verified email → atomic link (UserService.linkProviderByEmail)- No match → create new user with
emailVerifiedreflecting provider verification
Linking attaches the OAuth providerData under user.additionalProvidersData[provider] and does not overwrite user.provider — so password reset (gated on provider === 'local') and local login keep working for linked users.
- Provider + key allowlists (
ALLOWED_PROVIDERS = {google, apple},ALLOWED_PROVIDER_KEYS = {id, sub, email}) validate the dynamic query path before Mongo. emailVerifiedByProvider: truerequired before linking — prevents takeover via a future OIDC provider that returnsemail_verified: falsefor someone else's address./tokenresponse sanitizesaccessToken/refreshTokenout ofadditionalProvidersDatabefore serialization.
- Run
/update-stackto pull both fixes in one go. - Env vars to set in prod K8s for Google (per project that wants OAuth enabled):
DEVKIT_NODE_oAuth_google_clientIDDEVKIT_NODE_oAuth_google_clientSecretDEVKIT_NODE_oAuth_google_callbackURL— e.g.https://api.{project}.{tld}/api/auth/google/callback
- Register the callback URL in Google Cloud Console (OAuth 2.0 client, Web type). For Apple: same pattern on
decodedIdToken.email_verified. /api/auth/configreturnsoAuth.google: trueonce the clientID is set — the Vue signin/signup buttons activate automatically viaserverConfig.oAuth.google.
additionalProvidersData already existed in the Mongoose user schema and is now exposed in the Zod user schema too. No Mongo migration needed — existing users have an empty field.
analytics service gains two sugar helpers that extract the PostHog distinctId from an Express request, so routes no longer need to repeat req.user?.id ?? req.sessionID ?? 'anonymous' (and can never forget the anonymous fallback):
import analytics from '../../../lib/services/analytics.js';
// Route handler / middleware
const flag = await analytics.getFeatureFlagForRequest('checkout-v2', req);
if (await analytics.isFeatureEnabledForRequest('billing-portal', req)) { ... }Resolution chain: req.user?.id → req.sessionID → 'anonymous'. Defensive fallback: req == null also resolves to 'anonymous'.
- The existing
getFeatureFlag(flag, distinctId, options)/isFeatureEnabled(flag, distinctId, options)remain public for cron, worker, and scheduled-job callers that have noreq. - Higher-level
FeatureFlagsService(analytics.featureFlags.js) is unchanged.
Optional — pull via /update-stack. Existing route code keeps working. New routes should prefer the *ForRequest variants to avoid the repeated distinctId boilerplate.
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 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 |
PUT |
/api/organizations/:organizationId/members/:memberId |
JWT | Update member role |
DELETE |
/api/organizations/:organizationId/members/:memberId |
JWT | Remove member |
POST |
/api/organizations/:organizationId/requests |
JWT | Request to join |
PUT |
/api/organizations/:organizationId/requests/:membershipRequestId/approve |
JWT | Approve join request |
PUT |
/api/organizations/:organizationId/requests/:membershipRequestId/reject |
JWT | Reject join request |
- 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, 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 (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, openapi, api, db, log, cors, cookie, mailer, seedDB |
global | Pure infrastructure, no module owns them |
config/defaults/
development.config.js ← infra only (app, openapi, 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.