Compare commits
16 commits
feat/disco
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d1daceb58a | |||
| 6cc13dae06 | |||
|
|
d38d56c286 | ||
|
|
46929ea619 | ||
|
|
62177b8ad3 | ||
|
|
fa49f1ea1a | ||
|
|
52383c4b46 | ||
|
|
27f91b5905 | ||
|
|
ef68ee82f4 | ||
|
|
77d8687f06 | ||
|
|
e64fea87ae | ||
|
|
97889d04cc | ||
|
|
39bca812b3 | ||
| fe2df90a1a | |||
| b7d2408eaf | |||
| cdb7dddd44 |
16 changed files with 563 additions and 158 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
|
||||||
|
|
@ -54,7 +54,7 @@ class EncryptTwoFactorSecrets extends Command
|
||||||
|
|
||||||
$this->info("Found {$records->count()} 2FA records total.");
|
$this->info("Found {$records->count()} 2FA records total.");
|
||||||
$this->info("Already encrypted: {$alreadyEncrypted}");
|
$this->info("Already encrypted: {$alreadyEncrypted}");
|
||||||
$this->info("Need migration: ".count($toMigrate));
|
$this->info('Need migration: '.count($toMigrate));
|
||||||
|
|
||||||
if (empty($toMigrate)) {
|
if (empty($toMigrate)) {
|
||||||
$this->info('All secrets are already encrypted. Nothing to do.');
|
$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("Found {$records->count()} invitation records in scope.");
|
||||||
$this->info("Already hashed: {$alreadyHashed}");
|
$this->info("Already hashed: {$alreadyHashed}");
|
||||||
$this->info("Need migration: ".count($toMigrate));
|
$this->info('Need migration: '.count($toMigrate));
|
||||||
|
|
||||||
if (empty($toMigrate)) {
|
if (empty($toMigrate)) {
|
||||||
$this->info('All tokens are already hashed. Nothing to do.');
|
$this->info('All tokens are already hashed. Nothing to do.');
|
||||||
|
|
@ -86,7 +86,7 @@ class HashInvitationTokens extends Command
|
||||||
$nonPendingCount = count($toMigrate) - $pendingCount;
|
$nonPendingCount = count($toMigrate) - $pendingCount;
|
||||||
|
|
||||||
$this->newLine();
|
$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("- Pending invitations ({$pendingCount}): Links will STOP working");
|
||||||
$this->warn("- Expired/Accepted ({$nonPendingCount}): Safe to hash");
|
$this->warn("- Expired/Accepted ({$nonPendingCount}): Safe to hash");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,16 @@ namespace Core\Tenant\Controllers;
|
||||||
|
|
||||||
use Core\Api\RateLimit\RateLimit;
|
use Core\Api\RateLimit\RateLimit;
|
||||||
use Core\Front\Controller;
|
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\EntitlementLog;
|
||||||
use Core\Tenant\Models\Package;
|
use Core\Tenant\Models\Package;
|
||||||
use Core\Tenant\Models\User;
|
use Core\Tenant\Models\User;
|
||||||
use Core\Tenant\Models\Workspace;
|
use Core\Tenant\Models\Workspace;
|
||||||
use Core\Tenant\Models\WorkspacePackage;
|
use Core\Tenant\Models\WorkspacePackage;
|
||||||
use Core\Tenant\Services\EntitlementService;
|
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.
|
* API controller for entitlement management.
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,14 @@ declare(strict_types=1);
|
||||||
namespace Core\Tenant\Controllers;
|
namespace Core\Tenant\Controllers;
|
||||||
|
|
||||||
use Core\Front\Controller;
|
use Core\Front\Controller;
|
||||||
|
use Core\Tenant\Models\User;
|
||||||
|
use Core\Tenant\Models\Workspace;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Mod\Api\Controllers\Concerns\HasApiResponses;
|
use Mod\Api\Controllers\Concerns\HasApiResponses;
|
||||||
use Mod\Api\Controllers\Concerns\ResolvesWorkspace;
|
use Mod\Api\Controllers\Concerns\ResolvesWorkspace;
|
||||||
use Mod\Api\Resources\PaginatedCollection;
|
use Mod\Api\Resources\PaginatedCollection;
|
||||||
use Mod\Api\Resources\WorkspaceResource;
|
use Mod\Api\Resources\WorkspaceResource;
|
||||||
use Core\Tenant\Models\User;
|
|
||||||
use Core\Tenant\Models\Workspace;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workspace API controller.
|
* Workspace API controller.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ namespace Core\Tenant\Database\Factories;
|
||||||
|
|
||||||
use Core\Tenant\Models\WorkspaceInvitation;
|
use Core\Tenant\Models\WorkspaceInvitation;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,10 @@ class EntitlementCacheInvalidated
|
||||||
/**
|
/**
|
||||||
* Create a new event instance.
|
* Create a new event instance.
|
||||||
*
|
*
|
||||||
* @param Workspace|null $workspace The affected workspace (null for namespace-only 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 Namespace_|null $namespace The affected namespace (null for workspace-only invalidation)
|
||||||
* @param array<string> $featureCodes Specific feature codes invalidated (empty = all features)
|
* @param array<string> $featureCodes Specific feature codes invalidated (empty = all features)
|
||||||
* @param string $reason The reason for invalidation
|
* @param string $reason The reason for invalidation
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly ?Workspace $workspace,
|
public readonly ?Workspace $workspace,
|
||||||
|
|
@ -74,9 +74,9 @@ class EntitlementCacheInvalidated
|
||||||
/**
|
/**
|
||||||
* Create an event for workspace cache invalidation.
|
* Create an event for workspace cache invalidation.
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace whose cache was invalidated
|
* @param Workspace $workspace The workspace whose cache was invalidated
|
||||||
* @param array<string> $featureCodes Specific feature codes (empty = all)
|
* @param array<string> $featureCodes Specific feature codes (empty = all)
|
||||||
* @param string $reason The reason for invalidation
|
* @param string $reason The reason for invalidation
|
||||||
*/
|
*/
|
||||||
public static function forWorkspace(
|
public static function forWorkspace(
|
||||||
Workspace $workspace,
|
Workspace $workspace,
|
||||||
|
|
@ -89,9 +89,9 @@ class EntitlementCacheInvalidated
|
||||||
/**
|
/**
|
||||||
* Create an event for namespace cache invalidation.
|
* Create an event for namespace cache invalidation.
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace whose cache was invalidated
|
* @param Namespace_ $namespace The namespace whose cache was invalidated
|
||||||
* @param array<string> $featureCodes Specific feature codes (empty = all)
|
* @param array<string> $featureCodes Specific feature codes (empty = all)
|
||||||
* @param string $reason The reason for invalidation
|
* @param string $reason The reason for invalidation
|
||||||
*/
|
*/
|
||||||
public static function forNamespace(
|
public static function forNamespace(
|
||||||
Namespace_ $namespace,
|
Namespace_ $namespace,
|
||||||
|
|
|
||||||
248
FINDINGS.md
Normal file
248
FINDINGS.md
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
# Phase 0 Findings — core/php-tenant
|
||||||
|
|
||||||
|
**Date:** 2026-02-20
|
||||||
|
**Branch:** feat/phase-0-assessment
|
||||||
|
**Analyst:** Clotho (darbs-claude)
|
||||||
|
**Issue:** #2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Environment
|
||||||
|
|
||||||
|
| Tool | Version | Status |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| PHP | 8.3.6 | OK |
|
||||||
|
| Composer | 2.9.5 | OK |
|
||||||
|
| Vendor | — | MISSING |
|
||||||
|
|
||||||
|
### 1.1 Composer Install
|
||||||
|
|
||||||
|
```
|
||||||
|
composer install --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result: FAILED**
|
||||||
|
|
||||||
|
```
|
||||||
|
Your requirements could not be resolved to an installable set of packages.
|
||||||
|
|
||||||
|
Problem 1
|
||||||
|
- Root composer.json requires host-uk/core, it could not be found in any version,
|
||||||
|
there may be a typo in the package name.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root cause:** `host-uk/core` (the host framework) is a private package. The `composer.json` has no `repositories` section to point Composer at the private registry or a local path.
|
||||||
|
|
||||||
|
**Resolution needed:** One of:
|
||||||
|
1. Add a `repositories` entry pointing to the Forgejo package registry (`darbs.lthn.ai`), or
|
||||||
|
2. Set `COMPOSER_AUTH` env var with appropriate credentials, or
|
||||||
|
3. Add a local path repository for development if `core` is checked out alongside this package.
|
||||||
|
|
||||||
|
Until resolved, **all tooling that requires `vendor/` is blocked** (tests, Pint, PHPStan).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Test Baseline
|
||||||
|
|
||||||
|
**Status: CANNOT RUN** — vendor directory is empty.
|
||||||
|
|
||||||
|
### Test inventory (static count)
|
||||||
|
|
||||||
|
| File | Lines |
|
||||||
|
|------|-------|
|
||||||
|
| `tests/Feature/AccountDeletionTest.php` | ~120 |
|
||||||
|
| `tests/Feature/AuthenticationTest.php` | ~160 |
|
||||||
|
| `tests/Feature/EntitlementApiTest.php` | ~700 |
|
||||||
|
| `tests/Feature/EntitlementServiceTest.php` | ~800 |
|
||||||
|
| `tests/Feature/ProfileTest.php` | ~100 |
|
||||||
|
| `tests/Feature/ResetBillingCyclesTest.php` | ~180 |
|
||||||
|
| `tests/Feature/SettingsTest.php` | ~120 |
|
||||||
|
| `tests/Feature/TwoFactorAuthenticatableTest.php` | ~140 |
|
||||||
|
| `tests/Feature/UsageAlertServiceTest.php` | ~180 |
|
||||||
|
| `tests/Feature/WaitlistTest.php` | ~120 |
|
||||||
|
| `tests/Feature/WorkspaceCacheTest.php` | ~300 |
|
||||||
|
| `tests/Feature/WorkspaceInvitationTest.php` | ~255 |
|
||||||
|
| `tests/Feature/WorkspaceSecurityTest.php` | ~433 |
|
||||||
|
| `tests/Feature/WorkspaceTenancyTest.php` | ~165 |
|
||||||
|
| `tests/Feature/Guards/AccessTokenGuardTest.php` | ~180 |
|
||||||
|
| **Total** | **~4,053 lines across 15 files** |
|
||||||
|
|
||||||
|
Tests use Pest with Orchestra Testbench. Coverage appears comprehensive for workspace- and entitlement-level tests.
|
||||||
|
|
||||||
|
**Pass/fail counts:** Cannot determine — blocked by missing vendor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Code Quality (Static Review)
|
||||||
|
|
||||||
|
### 3.1 Pint / PHPStan
|
||||||
|
|
||||||
|
Cannot run — vendor missing. Tools (`vendor/bin/pint`, `vendor/bin/phpstan`) not available.
|
||||||
|
|
||||||
|
### 3.2 Missing `declare(strict_types=1)` — identified via static scan
|
||||||
|
|
||||||
|
The following files are missing the strict types declaration despite this being a documented coding standard:
|
||||||
|
|
||||||
|
| File | Priority |
|
||||||
|
|------|----------|
|
||||||
|
| `Models/AccountDeletionRequest.php` | P3 |
|
||||||
|
| `Models/Boost.php` | P3 |
|
||||||
|
| `Models/EntitlementLog.php` | P3 |
|
||||||
|
| `Models/Feature.php` | P3 |
|
||||||
|
| `Models/Package.php` | P3 |
|
||||||
|
| `Models/UsageRecord.php` | P3 |
|
||||||
|
| `Models/WaitlistEntry.php` | P3 |
|
||||||
|
| `Models/WorkspacePackage.php` | P3 |
|
||||||
|
| `Services/EntitlementResult.php` | P3 |
|
||||||
|
|
||||||
|
> NOTE: TODO DX-001 records this as fixed for `Models/Workspace.php`, `Models/User.php`, and `Services/EntitlementService.php` — but 9 other files still lack the declaration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture Review
|
||||||
|
|
||||||
|
### 4.1 Workspace Isolation — BelongsToWorkspace Trait
|
||||||
|
|
||||||
|
**File:** `Concerns/BelongsToWorkspace.php`
|
||||||
|
|
||||||
|
The trait is the primary tenancy enforcement mechanism for Eloquent models.
|
||||||
|
|
||||||
|
**Isolation mechanisms:**
|
||||||
|
1. `bootBelongsToWorkspace()` registers an Eloquent `creating` hook that:
|
||||||
|
- Reads the current workspace from request attributes (`workspace_model`) or the authenticated user's default workspace
|
||||||
|
- Auto-assigns `workspace_id` if not already set
|
||||||
|
- Throws `MissingWorkspaceContextException` if workspace context is absent and strict mode is on
|
||||||
|
|
||||||
|
2. `scopeOwnedByCurrentWorkspace(Builder $query)` filters queries to the current workspace. In non-strict mode it returns `whereRaw('1 = 0')` (empty, fail-safe).
|
||||||
|
|
||||||
|
3. Cache invalidation is wired to `saved` and `deleted` model events via `clearWorkspaceCache()`.
|
||||||
|
|
||||||
|
**Opt-out:** Setting `protected bool $workspaceContextRequired = false;` on a model disables the exception — useful for legacy code but discouraged.
|
||||||
|
|
||||||
|
**Workspace resolution order:**
|
||||||
|
```
|
||||||
|
request()->attributes->get('workspace_model') // set by ResolveWorkspaceFromSubdomain
|
||||||
|
→ auth()->user()->defaultHostWorkspace() // falls back to auth user
|
||||||
|
→ null → MissingWorkspaceContextException
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 WorkspaceScope — Global Query Scope
|
||||||
|
|
||||||
|
**File:** `Scopes/WorkspaceScope.php`
|
||||||
|
|
||||||
|
An Eloquent global scope (implements `Scope`) that automatically filters all queries by the current workspace.
|
||||||
|
|
||||||
|
Key design decisions:
|
||||||
|
- `static bool $strictModeEnabled = true` — global toggle
|
||||||
|
- `withoutStrictMode(callable $callback)` — safe scoped disable (restores on exit)
|
||||||
|
- Builder macros: `forWorkspace(Workspace|int)`, `acrossWorkspaces()`, `currentWorkspaceId()`
|
||||||
|
- Console commands bypass strict mode automatically (`runningInConsole()` + not unit tests)
|
||||||
|
- Models can opt out: `public bool $workspaceScopeStrict = false;`
|
||||||
|
|
||||||
|
### 4.3 Middleware for Tenant Resolution
|
||||||
|
|
||||||
|
**Files:** `Middleware/`
|
||||||
|
|
||||||
|
| Middleware | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `ResolveWorkspaceFromSubdomain` | Resolves workspace from `{slug}.host.uk.com` subdomain. Hardcoded mappings (hub→main, bio→bio, etc.). Sets `workspace_model` on request attributes. |
|
||||||
|
| `RequireWorkspaceContext` | Validates workspace exists; validates user has access. Resolution order: subdomain attr → `Workspace::current()` → `workspace_id` input → `X-Workspace-ID` header → `?workspace` query param. Logs denied attempts. |
|
||||||
|
| `CheckWorkspacePermission` | Per-permission authorisation within a resolved workspace. |
|
||||||
|
| `RequireAdminDomain` | Restricts routes to admin subdomains (hub, www, hestia). |
|
||||||
|
| `ResolveNamespace` | Resolves current namespace from `?namespace` query, `X-Namespace` header, or session. |
|
||||||
|
|
||||||
|
**Subdomain hardcoding concern:** `ResolveWorkspaceFromSubdomain` contains a hardcoded mapping of subdomains → workspace slugs. Adding a new service workspace requires a code change. Consider moving to database/config.
|
||||||
|
|
||||||
|
### 4.4 Migration Patterns
|
||||||
|
|
||||||
|
**Files:** `Migrations/`
|
||||||
|
|
||||||
|
| Migration | Tables created/modified |
|
||||||
|
|-----------|------------------------|
|
||||||
|
| `0001_01_01_000000_create_tenant_tables.php` | 15 tables: users, workspaces, namespaces, entitlement_features, entitlement_packages, entitlement_package_features, entitlement_workspace_packages, entitlement_namespace_packages, entitlement_boosts, entitlement_usage_records, entitlement_logs, user_two_factor_auth, sessions, password_reset_tokens, user_workspace |
|
||||||
|
| `2026_01_26_000000_create_workspace_invitations_table.php` | workspace_invitations |
|
||||||
|
| `2026_01_26_120000_create_usage_alert_history_table.php` | usage_alert_history |
|
||||||
|
| `2026_01_26_140000_create_entitlement_webhooks_tables.php` | entitlement_webhooks, entitlement_webhook_deliveries |
|
||||||
|
| `2026_01_26_140000_create_workspace_teams_table.php` | workspace_teams |
|
||||||
|
| `2026_01_29_000000_add_performance_indexes.php` | Indexes on users.tier, namespaces.slug, workspaces.{is_active,type,domain}, user_workspace.team_id, entitlement_usage_records.user_id, entitlement_logs.user_id |
|
||||||
|
|
||||||
|
All tables use appropriate composite indexes on commonly filtered columns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Critical Bug: Missing `namespace_id` Columns in Entitlement Tables
|
||||||
|
|
||||||
|
**Severity: P1 — Runtime crash**
|
||||||
|
|
||||||
|
### 5.1 Description
|
||||||
|
|
||||||
|
The namespace-level entitlement features (`recordNamespaceUsage`, `provisionNamespaceBoost`) will fail at runtime with a database error because the underlying tables are missing the `namespace_id` column.
|
||||||
|
|
||||||
|
**Models that declare `namespace_id` as fillable:**
|
||||||
|
|
||||||
|
| Model | File |
|
||||||
|
|-------|------|
|
||||||
|
| `UsageRecord` | `Models/UsageRecord.php:18` |
|
||||||
|
| `Boost` | `Models/Boost.php:17` |
|
||||||
|
|
||||||
|
**Service methods that write `namespace_id`:**
|
||||||
|
|
||||||
|
| Method | File |
|
||||||
|
|--------|------|
|
||||||
|
| `EntitlementService::recordNamespaceUsage()` | `Services/EntitlementService.php:453` |
|
||||||
|
| `EntitlementService::provisionNamespaceBoost()` | `Services/EntitlementService.php:1861` |
|
||||||
|
|
||||||
|
**Migrations that DO NOT create the column:**
|
||||||
|
|
||||||
|
- `entitlement_usage_records` — `0001_01_01_000000_create_tenant_tables.php:248-261`
|
||||||
|
- `entitlement_boosts` — `0001_01_01_000000_create_tenant_tables.php:226-245`
|
||||||
|
|
||||||
|
**Query methods that filter by `namespace_id` without it existing:**
|
||||||
|
|
||||||
|
- `EntitlementService::getNamespaceCurrentUsage()` lines 1605, 1615, 1622 — `WHERE namespace_id = ?`
|
||||||
|
- `Namespace_::boosts()` relationship — references a non-existent FK
|
||||||
|
|
||||||
|
### 5.2 Impact
|
||||||
|
|
||||||
|
Any call to:
|
||||||
|
- `$entitlementService->recordNamespaceUsage($namespace, 'links')`
|
||||||
|
- `$entitlementService->provisionNamespaceBoost($namespace, 'links', [...])`
|
||||||
|
- `$entitlementService->canForNamespace($namespace, 'links')` (usage query path)
|
||||||
|
|
||||||
|
will throw:
|
||||||
|
```
|
||||||
|
SQLSTATE[HY000]: General error: 1 table entitlement_usage_records has no column named namespace_id
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite in `tests/Feature/EntitlementServiceTest.php` likely exercises these paths and would be failing if runnable.
|
||||||
|
|
||||||
|
### 5.3 Fix Required
|
||||||
|
|
||||||
|
A migration is needed to add `namespace_id` (nullable FK → namespaces, null on delete) to:
|
||||||
|
1. `entitlement_usage_records`
|
||||||
|
2. `entitlement_boosts`
|
||||||
|
|
||||||
|
Plus indexes: `(namespace_id, feature_code, recorded_at)` on usage records, `(namespace_id, feature_code, status)` on boosts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Summary of Open Issues
|
||||||
|
|
||||||
|
| ID | Severity | Description | Status |
|
||||||
|
|----|----------|-------------|--------|
|
||||||
|
| BUG-001 | **P1** | `namespace_id` column missing from `entitlement_usage_records` and `entitlement_boosts` migrations — runtime crash | **NEW** |
|
||||||
|
| DX-003 | P3 | 9 model/service files missing `declare(strict_types=1)` | NEW |
|
||||||
|
| ENV-001 | P0-blocker | `composer install` fails — `host-uk/core` repository not configured | NEW |
|
||||||
|
| ARCH-001 | P4 | `ResolveWorkspaceFromSubdomain` hardcodes subdomain→workspace mappings; new services require code change | NEW |
|
||||||
|
|
||||||
|
Existing items from `TODO.md` P1–P3 (SEC-001 through SEC-006, DX-001, DX-002, TEST-001, TEST-002, PERF-001, PERF-002) are marked Fixed in the TODO and have corresponding code in place. These will be verifiable once `composer install` succeeds and tests run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Recommended Next Steps
|
||||||
|
|
||||||
|
1. **Immediate:** Fix `composer.json` to resolve `host-uk/core` (add repository entry or document setup instructions in README).
|
||||||
|
2. **Immediate:** Add migration for `namespace_id` columns on `entitlement_usage_records` and `entitlement_boosts` — this is a production crash bug.
|
||||||
|
3. **Short-term:** Once vendor installs, run full test suite and document pass/fail baseline.
|
||||||
|
4. **Short-term:** Add `declare(strict_types=1)` to the 9 remaining files.
|
||||||
|
5. **Medium-term:** Consider moving subdomain→workspace mapping to config/database.
|
||||||
|
|
@ -137,8 +137,8 @@ class EntitlementService
|
||||||
/**
|
/**
|
||||||
* Get cache tags for workspace entitlements.
|
* Get cache tags for workspace entitlements.
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace
|
* @param Workspace $workspace The workspace
|
||||||
* @param string $type The cache type ('limit' or 'usage')
|
* @param string $type The cache type ('limit' or 'usage')
|
||||||
* @return array<string> Cache tags
|
* @return array<string> Cache tags
|
||||||
*/
|
*/
|
||||||
protected function getWorkspaceCacheTags(Workspace $workspace, string $type = 'limit'): array
|
protected function getWorkspaceCacheTags(Workspace $workspace, string $type = 'limit'): array
|
||||||
|
|
@ -159,8 +159,8 @@ class EntitlementService
|
||||||
/**
|
/**
|
||||||
* Get cache tags for namespace entitlements.
|
* Get cache tags for namespace entitlements.
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace
|
* @param Namespace_ $namespace The namespace
|
||||||
* @param string $type The cache type ('limit' or 'usage')
|
* @param string $type The cache type ('limit' or 'usage')
|
||||||
* @return array<string> Cache tags
|
* @return array<string> Cache tags
|
||||||
*/
|
*/
|
||||||
protected function getNamespaceCacheTags(Namespace_ $namespace, string $type = 'limit'): array
|
protected function getNamespaceCacheTags(Namespace_ $namespace, string $type = 'limit'): array
|
||||||
|
|
@ -213,11 +213,10 @@ class EntitlementService
|
||||||
* echo "Remaining: {$result->getRemaining()}";
|
* echo "Remaining: {$result->getRemaining()}";
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to check entitlements for
|
* @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 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,
|
* @param int $quantity The quantity being requested (default: 1). For limit-based features,
|
||||||
* checks if current usage plus this quantity exceeds the limit.
|
* checks if current usage plus this quantity exceeds the limit.
|
||||||
*
|
|
||||||
* @return EntitlementResult Contains:
|
* @return EntitlementResult Contains:
|
||||||
* - `isAllowed()`: Whether the feature can be used
|
* - `isAllowed()`: Whether the feature can be used
|
||||||
* - `isDenied()`: Inverse of isAllowed
|
* - `isDenied()`: Inverse of isAllowed
|
||||||
|
|
@ -316,10 +315,9 @@ class EntitlementService
|
||||||
* // Uses workspace's 'pages' limit if namespace has no direct package
|
* // Uses workspace's 'pages' limit if namespace has no direct package
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace to check entitlements for
|
* @param Namespace_ $namespace The namespace to check entitlements for
|
||||||
* @param string $featureCode The feature code to check
|
* @param string $featureCode The feature code to check
|
||||||
* @param int $quantity The quantity being requested (default: 1)
|
* @param int $quantity The quantity being requested (default: 1)
|
||||||
*
|
|
||||||
* @return EntitlementResult Contains allowed status, limits, and usage information
|
* @return EntitlementResult Contains allowed status, limits, and usage information
|
||||||
*
|
*
|
||||||
* @see self::can() For workspace-level checks
|
* @see self::can() For workspace-level checks
|
||||||
|
|
@ -432,12 +430,11 @@ class EntitlementService
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace to record usage for
|
* @param Namespace_ $namespace The namespace to record usage for
|
||||||
* @param string $featureCode The feature code being consumed
|
* @param string $featureCode The feature code being consumed
|
||||||
* @param int $quantity The amount to record (default: 1)
|
* @param int $quantity The amount to record (default: 1)
|
||||||
* @param User|null $user Optional user who triggered the usage (for attribution)
|
* @param User|null $user Optional user who triggered the usage (for attribution)
|
||||||
* @param array<string, mixed>|null $metadata Optional metadata for audit/debugging
|
* @param array<string, mixed>|null $metadata Optional metadata for audit/debugging
|
||||||
*
|
|
||||||
* @return UsageRecord The created usage record
|
* @return UsageRecord The created usage record
|
||||||
*/
|
*/
|
||||||
public function recordNamespaceUsage(
|
public function recordNamespaceUsage(
|
||||||
|
|
@ -500,12 +497,11 @@ class EntitlementService
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to record usage for
|
* @param Workspace $workspace The workspace to record usage for
|
||||||
* @param string $featureCode The feature code being consumed
|
* @param string $featureCode The feature code being consumed
|
||||||
* @param int $quantity The amount to record (default: 1)
|
* @param int $quantity The amount to record (default: 1)
|
||||||
* @param User|null $user Optional user who triggered the usage
|
* @param User|null $user Optional user who triggered the usage
|
||||||
* @param array<string, mixed>|null $metadata Optional metadata for audit/debugging
|
* @param array<string, mixed>|null $metadata Optional metadata for audit/debugging
|
||||||
*
|
|
||||||
* @return UsageRecord The created usage record
|
* @return UsageRecord The created usage record
|
||||||
*/
|
*/
|
||||||
public function recordUsage(
|
public function recordUsage(
|
||||||
|
|
@ -576,8 +572,8 @@ class EntitlementService
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to provision the package for
|
* @param Workspace $workspace The workspace to provision the package for
|
||||||
* @param string $packageCode The unique code of the package to provision
|
* @param string $packageCode The unique code of the package to provision
|
||||||
* @param array{
|
* @param array{
|
||||||
* source?: string,
|
* source?: string,
|
||||||
* starts_at?: \DateTimeInterface,
|
* starts_at?: \DateTimeInterface,
|
||||||
|
|
@ -592,7 +588,6 @@ class EntitlementService
|
||||||
* - `billing_cycle_anchor`: Date for monthly usage resets
|
* - `billing_cycle_anchor`: Date for monthly usage resets
|
||||||
* - `blesta_service_id`: External billing system reference
|
* - `blesta_service_id`: External billing system reference
|
||||||
* - `metadata`: Additional data to store with the package
|
* - `metadata`: Additional data to store with the package
|
||||||
*
|
|
||||||
* @return WorkspacePackage The created workspace package record
|
* @return WorkspacePackage The created workspace package record
|
||||||
*
|
*
|
||||||
* @throws ModelNotFoundException If the package code does not exist
|
* @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 Workspace $workspace The workspace to provision the boost for
|
||||||
* @param string $featureCode The feature code to boost
|
* @param string $featureCode The feature code to boost
|
||||||
* @param array{
|
* @param array{
|
||||||
* boost_type?: string,
|
* boost_type?: string,
|
||||||
* duration_type?: string,
|
* duration_type?: string,
|
||||||
|
|
@ -721,7 +716,6 @@ class EntitlementService
|
||||||
* - `expires_at`: When the boost expires
|
* - `expires_at`: When the boost expires
|
||||||
* - `blesta_addon_id`: External billing reference
|
* - `blesta_addon_id`: External billing reference
|
||||||
* - `metadata`: Additional data to store
|
* - `metadata`: Additional data to store
|
||||||
*
|
|
||||||
* @return Boost The created boost record
|
* @return Boost The created boost record
|
||||||
*/
|
*/
|
||||||
public function provisionBoost(
|
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{
|
* @return Collection<string, Collection<int, array{
|
||||||
* feature: Feature,
|
* feature: Feature,
|
||||||
* code: string,
|
* 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
|
* @return Collection<int, WorkspacePackage> Active workspace packages with
|
||||||
* Package and Feature relations loaded
|
* 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)
|
* @return Collection<int, Boost> Active, usable boosts ordered by expiry (soonest first)
|
||||||
*/
|
*/
|
||||||
public function getActiveBoosts(Workspace $workspace): Collection
|
public function getActiveBoosts(Workspace $workspace): Collection
|
||||||
|
|
@ -949,9 +940,9 @@ class EntitlementService
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to suspend
|
* @param Workspace $workspace The workspace to suspend
|
||||||
* @param string|null $source The source of the suspension for audit logging
|
* @param string|null $source The source of the suspension for audit logging
|
||||||
* (e.g., 'stripe', 'admin', 'system')
|
* (e.g., 'stripe', 'admin', 'system')
|
||||||
*
|
*
|
||||||
* @see self::reactivateWorkspace() To lift the suspension
|
* @see self::reactivateWorkspace() To lift the suspension
|
||||||
*/
|
*/
|
||||||
|
|
@ -999,8 +990,8 @@ class EntitlementService
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to reactivate
|
* @param Workspace $workspace The workspace to reactivate
|
||||||
* @param string|null $source The source of the reactivation for audit logging
|
* @param string|null $source The source of the reactivation for audit logging
|
||||||
*
|
*
|
||||||
* @see self::suspendWorkspace() To suspend packages
|
* @see self::suspendWorkspace() To suspend packages
|
||||||
*/
|
*/
|
||||||
|
|
@ -1060,9 +1051,9 @@ class EntitlementService
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to revoke the package from
|
* @param Workspace $workspace The workspace to revoke the package from
|
||||||
* @param string $packageCode The unique code of the package to revoke
|
* @param string $packageCode The unique code of the package to revoke
|
||||||
* @param string|null $source The source of the revocation for audit logging
|
* @param string|null $source The source of the revocation for audit logging
|
||||||
*/
|
*/
|
||||||
public function revokePackage(Workspace $workspace, string $packageCode, ?string $source = null): void
|
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
|
* the workspace's total capacity for a feature. This is an internal method
|
||||||
* used by `can()` and is cached for performance.
|
* used by `can()` and is cached for performance.
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to calculate limits for
|
* @param Workspace $workspace The workspace to calculate limits for
|
||||||
* @param string $featureCode The feature code to get the limit for
|
* @param string $featureCode The feature code to get the limit for
|
||||||
*
|
|
||||||
* @return int|null Returns:
|
* @return int|null Returns:
|
||||||
* - `null` if the feature is not included in any package
|
* - `null` if the feature is not included in any package
|
||||||
* - `-1` if the feature is unlimited
|
* - `-1` if the feature is unlimited
|
||||||
|
|
@ -1190,10 +1180,9 @@ class EntitlementService
|
||||||
*
|
*
|
||||||
* Results are cached for 60 seconds to reduce database load.
|
* Results are cached for 60 seconds to reduce database load.
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to get usage for
|
* @param Workspace $workspace The workspace to get usage for
|
||||||
* @param string $featureCode The feature code to get usage for
|
* @param string $featureCode The feature code to get usage for
|
||||||
* @param Feature $feature The feature model (for reset configuration)
|
* @param Feature $feature The feature model (for reset configuration)
|
||||||
*
|
|
||||||
* @return int The current usage count
|
* @return int The current usage count
|
||||||
*/
|
*/
|
||||||
protected function getCurrentUsage(Workspace $workspace, string $featureCode, Feature $feature): int
|
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
|
* Retrieves the Feature model from the database, with results cached
|
||||||
* for the standard cache TTL (5 minutes).
|
* 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
|
* @return Feature|null The feature model, or null if not found
|
||||||
*/
|
*/
|
||||||
protected function getFeature(string $code): ?Feature
|
protected function getFeature(string $code): ?Feature
|
||||||
|
|
@ -1283,9 +1271,9 @@ class EntitlementService
|
||||||
* $entitlementService->invalidateCache($workspace);
|
* $entitlementService->invalidateCache($workspace);
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to invalidate caches for
|
* @param Workspace $workspace The workspace to invalidate caches for
|
||||||
* @param array<string> $featureCodes Specific features to invalidate (empty = all)
|
* @param array<string> $featureCodes Specific features to invalidate (empty = all)
|
||||||
* @param string $reason The reason for invalidation (for event dispatch)
|
* @param string $reason The reason for invalidation (for event dispatch)
|
||||||
*/
|
*/
|
||||||
public function invalidateCache(
|
public function invalidateCache(
|
||||||
Workspace $workspace,
|
Workspace $workspace,
|
||||||
|
|
@ -1311,8 +1299,8 @@ class EntitlementService
|
||||||
/**
|
/**
|
||||||
* Invalidate cache using cache tags (O(1) operation).
|
* Invalidate cache using cache tags (O(1) operation).
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to invalidate
|
* @param Workspace $workspace The workspace to invalidate
|
||||||
* @param array<string> $featureCodes Specific features (empty = all)
|
* @param array<string> $featureCodes Specific features (empty = all)
|
||||||
*/
|
*/
|
||||||
protected function invalidateCacheWithTags(Workspace $workspace, array $featureCodes = []): void
|
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
|
* This is O(n) where n = number of features when no specific features
|
||||||
* are provided.
|
* are provided.
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to invalidate
|
* @param Workspace $workspace The workspace to invalidate
|
||||||
* @param array<string> $featureCodes Specific features (empty = all)
|
* @param array<string> $featureCodes Specific features (empty = all)
|
||||||
*/
|
*/
|
||||||
protected function invalidateCacheWithoutTags(Workspace $workspace, array $featureCodes = []): void
|
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
|
* Use this for performance when only usage has changed (e.g., after recording
|
||||||
* usage) and limits are known to be unchanged.
|
* usage) and limits are known to be unchanged.
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to invalidate usage cache for
|
* @param Workspace $workspace The workspace to invalidate usage cache for
|
||||||
* @param string $featureCode The specific feature code to invalidate
|
* @param string $featureCode The specific feature code to invalidate
|
||||||
*/
|
*/
|
||||||
public function invalidateUsageCache(Workspace $workspace, string $featureCode): void
|
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
|
* Use this for performance when only limits have changed (e.g., after
|
||||||
* provisioning a package or boost) and usage data is unchanged.
|
* provisioning a package or boost) and usage data is unchanged.
|
||||||
*
|
*
|
||||||
* @param Workspace $workspace The workspace to invalidate limit cache for
|
* @param Workspace $workspace The workspace to invalidate limit cache for
|
||||||
* @param array<string> $featureCodes Specific features (empty = all limit caches)
|
* @param array<string> $featureCodes Specific features (empty = all limit caches)
|
||||||
*/
|
*/
|
||||||
public function invalidateLimitCache(Workspace $workspace, array $featureCodes = []): void
|
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
|
public function expireCycleBoundBoosts(Workspace $workspace): void
|
||||||
{
|
{
|
||||||
|
|
@ -1489,9 +1477,8 @@ class EntitlementService
|
||||||
* Does not include workspace-level entitlements (that cascade is handled
|
* Does not include workspace-level entitlements (that cascade is handled
|
||||||
* by `canForNamespace()`).
|
* by `canForNamespace()`).
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace to calculate limits for
|
* @param Namespace_ $namespace The namespace to calculate limits for
|
||||||
* @param string $featureCode The feature code to get the limit for
|
* @param string $featureCode The feature code to get the limit for
|
||||||
*
|
|
||||||
* @return int|null Returns:
|
* @return int|null Returns:
|
||||||
* - `null` if the feature is not included in any namespace package
|
* - `null` if the feature is not included in any namespace package
|
||||||
* - `-1` if the feature is unlimited
|
* - `-1` if the feature is unlimited
|
||||||
|
|
@ -1579,10 +1566,9 @@ class EntitlementService
|
||||||
* The time window for calculation follows the same rules based on feature
|
* The time window for calculation follows the same rules based on feature
|
||||||
* reset configuration.
|
* reset configuration.
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace to get usage for
|
* @param Namespace_ $namespace The namespace to get usage for
|
||||||
* @param string $featureCode The feature code to get usage for
|
* @param string $featureCode The feature code to get usage for
|
||||||
* @param Feature $feature The feature model (for reset configuration)
|
* @param Feature $feature The feature model (for reset configuration)
|
||||||
*
|
|
||||||
* @return int The current usage count for the namespace
|
* @return int The current usage count for the namespace
|
||||||
*/
|
*/
|
||||||
protected function getNamespaceCurrentUsage(Namespace_ $namespace, string $featureCode, Feature $feature): int
|
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{
|
* @return Collection<string, Collection<int, array{
|
||||||
* feature: Feature,
|
* feature: Feature,
|
||||||
* code: string,
|
* code: string,
|
||||||
|
|
@ -1737,8 +1722,8 @@ class EntitlementService
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace to provision the package for
|
* @param Namespace_ $namespace The namespace to provision the package for
|
||||||
* @param string $packageCode The unique code of the package to provision
|
* @param string $packageCode The unique code of the package to provision
|
||||||
* @param array{
|
* @param array{
|
||||||
* starts_at?: \DateTimeInterface,
|
* starts_at?: \DateTimeInterface,
|
||||||
* expires_at?: \DateTimeInterface|null,
|
* expires_at?: \DateTimeInterface|null,
|
||||||
|
|
@ -1749,7 +1734,6 @@ class EntitlementService
|
||||||
* - `expires_at`: When the package expires (null for indefinite)
|
* - `expires_at`: When the package expires (null for indefinite)
|
||||||
* - `billing_cycle_anchor`: Date for monthly usage resets
|
* - `billing_cycle_anchor`: Date for monthly usage resets
|
||||||
* - `metadata`: Additional data to store with the package
|
* - `metadata`: Additional data to store with the package
|
||||||
*
|
|
||||||
* @return NamespacePackage The created namespace package record
|
* @return NamespacePackage The created namespace package record
|
||||||
*
|
*
|
||||||
* @throws ModelNotFoundException If the package code does not exist
|
* @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 Namespace_ $namespace The namespace to provision the boost for
|
||||||
* @param string $featureCode The feature code to boost
|
* @param string $featureCode The feature code to boost
|
||||||
* @param array{
|
* @param array{
|
||||||
* boost_type?: string,
|
* boost_type?: string,
|
||||||
* duration_type?: string,
|
* duration_type?: string,
|
||||||
|
|
@ -1853,7 +1837,6 @@ class EntitlementService
|
||||||
* - `starts_at`: When the boost becomes active (default: now)
|
* - `starts_at`: When the boost becomes active (default: now)
|
||||||
* - `expires_at`: When the boost expires
|
* - `expires_at`: When the boost expires
|
||||||
* - `metadata`: Additional data to store
|
* - `metadata`: Additional data to store
|
||||||
*
|
|
||||||
* @return Boost The created boost record
|
* @return Boost The created boost record
|
||||||
*
|
*
|
||||||
* @see self::provisionBoost() For workspace-level boost provisioning
|
* @see self::provisionBoost() For workspace-level boost provisioning
|
||||||
|
|
@ -1911,9 +1894,9 @@ class EntitlementService
|
||||||
* $entitlementService->invalidateNamespaceCache($namespace);
|
* $entitlementService->invalidateNamespaceCache($namespace);
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace to invalidate caches for
|
* @param Namespace_ $namespace The namespace to invalidate caches for
|
||||||
* @param array<string> $featureCodes Specific features to invalidate (empty = all)
|
* @param array<string> $featureCodes Specific features to invalidate (empty = all)
|
||||||
* @param string $reason The reason for invalidation (for event dispatch)
|
* @param string $reason The reason for invalidation (for event dispatch)
|
||||||
*
|
*
|
||||||
* @see self::invalidateCache() For workspace-level cache invalidation
|
* @see self::invalidateCache() For workspace-level cache invalidation
|
||||||
*/
|
*/
|
||||||
|
|
@ -1941,8 +1924,8 @@ class EntitlementService
|
||||||
/**
|
/**
|
||||||
* Invalidate namespace cache using cache tags (O(1) operation).
|
* Invalidate namespace cache using cache tags (O(1) operation).
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace to invalidate
|
* @param Namespace_ $namespace The namespace to invalidate
|
||||||
* @param array<string> $featureCodes Specific features (empty = all)
|
* @param array<string> $featureCodes Specific features (empty = all)
|
||||||
*/
|
*/
|
||||||
protected function invalidateNamespaceCacheWithTags(Namespace_ $namespace, array $featureCodes = []): void
|
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
|
* This is O(n) where n = number of features when no specific features
|
||||||
* are provided.
|
* are provided.
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace to invalidate
|
* @param Namespace_ $namespace The namespace to invalidate
|
||||||
* @param array<string> $featureCodes Specific features (empty = all)
|
* @param array<string> $featureCodes Specific features (empty = all)
|
||||||
*/
|
*/
|
||||||
protected function invalidateNamespaceCacheWithoutTags(Namespace_ $namespace, array $featureCodes = []): void
|
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
|
* Use this for performance when only usage has changed (e.g., after recording
|
||||||
* usage) and limits are known to be unchanged.
|
* usage) and limits are known to be unchanged.
|
||||||
*
|
*
|
||||||
* @param Namespace_ $namespace The namespace to invalidate usage cache for
|
* @param Namespace_ $namespace The namespace to invalidate usage cache for
|
||||||
* @param string $featureCode The specific feature code to invalidate
|
* @param string $featureCode The specific feature code to invalidate
|
||||||
*/
|
*/
|
||||||
public function invalidateNamespaceUsageCache(Namespace_ $namespace, string $featureCode): void
|
public function invalidateNamespaceUsageCache(Namespace_ $namespace, string $featureCode): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ use Illuminate\Support\Str;
|
||||||
class EntitlementWebhookService
|
class EntitlementWebhookService
|
||||||
{
|
{
|
||||||
use PreventsSSRF;
|
use PreventsSSRF;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch an event to all matching webhooks for a workspace.
|
* Dispatch an event to all matching webhooks for a workspace.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
42
TODO.md
42
TODO.md
|
|
@ -15,6 +15,22 @@ Comprehensive task list for improving the multi-tenancy package. Items are prior
|
||||||
|
|
||||||
## P1 - Critical / Security
|
## P1 - Critical / Security
|
||||||
|
|
||||||
|
### BUG-001: Missing `namespace_id` columns — namespace entitlements crash at runtime
|
||||||
|
**Status:** Open (discovered 2026-02-20, issue #2 phase-0 assessment)
|
||||||
|
**Files:** `Migrations/0001_01_01_000000_create_tenant_tables.php`, `Models/UsageRecord.php`, `Models/Boost.php`, `Services/EntitlementService.php`
|
||||||
|
|
||||||
|
The `entitlement_usage_records` and `entitlement_boosts` tables are missing a `namespace_id` column.
|
||||||
|
The `UsageRecord` and `Boost` models declare `namespace_id` as `$fillable`, and `EntitlementService`
|
||||||
|
writes `namespace_id` in `recordNamespaceUsage()` and `provisionNamespaceBoost()`. Any call to either
|
||||||
|
method will throw a database error at runtime.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Create migration adding `namespace_id` (nullable FK → namespaces, nullOnDelete) to both tables
|
||||||
|
- Add indexes: `(namespace_id, feature_code, recorded_at)` on usage records; `(namespace_id, feature_code, status)` on boosts
|
||||||
|
- Verify namespace-level entitlement tests pass after migration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### SEC-001: Add rate limiting to EntitlementApiController
|
### SEC-001: Add rate limiting to EntitlementApiController
|
||||||
**Status:** Fixed (2026-01-29)
|
**Status:** Fixed (2026-01-29)
|
||||||
**File:** `Controllers/EntitlementApiController.php`
|
**File:** `Controllers/EntitlementApiController.php`
|
||||||
|
|
@ -296,6 +312,32 @@ The workspace creation uses hardcoded domain `'hub.host.uk.com'`. This should be
|
||||||
|
|
||||||
## P3 - Medium Priority
|
## P3 - Medium Priority
|
||||||
|
|
||||||
|
### DX-005: Add `declare(strict_types=1)` to remaining model/service files
|
||||||
|
**Status:** Open (discovered 2026-02-20, issue #2 phase-0 assessment)
|
||||||
|
**Files:** `Models/AccountDeletionRequest.php`, `Models/Boost.php`, `Models/EntitlementLog.php`, `Models/Feature.php`, `Models/Package.php`, `Models/UsageRecord.php`, `Models/WaitlistEntry.php`, `Models/WorkspacePackage.php`, `Services/EntitlementResult.php`
|
||||||
|
|
||||||
|
Nine files are still missing `declare(strict_types=1)` despite it being a documented coding standard (DX-001 only fixed three files).
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Add `declare(strict_types=1);` immediately after `<?php` in all nine files
|
||||||
|
- Run `vendor/bin/pint --dirty` to verify no style issues are introduced
|
||||||
|
- Confirm no type errors surface in existing tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ENV-001: Configure `host-uk/core` repository so `composer install` succeeds
|
||||||
|
**Status:** Open (discovered 2026-02-20, issue #2 phase-0 assessment)
|
||||||
|
**File:** `composer.json`
|
||||||
|
|
||||||
|
`composer install` fails because `host-uk/core` is a private package and no `repositories` entry is present. This blocks all tooling (tests, Pint, PHPStan).
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Add `repositories` entry to `composer.json` (Forgejo Composer registry or local path for dev)
|
||||||
|
- Document the required auth token / env var setup in `README.md` or a `CONTRIBUTING.md`
|
||||||
|
- `composer install` completes without errors in CI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### DX-003: Add return type hints to all Workspace relationships
|
### DX-003: Add return type hints to all Workspace relationships
|
||||||
**Status:** Open
|
**Status:** Open
|
||||||
**File:** `Models/Workspace.php`
|
**File:** `Models/Workspace.php`
|
||||||
|
|
|
||||||
100
composer.json
100
composer.json
|
|
@ -1,48 +1,56 @@
|
||||||
{
|
{
|
||||||
"name": "host-uk/core-tenant",
|
"name": "host-uk/core-tenant",
|
||||||
"description": "Multi-tenancy and workspaces for Laravel",
|
"description": "Multi-tenancy and workspaces for Laravel",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"multi-tenant",
|
"multi-tenant",
|
||||||
"workspaces",
|
"workspaces",
|
||||||
"teams"
|
"teams"
|
||||||
],
|
],
|
||||||
"license": "EUPL-1.2",
|
"license": "EUPL-1.2",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"host-uk/core": "dev-main"
|
"host-uk/core": "dev-main"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"laravel/pint": "^1.18",
|
"laravel/pint": "^1.18",
|
||||||
"orchestra/testbench": "^9.0|^10.0",
|
"orchestra/testbench": "^9.0|^10.0",
|
||||||
"pestphp/pest": "^3.0"
|
"pestphp/pest": "^3.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Core\\Tenant\\": ""
|
"Core\\Tenant\\": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Core\\Tenant\\Tests\\": "Tests/"
|
"Core\\Tenant\\Tests\\": "Tests/",
|
||||||
}
|
"Tests\\": "tests/"
|
||||||
},
|
}
|
||||||
"extra": {
|
},
|
||||||
"laravel": {
|
"extra": {
|
||||||
"providers": [
|
"laravel": {
|
||||||
"Core\\Tenant\\Boot"
|
"providers": [
|
||||||
]
|
"Core\\Tenant\\Boot"
|
||||||
}
|
]
|
||||||
},
|
}
|
||||||
"scripts": {
|
},
|
||||||
"lint": "pint",
|
"scripts": {
|
||||||
"test": "pest"
|
"lint": "pint",
|
||||||
},
|
"test": "pest"
|
||||||
"config": {
|
},
|
||||||
"sort-packages": true,
|
"config": {
|
||||||
"allow-plugins": {
|
"sort-packages": true,
|
||||||
"pestphp/pest-plugin": true
|
"allow-plugins": {
|
||||||
}
|
"pestphp/pest-plugin": true
|
||||||
},
|
}
|
||||||
"minimum-stability": "dev",
|
},
|
||||||
"prefer-stable": true
|
"minimum-stability": "dev",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "core",
|
||||||
|
"type": "path",
|
||||||
|
"url": "../php-framework"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
namespace Core\Tenant\Tests\Feature;
|
||||||
|
|
||||||
use Core\Tenant\Database\Factories\WorkspaceInvitationFactory;
|
|
||||||
use Core\Tenant\Models\User;
|
use Core\Tenant\Models\User;
|
||||||
use Core\Tenant\Models\Workspace;
|
use Core\Tenant\Models\Workspace;
|
||||||
use Core\Tenant\Models\WorkspaceInvitation;
|
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