First pricing enforcement. Per PRODUCT-SPEC-DECISIONS.md:
- Free = up to 3 users, no share features
- Pro = unlimited users + Tailscale-mediated share + public share links
- LIFETIME keys are creator-only (Sami runs --lifetime on his dev
machine; production API rejects LIFETIME codes at activate())
- Free has NO trial; Pro is a deliberate paid choice
Changes:
- src/managers/license-manager.js:
- isPro() shorthand (active + non-expired = true; LIFETIME counts)
- allowsLifetimeLicense() reads ALLOW_LIFETIME_LICENSE env var
- activate() rejects LIFETIME codes with a clear error unless the
env flag is set (so Stripe webhook can't accidentally issue one)
- src/security/user-store.js: countUsers() helper for the tier gate
- src/utilities/errors.js: PaymentRequiredError (HTTP 402, code DC-402)
- routes/auth/admin.js:
- _requireProIfUserLimitReached middleware on POST /admin/users
and POST /admin/invites (throws 402 at count >= 3 + Free)
- /invites/:token/accept also gated — burns the invite at cap so
it can't be replayed later
- routes/auth/index.js: bridge licenseManager + userStore onto
req.app.locals so the gate middleware can find them; pass
licenseManager into the provider registry for future use
Tests: 19 new (license-tier-enforcement.test.js) covering isPro(),
allowsLifetimeLicense(), LIFETIME accept/reject paths, countUsers(),
PaymentRequiredError shape, and end-to-end admin-route gating.
Full suite: 1317/1317 passing across 50 suites.
Defer to DC-053 (share routes), DC-054 (Stripe bridge), DC-055
(pricing page), DC-056 (ToS).
178 lines
7.0 KiB
JavaScript
178 lines
7.0 KiB
JavaScript
const express = require('express');
|
|
const initTotp = require('./totp');
|
|
const initKeys = require('./keys');
|
|
const initSessionHandlers = require('./session-handlers');
|
|
const initSsoGate = require('./sso-gate');
|
|
const initLogin = require('./login');
|
|
const initAdmin = require('./admin');
|
|
const { createAuthProviderRegistry } = require('../../src/auth/providers');
|
|
const { createUserStore } = require('../../src/security/user-store');
|
|
|
|
/**
|
|
* Auth routes aggregator
|
|
* Assembles all auth sub-routes with their dependencies
|
|
* @param {Object} ctx - Application context (for backward compatibility)
|
|
* @returns {express.Router}
|
|
*/
|
|
|
|
/**
|
|
* Pull the SMTP/email provider config from whichever source has it.
|
|
*
|
|
* Resolution order:
|
|
* 1. ctx.emailProviderConfig — explicit override (operator or env)
|
|
* 2. ctx.notification.getConfig?.().providers.email — reuse the same
|
|
* SMTP settings notifications use. This is the "magic" — operators
|
|
* configure SMTP once for system notifications and email-auth picks
|
|
* it up automatically.
|
|
* 3. null — provider will operate in dev-console fallback mode.
|
|
*/
|
|
function _extractEmailConfig(ctx) {
|
|
if (ctx.emailProviderConfig && typeof ctx.emailProviderConfig === 'object') {
|
|
return ctx.emailProviderConfig;
|
|
}
|
|
const n = ctx.notification;
|
|
if (n && typeof n.getConfig === 'function') {
|
|
const cfg = n.getConfig();
|
|
if (cfg && cfg.providers && cfg.providers.email) return cfg.providers.email;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
module.exports = function(ctx) {
|
|
const router = express.Router();
|
|
|
|
// DC-048: opt-in user store. Only instantiated when the operator has
|
|
// explicitly enabled email auth in siteConfig. The default for new
|
|
// installs is "no user-store, no allowlist, no admin invites" — the
|
|
// legacy single-user TOTP flow. Operators who turn email auth on
|
|
// (siteConfig.authProviders.email.enabled = true) opt into multi-user.
|
|
// Once opted in, the first email to log in is the bootstrap admin.
|
|
const platformPaths = ctx.platformPaths || require('../../platform-paths');
|
|
let userStore = null;
|
|
|
|
const _emailExplicitlyEnabled =
|
|
ctx.siteConfig &&
|
|
ctx.siteConfig.authProviders &&
|
|
ctx.siteConfig.authProviders.email &&
|
|
ctx.siteConfig.authProviders.email.enabled === true;
|
|
|
|
if (_emailExplicitlyEnabled) {
|
|
userStore = createUserStore({
|
|
dataDir: platformPaths.dataDir,
|
|
log: ctx.log,
|
|
});
|
|
ctx.userStore = userStore;
|
|
ctx.log && ctx.log.info && ctx.log.info('user', 'multi-user mode enabled (email auth on)');
|
|
} else {
|
|
ctx.log && ctx.log.info && ctx.log.info('user', 'single-user mode (email auth not enabled — set siteConfig.authProviders.email.enabled = true to opt into multi-user)');
|
|
}
|
|
|
|
// Extract dependencies from context
|
|
const deps = {
|
|
authManager: ctx.authManager,
|
|
credentialManager: ctx.credentialManager,
|
|
totpConfig: ctx.totpConfig,
|
|
saveTotpConfig: ctx.saveTotpConfig,
|
|
session: ctx.session,
|
|
asyncHandler: ctx.asyncHandler,
|
|
errorResponse: ctx.errorResponse,
|
|
log: ctx.log,
|
|
// Additional deps for sso-gate
|
|
fetchT: ctx.fetchT,
|
|
getServiceById: ctx.getServiceById,
|
|
licenseManager: ctx.licenseManager,
|
|
servicesStateManager: ctx.servicesStateManager,
|
|
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken,
|
|
// For DC-046 pluggable auth providers (EmailMagicLink, OIDC, …).
|
|
// Pass-through — providers like the EmailMagicLinkProvider need
|
|
// notificationManager for SMTP delivery, plus the siteConfig for
|
|
// building verification links.
|
|
notificationManager: ctx.notification,
|
|
siteConfig: ctx.siteConfig,
|
|
// DC-047: data-directory resolution for the email-token JSON store.
|
|
platformPaths,
|
|
// DC-048: user store for allowlist + bootstrap. Null when email
|
|
// auth is disabled — providers fall back to "allow everyone" legacy
|
|
// behavior (DC-046/047 semantics).
|
|
userStore,
|
|
};
|
|
|
|
const { getAppSession, appSessionCache } = initSessionHandlers(deps);
|
|
|
|
// DC-046: pluggable auth provider registry. The TOTP provider is wired
|
|
// here against the existing totpConfig / saveTotpConfig objects so it
|
|
// behaves identically to the legacy /api/v1/totp/* routes mounted below.
|
|
const registry = createAuthProviderRegistry(
|
|
{
|
|
credentialManager: ctx.credentialManager,
|
|
session: ctx.session,
|
|
saveTotpConfig: ctx.saveTotpConfig,
|
|
config: { totp: ctx.totpConfig, email: ctx.emailProviderConfig || { enabled: false } },
|
|
log: ctx.log,
|
|
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken,
|
|
// DC-047: EmailMagicLinkProvider needs SMTP config + a public URL
|
|
// resolver + the data dir for the token store. All three come from
|
|
// existing global config — no new config knobs required.
|
|
emailConfig: _extractEmailConfig(ctx),
|
|
siteConfig: ctx.siteConfig || {},
|
|
platformPaths: deps.platformPaths,
|
|
// DC-048: user store shared by every provider for allowlist checks
|
|
// and the bootstrap-admin-on-first-login rule.
|
|
userStore: deps.userStore,
|
|
// DC-052: license manager so providers can gate Pro-only flows
|
|
// (e.g. magic-link signup that crosses the 3-user cap).
|
|
licenseManager: ctx.licenseManager,
|
|
},
|
|
ctx.siteConfig
|
|
);
|
|
ctx.authProviders = registry; // exposed for /api/v1/auth/methods, etc.
|
|
|
|
// NEW (DC-046): pluggable /api/v1/auth/login/* routes. Frontends should
|
|
// migrate here over time — the legacy /api/v1/totp/* routes below stay
|
|
// for back-compat. Mounted under `/auth` so internal paths
|
|
// (`/login/methods`, `/disable/:provider`) resolve at the canonical
|
|
// `/api/v1/auth/login/*` and `/api/v1/auth/disable/*` URLs that match
|
|
// PUBLIC_ROUTES and the documented login UI contract.
|
|
router.use('/auth', initLogin({
|
|
registry,
|
|
asyncHandler: ctx.asyncHandler,
|
|
errorResponse: ctx.errorResponse,
|
|
log: ctx.log,
|
|
}));
|
|
|
|
router.use(initTotp(deps));
|
|
router.use(initKeys(deps));
|
|
router.use(initSsoGate({ ...deps, getAppSession, appSessionCache }));
|
|
|
|
// DC-048: mount admin routes ONLY when the user-store was instantiated
|
|
// (i.e. email auth is enabled). Single-user installs don't see /me,
|
|
// /admin/*, or /invites/* at all. The route paths simply don't exist
|
|
// so a request to /api/v1/auth/me returns 404 from the apiRouter.
|
|
if (userStore) {
|
|
// DC-052: pass licenseManager + userStore through so the tier-gate
|
|
// middleware can read them. Both are optional — the gate short-
|
|
// circuits when licenseManager is absent.
|
|
const adminRouter = initAdmin({
|
|
asyncHandler: ctx.asyncHandler,
|
|
errorResponse: ctx.errorResponse,
|
|
log: ctx.log,
|
|
session: ctx.session,
|
|
licenseManager: ctx.licenseManager,
|
|
userStore,
|
|
});
|
|
|
|
// DC-048 attach: licenseManager + userStore on app.locals
|
|
if (ctx.licenseManager || userStore) {
|
|
router.use('/auth', (req, _res, next) => {
|
|
if (ctx.licenseManager) req.app.locals.licenseManager = ctx.licenseManager;
|
|
if (userStore) req.app.locals.userStore = userStore;
|
|
next();
|
|
});
|
|
}
|
|
|
|
router.use('/auth', adminRouter);
|
|
}
|
|
|
|
return router;
|
|
};
|