Compare commits

...
1 Commits
Author SHA1 Message Date
aneurinandClaude Sonnet 5 fc1fbcc762 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 <noreply@anthropic.com>
2026-09-05 09:15:06 +01:00
7 changed files with 16 additions and 6 deletions
+3
View File
@@ -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.
# <APP_URL>/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.
+1 -1
View File
@@ -103,7 +103,7 @@ All routes below require `Authorization: Bearer <jwt>`. 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, 1255 chars).
+1
View File
@@ -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 |
+4 -3
View File
@@ -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,
);
}
+4
View File
@@ -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,
);
}
+1 -1
View File
@@ -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);
+2 -1
View File
@@ -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++) {