From 16f94293ab8e31de883bc73b657e4f383a54af1d Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Sat, 5 Sep 2026 09:15:06 +0100 Subject: [PATCH] Make the per-owner project cap configurable via env Self-hosters shouldn't be stuck with a hardcoded 100-project limit; MAX_PROJECTS_PER_OWNER now controls it, defaulting to 0 (unlimited). Co-Authored-By: Claude Sonnet 5 --- .env.example | 3 +++ docs/api.md | 2 +- docs/setup.md | 1 + src/Http/Controllers/ProjectController.php | 7 ++++--- src/Support/Config.php | 4 ++++ src/bootstrap.php | 2 +- tests/ProjectTest.php | 3 ++- 7 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 2f6283c..793cf6d 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,9 @@ APP_ALLOW_REGISTRATION=true # to 0 for local development, so links can be resent immediately. MAGIC_LINK_RESEND_SECONDS=60 +# Maximum number of projects a single user may create. 0 means unlimited. +MAX_PROJECTS_PER_OWNER=0 + # Base URL the app is reached at. Verification magic links point here, e.g. # /verify-email?token=... The Docker image serves the SPA and the API # together on http://localhost:8080; a host `npm run dev` serves it on :5173. diff --git a/docs/api.md b/docs/api.md index 6b1a863..e703d9c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -103,7 +103,7 @@ All routes below require `Authorization: Bearer `. A project belongs to one | `PATCH` | `/api/projects/{id}` | rename the project (`title`) | | `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) | -`GET /api/projects` is always ordered alphabetically (case-insensitive) by title; there is no other sort option. A user may own at most **100 projects** — creating one beyond that responds `409`. +`GET /api/projects` is always ordered alphabetically (case-insensitive) by title; there is no other sort option. A user may own at most `MAX_PROJECTS_PER_OWNER` projects (default: unlimited) — creating one beyond that responds `409`. Create/update body: `title` (required, 1–255 chars). diff --git a/docs/setup.md b/docs/setup.md index 59b9a76..831ad55 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -56,6 +56,7 @@ All settings are optional environment variables (read from `.env` or the real en | `JWT_TTL` | `86400` | Token lifetime in seconds | | `APP_ALLOW_REGISTRATION` | `true` | When `false`, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created | | `MAGIC_LINK_RESEND_SECONDS` | `60` | Minimum gap before a magic link can be resent to the same address (sign-in or email-change). Docker Compose overrides this to `0`, so links resend immediately in development | +| `MAX_PROJECTS_PER_OWNER` | `0` | Maximum number of projects a single user may create. `0` means unlimited | | `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) | | `WEBAUTHN_RP_ID` | `APP_URL`'s host | Passkey relying party ID (domain). Must be `localhost` or a real domain over HTTPS — a LAN IP won't work | | `WEBAUTHN_RP_NAME` | `Projects` | Passkey relying party display name, shown in the browser/OS prompt | diff --git a/src/Http/Controllers/ProjectController.php b/src/Http/Controllers/ProjectController.php index 0587c21..1183c1a 100644 --- a/src/Http/Controllers/ProjectController.php +++ b/src/Http/Controllers/ProjectController.php @@ -18,11 +18,12 @@ use Psr\Http\Message\ServerRequestInterface as Request; final class ProjectController extends ProjectScopedController { private const TITLE_MAX = 255; - private const MAX_PROJECTS_PER_OWNER = 100; public function __construct( ProjectRepository $projects, private readonly CardStatusRepository $statuses, + /** Maximum number of projects a single owner may create. 0 means unlimited. */ + private readonly int $maxProjectsPerOwner, ) { parent::__construct($projects); } @@ -48,9 +49,9 @@ final class ProjectController extends ProjectScopedController $title = $validator->requiredString('title', self::TITLE_MAX); $validator->assert(); - if ($this->projects->countForOwner($ownerId) >= self::MAX_PROJECTS_PER_OWNER) { + if ($this->maxProjectsPerOwner > 0 && $this->projects->countForOwner($ownerId) >= $this->maxProjectsPerOwner) { throw new ApiException( - sprintf('You have reached the maximum of %d projects.', self::MAX_PROJECTS_PER_OWNER), + sprintf('You have reached the maximum of %d projects.', $this->maxProjectsPerOwner), 409, ); } diff --git a/src/Support/Config.php b/src/Support/Config.php index 8a406d4..ce5dfd5 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -25,6 +25,8 @@ final class Config public readonly string $webauthnRpName, /** Minimum gap between magic links sent to the same address. */ public readonly int $resendIntervalSeconds, + /** Maximum number of projects a single owner may create. 0 means unlimited. */ + public readonly int $maxProjectsPerOwner, public readonly MailConfig $mail, ) { } @@ -59,6 +61,7 @@ final class Config $webauthnRpName = self::env('WEBAUTHN_RP_NAME', 'Projects'); $resendIntervalSeconds = (int) (self::env('MAGIC_LINK_RESEND_SECONDS') ?? '60'); + $maxProjectsPerOwner = (int) (self::env('MAX_PROJECTS_PER_OWNER') ?? '0'); $mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log'); if (!self::isAbsolutePath($mailLogPath)) { @@ -87,6 +90,7 @@ final class Config $webauthnRpId, $webauthnRpName, $resendIntervalSeconds, + $maxProjectsPerOwner, $mail, ); } diff --git a/src/bootstrap.php b/src/bootstrap.php index 6bcc347..e3e18c2 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -71,7 +71,7 @@ $webAuthn = new WebAuthn($config->webauthnRpName, $config->webauthnRpId, ['none' $authController = new AuthController($users, $session, $verifier, $config->allowRegistration); $emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session); -$projectController = new ProjectController($projects, $cardStatuses); +$projectController = new ProjectController($projects, $cardStatuses, $config->maxProjectsPerOwner); $cardController = new CardController($projects, $cards, $cardStatuses); $cardStatusController = new CardStatusController($projects, $cardStatuses, $cards); $passkeyController = new PasskeyController($webAuthn, $passkeys, $webauthnChallenges, $users, $session); diff --git a/tests/ProjectTest.php b/tests/ProjectTest.php index 8107434..3bd40d7 100644 --- a/tests/ProjectTest.php +++ b/tests/ProjectTest.php @@ -39,8 +39,9 @@ final class ProjectTest extends ApiTestCase self::assertSame(['apple', 'Banana', 'Cherry'], $titles); } - public function test_an_owner_cannot_exceed_100_projects(): void + public function test_an_owner_cannot_exceed_the_configured_project_limit(): void { + $this->reconfigure(['MAX_PROJECTS_PER_OWNER' => '100']); $auth = $this->authHeader(); for ($i = 1; $i <= 100; $i++) { -- 2.54.0