DC-052: license-tier enforcement (Free caps at 3, gates share on Pro)
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:
@@ -183,10 +183,24 @@ class LicenseManager {
|
||||
return { success: false, message: offlineResult.reason || 'Invalid license code' };
|
||||
}
|
||||
|
||||
// Code is cryptographically valid
|
||||
// DC-052: LIFETIME keys are creator-only. Reject any lifetime code
|
||||
// unless ALLOW_LIFETIME_LICENSE=true is set on this host (Sami's
|
||||
// dev machine). Production / paid customers must NEVER be able to
|
||||
// activate a LIFETIME code — every other license is time-bound.
|
||||
const isLifetime = offlineResult.durationDays === 0;
|
||||
if (isLifetime && !this.allowsLifetimeLicense()) {
|
||||
this.log.warn?.('license', 'LIFETIME code rejected — not allowed on this host', {
|
||||
code: this._maskCode(code),
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: 'Lifetime licenses are not available. Please use a time-bounded license key.',
|
||||
};
|
||||
}
|
||||
|
||||
// Code is cryptographically valid AND lifetime check passed
|
||||
const machineId = this.getMachineFingerprint();
|
||||
const now = new Date();
|
||||
const isLifetime = offlineResult.durationDays === 0;
|
||||
const expiresAt = isLifetime
|
||||
? new Date('2099-12-31T23:59:59.999Z')
|
||||
: new Date(now.getTime() + offlineResult.durationDays * 86400000);
|
||||
@@ -313,6 +327,32 @@ class LicenseManager {
|
||||
return features.includes(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-052: shorthand for "is this host on a Pro license right now?"
|
||||
*
|
||||
* Returns true only when there's an active, non-expired license.
|
||||
* Lifetime keys also count as Pro (they're just permanent Pro).
|
||||
* Free tier = false. Returns false when no activation exists.
|
||||
*/
|
||||
isPro() {
|
||||
if (!this.activation) return false;
|
||||
if (this.isExpired()) return false;
|
||||
// Lifetime keys are active forever; treat as Pro.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-052: are LIFETIME license codes permitted on this host?
|
||||
*
|
||||
* Default false. Set ALLOW_LIFETIME_LICENSE=true ONLY on the operator's
|
||||
* own dev machine — production hosts and paid customers must never be
|
||||
* able to activate a LIFETIME code. Per PRODUCT-SPEC-DECISIONS.md,
|
||||
* LIFETIME keys are creator-only; Stripe never issues them.
|
||||
*/
|
||||
allowsLifetimeLicense() {
|
||||
return process.env.ALLOW_LIFETIME_LICENSE === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the license has expired
|
||||
*/
|
||||
|
||||
@@ -335,6 +335,20 @@ function createUserStore(opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-052: count of users currently on this instance. Used by the
|
||||
* license-tier gate (Free = up to 3 users, Pro = unlimited). Counts
|
||||
* every user in users.json — including the TOTP-attributed system
|
||||
* record (`system@totp.local`) that DC-048 bootstraps on first
|
||||
* login. So a brand-new install always starts at count 1 (the host).
|
||||
*/
|
||||
function countUsers() {
|
||||
return _enqueue(() => {
|
||||
const users = _loadUsers();
|
||||
return users.order.length;
|
||||
});
|
||||
}
|
||||
|
||||
function listAllowlist() {
|
||||
return _enqueue(() => {
|
||||
const allowlist = _loadAllowlist();
|
||||
@@ -393,6 +407,7 @@ function createUserStore(opts = {}) {
|
||||
setRole,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
countUsers,
|
||||
listAllowlist,
|
||||
getUser,
|
||||
getUserByEmail,
|
||||
|
||||
@@ -49,6 +49,19 @@ class ConflictError extends AppError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-052: 402 Payment Required — used when a Pro-only feature is
|
||||
* blocked by the license tier. Distinguishes "you need to pay" from
|
||||
* 403 (forbidden) so the dashboard UI can render an upgrade prompt
|
||||
* instead of a generic permission error.
|
||||
*/
|
||||
class PaymentRequiredError extends AppError {
|
||||
constructor(message = 'Pro license required for this feature', feature = null) {
|
||||
super(message, 402, 'DC-402');
|
||||
this.feature = feature;
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError extends AppError {
|
||||
constructor(retryAfter = 60) {
|
||||
super('Rate limit exceeded', 429, 'DC-429');
|
||||
@@ -98,6 +111,8 @@ module.exports = {
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
RateLimitError,
|
||||
// DC-052
|
||||
PaymentRequiredError,
|
||||
DockerError,
|
||||
CaddyError,
|
||||
DNSError,
|
||||
|
||||
Reference in New Issue
Block a user