Compare commits
23 commits
feat/phase
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3546cb1dad | ||
|
|
08180c223c | ||
|
|
433c060b7c | ||
|
|
cc74df85c0 | ||
|
|
4374bbf7dc | ||
|
|
74ac2a5b64 | ||
|
|
ead5e1dcef | ||
| d1daceb58a | |||
| 6cc13dae06 | |||
|
|
d38d56c286 | ||
|
|
46929ea619 | ||
|
|
62177b8ad3 | ||
|
|
fa49f1ea1a | ||
|
|
52383c4b46 | ||
|
|
27f91b5905 | ||
|
|
ef68ee82f4 | ||
|
|
77d8687f06 | ||
|
|
e64fea87ae | ||
|
|
97889d04cc | ||
|
|
39bca812b3 | ||
| fe2df90a1a | |||
| b7d2408eaf | |||
| 9a5f9d7a8e |
17 changed files with 405 additions and 161 deletions
63
.forgejo/workflows/ci.yml
Normal file
63
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: PHP ${{ matrix.php }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: lthn/build:php-${{ matrix.php }}
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
php: ["8.3", "8.4"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Clone sister packages
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
echo "Cloning php-framework into ../php-framework"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${GITHUB_TOKEN}@forge.lthn.ai/core/php-framework.git" \
|
||||
../php-framework
|
||||
ls -la ../php-framework/composer.json
|
||||
|
||||
- name: Configure path repositories
|
||||
run: |
|
||||
composer config repositories.core path ../php-framework --no-interaction
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-interaction --no-progress
|
||||
|
||||
- name: Run Pint
|
||||
run: |
|
||||
if [ -f vendor/bin/pint ]; then
|
||||
vendor/bin/pint --test
|
||||
else
|
||||
echo "Pint not installed, skipping"
|
||||
fi
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
if [ -f vendor/bin/pest ]; then
|
||||
if [ -d tests/Unit ] || [ -d tests/unit ]; then
|
||||
vendor/bin/pest tests/Unit --ci
|
||||
elif [ -d src/Tests/Unit ]; then
|
||||
vendor/bin/pest src/Tests/Unit --ci
|
||||
else
|
||||
echo "No unit test directory found, skipping"
|
||||
fi
|
||||
elif [ -f vendor/bin/phpunit ]; then
|
||||
vendor/bin/phpunit --testsuite=Unit
|
||||
else
|
||||
echo "No test runner found, skipping"
|
||||
fi
|
||||
38
.forgejo/workflows/release.yml
Normal file
38
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
name: Publish Composer Package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Create package archive
|
||||
run: |
|
||||
apt-get update && apt-get install -y zip
|
||||
zip -r package.zip . \
|
||||
-x ".forgejo/*" \
|
||||
-x ".git/*" \
|
||||
-x "tests/*" \
|
||||
-x "docker/*" \
|
||||
-x "*.yaml" \
|
||||
-x "infection.json5" \
|
||||
-x "phpstan.neon" \
|
||||
-x "phpunit.xml" \
|
||||
-x "psalm.xml" \
|
||||
-x "rector.php" \
|
||||
-x "TODO.md" \
|
||||
-x "ROADMAP.md" \
|
||||
-x "CONTRIBUTING.md" \
|
||||
-x "package.json" \
|
||||
-x "package-lock.json"
|
||||
|
||||
- name: Publish to Forgejo Composer registry
|
||||
run: |
|
||||
curl --fail --user "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \
|
||||
--upload-file package.zip \
|
||||
"https://forge.lthn.ai/api/packages/core/composer?version=${FORGEJO_REF_NAME#v}"
|
||||
|
|
@ -54,7 +54,7 @@ class EncryptTwoFactorSecrets extends Command
|
|||
|
||||
$this->info("Found {$records->count()} 2FA records total.");
|
||||
$this->info("Already encrypted: {$alreadyEncrypted}");
|
||||
$this->info("Need migration: ".count($toMigrate));
|
||||
$this->info('Need migration: '.count($toMigrate));
|
||||
|
||||
if (empty($toMigrate)) {
|
||||
$this->info('All secrets are already encrypted. Nothing to do.');
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class HashInvitationTokens extends Command
|
|||
|
||||
$this->info("Found {$records->count()} invitation records in scope.");
|
||||
$this->info("Already hashed: {$alreadyHashed}");
|
||||
$this->info("Need migration: ".count($toMigrate));
|
||||
$this->info('Need migration: '.count($toMigrate));
|
||||
|
||||
if (empty($toMigrate)) {
|
||||
$this->info('All tokens are already hashed. Nothing to do.');
|
||||
|
|
@ -86,7 +86,7 @@ class HashInvitationTokens extends Command
|
|||
$nonPendingCount = count($toMigrate) - $pendingCount;
|
||||
|
||||
$this->newLine();
|
||||
$this->warn("IMPORTANT: Hashing tokens is a one-way operation!");
|
||||
$this->warn('IMPORTANT: Hashing tokens is a one-way operation!');
|
||||
$this->warn("- Pending invitations ({$pendingCount}): Links will STOP working");
|
||||
$this->warn("- Expired/Accepted ({$nonPendingCount}): Safe to hash");
|
||||
|
||||
|
|
|
|||
|
|
@ -6,17 +6,16 @@ namespace Core\Tenant\Controllers;
|
|||
|
||||
use Core\Api\RateLimit\RateLimit;
|
||||
use Core\Front\Controller;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Tenant\Models\EntitlementLog;
|
||||
use Core\Tenant\Models\Package;
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Tenant\Models\WorkspacePackage;
|
||||
use Core\Tenant\Services\EntitlementService;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* API controller for entitlement management.
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ declare(strict_types=1);
|
|||
namespace Core\Tenant\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Mod\Api\Controllers\Concerns\HasApiResponses;
|
||||
use Mod\Api\Controllers\Concerns\ResolvesWorkspace;
|
||||
use Mod\Api\Resources\PaginatedCollection;
|
||||
use Mod\Api\Resources\WorkspaceResource;
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Workspace API controller.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ namespace Core\Tenant\Database\Factories;
|
|||
|
||||
use Core\Tenant\Models\WorkspaceInvitation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -59,10 +59,10 @@ class EntitlementCacheInvalidated
|
|||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param Workspace|null $workspace The affected workspace (null for namespace-only invalidation)
|
||||
* @param Namespace_|null $namespace The affected namespace (null for workspace-only invalidation)
|
||||
* @param array<string> $featureCodes Specific feature codes invalidated (empty = all features)
|
||||
* @param string $reason The reason for invalidation
|
||||
* @param Workspace|null $workspace The affected workspace (null for namespace-only invalidation)
|
||||
* @param Namespace_|null $namespace The affected namespace (null for workspace-only invalidation)
|
||||
* @param array<string> $featureCodes Specific feature codes invalidated (empty = all features)
|
||||
* @param string $reason The reason for invalidation
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?Workspace $workspace,
|
||||
|
|
@ -74,9 +74,9 @@ class EntitlementCacheInvalidated
|
|||
/**
|
||||
* Create an event for workspace cache invalidation.
|
||||
*
|
||||
* @param Workspace $workspace The workspace whose cache was invalidated
|
||||
* @param array<string> $featureCodes Specific feature codes (empty = all)
|
||||
* @param string $reason The reason for invalidation
|
||||
* @param Workspace $workspace The workspace whose cache was invalidated
|
||||
* @param array<string> $featureCodes Specific feature codes (empty = all)
|
||||
* @param string $reason The reason for invalidation
|
||||
*/
|
||||
public static function forWorkspace(
|
||||
Workspace $workspace,
|
||||
|
|
@ -89,9 +89,9 @@ class EntitlementCacheInvalidated
|
|||
/**
|
||||
* Create an event for namespace cache invalidation.
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace whose cache was invalidated
|
||||
* @param array<string> $featureCodes Specific feature codes (empty = all)
|
||||
* @param string $reason The reason for invalidation
|
||||
* @param Namespace_ $namespace The namespace whose cache was invalidated
|
||||
* @param array<string> $featureCodes Specific feature codes (empty = all)
|
||||
* @param string $reason The reason for invalidation
|
||||
*/
|
||||
public static function forNamespace(
|
||||
Namespace_ $namespace,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Core Tenant
|
||||
|
||||
[](https://github.com/host-uk/core-tenant/actions/workflows/ci.yml)
|
||||
[](https://packagist.org/packages/host-uk/core-tenant)
|
||||
[](https://github.com/lthn/php-tenant/actions/workflows/ci.yml)
|
||||
[](https://packagist.org/packages/lthn/php-tenant)
|
||||
[](https://laravel.com)
|
||||
[](LICENSE)
|
||||
|
||||
|
|
@ -20,12 +20,12 @@ Multi-tenancy module for the Core PHP Framework providing users, workspaces, and
|
|||
|
||||
- PHP 8.2+
|
||||
- Laravel 11.x or 12.x
|
||||
- Core PHP Framework (`host-uk/core`)
|
||||
- Core PHP Framework (`lthn/php`)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
composer require host-uk/core-tenant
|
||||
composer require lthn/php-tenant
|
||||
```
|
||||
|
||||
The service provider will be auto-discovered.
|
||||
|
|
|
|||
|
|
@ -137,8 +137,8 @@ class EntitlementService
|
|||
/**
|
||||
* Get cache tags for workspace entitlements.
|
||||
*
|
||||
* @param Workspace $workspace The workspace
|
||||
* @param string $type The cache type ('limit' or 'usage')
|
||||
* @param Workspace $workspace The workspace
|
||||
* @param string $type The cache type ('limit' or 'usage')
|
||||
* @return array<string> Cache tags
|
||||
*/
|
||||
protected function getWorkspaceCacheTags(Workspace $workspace, string $type = 'limit'): array
|
||||
|
|
@ -159,8 +159,8 @@ class EntitlementService
|
|||
/**
|
||||
* Get cache tags for namespace entitlements.
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace
|
||||
* @param string $type The cache type ('limit' or 'usage')
|
||||
* @param Namespace_ $namespace The namespace
|
||||
* @param string $type The cache type ('limit' or 'usage')
|
||||
* @return array<string> Cache tags
|
||||
*/
|
||||
protected function getNamespaceCacheTags(Namespace_ $namespace, string $type = 'limit'): array
|
||||
|
|
@ -213,11 +213,10 @@ class EntitlementService
|
|||
* echo "Remaining: {$result->getRemaining()}";
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to check entitlements for
|
||||
* @param string $featureCode The feature code to check (e.g., 'pages', 'api_calls', 'custom_domains')
|
||||
* @param int $quantity The quantity being requested (default: 1). For limit-based features,
|
||||
* checks if current usage plus this quantity exceeds the limit.
|
||||
*
|
||||
* @param Workspace $workspace The workspace to check entitlements for
|
||||
* @param string $featureCode The feature code to check (e.g., 'pages', 'api_calls', 'custom_domains')
|
||||
* @param int $quantity The quantity being requested (default: 1). For limit-based features,
|
||||
* checks if current usage plus this quantity exceeds the limit.
|
||||
* @return EntitlementResult Contains:
|
||||
* - `isAllowed()`: Whether the feature can be used
|
||||
* - `isDenied()`: Inverse of isAllowed
|
||||
|
|
@ -316,10 +315,9 @@ class EntitlementService
|
|||
* // Uses workspace's 'pages' limit if namespace has no direct package
|
||||
* ```
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to check entitlements for
|
||||
* @param string $featureCode The feature code to check
|
||||
* @param int $quantity The quantity being requested (default: 1)
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to check entitlements for
|
||||
* @param string $featureCode The feature code to check
|
||||
* @param int $quantity The quantity being requested (default: 1)
|
||||
* @return EntitlementResult Contains allowed status, limits, and usage information
|
||||
*
|
||||
* @see self::can() For workspace-level checks
|
||||
|
|
@ -432,12 +430,11 @@ class EntitlementService
|
|||
* );
|
||||
* ```
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to record usage for
|
||||
* @param string $featureCode The feature code being consumed
|
||||
* @param int $quantity The amount to record (default: 1)
|
||||
* @param User|null $user Optional user who triggered the usage (for attribution)
|
||||
* @param array<string, mixed>|null $metadata Optional metadata for audit/debugging
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to record usage for
|
||||
* @param string $featureCode The feature code being consumed
|
||||
* @param int $quantity The amount to record (default: 1)
|
||||
* @param User|null $user Optional user who triggered the usage (for attribution)
|
||||
* @param array<string, mixed>|null $metadata Optional metadata for audit/debugging
|
||||
* @return UsageRecord The created usage record
|
||||
*/
|
||||
public function recordNamespaceUsage(
|
||||
|
|
@ -500,12 +497,11 @@ class EntitlementService
|
|||
* );
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to record usage for
|
||||
* @param string $featureCode The feature code being consumed
|
||||
* @param int $quantity The amount to record (default: 1)
|
||||
* @param User|null $user Optional user who triggered the usage
|
||||
* @param array<string, mixed>|null $metadata Optional metadata for audit/debugging
|
||||
*
|
||||
* @param Workspace $workspace The workspace to record usage for
|
||||
* @param string $featureCode The feature code being consumed
|
||||
* @param int $quantity The amount to record (default: 1)
|
||||
* @param User|null $user Optional user who triggered the usage
|
||||
* @param array<string, mixed>|null $metadata Optional metadata for audit/debugging
|
||||
* @return UsageRecord The created usage record
|
||||
*/
|
||||
public function recordUsage(
|
||||
|
|
@ -576,8 +572,8 @@ class EntitlementService
|
|||
* );
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to provision the package for
|
||||
* @param string $packageCode The unique code of the package to provision
|
||||
* @param Workspace $workspace The workspace to provision the package for
|
||||
* @param string $packageCode The unique code of the package to provision
|
||||
* @param array{
|
||||
* source?: string,
|
||||
* starts_at?: \DateTimeInterface,
|
||||
|
|
@ -592,7 +588,6 @@ class EntitlementService
|
|||
* - `billing_cycle_anchor`: Date for monthly usage resets
|
||||
* - `blesta_service_id`: External billing system reference
|
||||
* - `metadata`: Additional data to store with the package
|
||||
*
|
||||
* @return WorkspacePackage The created workspace package record
|
||||
*
|
||||
* @throws ModelNotFoundException If the package code does not exist
|
||||
|
|
@ -701,8 +696,8 @@ class EntitlementService
|
|||
* );
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to provision the boost for
|
||||
* @param string $featureCode The feature code to boost
|
||||
* @param Workspace $workspace The workspace to provision the boost for
|
||||
* @param string $featureCode The feature code to boost
|
||||
* @param array{
|
||||
* boost_type?: string,
|
||||
* duration_type?: string,
|
||||
|
|
@ -721,7 +716,6 @@ class EntitlementService
|
|||
* - `expires_at`: When the boost expires
|
||||
* - `blesta_addon_id`: External billing reference
|
||||
* - `metadata`: Additional data to store
|
||||
*
|
||||
* @return Boost The created boost record
|
||||
*/
|
||||
public function provisionBoost(
|
||||
|
|
@ -800,8 +794,7 @@ class EntitlementService
|
|||
* }
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to get the summary for
|
||||
*
|
||||
* @param Workspace $workspace The workspace to get the summary for
|
||||
* @return Collection<string, Collection<int, array{
|
||||
* feature: Feature,
|
||||
* code: string,
|
||||
|
|
@ -867,8 +860,7 @@ class EntitlementService
|
|||
* }
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to get packages for
|
||||
*
|
||||
* @param Workspace $workspace The workspace to get packages for
|
||||
* @return Collection<int, WorkspacePackage> Active workspace packages with
|
||||
* Package and Feature relations loaded
|
||||
*/
|
||||
|
|
@ -907,8 +899,7 @@ class EntitlementService
|
|||
* }
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to get boosts for
|
||||
*
|
||||
* @param Workspace $workspace The workspace to get boosts for
|
||||
* @return Collection<int, Boost> Active, usable boosts ordered by expiry (soonest first)
|
||||
*/
|
||||
public function getActiveBoosts(Workspace $workspace): Collection
|
||||
|
|
@ -949,9 +940,9 @@ class EntitlementService
|
|||
* );
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to suspend
|
||||
* @param string|null $source The source of the suspension for audit logging
|
||||
* (e.g., 'stripe', 'admin', 'system')
|
||||
* @param Workspace $workspace The workspace to suspend
|
||||
* @param string|null $source The source of the suspension for audit logging
|
||||
* (e.g., 'stripe', 'admin', 'system')
|
||||
*
|
||||
* @see self::reactivateWorkspace() To lift the suspension
|
||||
*/
|
||||
|
|
@ -999,8 +990,8 @@ class EntitlementService
|
|||
* );
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to reactivate
|
||||
* @param string|null $source The source of the reactivation for audit logging
|
||||
* @param Workspace $workspace The workspace to reactivate
|
||||
* @param string|null $source The source of the reactivation for audit logging
|
||||
*
|
||||
* @see self::suspendWorkspace() To suspend packages
|
||||
*/
|
||||
|
|
@ -1060,9 +1051,9 @@ class EntitlementService
|
|||
* );
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to revoke the package from
|
||||
* @param string $packageCode The unique code of the package to revoke
|
||||
* @param string|null $source The source of the revocation for audit logging
|
||||
* @param Workspace $workspace The workspace to revoke the package from
|
||||
* @param string $packageCode The unique code of the package to revoke
|
||||
* @param string|null $source The source of the revocation for audit logging
|
||||
*/
|
||||
public function revokePackage(Workspace $workspace, string $packageCode, ?string $source = null): void
|
||||
{
|
||||
|
|
@ -1101,9 +1092,8 @@ class EntitlementService
|
|||
* the workspace's total capacity for a feature. This is an internal method
|
||||
* used by `can()` and is cached for performance.
|
||||
*
|
||||
* @param Workspace $workspace The workspace to calculate limits for
|
||||
* @param string $featureCode The feature code to get the limit for
|
||||
*
|
||||
* @param Workspace $workspace The workspace to calculate limits for
|
||||
* @param string $featureCode The feature code to get the limit for
|
||||
* @return int|null Returns:
|
||||
* - `null` if the feature is not included in any package
|
||||
* - `-1` if the feature is unlimited
|
||||
|
|
@ -1190,10 +1180,9 @@ class EntitlementService
|
|||
*
|
||||
* Results are cached for 60 seconds to reduce database load.
|
||||
*
|
||||
* @param Workspace $workspace The workspace to get usage for
|
||||
* @param string $featureCode The feature code to get usage for
|
||||
* @param Feature $feature The feature model (for reset configuration)
|
||||
*
|
||||
* @param Workspace $workspace The workspace to get usage for
|
||||
* @param string $featureCode The feature code to get usage for
|
||||
* @param Feature $feature The feature model (for reset configuration)
|
||||
* @return int The current usage count
|
||||
*/
|
||||
protected function getCurrentUsage(Workspace $workspace, string $featureCode, Feature $feature): int
|
||||
|
|
@ -1241,8 +1230,7 @@ class EntitlementService
|
|||
* Retrieves the Feature model from the database, with results cached
|
||||
* for the standard cache TTL (5 minutes).
|
||||
*
|
||||
* @param string $code The unique feature code (e.g., 'pages', 'api_calls')
|
||||
*
|
||||
* @param string $code The unique feature code (e.g., 'pages', 'api_calls')
|
||||
* @return Feature|null The feature model, or null if not found
|
||||
*/
|
||||
protected function getFeature(string $code): ?Feature
|
||||
|
|
@ -1283,9 +1271,9 @@ class EntitlementService
|
|||
* $entitlementService->invalidateCache($workspace);
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to invalidate caches for
|
||||
* @param array<string> $featureCodes Specific features to invalidate (empty = all)
|
||||
* @param string $reason The reason for invalidation (for event dispatch)
|
||||
* @param Workspace $workspace The workspace to invalidate caches for
|
||||
* @param array<string> $featureCodes Specific features to invalidate (empty = all)
|
||||
* @param string $reason The reason for invalidation (for event dispatch)
|
||||
*/
|
||||
public function invalidateCache(
|
||||
Workspace $workspace,
|
||||
|
|
@ -1311,8 +1299,8 @@ class EntitlementService
|
|||
/**
|
||||
* Invalidate cache using cache tags (O(1) operation).
|
||||
*
|
||||
* @param Workspace $workspace The workspace to invalidate
|
||||
* @param array<string> $featureCodes Specific features (empty = all)
|
||||
* @param Workspace $workspace The workspace to invalidate
|
||||
* @param array<string> $featureCodes Specific features (empty = all)
|
||||
*/
|
||||
protected function invalidateCacheWithTags(Workspace $workspace, array $featureCodes = []): void
|
||||
{
|
||||
|
|
@ -1341,8 +1329,8 @@ class EntitlementService
|
|||
* This is O(n) where n = number of features when no specific features
|
||||
* are provided.
|
||||
*
|
||||
* @param Workspace $workspace The workspace to invalidate
|
||||
* @param array<string> $featureCodes Specific features (empty = all)
|
||||
* @param Workspace $workspace The workspace to invalidate
|
||||
* @param array<string> $featureCodes Specific features (empty = all)
|
||||
*/
|
||||
protected function invalidateCacheWithoutTags(Workspace $workspace, array $featureCodes = []): void
|
||||
{
|
||||
|
|
@ -1363,8 +1351,8 @@ class EntitlementService
|
|||
* Use this for performance when only usage has changed (e.g., after recording
|
||||
* usage) and limits are known to be unchanged.
|
||||
*
|
||||
* @param Workspace $workspace The workspace to invalidate usage cache for
|
||||
* @param string $featureCode The specific feature code to invalidate
|
||||
* @param Workspace $workspace The workspace to invalidate usage cache for
|
||||
* @param string $featureCode The specific feature code to invalidate
|
||||
*/
|
||||
public function invalidateUsageCache(Workspace $workspace, string $featureCode): void
|
||||
{
|
||||
|
|
@ -1391,8 +1379,8 @@ class EntitlementService
|
|||
* Use this for performance when only limits have changed (e.g., after
|
||||
* provisioning a package or boost) and usage data is unchanged.
|
||||
*
|
||||
* @param Workspace $workspace The workspace to invalidate limit cache for
|
||||
* @param array<string> $featureCodes Specific features (empty = all limit caches)
|
||||
* @param Workspace $workspace The workspace to invalidate limit cache for
|
||||
* @param array<string> $featureCodes Specific features (empty = all limit caches)
|
||||
*/
|
||||
public function invalidateLimitCache(Workspace $workspace, array $featureCodes = []): void
|
||||
{
|
||||
|
|
@ -1444,7 +1432,7 @@ class EntitlementService
|
|||
* }
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to expire boosts for
|
||||
* @param Workspace $workspace The workspace to expire boosts for
|
||||
*/
|
||||
public function expireCycleBoundBoosts(Workspace $workspace): void
|
||||
{
|
||||
|
|
@ -1489,9 +1477,8 @@ class EntitlementService
|
|||
* Does not include workspace-level entitlements (that cascade is handled
|
||||
* by `canForNamespace()`).
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to calculate limits for
|
||||
* @param string $featureCode The feature code to get the limit for
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to calculate limits for
|
||||
* @param string $featureCode The feature code to get the limit for
|
||||
* @return int|null Returns:
|
||||
* - `null` if the feature is not included in any namespace package
|
||||
* - `-1` if the feature is unlimited
|
||||
|
|
@ -1579,10 +1566,9 @@ class EntitlementService
|
|||
* The time window for calculation follows the same rules based on feature
|
||||
* reset configuration.
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to get usage for
|
||||
* @param string $featureCode The feature code to get usage for
|
||||
* @param Feature $feature The feature model (for reset configuration)
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to get usage for
|
||||
* @param string $featureCode The feature code to get usage for
|
||||
* @param Feature $feature The feature model (for reset configuration)
|
||||
* @return int The current usage count for the namespace
|
||||
*/
|
||||
protected function getNamespaceCurrentUsage(Namespace_ $namespace, string $featureCode, Feature $feature): int
|
||||
|
|
@ -1652,8 +1638,7 @@ class EntitlementService
|
|||
* }
|
||||
* ```
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to get the summary for
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to get the summary for
|
||||
* @return Collection<string, Collection<int, array{
|
||||
* feature: Feature,
|
||||
* code: string,
|
||||
|
|
@ -1737,8 +1722,8 @@ class EntitlementService
|
|||
* );
|
||||
* ```
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to provision the package for
|
||||
* @param string $packageCode The unique code of the package to provision
|
||||
* @param Namespace_ $namespace The namespace to provision the package for
|
||||
* @param string $packageCode The unique code of the package to provision
|
||||
* @param array{
|
||||
* starts_at?: \DateTimeInterface,
|
||||
* expires_at?: \DateTimeInterface|null,
|
||||
|
|
@ -1749,7 +1734,6 @@ class EntitlementService
|
|||
* - `expires_at`: When the package expires (null for indefinite)
|
||||
* - `billing_cycle_anchor`: Date for monthly usage resets
|
||||
* - `metadata`: Additional data to store with the package
|
||||
*
|
||||
* @return NamespacePackage The created namespace package record
|
||||
*
|
||||
* @throws ModelNotFoundException If the package code does not exist
|
||||
|
|
@ -1837,8 +1821,8 @@ class EntitlementService
|
|||
* );
|
||||
* ```
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to provision the boost for
|
||||
* @param string $featureCode The feature code to boost
|
||||
* @param Namespace_ $namespace The namespace to provision the boost for
|
||||
* @param string $featureCode The feature code to boost
|
||||
* @param array{
|
||||
* boost_type?: string,
|
||||
* duration_type?: string,
|
||||
|
|
@ -1853,7 +1837,6 @@ class EntitlementService
|
|||
* - `starts_at`: When the boost becomes active (default: now)
|
||||
* - `expires_at`: When the boost expires
|
||||
* - `metadata`: Additional data to store
|
||||
*
|
||||
* @return Boost The created boost record
|
||||
*
|
||||
* @see self::provisionBoost() For workspace-level boost provisioning
|
||||
|
|
@ -1911,9 +1894,9 @@ class EntitlementService
|
|||
* $entitlementService->invalidateNamespaceCache($namespace);
|
||||
* ```
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to invalidate caches for
|
||||
* @param array<string> $featureCodes Specific features to invalidate (empty = all)
|
||||
* @param string $reason The reason for invalidation (for event dispatch)
|
||||
* @param Namespace_ $namespace The namespace to invalidate caches for
|
||||
* @param array<string> $featureCodes Specific features to invalidate (empty = all)
|
||||
* @param string $reason The reason for invalidation (for event dispatch)
|
||||
*
|
||||
* @see self::invalidateCache() For workspace-level cache invalidation
|
||||
*/
|
||||
|
|
@ -1941,8 +1924,8 @@ class EntitlementService
|
|||
/**
|
||||
* Invalidate namespace cache using cache tags (O(1) operation).
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to invalidate
|
||||
* @param array<string> $featureCodes Specific features (empty = all)
|
||||
* @param Namespace_ $namespace The namespace to invalidate
|
||||
* @param array<string> $featureCodes Specific features (empty = all)
|
||||
*/
|
||||
protected function invalidateNamespaceCacheWithTags(Namespace_ $namespace, array $featureCodes = []): void
|
||||
{
|
||||
|
|
@ -1971,8 +1954,8 @@ class EntitlementService
|
|||
* This is O(n) where n = number of features when no specific features
|
||||
* are provided.
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to invalidate
|
||||
* @param array<string> $featureCodes Specific features (empty = all)
|
||||
* @param Namespace_ $namespace The namespace to invalidate
|
||||
* @param array<string> $featureCodes Specific features (empty = all)
|
||||
*/
|
||||
protected function invalidateNamespaceCacheWithoutTags(Namespace_ $namespace, array $featureCodes = []): void
|
||||
{
|
||||
|
|
@ -1993,8 +1976,8 @@ class EntitlementService
|
|||
* Use this for performance when only usage has changed (e.g., after recording
|
||||
* usage) and limits are known to be unchanged.
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to invalidate usage cache for
|
||||
* @param string $featureCode The specific feature code to invalidate
|
||||
* @param Namespace_ $namespace The namespace to invalidate usage cache for
|
||||
* @param string $featureCode The specific feature code to invalidate
|
||||
*/
|
||||
public function invalidateNamespaceUsageCache(Namespace_ $namespace, string $featureCode): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ use Illuminate\Support\Str;
|
|||
class EntitlementWebhookService
|
||||
{
|
||||
use PreventsSSRF;
|
||||
|
||||
/**
|
||||
* Dispatch an event to all matching webhooks for a workspace.
|
||||
*
|
||||
|
|
|
|||
88
changelog/2026/feb/discovery-scan.md
Normal file
88
changelog/2026/feb/discovery-scan.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Discovery Scan — February 2026
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Scanner:** Clotho (automated scan)
|
||||
**Issue:** core/php-tenant#3
|
||||
|
||||
## Summary
|
||||
|
||||
Automated scan of all PHP source files, migrations, routes, tests, and documentation. 34 issues created plus 1 roadmap tracking issue.
|
||||
|
||||
## Issues Created
|
||||
|
||||
### Security (P1-equivalent)
|
||||
|
||||
| Issue | Description |
|
||||
|-------|-------------|
|
||||
| #9 | `WorkspaceInvitation::findByToken` O(n) timing attack (1000 bcrypt checks per request) |
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
| Issue | Description |
|
||||
|-------|-------------|
|
||||
| #7 | Hardcoded domain `hub.host.uk.com` in `EntitlementApiController` |
|
||||
| #8 | Hardcoded domain `hub.host.uk.com` in `WorkspaceController` (store + switch) |
|
||||
| #10 | `namespaces.workspace_id` nullOnDelete may orphan namespaces on workspace deletion |
|
||||
| #12 | `feature_code` in `usage_alert_history` lacks referential integrity |
|
||||
| #13 | `UserStatsService` has 5 unimplemented TODO stubs (quotas always return 0/empty) |
|
||||
| #28 | README.md shows incorrect namespace `Core\Mod\Tenant` (should be `Core\Tenant`) |
|
||||
|
||||
### Performance
|
||||
|
||||
| Issue | Description |
|
||||
|-------|-------------|
|
||||
| #11 | Missing composite index on `user_workspace(workspace_id, role)` |
|
||||
| #14 | N+1 query in `NamespaceService::groupedForUser` |
|
||||
|
||||
### Refactors
|
||||
|
||||
| Issue | Description |
|
||||
|-------|-------------|
|
||||
| #5 | Clarify `WorkspaceScope` vs `BelongsToWorkspace` architecture |
|
||||
| #6 | `User` model has undefined external class relationships |
|
||||
| #18 | Missing return type hints on `Workspace` model relationships |
|
||||
| #19 | `EntitlementException` needs hierarchy of subtypes |
|
||||
| #20 | Inconsistent API error response format across controllers |
|
||||
| #24 | `WorkspaceMember` role strings should be a PHP 8.1 enum |
|
||||
|
||||
### Missing Tests
|
||||
|
||||
| Issue | Description |
|
||||
|-------|-------------|
|
||||
| #15 | `WorkspaceTeamService` — zero test coverage |
|
||||
| #16 | `EntitlementWebhookService` — no tests for dispatch, circuit breaker, SSRF |
|
||||
| #17 | `TotpService` edge cases (clock drift, malformed secrets) |
|
||||
| #29 | `WorkspaceController` API endpoints |
|
||||
| #30 | `NamespaceService` |
|
||||
| #34 | Mutation testing with Infection PHP |
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
| Issue | Description |
|
||||
|-------|-------------|
|
||||
| #21 | Lazy-load `Workspace` relationships (30+ defined) |
|
||||
| #22 | Soft deletes for `WorkspaceInvitation` |
|
||||
| #23 | Invitation resend with rate limiting |
|
||||
| #25 | Configurable invitation expiry (currently hardcoded 7 days) |
|
||||
| #35 | Workspace ownership transfer |
|
||||
| #36 | Bulk workspace invitation |
|
||||
| #37 | Workspace activity audit log |
|
||||
|
||||
### Chores
|
||||
|
||||
| Issue | Description |
|
||||
|-------|-------------|
|
||||
| #26 | Add PHPStan/Larastan to dev dependencies |
|
||||
| #27 | Pin `host-uk/core` to stable version (currently `dev-main`) |
|
||||
| #31 | IDE helper annotations for Eloquent models |
|
||||
| #32 | Artisan command for manual package provisioning |
|
||||
|
||||
### Documentation
|
||||
|
||||
| Issue | Description |
|
||||
|-------|-------------|
|
||||
| #33 | OpenAPI/Swagger documentation for all API endpoints |
|
||||
|
||||
## Roadmap
|
||||
|
||||
#38 — `roadmap: php-tenant production readiness` contains the full prioritised checklist.
|
||||
101
composer.json
101
composer.json
|
|
@ -1,48 +1,59 @@
|
|||
{
|
||||
"name": "host-uk/core-tenant",
|
||||
"description": "Multi-tenancy and workspaces for Laravel",
|
||||
"keywords": [
|
||||
"multi-tenant",
|
||||
"workspaces",
|
||||
"teams"
|
||||
],
|
||||
"license": "EUPL-1.2",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"host-uk/core": "dev-main"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.18",
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"pestphp/pest": "^3.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Core\\Tenant\\": ""
|
||||
"name": "lthn/php-tenant",
|
||||
"description": "Multi-tenancy and workspaces for Laravel",
|
||||
"keywords": [
|
||||
"multi-tenant",
|
||||
"workspaces",
|
||||
"teams"
|
||||
],
|
||||
"license": "EUPL-1.2",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"lthn/php": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.18",
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"pestphp/pest": "^3.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Core\\Tenant\\": ""
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Core\\Tenant\\Tests\\": "Tests/",
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Core\\Tenant\\Boot"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "pint",
|
||||
"test": "pest"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
"repositories": [
|
||||
{
|
||||
"name": "core",
|
||||
"type": "path",
|
||||
"url": "../php"
|
||||
}
|
||||
],
|
||||
"replace": {
|
||||
"core/php-tenant": "self.version"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Core\\Tenant\\Tests\\": "Tests/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Core\\Tenant\\Boot"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "pint",
|
||||
"test": "pest"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
|
|
|
|||
39
phpunit.xml
Normal file
39
phpunit.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
executionOrder="random"
|
||||
requireCoverageMetadata="false"
|
||||
beStrictAboutCoverageMetadata="false"
|
||||
beStrictAboutOutputDuringTests="true"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_DEBUG" value="true"/>
|
||||
<env name="APP_KEY" value="base64:Kx0qLJZJAQcDSFE2gMpuOlwrJcC6kXHM0j0KJdMGqzQ="/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Tenant\Tests\Feature;
|
||||
|
||||
use Core\Tenant\Database\Factories\WorkspaceInvitationFactory;
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Tenant\Models\WorkspaceInvitation;
|
||||
|
|
|
|||
7
tests/Pest.php
Normal file
7
tests/Pest.php
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
uses(TestCase::class)->in('Feature', 'Unit');
|
||||
17
tests/TestCase.php
Normal file
17
tests/TestCase.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Orchestra\Testbench\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
protected function getPackageProviders($app): array
|
||||
{
|
||||
return [
|
||||
\Core\Tenant\Boot::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue