Compare commits

...

7 commits
v0.0.1 ... dev

Author SHA1 Message Date
Snider
98102e510d feat: CLAUDE.md for every directory in CorePHP — 155 files total
Every directory containing PHP or Blade files now has agent-readable
documentation. 134 new files, 2,103 lines across:

- 78 blade component dirs (accordion through web)
- 18 admin view components
- 20 Core subsystem internals (models, services, concerns, migrations)
- 10 Bouncer/Gate subsystem dirs
- 5 root namespaces (Core, Mod, Mod/Trees, Plug, Website)
- Tests, config, lang, media, seo, cdn, search, storage, webhook dirs

Any agent landing on any CorePHP directory now understands the code
before reading a single PHP file. The CLAUDE.md IS the index.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-29 13:41:35 +01:00
Snider
1d8a202bdf feat: add CLAUDE.md to all 21 CorePHP subsystems
Agent-readable documentation for every Core subsystem, extracted
from 530 PHP source files. Each CLAUDE.md documents key classes,
public API, patterns, and integration points.

Highlights discovered:
- Actions: #[Scheduled] attribute system wires to Laravel scheduler
- Bouncer Gate: training mode with #[Action] attributes (CoreGO pattern)
- Config: hierarchical scope (global→workspace→user) with version diffs
- Crypt: LthnHash = QuasiSalt from dAppServer, ported to PHP
- Database: Kahn's algorithm topological seeder sorting via attributes
- Events: 12 lifecycle events with HasEventVersion forward compat
- Front: 78 blade components + programmatic Component for MCP/agent UI
- Headers: DetectDevice identifies 14 in-app browser platforms
- Input: 9-step pre-boot sanitisation pipeline
- Lang: TranslationMemory with fuzzy matching + TMX import/export
- Mail: EmailShield with 100k+ disposable domain blocking
- Search: 7-source unified search with privacy-aware IP hashing
- Storage: Redis circuit breaker (Closed/Open/Half-Open)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-29 13:24:07 +01:00
8f2590477c Merge pull request 'DX audit and fix (PHP)' (#10) from agent/dx-audit-and-fix--laravel-php-package into dev
Reviewed-on: #10
2026-03-24 11:35:11 +00:00
Snider
be304e7b1a fix(lifecycle): deduplicate route names from multi-domain registrations
Some checks failed
CI / PHP 8.4 (pull_request) Failing after 2m3s
CI / PHP 8.3 (pull_request) Failing after 2m18s
CI / PHP 8.4 (push) Failing after 2m2s
CI / PHP 8.3 (push) Failing after 2m20s
Publish Composer Package / publish (push) Failing after 10s
When the same route file is registered on multiple domains (e.g.
core.test, hub.core.test, core.localhost), Laravel's route:cache
fails with "Another route has already been assigned name". Add
deduplicateRouteNames() to strip names from duplicate routes,
keeping only the first registration. Extract processViews(),
processLivewire(), and refreshRoutes() helpers to reduce
duplication across fire* methods.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 23:19:30 +00:00
Snider
fab9318f64 chore: sync dependencies for v0.0.3
Some checks failed
Publish Composer Package / publish (push) Failing after 11s
CI / PHP 8.3 (push) Failing after 2m13s
CI / PHP 8.4 (push) Failing after 2m8s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:53:15 +00:00
Snider
1fbac8f9ab chore: sync dependencies for v0.0.2
Some checks failed
CI / PHP 8.3 (push) Failing after 2m11s
CI / PHP 8.4 (push) Failing after 2m10s
Publish Composer Package / publish (push) Failing after 11s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:47:17 +00:00
Snider
208cb93c95 fix(dx): code style fixes, strict_types, and test repair
All checks were successful
CI / PHP 8.3 (pull_request) Successful in 2m32s
CI / PHP 8.4 (pull_request) Successful in 2m17s
- Remove non-existent src/Core/Service/ from CLAUDE.md L1 packages list
- Fix LifecycleEventsTest: remove dependency on McpToolHandler interface
  (lives in core-mcp, not needed since McpToolsRegistering stores class
  name strings)
- Run Laravel Pint to fix PSR-12 violations across all source and test files
- Add missing declare(strict_types=1) to 18 PHP files (tests, seeders,
  Layout.php, GenerateServiceOgImages.php)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 09:03:50 +00:00
295 changed files with 4157 additions and 521 deletions

View file

@ -71,7 +71,6 @@ src/Core/Lang/ # Translation system with ICU + locale fallback chains
src/Core/Media/ # Media handling with thumbnail helpers
src/Core/Search/ # Search functionality
src/Core/Seo/ # SEO utilities
src/Core/Service/ # Service discovery and dependency resolution
src/Core/Storage/ # Storage with Redis circuit breaker + fallback
src/Core/Webhook/ # Webhook system + CronTrigger scheduled action
```

View file

@ -1,5 +1,7 @@
<?php
use Core\Activity\Models\Activity;
return [
/*
@ -449,7 +451,7 @@ return [
// Custom Activity model class (optional).
// Set this to use a custom Activity model with additional scopes.
// Default: Core\Activity\Models\Activity::class
'activity_model' => env('CORE_ACTIVITY_MODEL', \Core\Activity\Models\Activity::class),
'activity_model' => env('CORE_ACTIVITY_MODEL', Activity::class),
],
];

10
go.mod
View file

@ -3,17 +3,17 @@ module forge.lthn.ai/core/php
go 1.26.0
require (
forge.lthn.ai/core/cli v0.3.5
forge.lthn.ai/core/go-i18n v0.1.5
forge.lthn.ai/core/go-io v0.1.5
forge.lthn.ai/core/cli v0.3.7
forge.lthn.ai/core/go-i18n v0.1.7
forge.lthn.ai/core/go-io v0.1.7
github.com/dunglas/frankenphp v1.12.1
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)
require (
forge.lthn.ai/core/go v0.3.1 // indirect
forge.lthn.ai/core/go-inference v0.1.4 // indirect
forge.lthn.ai/core/go v0.3.3 // indirect
forge.lthn.ai/core/go-inference v0.1.6 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect
github.com/RoaringBitmap/roaring/v2 v2.15.0 // indirect

20
go.sum
View file

@ -1,13 +1,13 @@
forge.lthn.ai/core/cli v0.3.5 h1:P7yK0DmSA1QnUMFuCjJZf/fk/akKPIxopQ6OwD8Sar8=
forge.lthn.ai/core/cli v0.3.5/go.mod h1:SeArHx+hbpX5iZqgASCD7Q1EDoc6uaaGiGBotmNzIx4=
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
forge.lthn.ai/core/go-i18n v0.1.5 h1:B4hV4eTl63akZiplM8lswuttctrcSOCWyFSGBZmu6Nc=
forge.lthn.ai/core/go-i18n v0.1.5/go.mod h1:hJsUxmqdPly73i3VkTDxvmbrpjxSd65hQVQqWA3+fnM=
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=
forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI=
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E=
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 h1:1yw6O62BReQ+uA1oyk9XaQTvLhcoHWmoQAgXmDFXpIY=

View file

@ -0,0 +1,57 @@
# Actions
Single-purpose business logic pattern with scheduling support.
## What It Does
Provides the `Action` trait for extracting business logic from controllers/components into focused, testable classes. Each action does one thing via a `handle()` method and gets a static `run()` shortcut that resolves dependencies from the container.
Also provides attribute-driven scheduling: annotate an Action with `#[Scheduled]` and it gets wired into Laravel's scheduler automatically.
## Key Classes
| Class | Purpose |
|-------|---------|
| `Action` (trait) | Adds `static run(...$args)` that resolves via container and calls `handle()` |
| `Actionable` (interface) | Optional contract for type-hinting actions |
| `Scheduled` (attribute) | Marks an Action for scheduled execution with frequency string |
| `ScheduledAction` (model) | Eloquent model persisted to `scheduled_actions` table |
| `ScheduledActionScanner` | Discovers `#[Scheduled]` attributes by scanning directories |
| `ScheduleServiceProvider` | Reads enabled scheduled actions from DB and registers with Laravel scheduler |
## Public API
```php
// Use the Action pattern
class CreateOrder {
use Action;
public function handle(User $user, array $data): Order { ... }
}
CreateOrder::run($user, $data); // resolves from container
// Schedule an action
#[Scheduled(frequency: 'dailyAt:09:00', timezone: 'Europe/London')]
class PublishDigest {
use Action;
public function handle(): void { ... }
}
```
## Frequency String Format
`method:arg1,arg2` maps directly to Laravel Schedule methods:
- `everyMinute` / `hourly` / `daily` / `weekly` / `monthly`
- `dailyAt:09:00` / `weeklyOn:1,09:00` / `cron:* * * * *`
## Integration
- Scanner skips `Tests/` directories and `*Test.php` files
- ScheduleServiceProvider validates namespace (`App\`, `Core\`, `Mod\`) and frequency method against allowlists before executing
- Actions are placed in `app/Mod/{Module}/Actions/`
## Conventions
- One action per file, named after what it does: `CreatePage`, `SendInvoice`
- Dependencies injected via constructor
- `handle()` is the single public method
- Scheduling state is DB-driven (enable/disable without code changes)

View file

@ -99,7 +99,7 @@ class ScheduleServiceProvider extends ServiceProvider
}
// Verify the class uses the Action trait
if (! in_array(\Core\Actions\Action::class, class_uses_recursive($class), true)) {
if (! in_array(Action::class, class_uses_recursive($class), true)) {
logger()->warning("Scheduled action {$class} does not use the Action trait — skipping");
continue;

View file

@ -13,6 +13,7 @@ namespace Core\Actions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Represents a scheduled action persisted in the database.
@ -24,10 +25,10 @@ use Illuminate\Database\Eloquent\Model;
* @property bool $without_overlapping
* @property bool $run_in_background
* @property bool $is_enabled
* @property \Illuminate\Support\Carbon|null $last_run_at
* @property \Illuminate\Support\Carbon|null $next_run_at
* @property \Illuminate\Support\Carbon $created_at
* @property \Illuminate\Support\Carbon $updated_at
* @property Carbon|null $last_run_at
* @property Carbon|null $next_run_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class ScheduledAction extends Model
{

View file

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Core\Actions;
use Core\ModuleScanner;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
@ -24,7 +25,7 @@ use ReflectionClass;
* It uses PHP's native reflection to read attributes no file parsing.
*
* @see Scheduled The attribute this scanner discovers
* @see \Core\ModuleScanner Similar pattern for Boot.php discovery
* @see ModuleScanner Similar pattern for Boot.php discovery
*/
class ScheduledActionScanner
{
@ -32,7 +33,7 @@ class ScheduledActionScanner
* Scan directories for classes with #[Scheduled] attribute.
*
* @param array<string> $paths Directories to scan recursively
* @return array<class-string, Scheduled> Map of class name to attribute instance
* @return array<class-string, Scheduled> Map of class name to attribute instance
*/
public function scan(array $paths): array
{

View file

@ -0,0 +1,48 @@
# Activity
Workspace-aware activity logging built on `spatie/laravel-activitylog`.
## What It Does
Wraps Spatie's activity log with automatic `workspace_id` tagging, a fluent query service, a Livewire feed component for the admin panel, and a prune command for retention management.
## Key Classes
| Class | Purpose |
|-------|---------|
| `Boot` | Registers console commands, Livewire component, and service binding via lifecycle events |
| `Activity` (model) | Extends Spatie's model with `ActivityScopes` trait. Adds `workspace_id`, `old_values`, `new_values`, `changes`, `causer_name`, `subject_name` accessors |
| `ActivityLogService` | Fluent query builder: `logFor($model)`, `logBy($user)`, `forWorkspace($ws)`, `ofType('updated')`, `search('term')`, `paginate()`, `statistics()`, `timeline()`, `prune()` |
| `LogsActivity` (trait) | Drop-in trait for models. Auto-logs dirty attributes, auto-tags `workspace_id` from model or request context, generates human descriptions |
| `ActivityScopes` (trait) | 20+ Eloquent scopes: `forWorkspace`, `forSubject`, `byCauser`, `ofType`, `betweenDates`, `today`, `lastDays`, `search`, `withChanges`, `withExistingSubject` |
| `ActivityPruneCommand` | `php artisan activity:prune [--days=N] [--dry-run]` |
| `ActivityFeed` (Livewire) | `<livewire:core.activity-feed :workspace-id="$id" />` with filters, search, pagination, detail modal |
## Public API
```php
// Make a model log activity
class Post extends Model {
use LogsActivity;
protected array $activityLogAttributes = ['title', 'status'];
}
// Query activities
$service = app(ActivityLogService::class);
$service->logFor($post)->lastDays(7)->paginate();
$service->forWorkspace($workspace)->ofType('deleted')->recent(10);
$service->statistics($workspace); // => [total, by_event, by_subject, by_user]
```
## Integration
- Listens to `ConsoleBooting` and `AdminPanelBooting` lifecycle events
- `LogsActivity` trait auto-detects workspace from model's `workspace_id` attribute, request `workspace_model` attribute, or authenticated user's `defaultHostWorkspace()`
- Config: `core.activity.enabled`, `core.activity.retention_days` (default 90), `core.activity.log_name`
- Override activity model in `config/activitylog.php`: `'activity_model' => Activity::class`
## Conventions
- `LogsActivity::withoutActivityLogging(fn() => ...)` to suppress logging during bulk operations
- Models can implement `customizeActivity($activity, $event)` for custom property injection
- Config properties on model: `$activityLogAttributes`, `$activityLogName`, `$activityLogEvents`, `$activityLogWorkspace`, `$activityLogOnlyDirty`

View file

@ -0,0 +1,17 @@
# Activity/Concerns/ — Activity Logging Trait
## Traits
| Trait | Purpose |
|-------|---------|
| `LogsActivity` | Drop-in trait for models that should log changes. Wraps `spatie/laravel-activitylog` with sensible defaults: auto workspace_id tagging, dirty-only logging, empty log suppression. |
## Configuration via Model Properties
- `$activityLogAttributes` — array of attributes to log (default: all dirty)
- `$activityLogName` — custom log name
- `$activityLogEvents` — events to log (default: created, updated, deleted)
- `$activityLogWorkspace` — include workspace_id (default: true)
- `$activityLogOnlyDirty` — only log changed attributes (default: true)
Static helpers: `activityLoggingEnabled()`, `withoutActivityLogging(callable)`.

View file

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Core\Activity\Concerns;
use Spatie\Activitylog\Contracts\Activity;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity as SpatieLogsActivity;
@ -77,7 +78,7 @@ trait LogsActivity
/**
* Tap into the activity before it's saved to add workspace_id.
*/
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
public function tapActivity(Activity $activity, string $eventName): void
{
if ($this->shouldIncludeWorkspace()) {
$workspaceId = $this->getActivityWorkspaceId();

View file

@ -13,6 +13,7 @@ namespace Core\Activity\Console;
use Core\Activity\Services\ActivityLogService;
use Illuminate\Console\Command;
use Spatie\Activitylog\Models\Activity;
/**
* Command to prune old activity logs.
@ -48,7 +49,7 @@ class ActivityPruneCommand extends Command
if ($this->option('dry-run')) {
// Count without deleting
$activityModel = config('core.activity.activity_model', \Spatie\Activitylog\Models\Activity::class);
$activityModel = config('core.activity.activity_model', Activity::class);
$count = $activityModel::where('created_at', '<', $cutoffDate)->count();
$this->info("Would delete {$count} activity records.");

View file

@ -0,0 +1,9 @@
# Activity/Console/ — Activity Log Commands
## Commands
| Command | Signature | Purpose |
|---------|-----------|---------|
| `ActivityPruneCommand` | `activity:prune` | Prunes old activity logs. Options: `--days=N` (retention period), `--dry-run` (show count without deleting). Uses retention from config when days not specified. |
Part of the Activity subsystem's maintenance tooling. Should be scheduled in the application's console kernel for regular cleanup.

View file

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Core\Activity\Models;
use Core\Activity\Scopes\ActivityScopes;
use Illuminate\Support\Collection;
use Spatie\Activitylog\Models\Activity as SpatieActivity;
/**
@ -81,9 +82,9 @@ class Activity extends SpatieActivity
/**
* Get the changed attributes.
*
* @return \Illuminate\Support\Collection<string, array{old: mixed, new: mixed}>
* @return Collection<string, array{old: mixed, new: mixed}>
*/
public function getChangesAttribute(): \Illuminate\Support\Collection
public function getChangesAttribute(): Collection
{
$old = $this->old_values;
$new = $this->new_values;

View file

@ -0,0 +1,14 @@
# Activity/Models/ — Activity Log Model
## Models
| Model | Extends | Purpose |
|-------|---------|---------|
| `Activity` | `Spatie\Activitylog\Models\Activity` | Extended activity model with workspace-aware scopes via the `ActivityScopes` trait. Adds query scopes for filtering by workspace, subject, causer, event type, date range, and search. |
Configure as the activity model in `config/activitylog.php`:
```php
'activity_model' => \Core\Activity\Models\Activity::class,
```
Requires `spatie/laravel-activitylog`.

View file

@ -0,0 +1,9 @@
# Activity/Scopes/ — Activity Query Scopes
## Traits
| Trait | Purpose |
|-------|---------|
| `ActivityScopes` | Comprehensive query scopes for activity log filtering. Includes: `forWorkspace`, `forSubject`, `forSubjectType`, `byCauser`, `byCauserId`, `ofType`, `createdEvents`, `updatedEvents`, `deletedEvents`, `betweenDates`, `today`, `lastDays`, `lastHours`, `search`, `inLog`, `withChanges`, `withExistingSubject`, `withDeletedSubject`, `newest`, `oldest`. |
Used by `Core\Activity\Models\Activity`. Workspace scoping checks both `properties->workspace_id` and subject model's `workspace_id`. Requires `spatie/laravel-activitylog`.

View file

@ -0,0 +1,11 @@
# Activity/Services/ — Activity Log Service
## Services
| Service | Purpose |
|---------|---------|
| `ActivityLogService` | Fluent interface for querying and managing activity logs. Methods: `logFor($model)`, `logBy($user)`, `forWorkspace($workspace)`, `recent()`, `search($term)`. Chainable query builder with workspace awareness. |
Provides the business logic layer over Spatie's activity log. Used by the `ActivityFeed` Livewire component and available for injection throughout the application.
Requires `spatie/laravel-activitylog`.

View file

@ -0,0 +1,9 @@
# Activity/View/Blade/admin/ — Activity Feed Blade Template
## Templates
| File | Purpose |
|------|---------|
| `activity-feed.blade.php` | Admin panel activity log display — paginated list with filters (user, model type, event type, date range), activity detail modal with full diff view, optional polling for real-time updates. |
Rendered by the `ActivityFeed` Livewire component via the `core.activity::admin.*` view namespace.

View file

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Core\Activity\View\Modal\Admin;
use Core\Activity\Services\ActivityLogService;
use Illuminate\Contracts\View\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
@ -362,7 +363,7 @@ class ActivityFeed extends Component
};
}
public function render(): \Illuminate\Contracts\View\View
public function render(): View
{
return view('core.activity::admin.activity-feed');
}

View file

@ -0,0 +1,11 @@
# Activity/View/Modal/Admin/ — Activity Feed Livewire Component
## Components
| Component | Purpose |
|-----------|---------|
| `ActivityFeed` | Livewire component for displaying activity logs in the admin panel. Paginated list with URL-bound filters (causer, subject type, event type, date range, search). Supports workspace scoping and optional polling for real-time updates. |
Usage: `<livewire:core.activity-feed />` or `<livewire:core.activity-feed :workspace-id="$workspace->id" poll="10s" />`
Requires `spatie/laravel-activitylog`.

View file

@ -14,6 +14,7 @@ namespace Core;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Session\Middleware\StartSession;
/**
* Application bootstrap - configures Laravel with Core framework patterns.
@ -36,16 +37,16 @@ class Boot
*/
public static array $providers = [
// Lifecycle events - must load first to wire lazy listeners
\Core\LifecycleEventProvider::class,
LifecycleEventProvider::class,
// Websites - domain-scoped, must wire before frontages fire events
\Core\Website\Boot::class,
Website\Boot::class,
// Core frontages - fire lifecycle events
\Core\Front\Boot::class,
Front\Boot::class,
// Base modules (from core-php package)
\Core\Mod\Boot::class,
Mod\Boot::class,
];
/**
@ -58,7 +59,7 @@ class Boot
->withMiddleware(function (Middleware $middleware): void {
// Session middleware priority
$middleware->priority([
\Illuminate\Session\Middleware\StartSession::class,
StartSession::class,
]);
$middleware->redirectGuestsTo('/login');

View file

@ -0,0 +1,61 @@
# Bouncer
Early-exit security middleware + whitelist-based action authorisation gate.
## What It Does
Two subsystems in one:
1. **Bouncer** (top-level): IP blocklist + SEO redirects, runs before all other middleware
2. **Gate** (subdirectory): Whitelist-based controller action authorisation with training mode
## Bouncer (IP Blocking + Redirects)
### Key Classes
| Class | Purpose |
|-------|---------|
| `Boot` | ServiceProvider registering `BlocklistService`, `RedirectService`, and migrations |
| `BouncerMiddleware` | Early-exit middleware: sets trusted proxies, checks blocklist (O(1) via cached set), handles SEO redirects, then passes through |
| `BlocklistService` | IP blocking with Redis-cached lookup. Statuses: `pending` (honeypot, needs review), `approved` (active block), `rejected` (reviewed, not blocked). Methods: `isBlocked()`, `block()`, `unblock()`, `syncFromHoneypot()`, `approve()`, `reject()`, `getPending()`, `getStats()` |
| `RedirectService` | Cached SEO redirects from `seo_redirects` table. Supports exact match and wildcard (`path/*`). Methods: `match()`, `add()`, `remove()` |
### Hidden Ideas
- Blocked IPs get `418 I'm a teapot` with `X-Powered-By: Earl Grey`
- Honeypot monitors paths from `robots.txt` disallow list; critical paths (`/admin`, `/.env`, `/wp-admin`) trigger auto-block
- Rate-limited honeypot logging prevents DoS via log flooding
- `TRUSTED_PROXIES` env var: comma-separated IPs or `*` (trust all)
## Gate (Action Whitelist)
Philosophy: **"If it wasn't trained, it doesn't exist."**
### Key Classes
| Class | Purpose |
|-------|---------|
| `Gate\Boot` | ServiceProvider registering middleware, migrations, route macros, and training routes |
| `ActionGateService` | Resolves action name from route (3-level priority), checks against `ActionPermission` table, logs to `ActionRequest`. Methods: `check()`, `allow()`, `deny()`, `resolveAction()` |
| `ActionGateMiddleware` | Enforces gate: allowed = pass, denied = 403, training = approval prompt (JSON for API, redirect for web) |
| `Action` (attribute) | `#[Action('product.create', scope: 'product')]` on controller methods |
| `ActionPermission` (model) | Whitelist record: action + guard + role + scope. Methods: `isAllowed()`, `train()`, `revoke()`, `allowedFor()` |
| `ActionRequest` (model) | Audit log of all permission checks. Methods: `log()`, `pending()`, `deniedActionsSummary()`, `prune()` |
| `RouteActionMacro` | Adds `->action('name')`, `->bypassGate()`, `->requiresTraining()` to Route |
### Action Resolution Priority
1. Route action: `Route::post(...)->action('product.create')`
2. Controller attribute: `#[Action('product.create')]`
3. Auto-resolved: `ProductController@store` becomes `product.store`
### Training Mode
When `core.bouncer.training_mode = true`, unknown actions prompt for approval instead of blocking. Training routes at `/_bouncer/approve` and `/_bouncer/pending`.
## Integration
- BouncerMiddleware runs FIRST in the stack (replaces Laravel TrustProxies)
- ActionGateMiddleware appends to `web`, `admin`, `api`, `client` groups
- Config: `core.bouncer.enabled`, `core.bouncer.training_mode`, `core.bouncer.guarded_middleware`
- DB tables: `blocked_ips`, `seo_redirects`, `honeypot_hits`, `core_action_permissions`, `core_action_requests`

View file

@ -0,0 +1,7 @@
# Bouncer/Database/Seeders/ — Bouncer Seeders
## Seeders
| File | Purpose |
|------|---------|
| `WebsiteRedirectSeeder.php` | Seeds 301 redirects for renamed website URLs. Uses the `RedirectService` to register old-to-new path mappings (e.g., `/services/biohost` -> `/services/bio`). Added during URL simplification (2026-01-16). |

View file

@ -0,0 +1,14 @@
# Bouncer/Gate/Attributes/ — Action Gate PHP Attributes
## Attributes
| Attribute | Target | Purpose |
|-----------|--------|---------|
| `#[Action(name, scope?)]` | Method, Class | Declares an explicit action name for permission checking, overriding auto-resolution from controller/method names. Optional `scope` for resource-specific permissions. |
Without this attribute, action names are auto-resolved: `ProductController@store` becomes `product.store`.
```php
#[Action('product.create')]
public function store(Request $request) { ... }
```

View file

@ -0,0 +1,18 @@
# Bouncer/Gate/ — Action Gate Authorisation
Whitelist-based request authorisation system. Philosophy: "If it wasn't trained, it doesn't exist."
## Files
| File | Purpose |
|------|---------|
| `Boot.php` | ServiceProvider — registers middleware, configures action gate. |
| `ActionGateMiddleware.php` | Intercepts requests, checks if the target action is permitted. Production mode blocks unknown actions (403). Training mode prompts for approval. |
| `ActionGateService.php` | Core service — resolves action names from routes/controllers, checks `ActionPermission` records. Supports `#[Action]` attribute, auto-resolution from controller names, and training mode. |
| `RouteActionMacro.php` | Adds `->action('name')` and `->bypassGate()` macros to Laravel routes for fluent action naming. |
## Integration Flow
```
Request -> ActionGateMiddleware -> ActionGateService::check() -> ActionPermission (allowed/denied) -> Controller
```

View file

@ -0,0 +1,7 @@
# Bouncer/Gate/Migrations/ — Action Gate Schema
## Migrations
| File | Purpose |
|------|---------|
| `0001_01_01_000002_create_action_permission_tables.php` | Creates `core_action_permissions` (whitelisted actions) and `core_action_requests` (audit log) tables. |

View file

@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Core\Bouncer\Gate\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -29,9 +31,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $source How this was created ('trained', 'seeded', 'manual')
* @property string|null $trained_route The route used during training
* @property int|null $trained_by User ID who trained this action
* @property \Carbon\Carbon|null $trained_at When training occurred
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property Carbon|null $trained_at When training occurred
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class ActionPermission extends Model
{
@ -174,9 +176,9 @@ class ActionPermission extends Model
/**
* Get all actions for a guard.
*
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function forGuard(string $guard): \Illuminate\Database\Eloquent\Collection
public static function forGuard(string $guard): Collection
{
return static::where('guard', $guard)->get();
}
@ -184,9 +186,9 @@ class ActionPermission extends Model
/**
* Get all allowed actions for a guard/role combination.
*
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function allowedFor(string $guard, ?string $role = null): \Illuminate\Database\Eloquent\Collection
public static function allowedFor(string $guard, ?string $role = null): Collection
{
$query = static::where('guard', $guard)
->where('allowed', true);

View file

@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Core\Bouncer\Gate\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -30,8 +32,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string|null $ip_address Client IP
* @property string $status Result: 'allowed', 'denied', 'pending'
* @property bool $was_trained Whether this request triggered training
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class ActionRequest extends Model
{
@ -103,9 +105,9 @@ class ActionRequest extends Model
/**
* Get pending requests (for training review).
*
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function pending(): \Illuminate\Database\Eloquent\Collection
public static function pending(): Collection
{
return static::where('status', self::STATUS_PENDING)
->orderBy('created_at', 'desc')
@ -115,9 +117,9 @@ class ActionRequest extends Model
/**
* Get denied requests for an action.
*
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function deniedFor(string $action): \Illuminate\Database\Eloquent\Collection
public static function deniedFor(string $action): Collection
{
return static::where('action', $action)
->where('status', self::STATUS_DENIED)
@ -128,9 +130,9 @@ class ActionRequest extends Model
/**
* Get requests by user.
*
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function forUser(int $userId): \Illuminate\Database\Eloquent\Collection
public static function forUser(int $userId): Collection
{
return static::where('user_id', $userId)
->orderBy('created_at', 'desc')

View file

@ -0,0 +1,8 @@
# Bouncer/Gate/Models/ — Action Gate Models
## Models
| Model | Table | Purpose |
|-------|-------|---------|
| `ActionPermission` | `core_action_permissions` | Whitelisted action record. Stores action identifier, scope, guard, role, allowed flag, and training metadata (who trained it, when, from which route). Source: `trained`, `seeded`, or `manual`. |
| `ActionRequest` | `core_action_requests` | Audit log entry for all action permission checks. Records HTTP method, route, action, guard, user, IP, status (allowed/denied/pending), and whether training was triggered. |

View file

@ -13,6 +13,7 @@ namespace Core\Bouncer\Gate\Tests\Feature;
use Core\Bouncer\Gate\ActionGateService;
use Core\Bouncer\Gate\Attributes\Action;
use Core\Bouncer\Gate\Boot;
use Core\Bouncer\Gate\Models\ActionPermission;
use Core\Bouncer\Gate\Models\ActionRequest;
use Core\Bouncer\Gate\RouteActionMacro;
@ -40,7 +41,7 @@ class ActionGateTest extends TestCase
protected function getPackageProviders($app): array
{
return [
\Core\Bouncer\Gate\Boot::class,
Boot::class,
];
}

View file

@ -0,0 +1,7 @@
# Bouncer/Gate/Tests/Feature/ — Action Gate Feature Tests
## Test Files
| File | Purpose |
|------|---------|
| `ActionGateTest.php` | Integration tests for the full action gate flow — middleware interception, permission enforcement, training mode responses, route macro behaviour. |

View file

@ -0,0 +1,7 @@
# Bouncer/Gate/Tests/Unit/ — Action Gate Unit Tests
## Test Files
| File | Purpose |
|------|---------|
| `ActionGateServiceTest.php` | Unit tests for the `ActionGateService`. Tests action name resolution from routes and controllers, permission checking, training mode behaviour, and `#[Action]` attribute support. |

View file

@ -0,0 +1,9 @@
# Bouncer/Migrations/ — Bouncer Schema Migrations
## Migrations
| File | Purpose |
|------|---------|
| `0001_01_01_000001_create_bouncer_tables.php` | Creates core bouncer tables for IP/domain blocklisting, redirect rules, and rate limiting configuration. |
Uses early timestamps to run before application migrations.

View file

@ -0,0 +1,7 @@
# Bouncer/Tests/Unit/ — Bouncer Unit Tests
## Test Files
| File | Purpose |
|------|---------|
| `BlocklistServiceTest.php` | Unit tests for the IP/domain blocklist service. Tests blocking, allowing, and checking IPs and domains against the blocklist. |

83
src/Core/CLAUDE.md Normal file
View file

@ -0,0 +1,83 @@
# Core Orchestration
Root-level files in `src/Core/` that wire the entire framework together. These are the bootstrap, module discovery, lazy loading, and pro-feature detection systems.
## Files
| File | Purpose |
|------|---------|
| `Init.php` | True entry point. `Core\Init::handle()` replaces Laravel's `bootstrap/app.php`. Runs WAF input filtering via `Input::capture()`, then delegates to `Boot::app()`. Prefers `App\Boot` if it exists. |
| `Boot.php` | Configures Laravel `Application` with providers, middleware, and exceptions. Provider load order is critical: `LifecycleEventProvider` -> `Website\Boot` -> `Front\Boot` -> `Mod\Boot`. |
| `LifecycleEventProvider.php` | The orchestrator. Registers `ModuleScanner` and `ModuleRegistry` as singletons, scans configured paths, wires lazy listeners. Static `fire*()` methods are called by frontage modules to dispatch lifecycle events and process collected requests (views, livewire, routes, middleware). |
| `ModuleScanner.php` | Discovers `Boot.php` files in subdirectories of given paths. Reads static `$listens` arrays via reflection without instantiating modules. Maps paths to namespaces (`/Core` -> `Core\`, `/Mod` -> `Mod\`, `/Website` -> `Website\`, `/Plug` -> `Plug\`). |
| `ModuleRegistry.php` | Coordinates scanner output into Laravel's event system. Sorts listeners by priority (highest first), creates `LazyModuleListener` instances, supports late-registration via `addPaths()`. |
| `LazyModuleListener.php` | The lazy-loading wrapper. Instantiates module on first event fire (cached thereafter). ServiceProviders use `resolveProvider()`, plain classes use `make()`. Records audit logs and profiling data. |
| `Pro.php` | Detects Flux Pro and FontAwesome Pro installations. Auto-enables pro features, falls back gracefully to free equivalents. Throws helpful dev-mode exceptions. |
| `config.php` | Framework configuration: branding, domains, CDN, organisation, social links, contact, FontAwesome, pro fallback behaviour, icon defaults, debug settings, seeder auto-discovery. |
## Bootstrap Sequence
```
public/index.php
-> Core\Init::handle()
-> Input::capture() # WAF layer sanitises $_GET/$_POST
-> Boot::app() # Build Laravel Application
-> LifecycleEventProvider # register(): scan + wire lazy listeners
-> Website\Boot # register(): domain resolution
-> Front\Boot # boot(): fires lifecycle events
-> Mod\Boot # aggregates feature modules
```
## Module Declaration Pattern
Modules declare interest in events via static `$listens`:
```php
class Boot
{
public static array $listens = [
WebRoutesRegistering::class => 'onWebRoutes',
AdminPanelBooting::class => ['onAdmin', 10], // priority 10
];
}
```
Modules are never instantiated until their event fires.
## Lifecycle Events (fire* methods)
| Method | Event | Middleware | Processes |
|--------|-------|-----------|-----------|
| `fireWebRoutes()` | `WebRoutesRegistering` | `web` | views, livewire, routes |
| `fireAdminBooting()` | `AdminPanelBooting` | `admin` | views, translations, livewire, routes |
| `fireClientRoutes()` | `ClientRoutesRegistering` | `client` | views, livewire, routes |
| `fireApiRoutes()` | `ApiRoutesRegistering` | `api` | routes |
| `fireMcpRoutes()` | `McpRoutesRegistering` | `mcp` | routes |
| `fireMcpTools()` | `McpToolsRegistering` | -- | returns handler class names |
| `fireConsoleBooting()` | `ConsoleBooting` | -- | artisan commands |
| `fireQueueWorkerBooting()` | `QueueWorkerBooting` | -- | queue-specific init |
All route-registering fire methods call `refreshRoutes()` afterward to deduplicate names and refresh lookups.
## Default Scan Paths
- `app_path('Core')` -- application-level core modules
- `app_path('Mod')` -- feature modules
- `app_path('Website')` -- domain-scoped website modules
- `src/Core` -- framework's own modules
- `src/Mod` -- framework's own feature modules
Configurable via `config('core.module_paths')`.
## Priority System
- Default: `0`
- Higher values run first: `['onAdmin', 100]` runs before `['onAdmin', 0]`
- Negative values run last: `['onCleanup', -10]`
## Key Integration Points
- `Init::boot()` returns `App\Boot` if it exists, allowing apps to customise providers
- `Boot::basePath()` auto-detects monorepo vs vendor structure
- `LifecycleEventProvider` processes middleware aliases, view namespaces, and Livewire components collected during event dispatch
- Route deduplication prevents `route:cache` failures when the same route file serves multiple domains

View file

@ -11,6 +11,10 @@ declare(strict_types=1);
namespace Core\Cdn;
use App\Facades\Cdn;
use App\Http\Middleware\RewriteOffloadedUrls;
use App\Jobs\PushAssetToCdn;
use App\Traits\HasCdnUrls;
use Core\Cdn\Console\CdnPurge;
use Core\Cdn\Console\OffloadMigrateCommand;
use Core\Cdn\Console\PushAssetsToCdn;
@ -21,6 +25,9 @@ use Core\Cdn\Services\BunnyStorageService;
use Core\Cdn\Services\FluxCdnService;
use Core\Cdn\Services\StorageOffload;
use Core\Cdn\Services\StorageUrlResolver;
use Core\Crypt\LthnHash;
use Core\Plug\Cdn\CdnManager;
use Core\Plug\Storage\StorageManager;
use Illuminate\Support\ServiceProvider;
/**
@ -45,11 +52,11 @@ class Boot extends ServiceProvider
$this->mergeConfigFrom(__DIR__.'/offload.php', 'offload');
// Register Plug managers as singletons (when available)
if (class_exists(\Core\Plug\Cdn\CdnManager::class)) {
$this->app->singleton(\Core\Plug\Cdn\CdnManager::class);
if (class_exists(CdnManager::class)) {
$this->app->singleton(CdnManager::class);
}
if (class_exists(\Core\Plug\Storage\StorageManager::class)) {
$this->app->singleton(\Core\Plug\Storage\StorageManager::class);
if (class_exists(StorageManager::class)) {
$this->app->singleton(StorageManager::class);
}
// Register legacy services as singletons (for backward compatibility)
@ -115,32 +122,32 @@ class Boot extends ServiceProvider
// Crypt
if (! class_exists(\App\Services\Crypt\LthnHash::class)) {
class_alias(\Core\Crypt\LthnHash::class, \App\Services\Crypt\LthnHash::class);
class_alias(LthnHash::class, \App\Services\Crypt\LthnHash::class);
}
// Models
if (! class_exists(\App\Models\StorageOffload::class)) {
class_alias(\Core\Cdn\Models\StorageOffload::class, \App\Models\StorageOffload::class);
class_alias(Models\StorageOffload::class, \App\Models\StorageOffload::class);
}
// Facades
if (! class_exists(\App\Facades\Cdn::class)) {
class_alias(\Core\Cdn\Facades\Cdn::class, \App\Facades\Cdn::class);
if (! class_exists(Cdn::class)) {
class_alias(Facades\Cdn::class, Cdn::class);
}
// Traits
if (! trait_exists(\App\Traits\HasCdnUrls::class)) {
class_alias(\Core\Cdn\Traits\HasCdnUrls::class, \App\Traits\HasCdnUrls::class);
if (! trait_exists(HasCdnUrls::class)) {
class_alias(Traits\HasCdnUrls::class, HasCdnUrls::class);
}
// Middleware
if (! class_exists(\App\Http\Middleware\RewriteOffloadedUrls::class)) {
class_alias(\Core\Cdn\Middleware\RewriteOffloadedUrls::class, \App\Http\Middleware\RewriteOffloadedUrls::class);
if (! class_exists(RewriteOffloadedUrls::class)) {
class_alias(Middleware\RewriteOffloadedUrls::class, RewriteOffloadedUrls::class);
}
// Jobs
if (! class_exists(\App\Jobs\PushAssetToCdn::class)) {
class_alias(\Core\Cdn\Jobs\PushAssetToCdn::class, \App\Jobs\PushAssetToCdn::class);
if (! class_exists(PushAssetToCdn::class)) {
class_alias(Jobs\PushAssetToCdn::class, PushAssetToCdn::class);
}
}
}

57
src/Core/Cdn/CLAUDE.md Normal file
View file

@ -0,0 +1,57 @@
# Cdn
BunnyCDN integration with vBucket workspace isolation and storage offloading.
## What It Does
Unified CDN and object storage layer providing:
- BunnyCDN pull zone operations (purge, stats)
- BunnyCDN storage zone operations (upload, download, list, delete)
- Context-aware URL building (CDN, origin, private, signed)
- vBucket-scoped paths using `LthnHash` for tenant isolation
- Asset pipeline for processing and offloading
- Flux Pro CDN delivery
- Storage offload migration from local to CDN
## Key Classes
| Class | Purpose |
|-------|---------|
| `Boot` | ServiceProvider registering all services as singletons + backward-compat aliases to `App\` namespaces |
| `BunnyCdnService` | Pull zone API: `purgeUrl()`, `purgeUrls()`, `purgeAll()`, `purgeByTag()`, `purgeWorkspace()`, `getStats()`, `getBandwidth()`, `listStorageFiles()`, `uploadFile()`, `deleteFile()`. Sanitises error messages to redact API keys |
| `BunnyStorageService` | Direct storage zone operations (separate from pull zone API) |
| `CdnUrlBuilder` | URL construction: `cdn()`, `origin()`, `private()`, `apex()`, `signed()`, `vBucket()`, `vBucketId()`, `vBucketPath()`, `asset()`, `withVersion()`, `urls()`, `allUrls()` |
| `StorageUrlResolver` | Context-aware URL resolution |
| `FluxCdnService` | Flux Pro component CDN delivery |
| `AssetPipeline` | Asset processing pipeline |
| `StorageOffload` (service) | Migrates files from local storage to CDN |
| `StorageOffload` (model) | Tracks offloaded files in DB |
| `Cdn` (facade) | `Cdn::purge(...)` etc. |
| `HasCdnUrls` (trait) | Adds CDN URL methods to Eloquent models |
## Console Commands
- `cdn:purge` -- Purge CDN cache
- `cdn:push-assets` -- Push assets to CDN storage
- `cdn:push-flux` -- Push Flux Pro assets to CDN
- `cdn:offload-migrate` -- Migrate local files to CDN storage
## Middleware
- `RewriteOffloadedUrls` -- Rewrites storage URLs in responses to CDN URLs
- `LocalCdnMiddleware` -- Serves CDN assets locally in development
## vBucket Pattern
Workspace-isolated CDN paths using `LthnHash::vBucketId()`:
```
cdn.example.com/{vBucketId}/path/to/asset.js
```
The vBucketId is a deterministic SHA-256 hash of the domain name, ensuring each workspace's assets are namespaced.
## Integration
- Reads credentials from `ConfigService` (DB-backed config), not just `.env`
- Signed URLs use HMAC-SHA256 with BunnyCDN token authentication
- Config files: `config.php` (CDN settings), `offload.php` (storage offload settings)
- Backward-compat aliases registered for all `App\Services\*` and `App\Models\*` namespaces

View file

@ -0,0 +1,10 @@
# Cdn/Console/ — CDN Artisan Commands
## Commands
| Command | Signature | Purpose |
|---------|-----------|---------|
| `CdnPurge` | `cdn:purge` | Purge CDN cache — by URL, tag, workspace, or global. |
| `OffloadMigrateCommand` | `cdn:offload-migrate` | Migrate local files to remote storage, creating offload records. |
| `PushAssetsToCdn` | `cdn:push` | Push local assets to CDN storage zone. |
| `PushFluxToCdn` | `cdn:push-flux` | Push Flux UI framework assets to CDN. |

View file

@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Core\Cdn\Console;
use Core\Plug\Cdn\Bunny\Purge;
use Core\Tenant\Models\Workspace;
use Illuminate\Console\Command;
class CdnPurge extends Command
@ -43,8 +45,8 @@ class CdnPurge extends Command
{
parent::__construct();
if (class_exists(\Core\Plug\Cdn\Bunny\Purge::class)) {
$this->purger = new \Core\Plug\Cdn\Bunny\Purge;
if (class_exists(Purge::class)) {
$this->purger = new Purge;
}
}
@ -96,8 +98,8 @@ class CdnPurge extends Command
// Purge by workspace
if (empty($workspaceArg)) {
$workspaceOptions = ['all', 'Select specific URLs'];
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
$workspaceOptions = array_merge($workspaceOptions, \Core\Tenant\Models\Workspace::pluck('slug')->toArray());
if (class_exists(Workspace::class)) {
$workspaceOptions = array_merge($workspaceOptions, Workspace::pluck('slug')->toArray());
}
$workspaceArg = $this->choice(
'What would you like to purge?',
@ -218,13 +220,13 @@ class CdnPurge extends Command
protected function purgeAllWorkspaces(bool $dryRun): int
{
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
if (! class_exists(Workspace::class)) {
$this->error('Workspace purge requires Tenant module to be installed.');
return self::FAILURE;
}
$workspaces = \Core\Tenant\Models\Workspace::all();
$workspaces = Workspace::all();
if ($workspaces->isEmpty()) {
$this->error('No workspaces found');
@ -276,19 +278,19 @@ class CdnPurge extends Command
protected function purgeWorkspace(string $slug, bool $dryRun): int
{
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
if (! class_exists(Workspace::class)) {
$this->error('Workspace purge requires Tenant module to be installed.');
return self::FAILURE;
}
$workspace = \Core\Tenant\Models\Workspace::where('slug', $slug)->first();
$workspace = Workspace::where('slug', $slug)->first();
if (! $workspace) {
$this->error("Workspace not found: {$slug}");
$this->newLine();
$this->info('Available workspaces:');
\Core\Tenant\Models\Workspace::pluck('slug')->each(fn ($s) => $this->line(" - {$s}"));
Workspace::pluck('slug')->each(fn ($s) => $this->line(" - {$s}"));
return self::FAILURE;
}

View file

@ -13,6 +13,7 @@ namespace Core\Cdn\Console;
use Core\Cdn\Services\FluxCdnService;
use Core\Cdn\Services\StorageUrlResolver;
use Core\Plug\Storage\Bunny\VBucket;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
@ -43,7 +44,7 @@ class PushAssetsToCdn extends Command
public function handle(FluxCdnService $flux, StorageUrlResolver $cdn): int
{
if (! class_exists(\Core\Plug\Storage\Bunny\VBucket::class)) {
if (! class_exists(VBucket::class)) {
$this->error('Push assets to CDN requires Core\Plug\Storage\Bunny\VBucket class. Plug module not installed.');
return self::FAILURE;
@ -54,7 +55,7 @@ class PushAssetsToCdn extends Command
// Create vBucket for workspace isolation
$domain = $this->option('domain');
$this->vbucket = \Core\Plug\Storage\Bunny\VBucket::public($domain);
$this->vbucket = VBucket::public($domain);
$pushFlux = $this->option('flux');
$pushFontawesome = $this->option('fontawesome');

View file

@ -0,0 +1,9 @@
# Cdn/Facades/ — CDN Facade
## Facades
| Facade | Resolves To | Purpose |
|--------|-------------|---------|
| `Cdn` | `StorageUrlResolver` | Static proxy for CDN operations — `cdn()`, `origin()`, `private()`, `signedUrl()`, `asset()`, `pushToCdn()`, `deleteFromCdn()`, `purge()`, `storePublic()`, `storePrivate()`, `vBucketCdn()`, and more. |
Usage: `Cdn::cdn('images/logo.png')` returns the CDN URL for the asset.

View file

@ -42,7 +42,7 @@ use Illuminate\Support\Facades\Facade;
* @method static string vBucketPath(string $domain, string $path)
* @method static array vBucketUrls(string $domain, string $path)
*
* @see \Core\Cdn\Services\StorageUrlResolver
* @see StorageUrlResolver
*/
class Cdn extends Facade
{

View file

@ -0,0 +1,7 @@
# Cdn/Jobs/ — CDN Background Jobs
## Jobs
| Job | Purpose |
|-----|---------|
| `PushAssetToCdn` | Queued job to push a local asset file to the CDN (BunnyCDN). Handles upload, verification, and StorageOffload record creation. |

View file

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Core\Cdn\Jobs;
use Core\Plug\Storage\StorageManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -60,7 +61,7 @@ class PushAssetToCdn implements ShouldQueue
*/
public function handle(?object $storage = null): void
{
if (! class_exists(\Core\Plug\Storage\StorageManager::class)) {
if (! class_exists(StorageManager::class)) {
Log::warning('PushAssetToCdn: StorageManager not available, Plug module not installed');
return;
@ -68,7 +69,7 @@ class PushAssetToCdn implements ShouldQueue
// Resolve from container if not injected
if ($storage === null) {
$storage = app(\Core\Plug\Storage\StorageManager::class);
$storage = app(StorageManager::class);
}
if (! config('cdn.bunny.push_enabled', false)) {

View file

@ -0,0 +1,8 @@
# Cdn/Middleware/ — CDN HTTP Middleware
## Middleware
| Class | Purpose |
|-------|---------|
| `LocalCdnMiddleware` | Adds aggressive caching headers and compression for requests on the `cdn.*` subdomain. Provides CDN-like behaviour without external services. |
| `RewriteOffloadedUrls` | Processes JSON responses and replaces local storage paths with remote equivalents when files have been offloaded to external storage. |

View file

@ -0,0 +1,7 @@
# Cdn/Models/ — CDN Storage Models
## Models
| Model | Purpose |
|-------|---------|
| `StorageOffload` | Tracks files offloaded to remote storage. Records local path, remote path, disk, SHA-256 hash, file size, MIME type, category, metadata, and offload timestamp. Used by the URL rewriting middleware. |

View file

@ -13,6 +13,7 @@ namespace Core\Cdn\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Tracks files that have been offloaded to remote storage.
@ -26,9 +27,9 @@ use Illuminate\Database\Eloquent\Model;
* @property string|null $mime_type MIME type
* @property string|null $category Category for path prefixing
* @property array|null $metadata Additional metadata
* @property \Illuminate\Support\Carbon|null $offloaded_at When file was offloaded
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property Carbon|null $offloaded_at When file was offloaded
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
class StorageOffload extends Model
{

View file

@ -13,6 +13,7 @@ namespace Core\Cdn\Services;
use Core\Cdn\Jobs\PushAssetToCdn;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
@ -339,7 +340,7 @@ class AssetPipeline
PushAssetToCdn::dispatch($disk, $path, $zone);
} elseif ($this->storage !== null) {
// Synchronous push if no queue configured (requires StorageManager from Plug module)
$diskInstance = \Illuminate\Support\Facades\Storage::disk($disk);
$diskInstance = Storage::disk($disk);
if ($diskInstance->exists($path)) {
$contents = $diskInstance->get($path);
$this->storage->zone($zone)->upload()->contents($path, $contents);

View file

@ -0,0 +1,13 @@
# Cdn/Services/ — CDN Service Layer
## Services
| Service | Purpose |
|---------|---------|
| `BunnyCdnService` | BunnyCDN pull zone API — cache purging (URL, tag, workspace, global), statistics retrieval, pull zone management. Uses config from `ConfigService`. |
| `BunnyStorageService` | BunnyCDN storage zone API — file upload, download, delete, list. Supports public and private storage zones. |
| `StorageOffload` (service) | Manages file offloading to remote storage — upload, track, verify. Creates `StorageOffload` model records. |
| `StorageUrlResolver` | URL builder for all asset contexts — CDN, origin, private, signed, apex. Supports virtual buckets (vBucket) per domain. Backs the `Cdn` facade. |
| `CdnUrlBuilder` | Low-level URL construction for CDN paths with cache-busting and domain resolution. |
| `AssetPipeline` | Orchestrates asset processing — push to CDN, cache headers, versioning. |
| `FluxCdnService` | Pushes Flux UI assets to CDN for faster component loading. |

View file

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Core\Cdn\Services;
use Core\Helpers\Cdn;
use Flux\AssetManager;
use Flux\Flux;
/**
@ -83,7 +84,7 @@ class FluxCdnService
// Use CDN when enabled (respects CDN_FORCE_LOCAL for testing)
if (! $this->shouldUseCdn()) {
return \Flux\AssetManager::editorScripts();
return AssetManager::editorScripts();
}
// In production, use CDN URL (no vBucket - shared platform asset)
@ -109,7 +110,7 @@ class FluxCdnService
// Use CDN when enabled (respects CDN_FORCE_LOCAL for testing)
if (! $this->shouldUseCdn()) {
return \Flux\AssetManager::editorStyles();
return AssetManager::editorStyles();
}
// In production, use CDN URL (no vBucket - shared platform asset)

View file

@ -13,6 +13,7 @@ namespace Core\Cdn\Services;
use Core\Cdn\Models\StorageOffload as OffloadModel;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@ -305,7 +306,7 @@ class StorageOffload
/**
* Get all offloaded files for a category.
*
* @return \Illuminate\Database\Eloquent\Collection<OffloadModel>
* @return Collection<OffloadModel>
*/
public function getByCategory(string $category)
{

View file

@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Core\Cdn\Services;
use Carbon\Carbon;
use Core\Cdn\Jobs\PushAssetToCdn;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Storage;
@ -372,7 +374,7 @@ class StorageUrlResolver
/**
* Get the public storage disk.
*
* @return \Illuminate\Contracts\Filesystem\Filesystem
* @return Filesystem
*/
public function publicDisk()
{
@ -382,7 +384,7 @@ class StorageUrlResolver
/**
* Get the private storage disk.
*
* @return \Illuminate\Contracts\Filesystem\Filesystem
* @return Filesystem
*/
public function privateDisk()
{
@ -403,7 +405,7 @@ class StorageUrlResolver
if ($stored && $pushToCdn && config('cdn.pipeline.auto_push', true)) {
// Queue the push if configured, otherwise push synchronously
if ($queue = config('cdn.pipeline.queue')) {
dispatch(new \Core\Cdn\Jobs\PushAssetToCdn('hetzner-public', $path, 'public'))->onQueue($queue);
dispatch(new PushAssetToCdn('hetzner-public', $path, 'public'))->onQueue($queue);
} else {
$this->pushToCdn('hetzner-public', $path, 'public');
}
@ -425,7 +427,7 @@ class StorageUrlResolver
if ($stored && $pushToCdn && config('cdn.pipeline.auto_push', true)) {
if ($queue = config('cdn.pipeline.queue')) {
dispatch(new \Core\Cdn\Jobs\PushAssetToCdn('hetzner-private', $path, 'private'))->onQueue($queue);
dispatch(new PushAssetToCdn('hetzner-private', $path, 'private'))->onQueue($queue);
} else {
$this->pushToCdn('hetzner-private', $path, 'private');
}

View file

@ -0,0 +1,7 @@
# Cdn/Traits/ — CDN Model Traits
## Traits
| Trait | Purpose |
|-------|---------|
| `HasCdnUrls` | For models with asset paths needing CDN URL resolution. Requires `$cdnPathAttribute` (attribute with storage path) and optional `$cdnBucket` (`public` or `private`). Provides `cdnUrl()` accessor. |

70
src/Core/Config/CLAUDE.md Normal file
View file

@ -0,0 +1,70 @@
# Config
Database-backed configuration with scoping, versioning, profiles, and admin UI.
## What It Does
Replaces/supplements Laravel's file-based config with a DB-backed system supporting:
- Hierarchical scope resolution (global -> workspace -> user)
- Configuration profiles (sets of values that can be switched)
- Version history with diffs
- Sensitive value encryption
- Import/export (JSON/YAML)
- Livewire admin panels
- Event-driven invalidation
## Key Classes
| Class | Purpose |
|-------|---------|
| `Boot` | Listens to `AdminPanelBooting` and `ConsoleBooting` for registration |
| `ConfigService` | Primary API: `get()`, `set()`, `isConfigured()`, plus scope-aware resolution |
| `ConfigResolver` | Resolves values through scope hierarchy: user -> workspace -> global -> default |
| `ConfigResult` | DTO wrapping resolved value with metadata (source scope, profile, etc.) |
| `ConfigVersioning` | Tracks changes with diffs between versions |
| `VersionDiff` | Computes and formats diffs between config versions |
| `ConfigExporter` | Export/import config as JSON/YAML |
| `ImportResult` | DTO for import operation results |
## Models
| Model | Table | Purpose |
|-------|-------|---------|
| `ConfigKey` | `config_keys` | Key definitions with type, default, validation rules, `is_sensitive` flag |
| `ConfigValue` | `config_values` | Actual values scoped by type (global/workspace/user) |
| `ConfigProfile` | `config_profiles` | Named sets of config values (soft-deletable) |
| `ConfigVersion` | `config_versions` | Version history snapshots |
| `ConfigResolved` | -- | Value object for resolved config |
| `Channel` | -- | Notification channel config |
## Enums
- `ConfigType` -- Value types (string, int, bool, json, etc.)
- `ScopeType` -- Resolution scopes (global, workspace, user)
## Events
- `ConfigChanged` -- Fired when any config value changes
- `ConfigInvalidated` -- Fired when cache needs clearing
- `ConfigLocked` -- Fired when a config key is locked
## Console Commands
- `config:prime` -- Pre-populate config cache
- `config:list` -- List all config keys and values
- `config:version` -- Show version history
- `config:import` -- Import config from file
- `config:export` -- Export config to file
## Admin UI
- `ConfigPanel` (Livewire) -- General config editing panel
- `WorkspaceConfig` (Livewire) -- Workspace-specific config panel
- Routes registered under admin prefix
## Integration
- `ConfigService` is used by other subsystems (e.g., `BunnyCdnService` reads CDN credentials via `$this->config->get('cdn.bunny.api_key')`)
- Sensitive keys (`is_sensitive = true`) are encrypted at rest
- Seeder: `ConfigKeySeeder` populates default keys
- 4 migrations covering base tables, soft deletes, versions, and sensitive flag

View file

@ -0,0 +1,13 @@
# Config/Console/ — Config Artisan Commands
Artisan commands for managing the hierarchical configuration system.
## Commands
| Command | Signature | Purpose |
|---------|-----------|---------|
| `ConfigListCommand` | `config:list` | List config keys with resolved values. Filters by workspace, category, or configured-only. |
| `ConfigPrimeCommand` | `config:prime` | Materialise resolved config into the fast-read table. Primes system and/or specific workspace. |
| `ConfigExportCommand` | `config:export` | Export config to JSON or YAML file. Supports workspace scope and sensitive value inclusion. |
| `ConfigImportCommand` | `config:import` | Import config from JSON or YAML file. Supports dry-run mode and automatic version snapshots. |
| `ConfigVersionCommand` | `config:version` | Manage config versions — list, create snapshots, show, rollback, and compare versions. |

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace Core\Config\Console;
use Core\Config\ConfigExporter;
use Core\Config\Models\ConfigKey;
use Core\Tenant\Models\Workspace;
use Illuminate\Console\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
/**
* Export config to JSON or YAML file.
@ -45,13 +48,13 @@ class ConfigExportCommand extends Command
// Resolve workspace
$workspace = null;
if ($workspaceSlug) {
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
if (! class_exists(Workspace::class)) {
$this->components->error('Tenant module not installed. Cannot export workspace config.');
return self::FAILURE;
}
$workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
$workspace = Workspace::where('slug', $workspaceSlug)->first();
if (! $workspace) {
$this->components->error("Workspace not found: {$workspaceSlug}");
@ -96,16 +99,16 @@ class ConfigExportCommand extends Command
/**
* Get autocompletion suggestions.
*/
public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions): void
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestOptionValuesFor('workspace')) {
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
$suggestions->suggestValues(\Core\Tenant\Models\Workspace::pluck('slug')->toArray());
if (class_exists(Workspace::class)) {
$suggestions->suggestValues(Workspace::pluck('slug')->toArray());
}
}
if ($input->mustSuggestOptionValuesFor('category')) {
$suggestions->suggestValues(\Core\Config\Models\ConfigKey::distinct()->pluck('category')->toArray());
$suggestions->suggestValues(ConfigKey::distinct()->pluck('category')->toArray());
}
}
}

View file

@ -13,8 +13,10 @@ namespace Core\Config\Console;
use Core\Config\ConfigExporter;
use Core\Config\ConfigVersioning;
use Core\Tenant\Models\Workspace;
use Illuminate\Console\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
/**
* Import config from JSON or YAML file.
@ -53,13 +55,13 @@ class ConfigImportCommand extends Command
// Resolve workspace
$workspace = null;
if ($workspaceSlug) {
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
if (! class_exists(Workspace::class)) {
$this->components->error('Tenant module not installed. Cannot import workspace config.');
return self::FAILURE;
}
$workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
$workspace = Workspace::where('slug', $workspaceSlug)->first();
if (! $workspace) {
$this->components->error("Workspace not found: {$workspaceSlug}");
@ -174,11 +176,11 @@ class ConfigImportCommand extends Command
/**
* Get autocompletion suggestions.
*/
public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions): void
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestOptionValuesFor('workspace')) {
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
$suggestions->suggestValues(\Core\Tenant\Models\Workspace::pluck('slug')->toArray());
if (class_exists(Workspace::class)) {
$suggestions->suggestValues(Workspace::pluck('slug')->toArray());
}
}
}

View file

@ -13,6 +13,7 @@ namespace Core\Config\Console;
use Core\Config\ConfigService;
use Core\Config\Models\ConfigKey;
use Core\Tenant\Models\Workspace;
use Illuminate\Console\Command;
class ConfigListCommand extends Command
@ -33,13 +34,13 @@ class ConfigListCommand extends Command
$workspace = null;
if ($workspaceSlug) {
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
if (! class_exists(Workspace::class)) {
$this->error('Tenant module not installed. Cannot filter by workspace.');
return self::FAILURE;
}
$workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
$workspace = Workspace::where('slug', $workspaceSlug)->first();
if (! $workspace) {
$this->error("Workspace not found: {$workspaceSlug}");

View file

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Core\Config\Console;
use Core\Config\ConfigService;
use Core\Tenant\Models\Workspace;
use Illuminate\Console\Command;
class ConfigPrimeCommand extends Command
@ -36,13 +37,13 @@ class ConfigPrimeCommand extends Command
}
if ($workspaceSlug) {
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
if (! class_exists(Workspace::class)) {
$this->error('Tenant module not installed. Cannot prime workspace config.');
return self::FAILURE;
}
$workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
$workspace = Workspace::where('slug', $workspaceSlug)->first();
if (! $workspace) {
$this->error("Workspace not found: {$workspaceSlug}");
@ -59,7 +60,7 @@ class ConfigPrimeCommand extends Command
$this->info('Priming config cache for all workspaces...');
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
if (! class_exists(Workspace::class)) {
$this->warn('Tenant module not installed. Only priming system config.');
$config->prime(null);
$this->info('System config cached.');
@ -67,7 +68,7 @@ class ConfigPrimeCommand extends Command
return self::SUCCESS;
}
$this->withProgressBar(\Core\Tenant\Models\Workspace::all(), function ($workspace) use ($config) {
$this->withProgressBar(Workspace::all(), function ($workspace) use ($config) {
$config->prime($workspace);
});

View file

@ -13,8 +13,11 @@ namespace Core\Config\Console;
use Core\Config\ConfigVersioning;
use Core\Config\Models\ConfigVersion;
use Core\Config\VersionDiff;
use Core\Tenant\Models\Workspace;
use Illuminate\Console\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
/**
* Manage config versions.
@ -50,13 +53,13 @@ class ConfigVersionCommand extends Command
// Resolve workspace
$workspace = null;
if ($workspaceSlug) {
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
if (! class_exists(Workspace::class)) {
$this->components->error('Tenant module not installed. Cannot manage workspace versions.');
return self::FAILURE;
}
$workspace = \Core\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
$workspace = Workspace::where('slug', $workspaceSlug)->first();
if (! $workspace) {
$this->components->error("Workspace not found: {$workspaceSlug}");
@ -282,7 +285,7 @@ class ConfigVersionCommand extends Command
/**
* Display a diff.
*/
protected function displayDiff(\Core\Config\VersionDiff $diff): void
protected function displayDiff(VersionDiff $diff): void
{
$this->components->info("Summary: {$diff->getSummary()}");
$this->newLine();
@ -402,15 +405,15 @@ class ConfigVersionCommand extends Command
/**
* Get autocompletion suggestions.
*/
public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions): void
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('action')) {
$suggestions->suggestValues(['list', 'create', 'show', 'rollback', 'compare', 'diff', 'delete']);
}
if ($input->mustSuggestOptionValuesFor('workspace')) {
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
$suggestions->suggestValues(\Core\Tenant\Models\Workspace::pluck('slug')->toArray());
if (class_exists(Workspace::class)) {
$suggestions->suggestValues(Workspace::pluck('slug')->toArray());
}
}
}

View file

@ -0,0 +1,9 @@
# Config/Contracts/ — Config System Interfaces
## Interfaces
| Interface | Purpose |
|-----------|---------|
| `ConfigProvider` | Virtual configuration provider. Supplies config values at runtime without database storage. Matched against key patterns (e.g., `bio.*`). Registered via `ConfigResolver::registerProvider()`. |
Providers implement `pattern()` (wildcard key matching) and `resolve()` (returns the value for a given key, workspace, and channel context). Returns `null` to fall through to database resolution.

View file

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Core\Config\Contracts;
use Core\Config\ConfigResolver;
use Core\Config\Models\Channel;
/**
@ -72,7 +73,7 @@ use Core\Config\Models\Channel;
* ```
*
*
* @see \Core\Config\ConfigResolver::registerProvider()
* @see ConfigResolver::registerProvider()
*/
interface ConfigProvider
{

View file

@ -0,0 +1,9 @@
# Config/Database/Seeders/ — Config Key Seeders
## Files
| File | Purpose |
|------|---------|
| `ConfigKeySeeder.php` | Seeds known configuration keys into the `config_keys` table. Defines CDN (Bunny), storage (Hetzner S3), social, analytics, and bio settings with their types, categories, and defaults. Uses `firstOrCreate` for idempotency. |
Part of the Config subsystem's M1 layer (key definitions). These keys are then assigned values via `ConfigValue` at system or workspace scope.

View file

@ -0,0 +1,12 @@
# Config/Enums/ — Config Type System
Backed enums for the configuration system's type safety and scope hierarchy.
## Enums
| Enum | Values | Purpose |
|------|--------|---------|
| `ConfigType` | STRING, BOOL, INT, FLOAT, ARRAY, JSON | Determines how config values are cast and validated. Has `cast()` and `default()` methods. |
| `ScopeType` | SYSTEM, ORG, WORKSPACE | Defines the inheritance hierarchy. Resolution order: workspace (priority 20) > org (10) > system (0). |
`ScopeType::resolutionOrder()` returns scopes from most specific to least specific for cascade resolution.

View file

@ -0,0 +1,13 @@
# Config/Events/ — Config System Events
Events dispatched by the configuration system for reactive integration.
## Events
| Event | Fired When | Key Properties |
|-------|-----------|----------------|
| `ConfigChanged` | A config value is set or updated via `ConfigService::set()` | `keyCode`, `value`, `previousValue`, `profile`, `channelId` |
| `ConfigInvalidated` | Config cache is manually cleared | `keyCode` (null = all), `workspaceId`, `channelId`. Has `isFull()` and `affectsKey()` helpers. |
| `ConfigLocked` | A config value is locked (FINAL) | `keyCode`, `profile`, `channelId` |
Modules can listen to these events via the standard `$listens` pattern in their Boot class to react to config changes (e.g., refreshing CDN clients, flushing caches).

View file

@ -0,0 +1,14 @@
# Config/Migrations/ — Config Schema Migrations
Database migrations for the hierarchical configuration system.
## Migrations
| File | Purpose |
|------|---------|
| `0001_01_01_000001_create_config_tables.php` | Creates core config tables: `config_keys`, `config_profiles`, `config_values`, `config_channels`, `config_resolved`. |
| `0001_01_01_000002_add_soft_deletes_to_config_profiles.php` | Adds soft delete support to `config_profiles`. |
| `0001_01_01_000003_add_is_sensitive_to_config_keys.php` | Adds `is_sensitive` flag for automatic encryption of values. |
| `0001_01_01_000004_create_config_versions_table.php` | Creates `config_versions` table for point-in-time snapshots and rollback. |
Uses early timestamps (`0001_01_01_*`) to run before application migrations.

View file

@ -0,0 +1,22 @@
# Config/Models/ — Config Eloquent Models
Eloquent models implementing the four-layer hierarchical configuration system.
## Models
| Model | Table | Purpose |
|-------|-------|---------|
| `ConfigKey` | `config_keys` | M1 layer — defines what keys exist. Dot-notation codes, typed (`ConfigType`), categorised. Supports sensitive flag for auto-encryption. Hierarchical parent/child grouping. |
| `ConfigProfile` | `config_profiles` | M2 layer — groups values at a scope level (system/org/workspace). Inherits from parent profiles. Soft-deletable. |
| `ConfigValue` | `config_values` | Junction table linking profiles to keys with actual values. `locked` flag implements FINAL (prevents child override). Auto-encrypts sensitive keys. Invalidates resolver hash on write. |
| `ConfigVersion` | `config_versions` | Point-in-time snapshots for version history and rollback. Immutable (no `updated_at`). Stores JSON snapshot of all values. |
| `Channel` | `config_channels` | Context dimension (web, api, mobile, instagram, etc.). Hierarchical inheritance chain with cycle detection. System or workspace-scoped. |
| `ConfigResolved` | `config_resolved` | Materialised READ table — all lookups hit this directly. No computation at read time. Populated by the `prime` operation. Composite key (workspace_id, channel_id, key_code). |
## Resolution Flow
```
ConfigService::get() → ConfigResolved (fast lookup)
→ miss: ConfigResolver computes from ConfigValue chain
→ stores result back to ConfigResolved + in-memory hash
```

View file

@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Core\Config\Models;
use Carbon\Carbon;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -35,8 +37,8 @@ use Illuminate\Support\Facades\Log;
* @property int|null $parent_id
* @property int|null $workspace_id
* @property array|null $metadata
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Channel extends Model
{
@ -77,8 +79,8 @@ class Channel extends Model
*/
public function workspace(): BelongsTo
{
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
return $this->belongsTo(\Core\Tenant\Models\Workspace::class);
if (class_exists(Workspace::class)) {
return $this->belongsTo(Workspace::class);
}
// Return a null relationship when Tenant module is not installed

View file

@ -11,7 +11,9 @@ declare(strict_types=1);
namespace Core\Config\Models;
use Carbon\Carbon;
use Core\Config\Enums\ConfigType;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -30,8 +32,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string|null $description
* @property mixed $default_value
* @property bool $is_sensitive
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class ConfigKey extends Model
{
@ -108,9 +110,9 @@ class ConfigKey extends Model
/**
* Get all keys for a category.
*
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function forCategory(string $category): \Illuminate\Database\Eloquent\Collection
public static function forCategory(string $category): Collection
{
return static::where('category', $category)->get();
}

View file

@ -11,7 +11,9 @@ declare(strict_types=1);
namespace Core\Config\Models;
use Carbon\Carbon;
use Core\Config\Enums\ScopeType;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -29,9 +31,9 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int|null $scope_id
* @property int|null $parent_profile_id
* @property int $priority
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property \Carbon\Carbon|null $deleted_at
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Carbon|null $deleted_at
*/
class ConfigProfile extends Model
{
@ -90,9 +92,9 @@ class ConfigProfile extends Model
/**
* Get profiles for a scope.
*
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function forScope(ScopeType $type, ?int $scopeId = null): \Illuminate\Database\Eloquent\Collection
public static function forScope(ScopeType $type, ?int $scopeId = null): Collection
{
return static::where('scope_type', $type)
->where('scope_id', $scopeId)

View file

@ -11,8 +11,11 @@ declare(strict_types=1);
namespace Core\Config\Models;
use Carbon\Carbon;
use Core\Config\ConfigResult;
use Core\Config\Enums\ConfigType;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -36,7 +39,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int|null $source_profile_id
* @property int|null $source_channel_id
* @property bool $virtual
* @property \Carbon\Carbon $computed_at
* @property Carbon $computed_at
*/
class ConfigResolved extends Model
{
@ -71,8 +74,8 @@ class ConfigResolved extends Model
*/
public function workspace(): BelongsTo
{
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
return $this->belongsTo(\Core\Tenant\Models\Workspace::class);
if (class_exists(Workspace::class)) {
return $this->belongsTo(Workspace::class);
}
// Return a null relationship when Tenant module is not installed
@ -155,9 +158,9 @@ class ConfigResolved extends Model
/**
* Get all resolved config for a scope.
*
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function forScope(?int $workspaceId = null, ?int $channelId = null): \Illuminate\Database\Eloquent\Collection
public static function forScope(?int $workspaceId = null, ?int $channelId = null): Collection
{
return static::where('workspace_id', $workspaceId)
->where('channel_id', $channelId)

View file

@ -11,8 +11,11 @@ declare(strict_types=1);
namespace Core\Config\Models;
use Carbon\Carbon;
use Core\Config\ConfigResolver;
use Core\Config\Enums\ScopeType;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
@ -31,8 +34,8 @@ use Illuminate\Support\Facades\Crypt;
* @property mixed $value
* @property bool $locked
* @property int|null $inherited_from
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class ConfigValue extends Model
{
@ -76,7 +79,7 @@ class ConfigValue extends Model
$encrypted = substr($decoded, strlen(self::ENCRYPTED_PREFIX));
return json_decode(Crypt::decryptString($encrypted), true);
} catch (\Illuminate\Contracts\Encryption\DecryptException) {
} catch (DecryptException) {
// Return null if decryption fails (key rotation, corruption, etc.)
return null;
}
@ -255,9 +258,9 @@ class ConfigValue extends Model
*
* @param array<int> $profileIds
* @param array<int>|null $channelIds Include null for "all channels" values
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function forKeyInProfiles(int $keyId, array $profileIds, ?array $channelIds = null): \Illuminate\Database\Eloquent\Collection
public static function forKeyInProfiles(int $keyId, array $profileIds, ?array $channelIds = null): Collection
{
return static::where('key_id', $keyId)
->whereIn('profile_id', $profileIds)

View file

@ -11,6 +11,9 @@ declare(strict_types=1);
namespace Core\Config\Models;
use Carbon\Carbon;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -26,7 +29,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $label
* @property string $snapshot
* @property string|null $author
* @property \Carbon\Carbon $created_at
* @property Carbon $created_at
*/
class ConfigVersion extends Model
{
@ -65,8 +68,8 @@ class ConfigVersion extends Model
*/
public function workspace(): BelongsTo
{
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
return $this->belongsTo(\Core\Tenant\Models\Workspace::class);
if (class_exists(Workspace::class)) {
return $this->belongsTo(Workspace::class);
}
// Return a null relationship when Tenant module is not installed
@ -136,9 +139,9 @@ class ConfigVersion extends Model
* Get versions for a scope.
*
* @param int|null $workspaceId Workspace ID or null for system
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function forScope(?int $workspaceId = null): \Illuminate\Database\Eloquent\Collection
public static function forScope(?int $workspaceId = null): Collection
{
return static::where('workspace_id', $workspaceId)
->orderByDesc('created_at')
@ -160,9 +163,9 @@ class ConfigVersion extends Model
/**
* Get versions created by a specific author.
*
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @return Collection<int, self>
*/
public static function byAuthor(string $author): \Illuminate\Database\Eloquent\Collection
public static function byAuthor(string $author): Collection
{
return static::where('author', $author)
->orderByDesc('created_at')
@ -172,11 +175,11 @@ class ConfigVersion extends Model
/**
* Get versions created within a date range.
*
* @param \Carbon\Carbon $from Start date
* @param \Carbon\Carbon $to End date
* @return \Illuminate\Database\Eloquent\Collection<int, self>
* @param Carbon $from Start date
* @param Carbon $to End date
* @return Collection<int, self>
*/
public static function inDateRange(\Carbon\Carbon $from, \Carbon\Carbon $to): \Illuminate\Database\Eloquent\Collection
public static function inDateRange(Carbon $from, Carbon $to): Collection
{
return static::whereBetween('created_at', [$from, $to])
->orderByDesc('created_at')

View file

@ -0,0 +1,7 @@
# Config/Routes/ — Config Admin Routes
## Files
| File | Purpose |
|------|---------|
| `admin.php` | Admin route definitions for the configuration panel. Registers routes under the `admin` middleware group for the `ConfigPanel` and `WorkspaceConfig` Livewire components. |

View file

@ -0,0 +1,11 @@
# Config/Tests/Feature/ — Config Integration Tests
Pest feature tests for the hierarchical configuration system.
## Test Files
| File | Purpose |
|------|---------|
| `ConfigServiceTest.php` | Full integration tests covering ConfigKey creation, ConfigProfile inheritance, ConfigResolver scope cascading, FINAL lock enforcement, ConfigService materialised reads/writes, ConfigResolved storage, and the single-hash lazy-load pattern. |
Tests cover the complete config lifecycle: key definition, profile hierarchy (system/workspace), value resolution with inheritance, lock semantics, cache invalidation, and the prime/materialise flow.

View file

@ -19,8 +19,9 @@ use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigResolved;
use Core\Config\Models\ConfigValue;
use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
uses(RefreshDatabase::class);
beforeEach(function () {
// Clear hash for clean test state

View file

@ -0,0 +1,12 @@
# Config/View/Blade/admin/ — Config Admin Blade Templates
Blade templates for the admin configuration panel.
## Templates
| File | Purpose |
|------|---------|
| `config-panel.blade.php` | Full config management panel — browse keys by category, edit values, toggle locks, manage system vs workspace scopes. Used by `ConfigPanel` Livewire component. |
| `workspace-config.blade.php` | Workspace-specific config panel — hierarchical namespace navigation, tab grouping, value editing with system inheritance display. Used by `WorkspaceConfig` Livewire component. |
Both templates use the `hub::admin.layouts.app` layout and are rendered via the `core.config::admin.*` view namespace.

View file

@ -0,0 +1,12 @@
# Config/View/Modal/Admin/ — Config Admin Livewire Components
Livewire components for the admin configuration interface.
## Components
| Component | Purpose |
|-----------|---------|
| `ConfigPanel` | Hades-only config management. Browse/search keys by category, edit values inline, toggle FINAL locks, manage system and workspace scopes. Respects parent lock enforcement. |
| `WorkspaceConfig` | Workspace-scoped settings. Hierarchical namespace navigation (cdn/bunny/storage), tab grouping by second-level prefix, value editing with inherited value display, system lock indicators. |
Both require the Tenant module for workspace support and fall back gracefully without it. `ConfigPanel` requires Hades (super-admin) access. Values are persisted via `ConfigService`.

View file

@ -15,6 +15,9 @@ use Core\Config\ConfigService;
use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigValue;
use Core\Tenant\Models\Workspace;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
@ -24,7 +27,7 @@ use Livewire\Component;
*
* @property-read ConfigProfile $activeProfile
* @property-read array<string> $categories
* @property-read \Illuminate\Database\Eloquent\Collection $workspaces
* @property-read Collection $workspaces
*/
class ConfigPanel extends Component
{
@ -79,17 +82,17 @@ class ConfigPanel extends Component
* Get all workspaces (requires Tenant module).
*/
#[Computed]
public function workspaces(): \Illuminate\Database\Eloquent\Collection
public function workspaces(): Collection
{
if (! class_exists(\Core\Tenant\Models\Workspace::class)) {
return new \Illuminate\Database\Eloquent\Collection;
if (! class_exists(Workspace::class)) {
return new Collection;
}
return \Core\Tenant\Models\Workspace::orderBy('name')->get();
return Workspace::orderBy('name')->get();
}
#[Computed]
public function keys(): \Illuminate\Database\Eloquent\Collection
public function keys(): Collection
{
return ConfigKey::query()
->when($this->category, fn ($q) => $q->where('category', $this->category))
@ -119,8 +122,8 @@ class ConfigPanel extends Component
#[Computed]
public function selectedWorkspace(): ?object
{
if ($this->workspaceId && class_exists(\Core\Tenant\Models\Workspace::class)) {
return \Core\Tenant\Models\Workspace::find($this->workspaceId);
if ($this->workspaceId && class_exists(Workspace::class)) {
return Workspace::find($this->workspaceId);
}
return null;
@ -271,7 +274,7 @@ class ConfigPanel extends Component
$this->dispatch('config-cleared');
}
public function render(): \Illuminate\Contracts\View\View
public function render(): View
{
return view('core.config::admin.config-panel')
->layout('hub::admin.layouts.app', ['title' => 'Configuration']);

View file

@ -15,6 +15,9 @@ use Core\Config\ConfigService;
use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigValue;
use Core\Tenant\Services\WorkspaceService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
@ -28,7 +31,7 @@ use Livewire\Component;
* @property-read ConfigProfile $systemProfile
* @property-read object|null $workspace
* @property-read string $prefix
* @property-read array<int, array{namespace: string, label: string, keys: \Illuminate\Support\Collection}> $tabs
* @property-read array<int, array{namespace: string, label: string, keys: Collection}> $tabs
*/
class WorkspaceConfig extends Component
{
@ -46,8 +49,8 @@ class WorkspaceConfig extends Component
$this->config = $config;
// Try to resolve WorkspaceService if Tenant module is installed
if (class_exists(\Core\Tenant\Services\WorkspaceService::class)) {
$this->workspaceService = app(\Core\Tenant\Services\WorkspaceService::class);
if (class_exists(WorkspaceService::class)) {
$this->workspaceService = app(WorkspaceService::class);
}
}
@ -277,7 +280,7 @@ class WorkspaceConfig extends Component
$this->dispatch('config-cleared');
}
public function render(): \Illuminate\Contracts\View\View
public function render(): View
{
return view('core.config::admin.workspace-config')
->layout('hub::admin.layouts.app', ['title' => 'Settings']);

View file

@ -0,0 +1,32 @@
# Core\Console
Framework artisan commands registered via the `ConsoleBooting` lifecycle event.
## Boot
Uses the event-driven module loading pattern:
```php
public static array $listens = [
ConsoleBooting::class => 'onConsole',
];
```
## Commands
| Command | Signature | Purpose |
|---------|-----------|---------|
| `InstallCommand` | `core:install` | Framework setup wizard: env file, app config, migrations, app key, storage link. Supports `--dry-run` and `--force` |
| `NewProjectCommand` | `core:new` | Scaffold a new project |
| `MakeModCommand` | `make:mod {name}` | Generate a module in the `Mod` namespace with Boot.php. Flags: `--web`, `--admin`, `--api`, `--console`, `--all` |
| `MakePlugCommand` | `make:plug` | Generate a plugin scaffold |
| `MakeWebsiteCommand` | `make:website` | Generate a Website module scaffold |
| `PruneEmailShieldStatsCommand` | prunes `email_shield_stats` | Cleans old EmailShield validation stats |
| `ScheduleSyncCommand` | schedule sync | Schedule synchronisation |
## Conventions
- All commands use `declare(strict_types=1)` and the `Core\Console\Commands` namespace.
- `MakeModCommand` generates a complete module scaffold with optional handler stubs (web routes, admin panel, API, console).
- `InstallCommand` tracks progress via named installation steps and supports dry-run mode.
- Commands are registered via `$event->command()` on the `ConsoleBooting` event, not via a service provider's `$this->commands()`.

View file

@ -0,0 +1,15 @@
# Console/Commands/ — Core Framework Commands
Artisan commands for framework scaffolding and maintenance.
## Commands
| Command | Signature | Purpose |
|---------|-----------|---------|
| `InstallCommand` | `core:install` | Framework installation wizard — sets up sensible defaults for new projects. |
| `MakeModCommand` | `core:make-mod` | Generates a new module scaffold in the `Mod` namespace with Boot.php event-driven loading pattern. |
| `MakePlugCommand` | `core:make-plug` | Generates a new plugin scaffold in the `Plug` namespace. |
| `MakeWebsiteCommand` | `core:make-website` | Generates a new website module scaffold in the `Website` namespace. |
| `NewProjectCommand` | `core:new` | Creates a complete new project from the Core PHP template. |
| `PruneEmailShieldStatsCommand` | `emailshield:prune` | Prunes old EmailShield validation statistics. |
| `ScheduleSyncCommand` | `core:schedule-sync` | Synchronises scheduled tasks across the application. |

View file

@ -13,6 +13,8 @@ namespace Core\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
/**
* Core PHP Framework Installation Command.
@ -484,8 +486,8 @@ class InstallCommand extends Command
* but implements the method for consistency with other commands.
*/
public function complete(
\Symfony\Component\Console\Completion\CompletionInput $input,
\Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
CompletionInput $input,
CompletionSuggestions $suggestions
): void {
// No argument/option values need completion for this command
// All options are flags (--force, --no-interaction, --dry-run)

View file

@ -14,6 +14,8 @@ namespace Core\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
/**
* Generate a new module scaffold.
@ -508,8 +510,8 @@ BLADE;
* Get shell completion suggestions for arguments.
*/
public function complete(
\Symfony\Component\Console\Completion\CompletionInput $input,
\Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
CompletionInput $input,
CompletionSuggestions $suggestions
): void {
if ($input->mustSuggestArgumentValuesFor('name')) {
// Suggest common module naming patterns

View file

@ -14,6 +14,8 @@ namespace Core\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
/**
* Generate a new Plug provider scaffold.
@ -604,8 +606,8 @@ PHP;
* Get shell completion suggestions for arguments and options.
*/
public function complete(
\Symfony\Component\Console\Completion\CompletionInput $input,
\Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
CompletionInput $input,
CompletionSuggestions $suggestions
): void {
if ($input->mustSuggestArgumentValuesFor('name')) {
// Suggest common social platform names

View file

@ -14,6 +14,8 @@ namespace Core\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
/**
* Generate a new Website scaffold.
@ -574,8 +576,8 @@ BLADE;
* Get shell completion suggestions for arguments and options.
*/
public function complete(
\Symfony\Component\Console\Completion\CompletionInput $input,
\Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
CompletionInput $input,
CompletionSuggestions $suggestions
): void {
if ($input->mustSuggestArgumentValuesFor('name')) {
// Suggest common website naming patterns

View file

@ -15,6 +15,8 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
/**
* Create a new Core PHP Framework project.
@ -343,8 +345,8 @@ class NewProjectCommand extends Command
* Get shell completion suggestions.
*/
public function complete(
\Symfony\Component\Console\Completion\CompletionInput $input,
\Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
CompletionInput $input,
CompletionSuggestions $suggestions
): void {
if ($input->mustSuggestArgumentValuesFor('name')) {
// Suggest common project naming patterns

View file

@ -13,6 +13,8 @@ namespace Core\Console\Commands;
use Core\Mail\EmailShieldStat;
use Illuminate\Console\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
/**
* Prune old Email Shield statistics records.
@ -135,8 +137,8 @@ class PruneEmailShieldStatsCommand extends Command
* Get shell completion suggestions for options.
*/
public function complete(
\Symfony\Component\Console\Completion\CompletionInput $input,
\Symfony\Component\Console\Completion\CompletionSuggestions $suggestions
CompletionInput $input,
CompletionSuggestions $suggestions
): void {
if ($input->mustSuggestOptionValuesFor('days')) {
// Suggest common retention periods

65
src/Core/Crypt/CLAUDE.md Normal file
View file

@ -0,0 +1,65 @@
# Crypt
Encryption utilities: encrypted Eloquent casts and LTHN QuasiHash identifier generator.
## What It Does
Two independent tools:
1. **EncryptArrayObject** -- Eloquent cast that encrypts/decrypts array data transparently using Laravel's `Crypt` facade
2. **LthnHash** -- Deterministic identifier generator for workspace scoping, vBucket CDN paths, and consistent sharding
## Key Classes
| Class | Purpose |
|-------|---------|
| `EncryptArrayObject` | `CastsAttributes` implementation. Encrypts arrays as JSON+AES on write, decrypts on read. Fails gracefully (returns null + logs warning) |
| `LthnHash` | Static utility: `hash()`, `shortHash()`, `fastHash()`, `vBucketId()`, `toInt()`, `verify()`, `benchmark()`. Supports key rotation |
## EncryptArrayObject Usage
```php
class ApiCredential extends Model {
protected $casts = ['secrets' => EncryptArrayObject::class];
}
$model->secrets['api_key'] = 'sk_live_xxx'; // encrypted in DB
```
## LthnHash API
| Method | Output | Use Case |
|--------|--------|----------|
| `hash($input)` | 64 hex chars (SHA-256) | Default, high quality |
| `shortHash($input, $len)` | 16-32 hex chars | Space-constrained IDs |
| `fastHash($input)` | 8-16 hex chars (xxHash/CRC32) | High-throughput |
| `vBucketId($domain)` | 64 hex chars | CDN path isolation |
| `toInt($input, $max)` | int (60 bits) | Sharding/partitioning |
| `verify($input, $hash)` | bool | Constant-time comparison, tries all key maps |
| `benchmark($iterations)` | timing array | Performance measurement |
## Algorithm
1. Reverse input, apply character substitution map (key map)
2. Concatenate original + substituted string
3. Hash with SHA-256 (or xxHash/CRC32 for `fastHash`)
## Key Rotation
```php
LthnHash::addKeyMap('v2', $newMap, setActive: true);
// New hashes use v2, verify() tries v2 first then falls back to older maps
LthnHash::removeKeyMap('v1'); // after migration
```
## NOT For
- Password hashing (use `password_hash()`)
- Security tokens (use `random_bytes()`)
- Cryptographic signatures
## Integration
- `CdnUrlBuilder::vBucketId()` delegates to `LthnHash::vBucketId()`
- `verify()` uses `hash_equals()` for timing-attack resistance
- `fastHash()` auto-selects xxh64 (PHP 8.1+) or CRC32b+CRC32c fallback
- `toInt()` uses GMP for safe large-integer modular arithmetic

View file

@ -12,7 +12,9 @@ declare(strict_types=1);
namespace Core\Crypt;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\ArrayObject;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
@ -27,7 +29,7 @@ class EncryptArrayObject implements CastsAttributes
/**
* Cast the given value to an ArrayObject.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param Model $model
* @param mixed $value
* @param array<string, mixed> $attributes
*/
@ -36,7 +38,7 @@ class EncryptArrayObject implements CastsAttributes
if (isset($attributes[$key])) {
try {
$decrypted = Crypt::decryptString($attributes[$key]);
} catch (\Illuminate\Contracts\Encryption\DecryptException $e) {
} catch (DecryptException $e) {
Log::warning('Failed to decrypt array object', ['key' => $key, 'error' => $e->getMessage()]);
return null;
@ -59,7 +61,7 @@ class EncryptArrayObject implements CastsAttributes
/**
* Prepare the given value for storage.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param Model $model
* @param mixed $value
* @param array<string, mixed> $attributes
* @return array<string, string>|null

View file

@ -0,0 +1,27 @@
# Database/Seeders/Attributes/ — Seeder Ordering Attributes
PHP 8 attributes for controlling seeder execution order in the auto-discovery system.
## Attributes
| Attribute | Target | Purpose |
|-----------|--------|---------|
| `#[SeederAfter(...)]` | Class | This seeder must run after the specified seeders. Repeatable. |
| `#[SeederBefore(...)]` | Class | This seeder must run before the specified seeders. Repeatable. |
| `#[SeederPriority(n)]` | Class | Numeric priority (lower runs first, default 50). |
## Priority Guidelines
- 0-20: Foundation (features, configuration)
- 20-40: Core data (packages, workspaces)
- 40-60: Default (general seeders)
- 60-80: Content (pages, posts)
- 80-100: Demo/test data
## Example
```php
#[SeederAfter(FeatureSeeder::class)]
#[SeederPriority(30)]
class PackageSeeder extends Seeder { ... }
```

View file

@ -0,0 +1,68 @@
# Database/Seeders
Auto-discovering, dependency-aware seeder orchestration.
## What It Does
Replaces Laravel's manual seeder ordering with automatic discovery and topological sorting. Seeders declare their dependencies via attributes or properties, and the framework figures out the correct execution order using Kahn's algorithm.
## Key Classes
| Class | Purpose |
|-------|---------|
| `CoreDatabaseSeeder` | Base seeder class. Extends Laravel's `Seeder`. Auto-discovers seeders from configured paths, applies `--exclude` and `--only` filters, runs them in dependency order |
| `SeederDiscovery` | Scans directories for `*Seeder.php` files, reads priority/dependency metadata from attributes or properties, produces topologically sorted list |
| `SeederRegistry` | Manual registration alternative: `register(Class, priority: 10, after: [...])`. Fluent API with `registerMany()`, `merge()`, `getOrdered()` |
## Attributes
| Attribute | Target | Purpose |
|-----------|--------|---------|
| `#[SeederPriority(10)]` | Class | Lower values run first (default: 50) |
| `#[SeederAfter(FeatureSeeder::class)]` | Class | Must run after specified seeders (repeatable) |
| `#[SeederBefore(PackageSeeder::class)]` | Class | Must run before specified seeders (repeatable) |
## Priority Guidelines
- 0-20: Foundation (features, configuration)
- 20-40: Core data (packages, workspaces)
- 40-60: Default (general seeders)
- 60-80: Content (pages, posts)
- 80-100: Demo/test data
## Ordering Rules
Dependencies take precedence over priority. Within the same dependency level, lower priority numbers run first. Circular dependencies throw `CircularDependencyException` with the full cycle path.
## Usage
```php
// Auto-discovery (default)
class DatabaseSeeder extends CoreDatabaseSeeder {
protected function getSeederPaths(): array {
return [app_path('Core'), app_path('Mod')];
}
}
// Manual registration
class DatabaseSeeder extends CoreDatabaseSeeder {
protected bool $autoDiscover = false;
protected function registerSeeders(SeederRegistry $registry): void {
$registry->register(FeatureSeeder::class, priority: 10)
->register(PackageSeeder::class, after: [FeatureSeeder::class]);
}
}
// CLI filtering
php artisan db:seed --exclude=DemoSeeder --only=FeatureSeeder
```
## Discovery Paths
Scans `{path}/*/Database/Seeders/*Seeder.php` (module subdirs) and `{path}/Database/Seeders/*Seeder.php` (direct). Configured via `core.seeders.paths` or defaults to `app/Core`, `app/Mod`, `app/Website`.
## Integration
- Properties alternative to attributes: `public int $priority = 10;`, `public array $after = [...]`, `public array $before = [...]`
- Pattern matching for `--exclude`/`--only`: full class name, short name, or partial match
- Config: `core.seeders.auto_discover`, `core.seeders.paths`, `core.seeders.exclude`

View file

@ -0,0 +1,9 @@
# Database/Seeders/Exceptions/ — Seeder Exception Types
## Files
| File | Purpose |
|------|---------|
| `CircularDependencyException.php` | Thrown when seeder dependency graph contains a cycle. Includes the `$cycle` array showing the loop path. Has `fromPath()` factory for building from a traversal path. |
Part of the seeder auto-discovery system. The `CoreDatabaseSeeder` uses topological sorting on `#[SeederAfter]`/`#[SeederBefore]` attributes and throws this when a cycle is detected.

84
src/Core/Events/CLAUDE.md Normal file
View file

@ -0,0 +1,84 @@
# Events
Lifecycle events that drive the module loading system.
## What It Does
Defines the event classes that modules listen to via `static $listens` arrays in their Boot classes. Events use a request/collect pattern: modules call methods like `routes()`, `views()`, `livewire()` during event dispatch, and `LifecycleEventProvider` processes the collected requests afterwards.
This is the **core of the module loading architecture**. Modules are never instantiated until their listened events fire.
## Lifecycle Events (Mutually Exclusive by Context)
| Event | Context | Middleware | Purpose |
|-------|---------|------------|---------|
| `WebRoutesRegistering` | Web requests | `web` | Public-facing routes, views |
| `AdminPanelBooting` | Admin requests | `admin` | Admin dashboard resources |
| `ApiRoutesRegistering` | API requests | `api` | REST API endpoints |
| `ClientRoutesRegistering` | Client dashboard | `client` | Authenticated SaaS user routes |
| `ConsoleBooting` | CLI | -- | Artisan commands |
| `QueueWorkerBooting` | Queue workers | -- | Job registration, queue init |
| `McpToolsRegistering` | MCP server | -- | MCP tool handlers |
| `McpRoutesRegistering` | MCP HTTP | `mcp` | MCP HTTP endpoints |
| `FrameworkBooted` | All contexts | -- | Late-stage cross-cutting init |
## Capability Events (On-Demand)
| Event | Purpose |
|-------|---------|
| `DomainResolving` | Multi-tenancy by domain. First provider to `register()` wins |
| `SearchRequested` | Lazy-load search: `searchable(Model::class)` |
| `MediaRequested` | Lazy-load media: `processor('image', ImageProcessor::class)` |
| `MailSending` | Lazy-load mail: `mailable(WelcomeEmail::class)` |
## Base Class: LifecycleEvent
All lifecycle events extend `LifecycleEvent`, which provides these request methods:
| Method | Purpose |
|--------|---------|
| `routes(callable)` | Register route callback |
| `views(namespace, path)` | Register view namespace |
| `livewire(alias, class)` | Register Livewire component |
| `middleware(alias, class)` | Register middleware alias |
| `command(class)` | Register Artisan command |
| `translations(namespace, path)` | Register translation namespace |
| `bladeComponentPath(path, namespace)` | Register anonymous Blade components |
| `policy(model, policy)` | Register model policy |
| `navigation(item)` | Register nav item |
Each has a corresponding `*Requests()` getter for `LifecycleEventProvider` to process.
## Observability
| Class | Purpose |
|-------|---------|
| `ListenerProfiler` | Measures execution time, memory, call count per listener. `enable()`, `getSlowListeners()`, `getSlowest(10)`, `getSummary()`, `export()` |
| `EventAuditLog` | Tracks success/failure of event handlers. `enable()`, `entries()`, `failures()`, `summary()` |
## Event Versioning
| Class | Purpose |
|-------|---------|
| `HasEventVersion` (trait) | Modules declare `$eventVersions` for compatibility checking |
Events carry `VERSION` and `MIN_SUPPORTED_VERSION` constants. Handlers check `$event->version()` or `$event->supportsVersion(2)` for forward compatibility.
## Integration
```php
// Module Boot class
class Boot {
public static array $listens = [
WebRoutesRegistering::class => 'onWebRoutes',
AdminPanelBooting::class => ['onAdmin', 10], // with priority
];
public function onWebRoutes(WebRoutesRegistering $event): void {
$event->views('mymod', __DIR__.'/Views');
$event->routes(fn() => require __DIR__.'/web.php');
}
}
```
Flow: `ModuleScanner` reads `$listens` -> `ModuleRegistry` registers `LazyModuleListener` with Laravel Events -> Event fires -> Module instantiated via container -> Method called with event -> Requests collected -> `LifecycleEventProvider` processes.

View file

@ -0,0 +1,9 @@
# Events/Concerns/ — Event Version Compatibility
## Traits
| Trait | Purpose |
|-------|---------|
| `HasEventVersion` | For Boot classes to declare which event API versions they support. Methods: `getRequiredEventVersion(eventClass)`, `isCompatibleWithEventVersion(eventClass, version)`, `getEventVersionRequirements()`. |
Enables graceful handling of event API changes. Modules declare minimum versions via `$eventVersions` static property. The framework checks compatibility during bootstrap and logs warnings for version mismatches.

View file

@ -14,6 +14,7 @@ namespace Core\Front\Admin;
use Core\Front\Admin\Contracts\AdminMenuProvider;
use Core\Front\Admin\Contracts\DynamicMenuProvider;
use Core\Front\Admin\Validation\IconValidator;
use Core\Tenant\Services\EntitlementService;
use Illuminate\Support\Facades\Cache;
/**
@ -111,8 +112,8 @@ class AdminMenuRegistry
public function __construct(?object $entitlements = null, ?IconValidator $iconValidator = null)
{
if ($entitlements === null && class_exists(\Core\Tenant\Services\EntitlementService::class)) {
$this->entitlements = app(\Core\Tenant\Services\EntitlementService::class);
if ($entitlements === null && class_exists(EntitlementService::class)) {
$this->entitlements = app(EntitlementService::class);
} else {
$this->entitlements = $entitlements;
}

View file

@ -0,0 +1,38 @@
# Front/Admin/Blade/components
Anonymous Blade components for the admin panel. Used via `<admin:xyz>` tag syntax.
## Components
| Component | Purpose |
|-----------|---------|
| action-link | Styled link for table row actions |
| activity-feed | Template for ActivityFeed class component |
| activity-log | Template for ActivityLog class component |
| alert | Template for Alert class component |
| card-grid | Template for CardGrid class component |
| clear-filters | Template for ClearFilters class component |
| data-table | Template for DataTable class component |
| editable-table | Template for EditableTable class component |
| empty-state | Empty state placeholder with icon and message |
| entitlement-gate | Conditionally renders content based on workspace entitlements |
| filter / filter-bar | Template for Filter/FilterBar class components |
| flash | Session flash message display |
| header | Page header with breadcrumbs and actions |
| link-grid | Template for LinkGrid class component |
| manager-table | Template for ManagerTable class component |
| metric-card / metrics | Individual metric card and grid template |
| module | Module wrapper with loading states |
| nav-group / nav-item / nav-link / nav-menu / nav-panel | Sidebar navigation primitives |
| page-header | Page title bar with optional subtitle and actions |
| panel | Content panel with optional header/footer |
| progress-list | Template for ProgressList class component |
| search | Template for Search class component |
| service-card / service-cards | Service overview cards |
| sidebar / sidemenu | Sidebar shell and menu template |
| stat-card / stats | Individual stat card and grid template |
| status-cards | Template for StatusCards class component |
| tabs | Tab navigation wrapper using `<core:tabs>` |
| workspace-card | Workspace overview card |
Most are templates for the class-backed components in `View/Components/`. A few are standalone anonymous components (empty-state, entitlement-gate, flash, nav-*, page-header, panel, workspace-card).

View file

@ -0,0 +1,22 @@
# Front/Admin/Blade/components/tabs
Tab panel sub-component for admin tabs.
## Files
- **panel.blade.php** -- Individual tab panel that auto-detects selected state from `TabContext::$selected`. Wraps `<core:tab.panel>` with automatic selection.
- Props: `name` (string, required) -- must match the tab key
- Reads `\Core\Front\Admin\TabContext::$selected` to determine visibility
## Usage
```blade
<admin:tabs :tabs="$tabs" :selected="$currentTab">
<admin:tabs.panel name="general">
General settings content
</admin:tabs.panel>
<admin:tabs.panel name="advanced">
Advanced settings content
</admin:tabs.panel>
</admin:tabs>
```

View file

@ -0,0 +1,10 @@
# Front/Admin/Blade/layouts
Layout templates for the admin panel.
## Files
- **app.blade.php** -- Full admin HTML shell with sidebar + content area layout. Includes dark mode (localStorage + cookie sync), FontAwesome Pro CSS, Vite assets (admin.css + app.js), Flux appearance/scripts, collapsible sidebar with `sidebarExpanded` Alpine state (persisted to localStorage), and light/dark mode toggle script.
- Props: `title` (string, default 'Admin')
- Slots: `$sidebar` (sidebar component), `$header` (top header), `$slot` (main content), `$head` (extra head content), `$scripts` (extra scripts)
- Responsive: sidebar hidden on mobile, 20px collapsed / 64px expanded on desktop.

View file

@ -31,9 +31,15 @@ use Core\Front\Admin\View\Components\Stats;
use Core\Front\Admin\View\Components\StatusCards;
use Core\Headers\SecurityHeaders;
use Core\LifecycleEventProvider;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\Middleware\ShareErrorsFromSession;
/**
* Admin frontage - admin dashboard stage.
@ -49,12 +55,12 @@ class Boot extends ServiceProvider
public static function middleware(Middleware $middleware): void
{
$middleware->group('admin', [
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
ValidateCsrfToken::class,
SubstituteBindings::class,
SecurityHeaders::class,
'auth',
]);

Some files were not shown because too many files have changed in this diff Show more