Files
dashcaddy/dashcaddy-api/src/utilities/errors.js
T
hermes 273f6b8edb
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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).
2026-07-20 21:40:26 -07:00

121 lines
2.9 KiB
JavaScript

/**
* DashCaddy API Error Classes
* All errors inherit from AppError and provide consistent structure.
*/
class AppError extends Error {
constructor(message, statusCode = 500, code = null) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code || this.constructor.name.toUpperCase().replace(/ERROR$/, '_ERROR');
this.isOperational = true; // Distinguishes from programming errors
}
}
// 4xx Client Errors
class ValidationError extends AppError {
constructor(message, field = null) {
super(message, 400, 'DC-400');
this.field = field;
}
}
class AuthenticationError extends AppError {
constructor(message = 'Authentication required', requiresTotp = false) {
super(message, 401, 'DC-401');
this.requiresTotp = requiresTotp;
}
}
class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'DC-403');
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'DC-404');
this.resource = resource;
}
}
class ConflictError extends AppError {
constructor(message, conflictingResource = null) {
super(message, 409, 'DC-409');
this.conflictingResource = conflictingResource;
}
}
/**
* 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');
this.retryAfter = retryAfter;
}
}
// 5xx Server Errors
class DockerError extends AppError {
constructor(message, operation = null, details = {}) {
super(message, 500, 'DC-500-DOCKER');
this.operation = operation;
this.details = details;
}
}
class CaddyError extends AppError {
constructor(message, operation = null, details = {}) {
super(message, 502, 'DC-502-CADDY');
this.operation = operation;
this.details = details;
}
}
class DNSError extends AppError {
constructor(message, operation = null, details = {}) {
super(message, 502, 'DC-502-DNS');
this.operation = operation;
this.details = details;
}
}
class ServiceUnavailableError extends AppError {
constructor(service, retryAfter = null) {
super(`Service unavailable: ${service}`, 503, 'DC-503');
this.service = service;
this.retryAfter = retryAfter;
}
}
module.exports = {
AppError,
ValidationError,
AuthenticationError,
ForbiddenError,
NotFoundError,
ConflictError,
RateLimitError,
// DC-052
PaymentRequiredError,
DockerError,
CaddyError,
DNSError,
ServiceUnavailableError
};