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
+21 -2
View File
@@ -119,6 +119,9 @@ module.exports = function(ctx) {
// 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
);
@@ -146,12 +149,28 @@ module.exports = function(ctx) {
// /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) {
router.use('/auth', initAdmin({
// 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;