Files
projectsend/app/Modules/Platform/Capabilities/Capability.php
T
ignacionelson 553f5fd2bf Declare the capability a managed installation's staff seats hang off
Cloud instances are sold seats rather than administering them, so the
tenant's own /users screens stay closed — capability:users.manage is
already Community-only — and a control plane creates, deactivates and
password-resets staff from outside. This is the key that plane gates on.

Only the declaration lives here, the same division StorageManaged and
Branding already use. Everything behind it is a module in the private
cloud-modules package.

Declared before that module exists, deliberately. A capability added
after a release is invisible to every image built from one, and that is
not hypothetical: StorageManaged landed 36 commits after v2.1.0 and has
never shipped, so a fleet with buckets provisioned, credentials scoped
and eight environment variables in place still writes every upload to
local disk — because the gate is here and the gate never left. Declaring
this one now is refusing to make the same mistake twice.

The seat *number* deliberately does not live here. There are no billing
or plan tiers in this application to key off, which is the reason
config/api.php gives for not inventing an installation-level rate limit,
and it holds for the same reason: the number lives where the plans do.
This capability says only who is in charge.

ModuleBoundaryTest grows the other half of its own rule. It filtered on
`api/v1/`, so a package claiming a route anywhere else passed — not
because that was sanctioned, but because nothing was looking, and
/platform/v1 is about to be somewhere else. What it polices now is
machine surfaces, the roots something other than a browser authenticates
to, with api/v1/modules and platform/v1 as the two sanctioned prefixes.

Written twice, because the first version was wrong in a useful way: it
policed every route and immediately caught community-modules' Custom
Assets screens. Those are a module doing exactly what a module is for,
through the host's session and capability middleware in plain sight, and
listing them would be the hardcoded URI list the test above it explains
it is avoiding. Web screens are not the boundary; trusted perimeters are.

Verified by making it fail: a package controller on platform/v2 is caught
and named.
2026-08-27 00:28:08 -03:00

114 lines
5.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Capabilities;
/**
* Every capability that differs between editions, in one place.
*
* The registry is bidirectional: each capability declares the editions it
* belongs to. Community-only capabilities exist because managed
* installations handle those concerns outside the application; cloud-only
* capabilities exist because some features cannot ship inside a self-hosted
* package (commercial trust providers, vendor credentials, legal posture).
*
* Adding a capability means adding a case here and its editions below —
* enforcement points (gates, Inertia shared props, API middleware) derive
* from this enum and must not need changes.
*/
enum Capability: string
{
// Community-only — cut where the installation is managed for you.
case UsersManage = 'users.manage';
case StorageConfigure = 'storage.configure';
case EmailTransportConfigure = 'email.transport.configure';
case SystemUpdates = 'system.updates';
// Community-only — scheduled-task run history and failed-queue-job
// visibility. Cut on managed installations, where infrastructure
// monitoring happens outside this application; a transient failure
// that self-heals on the next
// run showing up in a customer-facing UI would do more harm than good.
case SchedulerMonitoring = 'scheduler.monitoring';
// Community-only — code lives in the separate projectsend/community-modules
// package (github.com/projectsend/community-modules, public and GPLv2);
// arbitrary HTML/CSS/JS injection is too risky to offer on the hosted
// platform. Separate for operational reasons, not secret ones — unlike
// cloud-modules below.
case CustomAssets = 'custom_assets.manage';
// Cloud-only — code lives in the private projectsend/cloud-modules
// package (github.com/projectsend/cloud-modules), never in this repo.
case Branding = 'branding.customize';
// Cloud-only — the storage backend is ours, supplied by the
// environment when the instance is provisioned and not the customer's
// to see or change. The counterpart of StorageConfigure above rather
// than a contradiction of it: one edition configures its own bucket,
// the other is given one. Behaviour lives in the private
// projectsend/cloud-modules package; without it this capability is
// simply inert and files stay on local disk.
case StorageManaged = 'storage.managed';
// Cloud-only — managed installations supply CAPTCHA keys centrally, so
// protection is on before anybody finds the settings screen. The
// feature itself is in both editions and behind no capability: this
// covers only the option of using *our* credentials, which cannot ship
// inside a self-hosted package.
case CaptchaManagedKeys = 'captcha.managed_keys';
// Cloud-only — letting an AI assistant act on this installation on
// somebody's behalf. Code lives in the private
// projectsend/cloud-modules package; without it this capability is
// inert, which is the point: the edition boundary here is which
// package is installed, not a flag an installation can set. Present
// in this enum even so, because a package cannot extend a closed one
// — core has to publish the key before anything can gate on it.
// Cloud-only — staff seats on a managed instance belong to the
// platform that sold them rather than to the instance, so the tenant's
// own /users screens stay closed (see UsersManage above) and a control
// plane creates, deactivates and password-resets them from outside.
//
// The seat *number* deliberately does not live here. There are no
// billing or plan tiers in this application to key off — the same
// reason config/api.php gives for not inventing an installation-level
// rate limit — so the limit arrives from the environment and this
// capability only says who is in charge.
//
// Declared before the module that implements it exists, and that is
// the point: a capability added after a release is invisible to every
// image built from one, which is exactly how StorageManaged came to
// sit unusable for a fleet that had everything else in place.
case PlatformManaged = 'platform.managed';
case AiConnector = 'ai.connector';
/**
* @return list<Edition>
*/
public function editions(): array
{
return match ($this) {
self::UsersManage,
self::StorageConfigure,
self::EmailTransportConfigure,
self::SystemUpdates,
self::SchedulerMonitoring,
self::CustomAssets => [Edition::Community],
self::Branding,
self::StorageManaged,
self::CaptchaManagedKeys,
self::PlatformManaged,
self::AiConnector => [Edition::Cloud],
};
}
public function availableIn(Edition $edition): bool
{
return in_array($edition, $this->editions(), true);
}
}