DC-052: license-tier enforcement (Free caps at 3, gates share on Pro)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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).
This commit is contained in:
hermes
2026-07-20 21:40:26 -07:00
parent b105d5abae
commit 273f6b8edb
6 changed files with 555 additions and 7 deletions
+54 -3
View File
@@ -29,7 +29,7 @@ const platformPaths = require('../../platform-paths');
const { createUserStore } = require('../../src/security/user-store');
const { createInviteStore } = require('../../src/security/invite-store');
const emailSender = require('../../src/auth/providers/email-sender');
const { ValidationError, NotFoundError, ForbiddenError } = require('../../src/utilities/errors');
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
const { ok, successMessage } = require('../../src/utils/responses');
/**
@@ -56,6 +56,36 @@ function _requireAdmin(req, _res, next) {
next();
}
/**
* DC-052: license-tier gate for user-creation endpoints.
*
* Free = up to 3 users total. Pro = unlimited. When the count would
* exceed the cap and the host isn't Pro, throw a PaymentRequiredError
* so the caller knows exactly what to do. The error message names the
* tier name ("Pro") so the upsell is clear.
*
* NOTE: passes through when the userStore isn't mounted (single-user
* installs without email auth — those don't even have /admin/*).
*/
async function _requireProIfUserLimitReached(req, _res, next) {
try {
const licenseManager = req.app.locals && req.app.locals.licenseManager;
if (!licenseManager || typeof licenseManager.isPro !== 'function') return next();
if (licenseManager.isPro()) return next();
const userStore = req.app.locals && req.app.locals.userStore;
if (!userStore || typeof userStore.countUsers !== 'function') return next();
const count = await userStore.countUsers();
if (count >= 3) {
return next(new PaymentRequiredError(
'Free tier supports up to 3 users. Upgrade to Pro for unlimited users.'
));
}
next();
} catch (e) {
next(e);
}
}
function _buildEmailText({ acceptUrl, ttlHours, role }) {
return [
'Hi,',
@@ -134,7 +164,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
return ok(res, { users });
}, 'auth-admin-users-list'));
router.post('/admin/users', _requireAdmin, asyncHandler(async (req, res) => {
router.post('/admin/users', _requireAdmin, _requireProIfUserLimitReached, asyncHandler(async (req, res) => {
const { email, role } = req.body || {};
if (!email) throw new ValidationError('email is required', 'email');
if (role && !userStore.VALID_ROLES.has(role)) {
@@ -195,7 +225,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
return ok(res, { invites });
}, 'auth-admin-invites-list'));
router.post('/admin/invites', _requireAdmin, asyncHandler(async (req, res) => {
router.post('/admin/invites', _requireAdmin, _requireProIfUserLimitReached, asyncHandler(async (req, res) => {
const { email, role, ttlHours, sendEmail } = req.body || {};
if (!email) throw new ValidationError('email is required', 'email');
const ttlMs = (typeof ttlHours === 'number' && ttlHours > 0 && ttlHours <= 168)
@@ -280,7 +310,28 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
}, 'auth-invites-peek'));
// PUBLIC: accept an invite token. Creates the user, sets the session.
// DC-052: gated by Pro-or-room — if the user cap is hit and the host
// isn't Pro, reject before the user is created. The invite token is
// still marked used so a stale invite can't be replayed later when
// room opens up.
router.post('/invites/:token/accept', asyncHandler(async (req, res) => {
const licenseManager = req.app.locals && req.app.locals.licenseManager;
const localUserStore = req.app.locals && req.app.locals.userStore;
if (licenseManager && typeof licenseManager.isPro === 'function' && !licenseManager.isPro()
&& localUserStore && typeof localUserStore.countUsers === 'function') {
const count = await localUserStore.countUsers();
if (count >= 3) {
// Burn the invite — it can't be redeemed later under a paid tier
// without the host first running `addToAllowlist` to re-add the
// email. This prevents invite-leak spam from filling the user
// table and being immortalized.
await inviteStore.accept(req.params.token, { acceptedBy: null }).catch(() => {});
throw new PaymentRequiredError(
'Free tier supports up to 3 users. Upgrade to Pro to redeem this invitation.'
);
}
}
const result = await inviteStore.accept(req.params.token, {
acceptedBy: req.user ? req.user.email : null,
});