Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ Use this file as a compact memory of recurring AI mistakes.
- [2026-09-24] billing: `billing.meter.service.js unitsFromCosts` documented `ratios.default` as the per-key fallback (`billing.config.zod.js` JSDoc) but the code never read it, hardcoding `1` for any cost key absent from the plan's ratio map -> a plan configuring `ratios: { default: 2 }` silently billed every unlisted feature at `1`, not `2`; latent because the shipped example used `default: 1`, which happened to match the hardcoded fallback. Fix: compute the fallback once as `ratios.default` when it is a number `>= 0`, else `1`, and use it in place of the literal `1`; see pierreb-devkit/Node#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 by `stripeSessionId`) 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 its `expiresAt`; a fully spent pack gets a zero-amount `expiration` marker (schemas allow 0 for that kind only, hidden from `listLedgerPage`) so the `expire-<topupId>` guard still holds. The write is one `findOneAndUpdate` guarded 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 pierreb-devkit/Node#4120
- [2026-09-25] billing/stripe: `billing.plans.service.js fetchPlansFromStripe` fell back to the raw Stripe product id (`product.metadata?.planId || product.id`) when a product carried no `planId` metadata -> 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 via `GET /api/billing/plans`, often with a null price id; fix = filter to `product.metadata?.planId` truthy BEFORE mapping, drop the id fallback entirely — a product now needs `metadata.planId` to be listed. Left every OTHER `metadata?.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 pierreb-devkit/Node#4113
- [2026-09-25] auth: `oauthCallback` never provisioned an org, and review of the first fix caught that gating on `!user.currentOrganization` would also fire for an EXISTING org-less user (removed from org, pending join) on every login -> gate on `info.created` instead (set by `checkOAuthUserProfile`'s create branch, relayed via passport's verify-callback `info`), so only a genuine new signup provisions. `oauthCallback` also needed an outer try/catch (passport invokes it fire-and-forget) and a `headersSent` guard before any fallback redirect; see pierreb-devkit/Node#4115
97 changes: 78 additions & 19 deletions modules/auth/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,13 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
});
}
}
// Marks THIS resolution as a brand-new account (branch 4 only — never set
// on branches 1-3, which resolve to an existing/linked user). Non-enumerable
// so it never leaks into a JSON response or a DB write; the OAuth strategy
// wrappers (google.js/apple.js) read it to decide whether to report
// `created: true` to passport's verify callback (issue #4115 follow-up —
// org provisioning must fire only for a genuine new signup, see oauthCallback).
Object.defineProperty(createdUser, '_isOAuthSignup', { value: true, enumerable: false, configurable: true });
return createdUser;
} catch (err) {
if (err instanceof AppError) throw err;
Expand Down Expand Up @@ -559,6 +566,21 @@ const oauthErrorRedirect = (res, err, fallbackTitle) => {
res.redirect(302, target.toString());
};

/**
* @desc Log an OAuth callback failure with a consistent shape. Shared by every
* failure branch in `oauthCallback` (passport error, no user, and the outer
* catch-all) so the three sites can't drift on what gets logged.
* @param {string} strategy - OAuth strategy name (req.params.strategy)
* @param {Object|null} errArg - the error to log (may be null for the !user case)
* @returns {void}
*/
const logOAuthCallbackFailure = (strategy, errArg) => {
logger.error(
{ err: { message: errArg?.message, code: errArg?.code, stack: errArg?.stack }, strategy },
'OAuth callback failed',
);
};

/**
* @desc Endpoint for oautCallCallBack
* @param {Object} req - Express request object
Expand All @@ -579,26 +601,63 @@ const oauthCallback = async (req, res, next) => {
// function as the 2nd/3rd arg) never calls req.logIn() itself — session
// establishment is entirely the caller's responsibility below (JWT + cookie,
// no express-session) — so the `session` option has nothing to act on.
return passport.authenticate(strategy, (err, user) => {
if (err) {
logger.error(
{ err: { message: err?.message, code: err?.code, stack: err?.stack }, strategy },
'OAuth callback failed',
);
return oauthErrorRedirect(res, err, 'oAuth error');
}
if (!user) {
logger.error(
{ err: { message: err?.message, code: err?.code, stack: err?.stack }, strategy },
'OAuth callback failed',
);
return oauthErrorRedirect(res, null, 'Could not define user in oAuth');
// The callback below is async (org provisioning needs to `await`), but
// passport.authenticate() invokes it fire-and-forget — it never awaits or
// otherwise observes the promise this callback returns. Without the outer
// try/catch, any throw past the org-provisioning branch (which owns its own
// best-effort catch) would become an unhandled rejection instead of the
// client-facing error redirect every other failure in this callback gets.
return passport.authenticate(strategy, async (err, user, info) => {
try {
if (err) {
logOAuthCallbackFailure(strategy, err);
return oauthErrorRedirect(res, err, 'oAuth error');
}
if (!user) {
logOAuthCallbackFailure(strategy, null);
return oauthErrorRedirect(res, null, 'Could not define user in oAuth');
}
// Org provisioning parity with local signup/verifyEmail (issue #4115): a
// brand-new OAuth signup never went through either path, so it never
// provisioned a workspace and the user landed on the org-required page.
// Gated on `info.created` (set by checkOAuthUserProfile's create branch,
// relayed through the strategy's verify callback — passport-oauth2 forwards
// this `info` object all the way to this custom-callback's 3rd argument)
// rather than "no currentOrganization": an EXISTING user who currently has
// no org (removed from their org, org deleted, a pending join request —
// local signin already treats this as valid and never provisions) must not
// be silently handed a fresh workspace on every OAuth login. Only a genuine
// new signup provisions; every other resolution (existing/linked user, with
// or without a current org) is a no-op, zero extra queries or events. Best-
// effort, same pattern as `verifyEmail` above (#3762/#3765): a provisioning
// failure must never break the redirect.
if (info?.created) {
try {
await AuthOrganizationService.handleSignupOrganization(user);
} catch (orgErr) {
logger.warn('[auth.oauthCallback] org provisioning failed (non-fatal)', {
userId: user.id,
error: orgErr?.message,
});
}
}
const token = jwt.sign({ userId: user.id }, config.jwt.secret, {
expiresIn: config.jwt.expiresIn,
});
res.cookie('TOKEN', token, tokenCookieOptions);
return res.redirect(302, `${getBaseUrl()}/token`);
} catch (callbackErr) {
logOAuthCallbackFailure(strategy, callbackErr);
// If a throw happens after the success redirect already started writing
// (e.g. a future statement added between res.cookie and res.redirect),
// headers may already be sent — a second oauthErrorRedirect() would either
// throw again (ERR_HTTP_HEADERS_SENT, re-creating the exact unhandled-
// rejection risk the outer try/catch exists to prevent) or send garbage
// after the real response. Log only in that case; the client already got
// its redirect.
if (res.headersSent) return;
return oauthErrorRedirect(res, callbackErr, 'oAuth error');
}
const token = jwt.sign({ userId: user.id }, config.jwt.secret, {
expiresIn: config.jwt.expiresIn,
});
res.cookie('TOKEN', token, tokenCookieOptions);
return res.redirect(302, `${getBaseUrl()}/token`);
})(req, res, next);
};

Expand Down
5 changes: 3 additions & 2 deletions modules/auth/strategies/local/apple.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const callbackURL = `${config.api.protocol}://${config.api.host}${config.api.por
* @param {string} refreshToken - Apple refresh token
* @param {Object} decodedIdToken - Decoded Apple ID token
* @param {Object} profile - Apple profile (may be empty on repeat sign-ins)
* @param {Function} cb - Passport callback (err, user)
* @param {Function} cb - Passport callback (err, user, info) — `info.created` tells
* oauthCallback whether this resolution is a brand-new signup (see auth.controller.js)
* @returns {Promise<void>}
*/
const prepare = async (req, accessToken, refreshToken, decodedIdToken, profile, cb) => {
Expand All @@ -42,7 +43,7 @@ const prepare = async (req, accessToken, refreshToken, decodedIdToken, profile,
// Save the user OAuth profile
try {
const user = await auth.checkOAuthUserProfile(_profile, 'sub', 'apple');
return cb(null, user);
return cb(null, user, { created: !!user._isOAuthSignup });
} catch (err) {
return cb(err);
}
Expand Down
5 changes: 3 additions & 2 deletions modules/auth/strategies/local/google.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const callbackURL = `${config.api.protocol}://${config.api.host}${config.api.por
* @param {string} accessToken - Google access token
* @param {string} refreshToken - Google refresh token
* @param {Object} profile - Google profile object
* @param {Function} cb - Passport callback (err, user)
* @param {Function} cb - Passport callback (err, user, info) — `info.created` tells
* oauthCallback whether this resolution is a brand-new signup (see auth.controller.js)
* @returns {Promise<void>}
*/
const prepare = async (accessToken, refreshToken, profile, cb) => {
Expand All @@ -37,7 +38,7 @@ const prepare = async (accessToken, refreshToken, profile, cb) => {
// Save the user OAuth profile
try {
const user = await auth.checkOAuthUserProfile(_profile, 'sub', 'google');
return cb(null, user);
return cb(null, user, { created: !!user._isOAuthSignup });
} catch (err) {
return cb(err);
}
Expand Down
Loading
Loading