Compare commits

...
3 Commits
Author SHA1 Message Date
aneurinandClaude Sonnet 5 16f94293ab Make the per-owner project cap configurable via env
CI / php-tests (pull_request) Successful in 19s
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:34:08 +00:00
aneurinandClaude Sonnet 5 e59890618e Install Node in the CI job container before checkout
CI / php-tests (pull_request) Successful in 22s
actions/checkout is a JS action and needs a node binary on PATH; the
php:8.3-cli-alpine job container doesn't ship one, so checkout was
failing with "node: executable file not found in $PATH".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 10:32:15 +01:00
aneurinandClaude Sonnet 5 6faf4a72df Add Gitea Actions workflow to run PHPUnit on pull requests
CI / php-tests (pull_request) Failing after 5s
Runs the backend test suite against PHP 8.3 for every PR targeting
main, so branch protection can require it to pass before merging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 09:23:55 +01:00
8 changed files with 43 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.
+27
View File
@@ -0,0 +1,27 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
php-tests:
runs-on: ubuntu-latest
container: php:8.3-cli-alpine
steps:
# actions/checkout is a JS action -- php:8.3-cli-alpine has no node on
# PATH by default, so install it (musl-native, no glibc/Alpine mismatch)
# before any step that needs it.
- name: Install Node
run: apk add --no-cache nodejs
- uses: actions/checkout@v4
- name: Install Composer
run: curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- name: Install dependencies
run: composer install --no-progress --prefer-dist
- name: Run tests
run: vendor/bin/phpunit
+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++) {