Use this file as a compact memory of recurring AI mistakes.
- One error per line
- Keep each line actionable and specific
- Use format:
[YYYY-MM-DD] <scope>: <wrong> -> <right> - Add only confirmed/recurrent mistakes and avoid duplicates
- [2026-02-22] functions: new or modified functions without JSDoc header -> always add JSDoc (description +
@paramfor each arg +@returnsfor non-void return values and all async functions) - [2026-02-22] tests: never patch code to pass a test -> if a test is wrong, fix the test; if logic needs refactoring, refactor it
- [2026-02-23] pr skill: stopping after
gh pr ready-> always enter the monitor loop (wait CI → 3min grace → read feedback → iterate) until stop condition is met - [2026-02-23] pr skill: skipping issue creation when none found -> always create a GitHub issue before opening a PR (
gh issue create --webor via CLI) - [2026-02-23] zod v4: z.string({ error: 'msg' }) does NOT override invalid_type message -> Zod v4 produces 'Invalid input: expected string, received number'; update test expectations accordingly and remove the no-op error option
- [2026-03-11] architecture: importing mongoose in controllers/services/helpers -> mongoose must only be imported in repositories and models
- [2026-03-11] architecture: controller importing a Repository directly -> controllers call Services only; cross-module access goes through the other module's Service
- [2026-03-11] services: wrapping return values in Promise.resolve() -> unnecessary when calling async/promise-returning functions, just return directly
- [2026-03-13] architecture: service importing another module's Repository -> use target module's Service
- [2026-03-13] architecture: middleware using mongoose.model() directly -> use module's Service or Repository
- [2026-03-13] tests: test file placed outside its module (e.g. lib/middlewares/tests/) -> tests belong in modules/{module}/tests/
- [2026-03-13] docs: duplicate doc files covering the same topic (e.g. MIGRATION.md + MIGRATIONS.md) -> single file, no duplication
- [2026-03-14] middleware: assuming config section exists in all environments (e.g.
config.rateLimit) -> always handle missing config gracefully (passthrough/no-op); dev config often omits sections that only prod defines - [2026-03-15] cross-stack: changing a Node API without checking Vue E2E tests -> when modifying an endpoint Vue consumes, run Vue E2E tests before pushing
- [2026-03-15] pr scope: batching multiple unrelated fixes in one PR -> one fix = one PR to isolate blast radius and reduce iteration loops
- [2026-05-05] repository: Repository.update(doc) doing
new Model(doc).save()rewrites the full document from in-memory state, silently clobbering any concurrent partial update that landed after the read -> always useModel.updateOne({ _id }, { $set: ... })orfindOneAndUpdate({ _id }, { $set: ... })for partial updates to avoid race conditions; see #3605 - [2026-05-31] billing/stripe: reading
price.metadata.planIdincustomer.subscription.updatedwebhook handler -> field is EMPTY in real Stripe webhook payloads (planId lives on the Product, not the Price); use apriceId → planmap built at boot fromconfig.stripe.pricesinstead; see #3742 - [2026-06-04] repository: top-level
const Foo = mongoose.model('Foo')in a repository file -> this is evaluated at import time; safe in an HTTP server (loadModels() runs first) but silently crashes standalone scripts (crons, migrations) withMissingSchemaErrorwhen import order differs; tests miss it because jest mocks intercept the module entirely; fix = lazy getterconst Foo = () => mongoose.model('Foo')(call sites:Foo().find(...)) or dynamic import afterloadModels()in the entrypoint; see #3789 - [2026-06-15] deps/audit: leaving
npm auditadvisories unaddressed on the assumption they need a major bump -> runnpm audit fix(never--force) first; the runtime-tree DoS/ReDoS items (qs,path-to-regexp,brace-expansion) all fixed via in-range bumps, no residual. These are DoS-class but NOT attacker-reachable in this stack: Express route patterns are static (no user-controlledpath-to-regexpinput) andqs/brace-expansiononly parse server-side query strings under fixed code paths — still bump them to keep the tree clean and avoid scanner noise. - [2026-07-16] security:
users.repository.js findByIdAndUpdatePopulated()does.populate()with no.select(), soorganizations.controller.js switchOrganizationserialized the raw doc (password hash + OAuth tokens + reset/verification tokens) straight to the client;users.account.controller.js me()separately forwardedproviderData(OAuth tokens) verbatim -> any endpoint returning a Mongoose user doc must go throughUserService.removeSensitive()(whitelist,modules/users/utils/sanitizeUser.js) at the response boundary, never serializereq.user/a populated doc directly; a local-signup fixture'sproviderDatadefaults to{}so a naive falsy check won't catch this — seed a fake OAuth token in tests to prove the leak is actually closed; see #3963 - [2026-07-16] billing/stripe: the #3742 priceId-map fix (
resolvePlan/buildPriceIdToPlanMap) was applied only to the webhook handler;billing.admin.service.jsresolveStripePlan(admin force-sync, a DB WRITE path) andbilling.reconcile.service.jsresolveStripePlan(LOG-ONLY divergence check) kept their own copies reading onlymetadata.planId-> a paid org's Stripe subscription (which never carriesmetadata.planId) resolved to'free'on admin sync (silently downgrading a paying org) and produced a falseplanMismatchalert on every reconcile run; fix = one shared resolver (modules/billing/lib/billing.planResolver.js) used by all three call sites, and a null-unresolved sentinel (never guess'free') so the admin write path ABORTS instead of downgrading and the reconcile log path skips the comparison instead of alerting; see #3964 - [2026-07-16] architecture:
users.service.js remove()'s sole-owner cascade calledOrganizationsRepository.remove()directly instead oforganizations.crud.service.js#remove(), silently skippingrunOrganizationRemovedHandlers(theonOrganizationRemovedseammodules/tasks/tasks.init.jsregisters for org-scoped task cleanup) -> deleting an org must always route through the owning module's SERVICE method, never straight to its Repository, even for a "bare" cascade delete from another module — a direct-repository shortcut silently bypasses any registered removal hook; a stale cross-test fixture intasks.integration.tests.js(an orphaned-but-still-existing task reused across unrelated test cases) was inadvertently relying on this bug and needed a properly isolated fixture once the cascade actually cleaned up; see #3965 - [2026-07-17] error handling: shared helpers (
lib/helpers/mailer/index.js#sendMail,modules/audit/services/audit.service.js#log) wrapped the failing call in their own try/catch, logged a single generic line, and resolvednull-> every caller's own context-rich.catch()(action/userId/orgId) became dead code on a real outage, since the promise never rejected; a central helper must let the underlying call's rejection propagate and let each CALL SITE own the "never break the main flow".catch()with its own context — never swallow centrally just because most callers currently attach a catch; see #3966 - [2026-07-17] billing/stripe: #3964 fixed the admin (409 abort) and reconcile (skip-comparison) call sites of the shared plan resolver but left
billing.webhook.service.js resolvePlan's?? 'free'fallback in place -> a webhook for an existing paid org whose Stripe price is unresolvable (unmappedpriceId, no validmetadata.planId— e.g. a manually-sold enterprise price) silently downgraded the org to'free'on the nextcustomer.subscription.updated/.created/invoice.payment_succeededevent; a webhook cannot 409-abort mid-flight like the admin path, so the fix instead retains the org's/subscription's already-loaded.plan(never a hardcoded'free') and emitsbilling.webhook.plan_unresolvedfor manual review — only a brand-new subscription with zero prior plan reference (no DB row anywhere for that org) still defaults to'free'; see #3970 - [2026-07-25] billing:
forceRotateForPlanChange(week-quota snapshot rotation) was wired only intohandleSubscriptionUpdated's plan-change block ->handleCheckoutCompletedand both branches ofhandleSubscriptionCreatednever rotated the current-week quota snapshot after a mid-week plan activation, so an upgraded org kept the previous plan's weekly quota (often 0 on free→paid) until the next weekly reset; fixed by calling the same non-fatal, loggedforceRotateForPlanChange(organizationId, { preserveUsage: true })from all three activation call sites, and by makingbilling.usage.service.js incrementMeterread the live plan quota (already fetched for the snapshot write) instead of the potentially-staleupdatedDoc.meterQuotafor overflow decisions — mirrors the existing live-quota display fix inbilling.controller.js; see #3988 - [2026-09-04] deps/engines:
engines.nodedeclared">=22.0.0"while the committed lock was already npm-11-shaped, sonpm ciunder npm 10 (bundled by every Node 22.x) failed mid-install with a confusingMissing: conventional-commits-filter@6.0.1 from lock fileerror instead of a clean engines rejection -> a public package'sengines.node/engines.npmmust bound the exact npm major that wrote the lock, not just "a Node version that happens to work today"; pin the toolchain with.nvmrc+ CI'ssetup-nodereading it (node-version-file, not a floatinglts/*) and addengine-strict=trueto.npmrcso an unsupported toolchain fails fast at the engines check instead of at dependency resolution; see #4053 - [2026-09-04] docker: #4053 pinned the toolchain for CI and local installs, but the Dockerfile still copied
package*.json, rannpm ci, and only thenCOPY . .— so.npmrc(engine-strict=true) landed AFTER the install, andFROM node:lts-slimfloated independently of.nvmrc, so an unsupported base image inside Docker still reproduced the confusing lockfile error instead of a cleanEBADENGINE, and the image could silently drift onto a newer Node major once it enters LTS -> a toolchain fail-fast fix must be verified in every install path, not just the one exercised by CI; copy.npmrcalongsidepackage*.jsonbeforenpm ciin every installing stage, and pinFROMto the.nvmrcmajor with a unit test checking the two against each other instead of a floating tag; see #4060 - [2026-09-04] error handling: eight call sites (
auth.controller.jsOAuth catch blocks,uploads.repository.jsGridFS catch blocks) builtAppErrorwithdetails: err/details: err.details || err— a raw caught exception handed wholesale to the response layer — andgetDescription(lib/helpers/responses.js) readdetails.messageinto the client-facingdescriptionfield in EVERY environment, including production, so whatever a dependency's error object happened to carry (stack fragments, internal hostnames, driver taxonomy) decided what leaked -> curatedetailsat each throw site to an explicit, deliberately-chosen field set (here:{ message: err.message }— a short reason, nothing else) instead of forwarding the exception, AND separately production-gate everydetails-derived read (getDescription's string/array/.messageresolution, plusauth.controller.js'soauthErrorRedirect— a SECOND, independent consumer ofdetails.messagethat bypassesgetDescriptionentirely by building its own redirect envelope by hand) — NOTE the "same way" wording below turned out to be only partially true, see the 2026-09-05 follow-up entry; curating the call site alone is not sufficient — the curatedmessagestill flows through the samedetails.messageslot every consumer reads, so both the source (call sites) and every sink (each place that readsdetails.message) must be fixed together; see #4059 - [2026-09-05] error handling / review follow-up: #4059's review surfaced two gaps in the 2026-09-04 fix -> (1) "eight call sites" undercounted:
users.images.controller.js updateAvatarforwarded a raw Multer error wholesale viadetails: req.multerErr(code/field/etc. included), the identical anti-pattern, curated the same way (details: { message: req.multerErr.message }); (2)oauthErrorRedirectwas NOT gated "the same way" asgetDescriptionas claimed — only itsdetails.messageread was production-gated, itstitle(err?.message || fallbackTitle) was not, so a future non-AppError with a dynamic.messagecould still leak in production (today's real producers are safe, but nothing enforced it) -> when a fix claims parity with an existing safeguard ("gated the same way"), verify EVERY read the safeguard covers has a matching gate, not just the one that prompted the fix; and re-grep the whole pattern (not just the files named in the originating issue) before trusting a count like "eight call sites" — see the two new tests added foroauthErrorRedirect's title gate andusers.images.controller.js's curation; see #4059 - [2026-09-05] billing/error handling:
billing.requireQuota.js's catch block extractedconst details = err.details(for branching on the AppError sub-type) and then passed that extracted sub-object — noterritself — intoresponses.error(res, status, ...)(details)at all five call sites (402/429/503) ->responses.errorreadserror.detailsoff whatever it is handed, so it readdetails.details, alwaysundefined, silently dropping the whitelistedtype/upgradeUrlpayload from every response this middleware ever sent, in every environment; fix = passerr(keep the extracteddetailsvar only for the?.type ===branching). This also reshaped the dev-onlypayload.errorblob: it now serializes the real AppError, so its curated fields moved from the blob's top level to nested under.details— eight existing tests across two files (billing.quota.unit.tests.js,billing.webhook.hardening.unit.tests.js) were asserting the old (buggy) flat shape and needed the same nested-path update; see #4062 - [2026-09-05] error handling: #4062's sweep of the
responses.error(res, ...)(x)misuse (xnot error-shaped, sox.detailsisundefinedandpayload.detailsnever emits) coveredbilling.requireQuota.jsonly -> two more sites outside billing:analytics.requireFeatureFlag.js's 403 branch passed{ type, flag }flat (no.detailswrapper) — real signal loss, a client blocked by a feature flag got no machine-readabletypein production;home.controller.js's degraded-health 503 branch passed the raw health payload directly — same wrong shape, harmless today only because nothing on that payload matches the whitelist. Fixed both by wrapping the payload in a realAppError's.details. Deliberately did NOT whitelistflag(an internal PostHog feature-toggle key) alongsidetype— publishing it would let a client enumerate which flags gate which routes; a grep of every remainingresponses.error(res, ...)(x)call site in the repo found no further non-error-shapedx; see #4064 - [2026-09-19] billing/stripe: two of three terminal-cancel write sites left
cancelAtPeriodEnd/cancelAtstale —handleCustomerDeletedwrote{ plan: 'free', status: 'canceled' }only, and admincancelSubscriptionnever touched the fields at all ->handleCustomerDeleteddetaches the doc from Stripe entirely (stripeSubscriptionId: null), so once detachedgetSubscriptionserves the stored doc as-is with no live Stripe re-read to mask the drift — a subscription canceled whilecancelAtPeriodEndwas alreadytrue(a scheduled period-end cancel) kept that staletrueforever. Fix:handleCustomerDeletednow explicitly resets both fields tofalse/null(a customer payload carries no subscription object, so there is nothing to mirror); admincancelSubscriptionnow mirrors the post-cancel Stripe retrieve, guarded so a failed retrieve writes neither key rather than fabricating one;handleSubscriptionDeletednow re-syncs both fields from the deleted event's Stripe object instead of leaving them untouched, so an out-of-band drift (e.g. an immediate cancel superseding an earlier scheduled one) no longer survives with a stale value. Never hardcodefalseon a mirror path — only the customer-deleted reset does that, and only because no subscription object exists there; see #4100 - [2026-09-24] billing/meter: overflow units (usage past
meterQuota) are debited from extras, which may go negative, but the weekly reset only zeroedmeterUsedand never touched that debt -> on a plan with a weekly quota the debt cut every later week by the same amount and locked the org out for good once it reached the quota;resetWeeknow settles overflow debt once per week from the target week's REMAINING quota. Four traps on the way: (1) writingmeterUsed = settleinto the inserted week doc lost the settlement whenever the doc already existed (concurrent reset,incrementMeterfirst) -> fixed by charging the STORED credit (idempotentadjustment, refIdsettle:<weekKey>) with one guarded$inckeyedsettle:<weekKey>inconsumedAttributionKeys, on every call; (2) counting everyrefundentry as refund debt excluded overflow debt forever after any refund -> only the part of a refund that takes the replayed balance below zero counts, and a pack purchase repays it; (3) the below-zero part of a packexpirationis treated like refund debt: never settled from quota, only a new pack repays it (known limitation, unchanged: the expiry sweep removes a pack's full amount even when part was consumed), and the replay follows ledger array order (the$pushcommit order), notat; (4) cappingsettleat the plan quota assumed a fresh week, but the cron anchors onnow, so the target week is usually already partly used -> the part of the credit pushingmeterUsedpast the quota was forgiven debt; now the week doc is read (or created) FIRST andsettle = min(quota − meterUsed, overflowDebt), the rest stays as debt for the next reset; see #3914 - [2026-09-24] billing:
billing.meter.service.js unitsFromCostsdocumentedratios.defaultas the per-key fallback (billing.config.zod.jsJSDoc) but the code never read it, hardcoding1for any cost key absent from the plan's ratio map -> a plan configuringratios: { default: 2 }silently billed every unlisted feature at1, not2; latent because the shipped example useddefault: 1, which happened to match the hardcoded fallback. Fix: compute the fallback once asratios.defaultwhen it is a number>= 0, else1, and use it in place of the literal1; see #4025 - [2026-09-25] billing/extras: the expiry sweep (
addExpirationEntries) removed a pack's FULL amount even when part or all of it was already consumed or refunded -> phantom debt the next pack paid twice. Now the ledger is replayed in array order, debits (and a pack's own refunds, matched bystripeSessionId) are attributed to live credits earliest-expiry-first (no-expiry credits last, uncovered debt repaid by the next credit), and an expiring pack removes only its own remainder at itsexpiresAt; a fully spent pack gets a zero-amountexpirationmarker (schemas allow 0 for that kind only, hidden fromlistLedgerPage) so theexpire-<topupId>guard still holds. The write is onefindOneAndUpdateguarded by the snapshot's ledger$size(append-only ⇒ unchanged length = unchanged ledger), retried on a concurrent write. Legacy full-amount entries are left as is; see #4120 - [2026-09-25] billing/stripe:
billing.plans.service.js fetchPlansFromStripefell back to the raw Stripe product id (product.metadata?.planId || product.id) when a product carried noplanIdmetadata -> ANY active Stripe product (a one-time pack, a recurring product sold outside the plans catalogue via a Payment Link) advertised itself as a public plan viaGET /api/billing/plans, often with a null price id; fix = filter toproduct.metadata?.planIdtruthy BEFORE mapping, drop the id fallback entirely — a product now needsmetadata.planIdto be listed. Left every OTHERmetadata?.planId ||fallback untouched (billing.planResolver.js,billing.webhook.service.js,billing.admin.service.js) since those resolve an EXISTING subscription's plan, not the public catalogue, and must keep working for a recurring product sold outside it; see #4113 - [2026-09-25] auth:
oauthCallbacknever provisioned an org, and review of the first fix caught that gating on!user.currentOrganizationwould also fire for an EXISTING org-less user (removed from org, pending join) on every login -> gate oninfo.createdinstead (set bycheckOAuthUserProfile's create branch, relayed via passport's verify-callbackinfo), so only a genuine new signup provisions.oauthCallbackalso needed an outer try/catch (passport invokes it fire-and-forget) and aheadersSentguard before any fallback redirect; see #4115 - [2026-09-25] billing: computing a percent-of-grant level as
(1 - threshold/100) * signupGrant(e.g.500 * (1 - 80/100)) lands on99.99999999999997, not100, due to float imprecision -> silently misses an exactpost === levelboundary crossing; usesignupGrant * (100 - threshold) / 100instead, which is exact at common values; see #4117 - [2026-09-28] billing/stripe:
checkout.session.completed's subscription-retrievecatchreturned silently on failure -> the event was recorded as processed by the idempotency wrapper while the subscription row stayed unlinked on the free plan, with no retry; fix = throw instead of return, so the claim persists (attempts increments, the doc is never deleted on failure) and Stripe redelivers; see #4151 - [2026-09-28] billing/meter:
incrementMeteralways billed against the subscription's own plan quota, while the admission gate (billing.quota.service.js) already treated a fail-closed status (paused/unpaid/incomplete/incomplete_expired/canceled) as the free plan -> a fail-closed org kept consuming its stale paid-plan quota on the meter even though the gate let it through on free-plan grounds; the fail-closed status list is now one shared constant imported by both the gate and the meter — a literal duplicated in two enforcement paths drifts by design, not by mistake; see #4151 - [2026-09-28] auth:
verifyEmailread the user by token (getBrut) then wrote it in a separate step (update) -> two concurrent requests for the same token both passed the read check before either write landed, so both provisioned an organization/grant for the same signup; fix = one atomicfindOneAndUpdate(UserService.consumeEmailVerificationToken) that verifies and clears the token together, so only the first concurrent caller can match the still-unexpired token; see #4151 - [2026-10-03] billing/mailer: the 3 billing email builders (quota, credit, payment-failed) built their link from
config.app?.url ? ... : '', but no Devkit default config setsapp.url-> every billing email rendered an empty href unless a downstream happened to set it itself (undetected because no default-config test asserted a non-empty link). Fix =config.app?.url || getBaseUrl(), the same fallbackgetBrand()'surlfield already uses;app.urlstill wins when a project sets it. Also correctedbilling.email.js's JSDoc, which described a template "glob-merge" override mechanism that was never implemented — the real resolver isconfig.mailer.templates; see #4128